feat(mood): carry the five level-A values through backup, restore and CSV

- the backup select and the payload carry all five, on both the portable
  and the disaster-recovery arm
- the restore writes what the file holds and nothing else: a file from
  before these columns restores them empty rather than deriving them
- the CSV appends them after the existing columns so nothing shifts under
  a spreadsheet, and an unanswered value is an empty cell rather than a
  zero, which is a real answer on this scale
- the round trip asserts each of the five by value, including a zero
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 01:08:27 +02:00
1 parent 643d8a5a00
commit 9e44c8754e
6 files changed
+126

No files matched your search

@@ -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
+48
View File
@@ -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([
"",
"",
"",
"",
"",
]);
});
});
+15
View File
@@ -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 ?? "",
}));
}
+14
View File
@@ -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(),
+9
View File
@@ -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
@@ -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" },