fix(mood): stop offering to clear a value that cannot be cleared

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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 02:55:08 +02:00
1 parent aa56246ba2
commit 0a39d50b05
4 files changed
+75 -5

No files matched your search

+9 -1
View File
@@ -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;
@@ -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<LevelAState>(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", () => {
+12 -4
View File
@@ -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 });
@@ -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);
});
});