From 3dd79ec734900edcd38ee4b696ec76218a77484a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 03:03:38 +0200 Subject: [PATCH] fix(mood): one spelling on the wire, one day-key helper, one written decision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small things the audit named together. The mood reads spread the whole row, so adding the wire keys shipped every level-A value twice — once as the column name and once as the key the write path accepts. A client would have picked one and been stuck with it. The column spelling is stripped now, and the create response's key set is asserted so it stays stripped. The published contract is unaffected either way; these routes are not in it. The dimension series carried its own copy of the day-key subtraction that the freshness module already exports. One copy now. And the reason a stale dimension ships with a notice instead of being dropped the way the status surfaces drop one is written at the builder: a one-line card has nothing left to say about a value it cannot narrate, while an open conversation can say when the value is from — and silence there would leave no way to tell an unanswered dimension from one nobody has looked at in a week. --- src/app/api/mood-entries/[id]/route.ts | 8 ++-- src/app/api/mood-entries/route.ts | 11 +++-- src/lib/insights/features.ts | 13 ++++++ src/lib/insights/mood-dimension-series.ts | 16 ++----- src/lib/mood/level-a.ts | 44 ++++++++++++++++---- tests/integration/mood-writers-agree.test.ts | 25 +++++++++-- 6 files changed, 83 insertions(+), 34 deletions(-) diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index e20be463f..c3f09b24c 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -15,7 +15,7 @@ import { Prisma } from "@/generated/prisma/client"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; -import { deriveA1, levelAForWire } from "@/lib/mood/level-a"; +import { deriveA1, shapeLevelA } from "@/lib/mood/level-a"; import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher"; import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; @@ -59,9 +59,8 @@ export const GET = apiHandler( }); return apiSuccess({ - ...shapeMoodNote(entry), // v1.37 — level-A values under the keys the write path takes. - ...levelAForWire(entry), + ...shapeLevelA(shapeMoodNote(entry)), tags: parseTags(entry.tags), }); }, @@ -321,9 +320,8 @@ export const PUT = apiHandler( } return apiSuccess({ - ...entry, // v1.37 — level-A values under the keys the write path takes. - ...levelAForWire(entry), + ...shapeLevelA(entry), tags: parseTags(entry.tags), // v1.8.5 — surface the persisted structured-tag keys so a client // hydrating from the update response renders the tag set without a diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 3e27d018e..cc3ef97c8 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -24,7 +24,7 @@ import { annotate } from "@/lib/logging/context"; import { withIdempotency } from "@/lib/idempotency"; import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher"; import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; -import { levelAForWire, levelAForWrite } from "@/lib/mood/level-a"; +import { levelAForWrite, shapeLevelA } from "@/lib/mood/level-a"; import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; import { @@ -126,11 +126,11 @@ export const GET = apiHandler(async (request: NextRequest) => { const entriesWithParsedTags = entries.map(({ tagLinks, ...e }) => ({ // v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext. - ...shapeMoodNote(e), // v1.37 — the five level-A values under the keys the create path takes, // so the edit form reads back what it wrote instead of mapping column - // names to wire names by hand. - ...levelAForWire(e), + // names to wire names by hand. The column spelling is stripped, not + // joined: one number under two names is a choice nobody made. + ...shapeLevelA(shapeMoodNote(e)), tags: parseTags(e.tags), // v1.8.5 — flat list of binary structured-tag keys attached to the // entry (rated factors are surfaced separately below). @@ -402,9 +402,8 @@ async function postMoodEntry(request: NextRequest) { return apiSuccess( { // v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext. - ...shapeMoodNote(entry), // v1.37 — level-A values under their wire keys (see the list read). - ...levelAForWire(entry), + ...shapeLevelA(shapeMoodNote(entry)), tags: parseTags(entry.tags), // v1.8.5 — surface the persisted structured-tag keys so a client // hydrating from the create response renders the tag set without a diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index a1f5c0bf3..9fc67670f 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -117,6 +117,19 @@ export interface MoodDimensionFeature { * * Only dimensions somebody answered are returned. An empty block would invite * a sentence about a question that was never asked. + * + * A STALE dimension is still returned, carrying its age and an instruction, + * rather than filtered out the way the health-status surfaces drop a reading + * too old to describe. That is a deliberate difference and worth the sentence: + * those surfaces write one line about one metric, so a value they cannot + * narrate has nothing left to contribute and dropping it is the whole fix. + * This block is context for an open conversation, where "your stress was 9 on + * the third, and you have not answered it since" is a useful and true thing to + * say — and where silence is worse than history, because the reader then has + * no way to tell an unanswered dimension from one nobody has looked at in a + * week. The safety therefore rides on `current` plus `staleNotice` rather than + * on absence, and both are asserted at the shipped composition in + * `tests/integration/mood-dimension-features.test.ts`. */ async function buildMoodDimensionFeatures( userId: string, diff --git a/src/lib/insights/mood-dimension-series.ts b/src/lib/insights/mood-dimension-series.ts index 801205dd2..841741d69 100644 --- a/src/lib/insights/mood-dimension-series.ts +++ b/src/lib/insights/mood-dimension-series.ts @@ -15,10 +15,9 @@ * gives on a genuinely average day. */ import { MOOD_DIMENSIONS, type MoodDimensionKey } from "@/lib/mood/dimensions"; +import { dayKeyAgeInDays } from "@/lib/insights/measurement-freshness"; import { round } from "@/lib/insights/status-shared"; -const MS_PER_DAY = 24 * 60 * 60 * 1000; - /** Windows the trend reports, in days. Concept §10.1. */ export const MOOD_DIMENSION_WINDOWS = [7, 30, 90] as const; export type MoodDimensionWindow = (typeof MOOD_DIMENSION_WINDOWS)[number]; @@ -69,13 +68,6 @@ function meanOf(values: number[]): number | null { return round(sum / values.length, 1); } -function dayKeyDaysAgo(dayKey: string, todayKey: string): number | null { - const then = Date.parse(`${dayKey}T00:00:00.000Z`); - const today = Date.parse(`${todayKey}T00:00:00.000Z`); - if (!Number.isFinite(then) || !Number.isFinite(today)) return null; - return Math.round((today - then) / MS_PER_DAY); -} - /** * Summarise each of the five dimensions over the rows given. * @@ -96,7 +88,7 @@ export function computeMoodDimensionSeries( for (const row of rows) { const value = row[dimension.key]; if (typeof value !== "number" || !Number.isFinite(value)) continue; - const age = dayKeyDaysAgo(row.date, todayKey); + const age = dayKeyAgeInDays(row.date, todayKey); if (age === null || age < 0 || age >= longest) continue; const bucket = byDay.get(row.date); if (bucket) bucket.push(value); @@ -116,7 +108,7 @@ export function computeMoodDimensionSeries( const windowMean = (days: number): number | null => { const values: number[] = []; for (const point of series) { - const age = dayKeyDaysAgo(point.date, todayKey); + const age = dayKeyAgeInDays(point.date, todayKey); if (age === null || age >= days) continue; values.push(point.value); } @@ -139,7 +131,7 @@ export function computeMoodDimensionSeries( avg90: windowMean(90), latest: newest?.value ?? null, latestDate: newest?.date ?? null, - newestDaysAgo: newest ? dayKeyDaysAgo(newest.date, todayKey) : null, + newestDaysAgo: newest ? dayKeyAgeInDays(newest.date, todayKey) : null, series, }; }); diff --git a/src/lib/mood/level-a.ts b/src/lib/mood/level-a.ts index 283050295..cb1ae02a1 100644 --- a/src/lib/mood/level-a.ts +++ b/src/lib/mood/level-a.ts @@ -98,6 +98,15 @@ export interface LevelAWire { a5: number | null; } +/** The five columns as they sit on a `MoodEntry` row. */ +export interface LevelARow { + moodA1: number | null; + stressA2: number | null; + energyA3: number | null; + connectionA4: number | null; + stabilityA5: number | null; +} + /** * Turn a stored row's five columns into the wire keys the client sends. * @@ -105,13 +114,7 @@ export interface LevelAWire { * form mapping the pair by hand, and one of them would eventually map it * wrong. The names match in both directions. */ -export function levelAForWire(row: { - moodA1: number | null; - stressA2: number | null; - energyA3: number | null; - connectionA4: number | null; - stabilityA5: number | null; -}): LevelAWire { +export function levelAForWire(row: LevelARow): LevelAWire { return { a1: row.moodA1, a2: row.stressA2, @@ -120,3 +123,30 @@ export function levelAForWire(row: { a5: row.stabilityA5, }; } + +/** + * A row shaped for a response: the five columns replaced by their wire keys, + * not joined by them. + * + * The mood routes spread the whole row into their responses, so simply adding + * the wire keys left every entry carrying the same five values twice under two + * spellings. Two names for one number is a decision nobody made, and the one a + * client picks is the one it gets stuck with. This strips the column spelling + * and keeps the wire one, which is the spelling the write path accepts. + */ +export function shapeLevelA( + row: T, +): Omit & LevelAWire { + const { moodA1, stressA2, energyA3, connectionA4, stabilityA5, ...rest } = + row; + return { + ...rest, + ...levelAForWire({ + moodA1, + stressA2, + energyA3, + connectionA4, + stabilityA5, + }), + }; +} diff --git a/tests/integration/mood-writers-agree.test.ts b/tests/integration/mood-writers-agree.test.ts index e1b032423..5978d7311 100644 --- a/tests/integration/mood-writers-agree.test.ts +++ b/tests/integration/mood-writers-agree.test.ts @@ -103,9 +103,11 @@ describe("mood writers agree on the derived level-A value", () => { ); expect(single.status).toBe(201); - // 2 — bulk backfill. The request shape carries no level-A field; the - // server derives it, which is the whole point of this phase being - // invisible on the wire. + // 2 — bulk backfill. The request shape carries no level-A field and the + // server derives it, which is why the published contract does not move: + // this batch path is the one in `docs/api/openapi.yaml`, and it is + // byte-identical. The unpublished single routes DO answer five new keys + // on their reads, which is additive and which the client ignores. const bulk = await postBulk( jsonRequest("/api/mood-entries/bulk", { entries: [ @@ -370,12 +372,27 @@ describe("mood writers agree on the derived level-A value", () => { ); expect(res.status).toBe(201); const json = (await res.json()) as { - data: { a1: number; a2: number; a3: number; a4: null; a5: null }; + data: Record & { + a1: number; + a2: number; + a3: number; + a4: null; + a5: null; + }; }; // The response echoes the values under the keys the request used. expect(json.data.a1).toBe(4); expect(json.data.a2).toBe(9); expect(json.data.a5).toBeNull(); + // And under those keys ONLY. The routes spread the whole row, so simply + // adding the wire keys shipped every value twice under two spellings; a + // client would then have picked one and been stuck with it. + const keys = Object.keys(json.data as Record); + expect(keys).not.toContain("moodA1"); + expect(keys).not.toContain("stressA2"); + expect(keys).not.toContain("energyA3"); + expect(keys).not.toContain("connectionA4"); + expect(keys).not.toContain("stabilityA5"); const row = await getPrismaClient().moodEntry.findFirstOrThrow({ where: { userId: USER },