mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
fix(mood): a re-post no longer blanks the values it says nothing about
The single create's upsert restated all five columns on its update arm, so a client re-posting an entry with only a label and a timestamp — the phone, whose build has no sliders — cleared the stress, energy, connectedness and stability somebody had answered on the web. The batch and import paths already preserved them, so the two disagreed. One answer now, and it is preserve: absence in a request means nothing to say, an explicit null takes an answer back, and pleasantness still follows the label because the request always carries one. The create schema accepts the null that makes clearing expressible. A re-post that omits a dimension is asserted to leave it standing, and that assertion has been watched to fail against the old arm.
This commit is contained in:
1 parent
5d98a98c21
commit
b9a6eeb1d7
4 files changed
+156
-47
No files matched your search
@@ -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),
|
||||
|
||||
+42
-20
@@ -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 }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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:<text>"]` workaround. Capped at 500 chars so the
|
||||
// Coach evidence shelf renders cleanly without truncating chips.
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user