From 0a39d50b05319b1fff5169e1d62de0e7f617d11c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 02:55:08 +0200 Subject: [PATCH] fix(mood): stop offering to clear a value that cannot be cleared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An entry always carries a five-point label, so it always implies a pleasantness value: the create path derived one from a null and the capture sheet still showed a clear control for it, which meant a button the server undid on the way to the database. One rule on both routes now — a null asks for a value that cannot exist and gets the derivation, on create and on edit alike — and the control is gone for that field. The other four are genuinely optional and keep theirs. --- src/app/api/mood-entries/[id]/route.ts | 10 ++++- .../mood/__tests__/mood-qol-fixes.test.tsx | 13 ++++++ src/components/mood/mood-level-a-fields.tsx | 16 ++++++-- tests/integration/mood-writers-agree.test.ts | 41 +++++++++++++++++++ 4 files changed, 75 insertions(+), 5 deletions(-) diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index 9e7ea9565..e20be463f 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -139,7 +139,15 @@ export const PUT = apiHandler( // v1.37 — level-A values are per-field on this path: omitted keeps the // stored value, an explicit number replaces it, an explicit null clears // it. Built one at a time from the parsed body, never spread. - if (data.a1 !== undefined) updateData.moodA1 = data.a1; + // + // Pleasantness is the exception, and it is the same exception the create + // path makes: an entry always carries a five-point label, so it always + // implies a pleasantness value, and a null falls back to the derivation + // rather than emptying the column. One rule on both routes — the capture + // surfaces therefore offer no clear control for it. + if (data.a1 !== undefined) { + updateData.moodA1 = data.a1 ?? deriveA1(data.mood ?? existing.mood); + } if (data.a2 !== undefined) updateData.stressA2 = data.a2; if (data.a3 !== undefined) updateData.energyA3 = data.a3; if (data.a4 !== undefined) updateData.connectionA4 = data.a4; diff --git a/src/components/mood/__tests__/mood-qol-fixes.test.tsx b/src/components/mood/__tests__/mood-qol-fixes.test.tsx index 6073a01e6..26e0d8cf2 100644 --- a/src/components/mood/__tests__/mood-qol-fixes.test.tsx +++ b/src/components/mood/__tests__/mood-qol-fixes.test.tsx @@ -145,6 +145,19 @@ describe("MoodForm — level A replaces the factor ratings (v1.37)", () => { // Nothing here defaults a value: the state starts empty and stays empty. expect(formSrc).toContain("useState(EMPTY_LEVEL_A)"); }); + + it("offers no clear control for pleasantness, matching the write path", () => { + // An entry always carries a label, so it always implies a pleasantness + // value and both write routes derive one from a null. A clear button here + // would be a control the server undoes on the way to the database. + const fieldsSrc = readFileSync( + resolve(__dirname, "../mood-level-a-fields.tsx"), + "utf8", + ); + expect(fieldsSrc).toMatch( + /onClear=\{\s*dimension\.key === "a1"\s*\?\s*undefined/, + ); + }); }); describe("MoodList — rated factor editing", () => { diff --git a/src/components/mood/mood-level-a-fields.tsx b/src/components/mood/mood-level-a-fields.tsx index 8b1f15cda..2269908ee 100644 --- a/src/components/mood/mood-level-a-fields.tsx +++ b/src/components/mood/mood-level-a-fields.tsx @@ -169,10 +169,18 @@ export function MoodLevelASection({ }) } clearLabel={t("mood.dimensionClear")} - onClear={() => { - if (dimension.key === "a1") onA1Touched?.(); - onChange({ ...value, [dimension.key]: null }); - }} + // Pleasantness has no clear control, and that is the same rule + // the server keeps: an entry always carries a five-point label, + // so it always implies a pleasantness value, and there is no + // state in which the field is honestly unanswered. Offering a + // control that the write path would immediately undo would be a + // button that quietly does nothing. The other four are genuinely + // optional and keep theirs. + onClear={ + dimension.key === "a1" + ? undefined + : () => onChange({ ...value, [dimension.key]: null }) + } onValueChange={(next) => { if (dimension.key === "a1") onA1Touched?.(); onChange({ ...value, [dimension.key]: next }); diff --git a/tests/integration/mood-writers-agree.test.ts b/tests/integration/mood-writers-agree.test.ts index de6f7d61f..e1b032423 100644 --- a/tests/integration/mood-writers-agree.test.ts +++ b/tests/integration/mood-writers-agree.test.ts @@ -429,4 +429,45 @@ describe("mood writers agree on the derived level-A value", () => { // An explicit null takes back an answer. expect(row.stressA2).toBeNull(); }); + + it("will not empty pleasantness, on either route", async () => { + await createUserSession(); + const { POST: postSingle } = await import("@/app/api/mood-entries/route"); + const { PUT } = await import("@/app/api/mood-entries/[id]/route"); + + // An entry always carries a five-point label, so it always implies a + // pleasantness value. A null asks for one that cannot exist, and both + // routes answer it the same way: derive from the label. The capture + // surfaces therefore offer no clear control for this one field, which is + // the UI half of the same rule. + const created = await postSingle( + jsonRequest("/api/mood-entries", { + mood: "SUPER_GUT", + moodLoggedAt: "2026-05-16T14:00:00.000Z", + a1: null, + }), + ); + expect(created.status).toBe(201); + const createdJson = (await created.json()) as { + data: { id: string; a1: number }; + }; + expect(createdJson.data.a1).toBe(9); + const id = createdJson.data.id; + + const cleared = await PUT( + new NextRequest(`http://localhost/api/mood-entries/${id}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ a1: null }), + }), + { params: Promise.resolve({ id }) }, + ); + expect(cleared.status).toBe(200); + + const row = await getPrismaClient().moodEntry.findUniqueOrThrow({ + where: { id }, + }); + // Derived from the entry's own stored label, not emptied. + expect(row.moodA1).toBe(9); + }); });