From a6bb4f6577131e3441643a5be32114f9dc58749e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 01:43:32 +0200 Subject: [PATCH] feat(mood): give the assistant all five values, each with its own age - the level-A blocks ride the mood aggregate the coach and the briefing already share, so the ages are fixed once in the data preparation rather than once per sentence - every block carries its newest value, the day it belongs to, how old it is and whether that age still backs a present-tense claim, through the same freshness rule the vitals surfaces use - a stale dimension carries the instruction in words as well as in a flag, because that is the sentence this exists to prevent - the 1-5 scale line is untouched; the new axis describes itself, with the anchors resolved from the bundle the person read - the trend page and the assistant run the same calculator, so a number in a sentence cannot disagree with the number on the chart --- .../insights/__tests__/mood-staleness.test.ts | 111 ++++++++++++++ src/lib/insights/features.ts | 135 ++++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 src/lib/insights/__tests__/mood-staleness.test.ts diff --git a/src/lib/insights/__tests__/mood-staleness.test.ts b/src/lib/insights/__tests__/mood-staleness.test.ts new file mode 100644 index 000000000..63506b62e --- /dev/null +++ b/src/lib/insights/__tests__/mood-staleness.test.ts @@ -0,0 +1,111 @@ +/** + * v1.37 — per-dimension staleness on the level-A blocks. + * + * The failure this exists to prevent has already happened once on the vitals + * surfaces: a value was handed to the narrator with no notion of when it was + * taken, and last week's reading came back as this morning's. The five + * dimensions make that failure easier, not harder, because their ages differ + * — somebody taps a face every morning and opens the detail sliders once a + * week, so pleasantness is fresh while stress is six days old. + * + * The rule under test: every dimension carries its own age, and `current` + * answers "may this back a present-tense sentence" through the one shared + * freshness rule rather than through a comparison written here. + */ +import { describe, expect, it } from "vitest"; + +import { computeMoodDimensionSeries } from "@/lib/insights/mood-dimension-series"; +import { + TODAY_CLAIM_MAX_AGE_DAYS, + isCurrentForTodayClaim, +} from "@/lib/insights/measurement-freshness"; + +const TODAY = "2026-08-08"; + +function dayKey(daysAgo: number): string { + const d = new Date(Date.parse(`${TODAY}T00:00:00.000Z`)); + d.setUTCDate(d.getUTCDate() - daysAgo); + return d.toISOString().slice(0, 10); +} + +/** The shape `buildMoodDimensionFeatures` derives, without a database. */ +function blocksFor( + rows: Array<{ daysAgo: number; a1?: number; a2?: number }>, +): Array<{ + key: string; + latest: number | null; + newestDaysAgo: number | null; + current: boolean; +}> { + const summaries = computeMoodDimensionSeries( + rows.map((r) => ({ + date: dayKey(r.daysAgo), + a1: r.a1 ?? null, + a2: r.a2 ?? null, + a3: null, + a4: null, + a5: null, + })), + TODAY, + ); + return summaries + .filter((s) => s.present) + .map((s) => ({ + key: s.key, + latest: s.latest, + newestDaysAgo: s.newestDaysAgo, + current: isCurrentForTodayClaim(s.newestDaysAgo), + })); +} + +describe("level-A staleness", () => { + it("calls this morning's pleasantness current and a five-day-old stress stale", () => { + // The fixture the whole file is about: one entry today with a mood, one + // five days ago that also answered stress. + const blocks = blocksFor([ + { daysAgo: 0, a1: 8 }, + { daysAgo: 5, a1: 4, a2: 9 }, + ]); + + const a1 = blocks.find((b) => b.key === "a1")!; + const a2 = blocks.find((b) => b.key === "a2")!; + + expect(a1.newestDaysAgo).toBe(0); + expect(a1.current).toBe(true); + + expect(a2.latest).toBe(9); + expect(a2.newestDaysAgo).toBe(5); + // The claim under test: nothing may say "your stress is high today" from + // this row. It is history and is stated with its date. + expect(a2.current).toBe(false); + }); + + it("never carries a latest without an age beside it", () => { + // The structural half of the fix. A `latest` alone is exactly what + // produced the wrong sentence, so the shape is asserted, not the value. + for (const block of blocksFor([{ daysAgo: 2, a1: 5, a2: 5 }])) { + expect(block.latest).not.toBeNull(); + expect(block.newestDaysAgo).not.toBeNull(); + expect(typeof block.current).toBe("boolean"); + } + }); + + it("treats an unknown age as stale rather than as fresh", () => { + // A missing age is not a fresh one. This is the shared rule's own + // behaviour and is asserted here so a local reimplementation that + // defaulted to `true` would be caught. + expect(isCurrentForTodayClaim(null)).toBe(false); + expect(isCurrentForTodayClaim(undefined)).toBe(false); + }); + + it("puts the boundary exactly where the shared rule puts it", () => { + // Yesterday still backs a present claim; the day before does not. Read + // off the exported constant rather than the literal 1, so a change to the + // window moves this test with it instead of stranding it. + const fresh = blocksFor([{ daysAgo: TODAY_CLAIM_MAX_AGE_DAYS, a1: 6 }]); + expect(fresh[0].current).toBe(true); + + const stale = blocksFor([{ daysAgo: TODAY_CLAIM_MAX_AGE_DAYS + 1, a1: 6 }]); + expect(stale[0].current).toBe(false); + }); +}); diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index 7157651f5..b68197caf 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -13,6 +13,11 @@ import { SCHEDULE_COMPLIANCE_SELECT, } from "@/lib/analytics/compliance"; import { resolveUserTimezone } from "@/lib/tz/resolver"; +import { userDayKey } from "@/lib/tz/format"; +import { isCurrentForTodayClaim } from "@/lib/insights/measurement-freshness"; +import { computeMoodDimensionSeries } from "@/lib/insights/mood-dimension-series"; +import { MOOD_DIMENSIONS } from "@/lib/mood/dimensions"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; import { reconstructSleepNights, type SleepStageRow, @@ -64,6 +69,124 @@ interface DataCoverage { newestDaysAgo: number; } +/** + * One level-A dimension as the model reads it. + * + * `latest` never travels without `newestDaysAgo` and `current` beside it. A + * value with no age is exactly what produced a reading from last week stated + * as this morning's, so the age is part of the shape rather than something a + * caller is trusted to look up. + */ +export interface MoodDimensionFeature { + key: string; + /** The wording behind the numbers, so a 7 is not read as 7 out of 5. */ + scale: string; + /** True when a higher number means a worse day — stress, and only stress. */ + inverse: boolean; + avg7: number | null; + avg30: number | null; + latest: number | null; + /** The local day the newest value belongs to. */ + latestDate: string | null; + /** Whole days between that day and today. */ + newestDaysAgo: number | null; + /** + * Whether that value may back a present-tense claim. False for an absent + * age too: a missing age is not a fresh one. + */ + current: boolean; + /** + * Present only when `current` is false, and says in words what the flag + * says in a boolean. The model reads prose more reliably than it reads a + * field name, and a stale dimension narrated as today's is the one failure + * this block exists to prevent. + */ + staleNotice?: string; + count: number; +} + +/** + * The level-A blocks for the model, read once over a bounded window. + * + * The rollup tier caches the mean of `score` and nothing else, so this is its + * own read rather than a widened rollup: a cached tier for four columns that + * may all be NULL would cost a migration to answer a question the live read + * answers in one indexed range scan. If a surface measurably slows, the tier + * grows then, as a read-swap with a fallback on a coverage miss — never as a + * second engine running beside this one. + * + * Only dimensions somebody answered are returned. An empty block would invite + * a sentence about a question that was never asked. + */ +async function buildMoodDimensionFeatures( + userId: string, +): Promise { + const tz = await resolveUserTimezone(userId); + const { t } = getServerTranslator("en"); + const since = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); + const rows = await prisma.moodEntry.findMany({ + where: { userId, deletedAt: null, moodLoggedAt: { gte: since } }, + orderBy: { moodLoggedAt: "desc" }, + take: 2000, + select: { + date: true, + moodA1: true, + stressA2: true, + energyA3: true, + connectionA4: true, + stabilityA5: true, + }, + }); + if (rows.length === 0) return undefined; + + // The same calculator the trend page uses, so the number in a Coach + // sentence and the number on the chart cannot disagree. + const summaries = computeMoodDimensionSeries( + rows.map((row) => ({ + date: row.date, + a1: row.moodA1, + a2: row.stressA2, + a3: row.energyA3, + a4: row.connectionA4, + a5: row.stabilityA5, + })), + userDayKey(new Date(), tz), + ); + + const blocks = summaries + .filter((summary) => summary.present) + .map((summary) => { + const dimension = MOOD_DIMENSIONS.find((d) => d.key === summary.key)!; + const current = isCurrentForTodayClaim(summary.newestDaysAgo); + return { + key: summary.key, + // The anchors are resolved through the bundle rather than repeated + // here: the model must read the same words the person read when they + // moved the slider, and a second copy of that wording would drift + // from the one on screen the first time either was edited. English, + // like the legacy `scale` line beside it. + scale: `${t(dimension.labelKey)}, 0-10: 0 = ${t(dimension.lowAnchorKey)}, 10 = ${t(dimension.highAnchorKey)}`, + inverse: summary.inverse, + avg7: summary.avg7, + avg30: summary.avg30, + latest: summary.latest, + latestDate: summary.latestDate, + newestDaysAgo: summary.newestDaysAgo, + current, + ...(current + ? {} + : { + staleNotice: summary.latestDate + ? `Last answered on ${summary.latestDate}. State it as history with that date; do not describe it as today.` + : "No dated value. Do not describe this dimension as current.", + }), + count: summary.count, + }; + }); + + return blocks.length > 0 ? blocks : undefined; +} + export interface AggregatedFeatures { weight?: { latest: number; @@ -161,6 +284,17 @@ export interface AggregatedFeatures { trend30: "improving" | "declining" | "stable" | null; totalEntries: number; coverage: DataCoverage; + /** + * v1.37 — the five level-A self-state values, one block each, and only for + * the dimensions somebody actually answered. `scale` above describes the + * legacy 1-5 axis and is handed to the model verbatim, so it is left + * exactly as it was; these carry their own scale line beside it. + * + * Every block states its own age, because the ages differ: stress can be + * six days old while pleasantness was answered this morning, and a model + * given one age for the pair narrates the stale one as today's. + */ + dimensions?: MoodDimensionFeature[]; }; sleep?: { avg7: number | null; @@ -1055,6 +1189,7 @@ export async function extractFeatures( latest: moodLatestScore, trend30, totalEntries: moodTotalEntries, + dimensions: await buildMoodDimensionFeatures(userId), coverage: { count: moodTotalEntries, spanDays,