diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 8ede74cac..a6d393133 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -774,6 +774,15 @@ const handler = apiHandler( date: entry.date, mood: entry.mood, score: entry.score, + // The five level-A values. `?? null` rather than a derivation: + // a file that carries none is a file from before they existed, + // and filling them in here would put a number the person never + // gave into a restored row and call it their answer. + moodA1: entry.a1 ?? null, + stressA2: entry.a2 ?? null, + energyA3: entry.a3 ?? null, + connectionA4: entry.a4 ?? null, + stabilityA5: entry.a5 ?? null, tags: entry.tags ?? null, // The note comes back the way every other dual-column note // does: ciphertext verbatim when the file carries it, and a diff --git a/src/lib/__tests__/export.test.ts b/src/lib/__tests__/export.test.ts index c4a2a7ef1..8fcb32467 100644 --- a/src/lib/__tests__/export.test.ts +++ b/src/lib/__tests__/export.test.ts @@ -400,3 +400,51 @@ describe("formatMoodEntriesForExport with userTz", () => { expect(result[0].loggedAt).toBe("2026-05-15T14:30:00-04:00"); }); }); + +describe("formatMoodEntriesForExport level-A columns", () => { + const base = { + date: "2026-05-15", + mood: "OKAY", + score: 3, + tags: null, + source: "MANUAL", + moodLoggedAt: new Date("2026-05-15T18:30:00Z"), + }; + + it("appends the five values without moving an existing column", () => { + const csv = toCSV( + formatMoodEntriesForExport([ + { ...base, moodA1: 4, stressA2: 9, energyA3: 0 }, + ]), + ); + const [header, row] = csv.split("\n"); + // The pre-existing columns keep their order and their positions. Somebody's + // spreadsheet has a formula pointed at column D. + expect(header).toBe( + "date,mood,score,tags,note,source,loggedAt,a1,a2,a3,a4,a5", + ); + expect(row.split(",").slice(0, 3)).toEqual(["2026-05-15", "OKAY", "3"]); + }); + + it("writes an empty cell for an unanswered value and a zero for a zero", () => { + const csv = toCSV( + formatMoodEntriesForExport([ + { ...base, moodA1: 4, stressA2: 9, energyA3: 0 }, + ]), + ); + const cells = csv.split("\n")[1].split(","); + // a1, a2, a3, a4, a5 — the last two were never answered. + expect(cells.slice(-5)).toEqual(["4", "9", "0", "", ""]); + }); + + it("leaves every level-A cell empty for an entry written before the columns", () => { + const csv = toCSV(formatMoodEntriesForExport([base])); + expect(csv.split("\n")[1].split(",").slice(-5)).toEqual([ + "", + "", + "", + "", + "", + ]); + }); +}); diff --git a/src/lib/export.ts b/src/lib/export.ts index 5ed09e5a5..0dd083bab 100644 --- a/src/lib/export.ts +++ b/src/lib/export.ts @@ -336,6 +336,12 @@ export function formatMoodEntriesForExport( note?: string | null; source: string; moodLoggedAt: Date; + /** The five level-A values, absent on entries written before they existed. */ + moodA1?: number | null; + stressA2?: number | null; + energyA3?: number | null; + connectionA4?: number | null; + stabilityA5?: number | null; }>, userTz?: string, ): ExportableRecord[] { @@ -347,5 +353,14 @@ export function formatMoodEntriesForExport( note: e.note ?? "", source: e.source, loggedAt: formatTimestamp(e.moodLoggedAt, userTz), + // Appended after the existing columns rather than slotted in beside + // `score`, because this file lands in somebody's spreadsheet and a column + // that moves breaks every formula pointed at it. An unanswered value is an + // empty cell, not a zero: zero is a real answer on this scale. + a1: e.moodA1 ?? "", + a2: e.stressA2 ?? "", + a3: e.energyA3 ?? "", + a4: e.connectionA4 ?? "", + a5: e.stabilityA5 ?? "", })); } diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 9f4506de5..0d3375c99 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -117,6 +117,11 @@ const MOOD_ENTRY_BACKUP_SELECT = { date: true, mood: true, score: true, + moodA1: true, + stressA2: true, + energyA3: true, + connectionA4: true, + stabilityA5: true, tags: true, note: true, noteEncrypted: true, @@ -480,6 +485,15 @@ export async function buildFullBackupPayload( date: moodEntry.date, mood: moodEntry.mood, score: moodEntry.score, + // The five level-A values, under the keys the write path takes. Both + // arms carry them: they are the entry's own answers, not a storage + // detail, and a portable export that dropped them would hand back a + // file describing the day less well than the row it came from. + a1: moodEntry.moodA1, + a2: moodEntry.stressA2, + a3: moodEntry.energyA3, + a4: moodEntry.connectionA4, + a5: moodEntry.stabilityA5, tags: moodEntry.tags, source: moodEntry.source, loggedAt: moodEntry.moodLoggedAt.toISOString(), diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 3f1a981e1..374847ed1 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -254,6 +254,15 @@ const moodEntrySchema = z date: z.string().min(1), mood: z.string().min(1), score: z.number().int().min(0).max(10), + // The five level-A values. Optional and nullable: a file written before + // they existed carries none, and an entry whose sliders were never touched + // carries nulls. Absent restores as NULL, never as a defaulted midpoint — + // a restore that invents an answer is worse than one that admits none. + a1: z.number().int().min(0).max(10).nullable().optional(), + a2: z.number().int().min(0).max(10).nullable().optional(), + a3: z.number().int().min(0).max(10).nullable().optional(), + a4: z.number().int().min(0).max(10).nullable().optional(), + a5: z.number().int().min(0).max(10).nullable().optional(), tags: moodEntryTagsSchema, // The free text, in both storage shapes. A portable export carries the // decrypted `note`; a disaster-recovery file carries `noteEncrypted` plus diff --git a/tests/integration/mood-backup-round-trip.test.ts b/tests/integration/mood-backup-round-trip.test.ts index 42c43e088..677b40f6d 100644 --- a/tests/integration/mood-backup-round-trip.test.ts +++ b/tests/integration/mood-backup-round-trip.test.ts @@ -186,6 +186,15 @@ describe("mood backup round trip", () => { date: "2026-07-19", mood: "OKAY", score: 3, + // All five level-A values, and deliberately not the ones the label + // would derive: a restore that re-derived them instead of carrying + // them would look right on this row and be wrong on every row where + // the person disagreed with their own five-point pick. + moodA1: 4, + stressA2: 9, + energyA3: 2, + connectionA4: 6, + stabilityA5: 0, tags: JSON.stringify(["free-text-tag"]), note: null, noteEncrypted: encryptNote(NOTE), @@ -268,6 +277,14 @@ describe("mood backup round trip", () => { expect(restored.updatedAt).toEqual(UPDATED_AT); expect(restored.date).toBe("2026-07-19"); expect(restored.score).toBe(3); + // Each of the five asserted by value. Zero is included on purpose: it is a + // real answer on this scale, and a carrier that treated it as absence + // would round-trip it as NULL and pass a truthiness check. + expect(restored.moodA1).toBe(4); + expect(restored.stressA2).toBe(9); + expect(restored.energyA3).toBe(2); + expect(restored.connectionA4).toBe(6); + expect(restored.stabilityA5).toBe(0); expect(restored.tags).toBe(JSON.stringify(["free-text-tag"])); expect(restored.deletedAt).toBeNull(); @@ -276,6 +293,11 @@ describe("mood backup round trip", () => { }); expect(tombstone.deletedAt).toEqual(DELETED_AT); expect(tombstone.syncVersion).toBe(5); + // The tombstone was written without level-A values and comes back without + // them. A restore filling these in would put an answer nobody gave into a + // row the person had already deleted. + expect(tombstone.moodA1).toBeNull(); + expect(tombstone.stressA2).toBeNull(); const links = await prisma.moodEntryTagLink.findMany({ where: { moodEntryId: "mood-rt-entry" }, @@ -338,6 +360,7 @@ describe("mood backup round trip", () => { expect(parsed.moodEntries[0].structuredTags).toEqual([]); expect(parsed.moodEntries[0].tz).toBeUndefined(); expect(parsed.moodEntries[0].syncVersion).toBeUndefined(); + expect(parsed.moodEntries[0].a1).toBeUndefined(); const response = await restoreFromPayload( prisma, @@ -360,6 +383,14 @@ describe("mood backup round trip", () => { expect(restored.score).toBe(4); expect(restored.externalId).toBe("moodlog-legacy"); expect(restored.deletedAt).toBeNull(); + // A file from before these columns carries no answer for them, and the + // restore does not invent one. `GUT` would derive to 7; a restore that + // derived instead of carrying would write a number this file never held. + expect(restored.moodA1).toBeNull(); + expect(restored.stressA2).toBeNull(); + expect(restored.energyA3).toBeNull(); + expect(restored.connectionA4).toBeNull(); + expect(restored.stabilityA5).toBeNull(); const links = await prisma.moodEntryTagLink.findMany({ where: { moodEntryId: "mood-rt-legacy" },