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 },