mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
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
|
||||
|
||||
@@ -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([
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 ?? "",
|
||||
}));
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in new issue
Block a user