diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index b03b3bb04..3e27d018e 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -281,11 +281,7 @@ async function postMoodEntry(request: NextRequest) { tz, mood, score, - moodA1: levelA.moodA1, - stressA2: levelA.stressA2, - energyA3: levelA.energyA3, - connectionA4: levelA.connectionA4, - stabilityA5: levelA.stabilityA5, + ...levelA, tags: tags ? JSON.stringify(tags) : null, note: null, noteEncrypted: encryptNote(note ?? null), @@ -293,20 +289,19 @@ async function postMoodEntry(request: NextRequest) { externalId, moodLoggedAt, }, - // A re-post replaces the row, so the level-A values are restated - // here too. Omitting them would leave a stress reading from an - // earlier post sitting beside a mood the user has since changed - // — the same staleness the `score` restatement already avoids. + // A re-post restates pleasantness, because the label it carries + // always implies one and a stale A1 beside a changed mood would + // contradict the entry's own face. It does NOT restate the other + // four: `levelA` carries them only when this request did, so a + // client re-posting without sliders — the phone, whose build has + // none — leaves the stress and energy somebody answered on the + // web exactly where they were. An explicit null clears one. update: { date, tz, mood, score, - moodA1: levelA.moodA1, - stressA2: levelA.stressA2, - energyA3: levelA.energyA3, - connectionA4: levelA.connectionA4, - stabilityA5: levelA.stabilityA5, + ...levelA, tags: tags ? JSON.stringify(tags) : null, note: null, noteEncrypted: encryptNote(note ?? null), @@ -320,11 +315,9 @@ async function postMoodEntry(request: NextRequest) { tz, mood, score, - moodA1: levelA.moodA1, - stressA2: levelA.stressA2, - energyA3: levelA.energyA3, - connectionA4: levelA.connectionA4, - stabilityA5: levelA.stabilityA5, + // A fresh row: an omitted dimension lands as NULL, which is + // the same thing the upsert's arms mean by omitting it. + ...levelA, tags: tags ? JSON.stringify(tags) : null, note: null, noteEncrypted: encryptNote(note ?? null), diff --git a/src/lib/mood/level-a.ts b/src/lib/mood/level-a.ts index 15505eb75..283050295 100644 --- a/src/lib/mood/level-a.ts +++ b/src/lib/mood/level-a.ts @@ -24,41 +24,63 @@ export interface LevelAInput { a5?: number | null; } -/** Level-A values as they are written to `MoodEntry`. */ +/** + * Level-A values as they are written to `MoodEntry`. + * + * A1 is always stated, because it is always derivable from the label. The + * other four are present only when the request carried them: an absent key + * means the caller said nothing about that dimension, and on an upsert's + * `update:` arm saying nothing has to leave the stored answer alone. + */ export interface LevelAColumns { - moodA1: number | null; - stressA2: number | null; - energyA3: number | null; - connectionA4: number | null; - stabilityA5: number | null; + moodA1: number; + stressA2?: number | null; + energyA3?: number | null; + connectionA4?: number | null; + stabilityA5?: number | null; } /** * The five columns for a write of `mood`, with any explicit client value * winning over the derivation. * - * A client that sends only a label gets the derived A1 and four NULLs. A - * client that sends `a1` gets its own number — that is what makes the detail - * sliders a real capture surface rather than a suggestion the server overrides - * on the way to the database. + * A client that sends only a label gets the derived A1 and says nothing at all + * about the other four. A client that sends `a1` gets its own number — that is + * what makes the detail sliders a real capture surface rather than a + * suggestion the server overrides on the way to the database. * - * Every field is stated explicitly, including the NULLs, because the callers - * are upserts: on the `update:` arm an omitted field keeps a value from an - * earlier post, and a stale stress reading sitting beside a mood the user just - * changed is exactly the mismatch these columns exist to avoid. Callers whose - * write semantics are per-field rather than whole-row (the PATCH route) build - * their own object and use `deriveA1` directly. + * **A2 to A5 are omitted when the request did not carry them, and that is the + * point.** The callers are upserts, so this same object feeds an `update:` + * arm: a phone re-posting an entry it holds — the label, the timestamp, the + * tags, and no sliders, because its own build has none — must not blank the + * stress and energy somebody answered on the web. Absence in a request means + * "nothing to say", never "set this to nothing". Clearing an answer is what an + * explicit `null` is for, and it travels here as a stated null. + * + * The asymmetry with A1 is deliberate rather than an oversight. A1 can always + * be derived from the label the request already carries, so there is no state + * in which restating it loses information; the other four have no such source, + * and a value invented for them would be indistinguishable from one somebody + * gave — which would poison the trend and anything later trained on it. + * + * On the `create:` arm an omitted column lands as NULL anyway, so one object + * serves both arms. Callers whose write semantics are per-field rather than + * whole-row (the edit route) build their own object and use `deriveA1`. */ export function levelAForWrite( mood: string, explicit?: LevelAInput, ): LevelAColumns { + // Field by field, never a spread of the parsed body. return { + // An explicit null falls back to the derivation rather than clearing: an + // entry always carries a pleasantness value, because the five-point label + // it was saved with always implies one. moodA1: explicit?.a1 ?? getA1ForMood(mood), - stressA2: explicit?.a2 ?? null, - energyA3: explicit?.a3 ?? null, - connectionA4: explicit?.a4 ?? null, - stabilityA5: explicit?.a5 ?? null, + ...(explicit?.a2 === undefined ? {} : { stressA2: explicit.a2 }), + ...(explicit?.a3 === undefined ? {} : { energyA3: explicit.a3 }), + ...(explicit?.a4 === undefined ? {} : { connectionA4: explicit.a4 }), + ...(explicit?.a5 === undefined ? {} : { stabilityA5: explicit.a5 }), }; } diff --git a/src/lib/validations/mood.ts b/src/lib/validations/mood.ts index 6c58ac819..37ef68a6f 100644 --- a/src/lib/validations/mood.ts +++ b/src/lib/validations/mood.ts @@ -128,14 +128,19 @@ export const createMoodEntrySchema = z // `MoodEntryTagLink.rating`. Out-of-scale or non-RATED keys are // rejected (422) / dropped server-side per the catalog. ratedFactors: ratedFactors.optional(), - // v1.37 — the five level-A values the detail sliders capture. Absent - // means the user did not answer; `a1` absent means the server derives it - // from `mood`, which keeps the one-tap check-in a full entry. - a1: levelADimension.optional(), - a2: levelADimension.optional(), - a3: levelADimension.optional(), - a4: levelADimension.optional(), - a5: levelADimension.optional(), + // v1.37 — the five level-A values the detail sliders capture. Three + // states, and they mean three different things, because this create can + // land on the `update:` arm of an `externalId` upsert: + // absent — nothing to say; a stored answer is left alone + // number — the answer, stored literally + // null — take the answer back + // `a1` is the exception: absent or null, the server derives it from + // `mood`, so a one-tap check-in is a full entry and never a degraded one. + a1: levelADimension.nullable().optional(), + a2: levelADimension.nullable().optional(), + a3: levelADimension.nullable().optional(), + a4: levelADimension.nullable().optional(), + a5: levelADimension.nullable().optional(), // v1.4.30 H-5 — first-class free-text note. Replaces the // `tags: ["note:"]` workaround. Capped at 500 chars so the // Coach evidence shelf renders cleanly without truncating chips. diff --git a/tests/integration/mood-writers-agree.test.ts b/tests/integration/mood-writers-agree.test.ts index feb797bd3..de6f7d61f 100644 --- a/tests/integration/mood-writers-agree.test.ts +++ b/tests/integration/mood-writers-agree.test.ts @@ -264,6 +264,95 @@ describe("mood writers agree on the derived level-A value", () => { } }); + it("leaves a dimension the re-post said nothing about exactly where it was", async () => { + await createUserSession(); + const { POST: postSingle } = await import("@/app/api/mood-entries/route"); + const { POST: postBulk } = + await import("@/app/api/mood-entries/bulk/route"); + + // Somebody answers the sliders on the web. + await postSingle( + jsonRequest("/api/mood-entries", { + mood: "OKAY", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + externalId: "preserve-single", + a2: 9, + a3: 2, + }), + ); + + // The phone re-posts the same entry it holds: label, timestamp, id, and no + // sliders, because its build has none. This must not blank what the web + // wrote. Absence in a request means "nothing to say", not "set to + // nothing" — a request that cleared four columns by staying silent would + // destroy answers on every sync round. + await postSingle( + jsonRequest("/api/mood-entries", { + mood: "GUT", + moodLoggedAt: "2026-05-16T08:05:00.000Z", + externalId: "preserve-single", + }), + ); + + const afterRepost = await getPrismaClient().moodEntry.findFirstOrThrow({ + where: { userId: USER, externalId: "preserve-single" }, + }); + expect(afterRepost.mood).toBe("GUT"); + // Pleasantness DOES follow the label: the request carried one, and a + // stale A1 beside a changed face would contradict the entry itself. + expect(afterRepost.moodA1).toBe(7); + // The four the request said nothing about are untouched. + expect(afterRepost.stressA2).toBe(9); + expect(afterRepost.energyA3).toBe(2); + expect(afterRepost.connectionA4).toBeNull(); + expect(afterRepost.stabilityA5).toBeNull(); + + // An explicit null is how an answer is taken back — the other route out of + // the same three-state contract. + await postSingle( + jsonRequest("/api/mood-entries", { + mood: "GUT", + moodLoggedAt: "2026-05-16T08:06:00.000Z", + externalId: "preserve-single", + a2: null, + }), + ); + const afterClear = await getPrismaClient().moodEntry.findFirstOrThrow({ + where: { userId: USER, externalId: "preserve-single" }, + }); + expect(afterClear.stressA2).toBeNull(); + // Clearing one says nothing about the others. + expect(afterClear.energyA3).toBe(2); + + // The batch path answers the same way, and always has: it carries no + // level-A input at all, so every re-import preserves. + await postSingle( + jsonRequest("/api/mood-entries", { + mood: "OKAY", + moodLoggedAt: "2026-05-16T12:00:00.000Z", + externalId: "preserve-bulk", + a4: 6, + }), + ); + await postBulk( + jsonRequest("/api/mood-entries/bulk", { + entries: [ + { + mood: "SUPER_GUT", + moodLoggedAt: "2026-05-16T12:30:00.000Z", + externalId: "preserve-bulk", + }, + ], + }), + ); + const afterBulk = await getPrismaClient().moodEntry.findFirstOrThrow({ + where: { userId: USER, externalId: "preserve-bulk" }, + }); + expect(afterBulk.mood).toBe("SUPER_GUT"); + expect(afterBulk.moodA1).toBe(9); + expect(afterBulk.connectionA4).toBe(6); + }); + it("lets an explicit client value win over the derivation, and stores the other four literally", async () => { await createUserSession(); const { POST: postSingle } = await import("@/app/api/mood-entries/route");