diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3e34a56e6..160cf6350 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -59415,7 +59415,16 @@ components: description: Robust upper edge. latest: type: number - description: Today's latest reading, same units. + description: The latest reading, same units. Today's only when `latestIsToday` is true; otherwise it is from + `latestDate`. + latestDate: + type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + description: Calendar day of the latest reading in the account's time zone, `YYYY-MM-DD`. + latestIsToday: + type: boolean + description: True when `latestDate` is today in the account's time zone. When false, a client names the reading's date + instead of calling it today's. placement: type: string enum: @@ -59438,6 +59447,8 @@ components: - low - high - latest + - latestDate + - latestIsToday - placement - sampleDays additionalProperties: false diff --git a/messages/de.json b/messages/de.json index 20fedf333..18ab0b8cd 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3035,6 +3035,12 @@ "steadyWithin": "Deine Werte liegen meist bei {low} {unit}; heute passt {value} dazu.", "steadyAbove": "Deine Werte liegen meist bei {low} {unit}; heute liegt {value} darüber.", "steadyBelow": "Deine Werte liegen meist bei {low} {unit}; heute liegt {value} darunter.", + "latestWithin": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; dein letzter Wert vom {date} lag mit {value} darin.", + "latestAbove": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; dein letzter Wert vom {date} lag mit {value} darüber.", + "latestBelow": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; dein letzter Wert vom {date} lag mit {value} darunter.", + "steadyLatestWithin": "Deine Werte liegen meist bei {low} {unit}; dein letzter Wert vom {date} lag mit {value} genau dort.", + "steadyLatestAbove": "Deine Werte liegen meist bei {low} {unit}; dein letzter Wert vom {date} lag mit {value} darüber.", + "steadyLatestBelow": "Deine Werte liegen meist bei {low} {unit}; dein letzter Wert vom {date} lag mit {value} darunter.", "insufficient": "Lerne noch deinen üblichen Bereich — protokolliere noch ein paar Tage weiter.", "driver": "{note}" }, diff --git a/messages/en.json b/messages/en.json index da3488b46..b762e5b47 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3035,6 +3035,12 @@ "steadyWithin": "Your readings usually sit at {low} {unit}; today's {value} matches.", "steadyAbove": "Your readings usually sit at {low} {unit}; today's {value} is above that.", "steadyBelow": "Your readings usually sit at {low} {unit}; today's {value} is below that.", + "latestWithin": "Your usual range is {low}–{high} {unit}; your latest reading, {date}, was {value}, within it.", + "latestAbove": "Your usual range is {low}–{high} {unit}; your latest reading, {date}, was {value}, above it.", + "latestBelow": "Your usual range is {low}–{high} {unit}; your latest reading, {date}, was {value}, below it.", + "steadyLatestWithin": "Your readings usually sit at {low} {unit}; your latest reading, {date}, was {value} and matches.", + "steadyLatestAbove": "Your readings usually sit at {low} {unit}; your latest reading, {date}, was {value}, above that.", + "steadyLatestBelow": "Your readings usually sit at {low} {unit}; your latest reading, {date}, was {value}, below that.", "insufficient": "Still learning your usual range — keep logging for a few more days.", "driver": "{note}" }, diff --git a/messages/es.json b/messages/es.json index 520167f72..6a25701af 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3035,6 +3035,12 @@ "steadyWithin": "Tus valores suelen estar en {low} {unit}; hoy {value} coincide.", "steadyAbove": "Tus valores suelen estar en {low} {unit}; hoy {value} está por encima.", "steadyBelow": "Tus valores suelen estar en {low} {unit}; hoy {value} está por debajo.", + "latestWithin": "Tu rango habitual es {low}–{high} {unit}; tu última medición, del {date}, fue {value}, dentro de él.", + "latestAbove": "Tu rango habitual es {low}–{high} {unit}; tu última medición, del {date}, fue {value}, por encima.", + "latestBelow": "Tu rango habitual es {low}–{high} {unit}; tu última medición, del {date}, fue {value}, por debajo.", + "steadyLatestWithin": "Tus valores suelen estar en {low} {unit}; tu última medición, del {date}, fue {value} y coincide.", + "steadyLatestAbove": "Tus valores suelen estar en {low} {unit}; tu última medición, del {date}, fue {value}, por encima.", + "steadyLatestBelow": "Tus valores suelen estar en {low} {unit}; tu última medición, del {date}, fue {value}, por debajo.", "insufficient": "Todavía estoy aprendiendo tu rango habitual — sigue registrando unos días más.", "driver": "{note}" }, diff --git a/messages/fr.json b/messages/fr.json index d787f0c9c..855305c28 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3035,6 +3035,12 @@ "steadyWithin": "Tes valeurs sont habituellement à {low} {unit} ; aujourd'hui, {value} correspond.", "steadyAbove": "Tes valeurs sont habituellement à {low} {unit} ; aujourd'hui, {value} se situe au-dessus.", "steadyBelow": "Tes valeurs sont habituellement à {low} {unit} ; aujourd'hui, {value} se situe en dessous.", + "latestWithin": "Ta plage habituelle est de {low}–{high} {unit} ; ta dernière mesure, du {date}, était de {value}, dans cette plage.", + "latestAbove": "Ta plage habituelle est de {low}–{high} {unit} ; ta dernière mesure, du {date}, était de {value}, au-dessus.", + "latestBelow": "Ta plage habituelle est de {low}–{high} {unit} ; ta dernière mesure, du {date}, était de {value}, en dessous.", + "steadyLatestWithin": "Tes valeurs sont habituellement à {low} {unit} ; ta dernière mesure, du {date}, était de {value}, comme d'habitude.", + "steadyLatestAbove": "Tes valeurs sont habituellement à {low} {unit} ; ta dernière mesure, du {date}, était de {value}, au-dessus.", + "steadyLatestBelow": "Tes valeurs sont habituellement à {low} {unit} ; ta dernière mesure, du {date}, était de {value}, en dessous.", "insufficient": "J'apprends encore ta plage habituelle — continue à enregistrer quelques jours de plus.", "driver": "{note}" }, diff --git a/messages/it.json b/messages/it.json index e9910141d..b56cee77e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3035,6 +3035,12 @@ "steadyWithin": "I tuoi valori sono di solito {low} {unit}; oggi {value} corrisponde.", "steadyAbove": "I tuoi valori sono di solito {low} {unit}; oggi {value} è al di sopra.", "steadyBelow": "I tuoi valori sono di solito {low} {unit}; oggi {value} è al di sotto.", + "latestWithin": "Il tuo intervallo abituale è {low}–{high} {unit}; la tua ultima misurazione, del {date}, era {value}, al suo interno.", + "latestAbove": "Il tuo intervallo abituale è {low}–{high} {unit}; la tua ultima misurazione, del {date}, era {value}, al di sopra.", + "latestBelow": "Il tuo intervallo abituale è {low}–{high} {unit}; la tua ultima misurazione, del {date}, era {value}, al di sotto.", + "steadyLatestWithin": "I tuoi valori sono di solito {low} {unit}; la tua ultima misurazione, del {date}, era {value}, in linea.", + "steadyLatestAbove": "I tuoi valori sono di solito {low} {unit}; la tua ultima misurazione, del {date}, era {value}, al di sopra.", + "steadyLatestBelow": "I tuoi valori sono di solito {low} {unit}; la tua ultima misurazione, del {date}, era {value}, al di sotto.", "insufficient": "Sto ancora imparando il tuo intervallo abituale — continua a registrare per qualche giorno in più.", "driver": "{note}" }, diff --git a/messages/ko.json b/messages/ko.json index a1c5a4aeb..44689fd9c 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -3035,6 +3035,12 @@ "steadyWithin": "평소 값: {low} {unit}. 오늘 값 {value} — 평소와 같아요.", "steadyAbove": "평소 값: {low} {unit}. 오늘 값 {value} — 평소보다 높아요.", "steadyBelow": "평소 값: {low} {unit}. 오늘 값 {value} — 평소보다 낮아요.", + "latestWithin": "평소 범위: {low}–{high} {unit}. 마지막 측정({date}) 값 {value} — 범위 안이었어요.", + "latestAbove": "평소 범위: {low}–{high} {unit}. 마지막 측정({date}) 값 {value} — 범위보다 높았어요.", + "latestBelow": "평소 범위: {low}–{high} {unit}. 마지막 측정({date}) 값 {value} — 범위 아래였어요.", + "steadyLatestWithin": "평소 값: {low} {unit}. 마지막 측정({date}) 값 {value} — 평소와 같았어요.", + "steadyLatestAbove": "평소 값: {low} {unit}. 마지막 측정({date}) 값 {value} — 평소보다 높았어요.", + "steadyLatestBelow": "평소 값: {low} {unit}. 마지막 측정({date}) 값 {value} — 평소보다 낮았어요.", "insufficient": "아직 평소 범위를 배우는 중이에요 — 며칠 더 기록해 주세요.", "driver": "{note}" }, diff --git a/messages/pl.json b/messages/pl.json index ab399a19c..ffdf2715a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3035,6 +3035,12 @@ "steadyWithin": "Twoje pomiary zwykle wynoszą {low} {unit}; dzisiejsza wartość {value} jest taka sama.", "steadyAbove": "Twoje pomiary zwykle wynoszą {low} {unit}; dzisiejsza wartość {value} jest wyższa.", "steadyBelow": "Twoje pomiary zwykle wynoszą {low} {unit}; dzisiejsza wartość {value} jest niższa.", + "latestWithin": "Twój zwykły zakres to {low}–{high} {unit}; ostatni pomiar, z {date}, wyniósł {value} i mieścił się w nim.", + "latestAbove": "Twój zwykły zakres to {low}–{high} {unit}; ostatni pomiar, z {date}, wyniósł {value} i był powyżej niego.", + "latestBelow": "Twój zwykły zakres to {low}–{high} {unit}; ostatni pomiar, z {date}, wyniósł {value} i był poniżej niego.", + "steadyLatestWithin": "Twoje pomiary zwykle wynoszą {low} {unit}; ostatni pomiar, z {date}, wyniósł {value} i był taki sam.", + "steadyLatestAbove": "Twoje pomiary zwykle wynoszą {low} {unit}; ostatni pomiar, z {date}, wyniósł {value} i był wyższy.", + "steadyLatestBelow": "Twoje pomiary zwykle wynoszą {low} {unit}; ostatni pomiar, z {date}, wyniósł {value} i był niższy.", "insufficient": "Wciąż poznaję twój zwykły zakres — rejestruj jeszcze przez kilka dni.", "driver": "{note}" }, diff --git a/src/components/insights/derived/__tests__/coach-read-strip-latest-date.test.tsx b/src/components/insights/derived/__tests__/coach-read-strip-latest-date.test.tsx new file mode 100644 index 000000000..b10d567eb --- /dev/null +++ b/src/components/insights/derived/__tests__/coach-read-strip-latest-date.test.tsx @@ -0,0 +1,103 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { Locale } from "@/lib/i18n/config"; +import { queryKeys } from "@/lib/query-keys"; +import type { CoachReadStripData } from "@/lib/insights/derived/coach-read-shape"; + +/** + * The strip used to call the newest reading "today's" on every metric page, + * even when it was days old. The server now says which day the reading is + * from and whether that is today; the sentence names the date otherwise. + */ + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { timezone: "UTC", dateOfBirth: null, gender: null }, + isAuthenticated: true, + }), +})); + +vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true })); + +import { CoachReadStrip } from "../coach-read-strip"; + +const METRIC = "RESTING_HEART_RATE"; + +function render(data: CoachReadStripData, locale: Locale = "en"): string { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + client.setQueryData(queryKeys.insightsCoachRead(METRIC, locale), data); + return renderToStaticMarkup( + + + + + , + ); +} + +function strip( + latestIsToday: boolean, + placement: "within" | "above" | "below" = "within", + band: { low: number; high: number } = { low: 55, high: 62 }, +): CoachReadStripData { + return { + baseline: { + ...band, + latest: 58, + latestDate: latestIsToday ? "2026-10-06" : "2026-10-01", + latestIsToday, + placement, + sampleDays: 20, + }, + learning: false, + driver: null, + }; +} + +describe("CoachReadStrip — the latest reading's day", () => { + it("calls a reading from today today's", () => { + const markup = render(strip(true)); + expect(markup).toContain("today's 58 sits within it"); + expect(markup).not.toContain("latest reading"); + }); + + it("names the date of an older reading instead of calling it today's", () => { + const markup = render(strip(false)); + expect(markup).not.toContain("today"); + expect(markup).toContain( + "your latest reading, October 1, was 58, within it", + ); + expect(render(strip(false, "above"))).toContain("was 58, above it"); + expect(render(strip(false, "below"))).toContain("was 58, below it"); + }); + + it("names the date on a steady band too", () => { + const markup = render(strip(false, "within", { low: 58, high: 58 })); + expect(markup).not.toContain("today"); + expect(markup).toContain("your latest reading, October 1, was 58"); + }); + + it("says it in every shipped language", () => { + const expected: Record = { + de: "1. Oktober", + en: "October 1", + es: "1 de octubre", + fr: "1 octobre", + it: "1 ottobre", + ko: "10월 1일", + pl: "1 października", + }; + for (const [locale, date] of Object.entries(expected) as Array< + [Locale, string] + >) { + const markup = render(strip(false), locale); + expect(markup, locale).toContain(date); + expect(markup, locale).toContain("58"); + } + }); +}); diff --git a/src/components/insights/derived/__tests__/coach-read-strip-same-hours.test.tsx b/src/components/insights/derived/__tests__/coach-read-strip-same-hours.test.tsx index 5e5e3184e..2999b1d8d 100644 --- a/src/components/insights/derived/__tests__/coach-read-strip-same-hours.test.tsx +++ b/src/components/insights/derived/__tests__/coach-read-strip-same-hours.test.tsx @@ -51,6 +51,8 @@ function sameHours( latest, placement, sampleDays: 14, + latestDate: "2026-10-06", + latestIsToday: true, basis: "sameHours", }, learning: false, @@ -85,6 +87,8 @@ describe("CoachReadStrip — a glucose day in progress", () => { latest: 132, placement: "within", sampleDays: 30, + latestDate: "2026-10-06", + latestIsToday: true, }, learning: false, driver: null, diff --git a/src/components/insights/derived/__tests__/coach-read-strip-steady-band.test.tsx b/src/components/insights/derived/__tests__/coach-read-strip-steady-band.test.tsx index 9023a1c9f..51eefa416 100644 --- a/src/components/insights/derived/__tests__/coach-read-strip-steady-band.test.tsx +++ b/src/components/insights/derived/__tests__/coach-read-strip-steady-band.test.tsx @@ -48,7 +48,15 @@ function band( placement: "within" | "above" | "below", ): CoachReadStripData { return { - baseline: { low, high, latest, placement, sampleDays: 30 }, + baseline: { + low, + high, + latest, + placement, + sampleDays: 30, + latestDate: "2026-10-06", + latestIsToday: true, + }, learning: false, driver: null, }; diff --git a/src/components/insights/derived/coach-read-strip.tsx b/src/components/insights/derived/coach-read-strip.tsx index ebe37a727..9fc2688e6 100644 --- a/src/components/insights/derived/coach-read-strip.tsx +++ b/src/components/insights/derived/coach-read-strip.tsx @@ -20,7 +20,8 @@ import type { CoachReadStripData } from "@/lib/insights/derived/coach-read-shape * A compact two-line read rendered ABOVE the chart on each metric sub-page: * * 1. own-baseline — "Your usual range is X–Y; today's Z sits within / - * above / below". For a glucose day still in progress the range is the + * above / below", or "your latest reading, , was Z" when the + * newest reading is not from today in the reader's zone. For a glucose day still in progress the range is the * one for this time of day and Z today's mean so far. Below the engine's 7-day history floor it reads * "still learning your range" — never a fabricated band. * 2. one lagged association — the single strongest discovered driver whose @@ -102,17 +103,34 @@ export function CoachReadStrip({ : value * valueScale, ); + // `YYYY-MM-DD` is already the reader's calendar day; formatting it at UTC + // midnight in UTC keeps a second zone conversion from shifting it. + const fmtDay = (day: string | undefined): string => { + const m = day ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(day) : null; + if (!m) return ""; + return new Intl.DateTimeFormat(locale, { + day: "numeric", + month: "long", + timeZone: "UTC", + }).format(new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]))); + }; + const baselineLine = ((): string | null => { if (data.learning || !data.baseline) { return t("insights.coach.readStrip.insufficient"); } - const { low, high, latest, placement, basis } = data.baseline; + const { low, high, latest, placement, basis, latestIsToday } = + data.baseline; // A band whose ends format to the same figure ("61–61 bpm") is a steady // value, not a range; say it as one. const steady = fmt(low) === fmt(high); // A glucose day still in progress is held against the same hours of the // earlier days, so the range is the one for this time of day and today's // figure is its mean so far; the sentence says both. + // A latest reading from an earlier day is not today's: the sentence names + // its date instead (`latestIsToday`, resolved server-side). The same-hours + // basis only exists for a day in progress, so it is always today. + const dated = basis !== "sameHours" && latestIsToday === false; const key = basis === "sameHours" ? placement === "above" @@ -120,22 +138,35 @@ export function CoachReadStrip({ : placement === "below" ? "insights.coach.readStrip.sameHoursBelow" : "insights.coach.readStrip.sameHoursWithin" - : steady - ? placement === "above" - ? "insights.coach.readStrip.steadyAbove" - : placement === "below" - ? "insights.coach.readStrip.steadyBelow" - : "insights.coach.readStrip.steadyWithin" - : placement === "above" - ? "insights.coach.readStrip.baselineAbove" - : placement === "below" - ? "insights.coach.readStrip.baselineBelow" - : "insights.coach.readStrip.baselineWithin"; + : dated + ? steady + ? placement === "above" + ? "insights.coach.readStrip.steadyLatestAbove" + : placement === "below" + ? "insights.coach.readStrip.steadyLatestBelow" + : "insights.coach.readStrip.steadyLatestWithin" + : placement === "above" + ? "insights.coach.readStrip.latestAbove" + : placement === "below" + ? "insights.coach.readStrip.latestBelow" + : "insights.coach.readStrip.latestWithin" + : steady + ? placement === "above" + ? "insights.coach.readStrip.steadyAbove" + : placement === "below" + ? "insights.coach.readStrip.steadyBelow" + : "insights.coach.readStrip.steadyWithin" + : placement === "above" + ? "insights.coach.readStrip.baselineAbove" + : placement === "below" + ? "insights.coach.readStrip.baselineBelow" + : "insights.coach.readStrip.baselineWithin"; return t(key, { low: fmt(low), high: fmt(high), value: fmt(latest), unit: resolvedUnit, + date: fmtDay(data.baseline.latestDate), }); })(); diff --git a/src/lib/ai/prompts/insight-generator.ts b/src/lib/ai/prompts/insight-generator.ts index a17c28c14..8582d31c6 100644 --- a/src/lib/ai/prompts/insight-generator.ts +++ b/src/lib/ai/prompts/insight-generator.ts @@ -297,6 +297,10 @@ gib die obige Verweigerung zurück.`, reader's own timezone. A metric missing from the block was not measured today: never call its last reading "today's", and never build a signal or a "today" sentence from it. + An entry with basis "sameHours" (blood glucose) compares today + so far with the same hours of the earlier days: "latest" is + today's mean so far and avg7 / avg30 are the usual level by this + time of day. Say "by this time of day", never "your average". - keyFindings: 0-5 short rows — the longer-horizon trend list below the signals. Each row has tone ("good" | "watch" | "info"), a headline (≤ 60 chars), a one-sentence detail, an @@ -512,6 +516,11 @@ gib die obige Verweigerung zurück.`, der Zeitzone der Person. Eine Metrik, die im Block fehlt, wurde heute nicht gemessen: nenne ihren letzten Wert nie "heute" und baue daraus weder ein Signal noch einen Satz über heute. + Ein Eintrag mit basis "sameHours" (Blutzucker) vergleicht den + bisherigen Tag mit denselben Stunden der Vortage: "latest" ist der + Mittelwert von heute bis jetzt, avg7 / avg30 sind das übliche + Niveau bis zu dieser Tageszeit. Sag "bis zu dieser Tageszeit", + nie "dein Durchschnitt". - keyFindings: 0-5 kurze Zeilen — die Trend-Liste mit längerem Horizont unter den Signalen. Jede Zeile hat tone ("good" | "watch" | "info"), eine headline (≤ 60 Zeichen), ein detail im Einzelsatz, diff --git a/src/lib/insights/__tests__/signals-of-day-same-hours.test.ts b/src/lib/insights/__tests__/signals-of-day-same-hours.test.ts new file mode 100644 index 000000000..3279dbcad --- /dev/null +++ b/src/lib/insights/__tests__/signals-of-day-same-hours.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; + +import { findUngroundedBriefingNumbers } from "@/lib/ai/briefing-grounding"; + +import { computeSignalsOfDay } from "../signals-of-day"; + +const HOUR = 3_600_000; +const DAY = 24 * HOUR; + +/** + * Glucose climbs after every meal, so a fasting morning reading held against + * the mean of whole days (fasting, lunch, dinner) reads as low every morning. + * Twenty days of a fasting value near 90 at 07:00 local, about 140 after lunch + * and about 150 after dinner; today one fasting reading so far. Berlin is + * UTC+2 in October, so 07:00 local is 05:00 UTC. + */ +const TZ = "Europe/Berlin"; +const TODAY_0700 = Date.UTC(2026, 9, 5, 5, 0); + +function glucose(todayValues: Array<{ value: number; at: number }>) { + const out: Array<{ value: number; measuredAt: Date }> = []; + for (let d = 20; d >= 1; d -= 1) { + const morning = TODAY_0700 - d * DAY; + const wobble = (d % 3) - 1; // -1, 0, +1 + out.push({ value: 90 + wobble, measuredAt: new Date(morning) }); + out.push({ value: 140 + wobble, measuredAt: new Date(morning + 6 * HOUR) }); + out.push({ + value: 150 + wobble, + measuredAt: new Date(morning + 12 * HOUR), + }); + } + for (const t of todayValues) { + out.push({ value: t.value, measuredAt: new Date(t.at) }); + } + return out; +} + +function signalFor( + rows: Array<{ value: number; measuredAt: Date }>, + now: number, +) { + return computeSignalsOfDay( + (type) => (type === "BLOOD_GLUCOSE" ? rows : []), + now, + TZ, + ).find((s) => s.metric === "glucose"); +} + +describe("the glucose signal of the day compares like with like", () => { + it("holds a fasting morning against earlier mornings, not whole days", () => { + const now = TODAY_0700 + 2 * HOUR; // 09:00 local + const signal = signalFor(glucose([{ value: 90, at: TODAY_0700 }]), now); + expect(signal).toBeDefined(); + expect(signal?.basis).toBe("sameHours"); + expect(signal?.avg30).toBeCloseTo(90, 0); + expect(Math.abs(signal?.deltaVs30 ?? 99)).toBeLessThan(2); + expect(signal?.outsideNormalSwing).toBe(false); + expect(signal?.recentAnomaly).toBeNull(); + }); + + it("still flags a fasting value well above the usual mornings", () => { + const now = TODAY_0700 + 2 * HOUR; + const signal = signalFor(glucose([{ value: 120, at: TODAY_0700 }]), now); + expect(signal?.basis).toBe("sameHours"); + expect(signal?.latest).toBe(120); + expect(signal?.deltaVs30).toBeCloseTo(30, 0); + expect(signal?.outsideNormalSwing).toBe(true); + }); + + it("hands the briefing grounding check the figures it states", () => { + const now = TODAY_0700 + 2 * HOUR; + const signal = signalFor(glucose([{ value: 120, at: TODAY_0700 }]), now)!; + const briefing = { + paragraph: `Glucose so far today is ${signal.latest}, ${signal.deltaVs30} above the ${signal.avg30} you usually sit at by this time of day.`, + }; + expect(findUngroundedBriefingNumbers(briefing, [signal])).toEqual([]); + // The whole-day mean the old comparison stated is no longer a figure. + expect( + findUngroundedBriefingNumbers({ paragraph: "against 127.4" }, [signal]), + ).toHaveLength(1); + }); + + it("cuts the earlier days at the time of today's latest reading", () => { + // Today: fasting and lunch so far, read in the afternoon. Earlier days + // count their fasting and lunch readings, not their dinner. + const now = TODAY_0700 + 8 * HOUR; // 15:00 local + const signal = signalFor( + glucose([ + { value: 90, at: TODAY_0700 }, + { value: 140, at: TODAY_0700 + 6 * HOUR }, + ]), + now, + ); + expect(signal?.latest).toBe(115); + expect(signal?.avg30).toBeCloseTo(115, 0); + expect(signal?.outsideNormalSwing).toBe(false); + }); + + it("gives no signal without a week of earlier days in the same hours", () => { + const rows = glucose([{ value: 91, at: TODAY_0700 }]).filter( + (r) => r.measuredAt.getTime() >= TODAY_0700 - 5 * DAY, + ); + expect(signalFor(rows, TODAY_0700 + 2 * HOUR)).toBeUndefined(); + }); + + it("leaves pulse on the reading-against-window comparison", () => { + const pulse: Array<{ value: number; measuredAt: Date }> = []; + for (let d = 20; d >= 1; d -= 1) { + pulse.push({ value: 64, measuredAt: new Date(TODAY_0700 - d * DAY) }); + } + pulse.push({ value: 70, measuredAt: new Date(TODAY_0700) }); + const [signal] = computeSignalsOfDay( + (type) => (type === "PULSE" ? pulse : []), + TODAY_0700 + 2 * HOUR, + TZ, + ); + expect(signal?.metric).toBe("pulse"); + expect(signal?.basis).toBeUndefined(); + expect(signal?.latest).toBe(70); + }); +}); diff --git a/src/lib/insights/derived/__tests__/coach-read-glucose.test.ts b/src/lib/insights/derived/__tests__/coach-read-glucose.test.ts index 1cb8a2855..c78d52fdb 100644 --- a/src/lib/insights/derived/__tests__/coach-read-glucose.test.ts +++ b/src/lib/insights/derived/__tests__/coach-read-glucose.test.ts @@ -169,3 +169,69 @@ describe("coach read — glucose on a day still in progress", () => { expect(strip.baseline!.basis).toBeUndefined(); }); }); + +describe("coach read — the latest reading's day", () => { + function restingUpTo(newest: Date): Row[] { + const rows = Array.from({ length: 10 }, (_, i) => ({ + value: 58, + measuredAt: new Date( + `2026-05-${String(18 + i).padStart(2, "0")}T05:00:00Z`, + ), + })); + return [...rows, { value: 59, measuredAt: newest }]; + } + + async function restingStrip(newest: Date) { + serve(restingUpTo(newest)); + return buildCoachReadStrip("u1", "RESTING_HEART_RATE", "en", { + tz: TZ, + now: NOW, + }); + } + + it("says an older reading is not today's and dates it", async () => { + const strip = await restingStrip(new Date("2026-05-29T05:00:00Z")); + expect(strip.baseline!.latest).toBe(59); + expect(strip.baseline!.latestDate).toBe("2026-05-29"); + expect(strip.baseline!.latestIsToday).toBe(false); + }); + + it("marks a reading from this morning as today's", async () => { + const strip = await restingStrip(new Date("2026-06-02T05:00:00Z")); + expect(strip.baseline!.latestDate).toBe("2026-06-02"); + expect(strip.baseline!.latestIsToday).toBe(true); + }); + + it("reads the day in the reader's zone, not in UTC", async () => { + // 22:30 UTC on the 1st is 00:30 on the 2nd in Berlin: today. + const after = await restingStrip(new Date("2026-06-01T22:30:00Z")); + expect(after.baseline!.latestDate).toBe("2026-06-02"); + expect(after.baseline!.latestIsToday).toBe(true); + // 21:50 UTC is 23:50 on the 1st in Berlin: yesterday. + const before = await restingStrip(new Date("2026-06-01T21:50:00Z")); + expect(before.baseline!.latestDate).toBe("2026-06-01"); + expect(before.baseline!.latestIsToday).toBe(false); + }); + + it("dates a finished glucose day, and marks a day in progress as today", async () => { + serve(wholeDays()); + const finished = await buildCoachReadStrip("u1", "BLOOD_GLUCOSE", "en", { + tz: TZ, + now: NOW, + }); + expect(finished.baseline!.latestDate).toBe("2026-05-31"); + expect(finished.baseline!.latestIsToday).toBe(false); + + serve([ + ...wholeDays(), + { value: 86, measuredAt: new Date("2026-06-02T05:10:00Z") }, + ]); + const today = await buildCoachReadStrip("u1", "BLOOD_GLUCOSE", "en", { + tz: TZ, + now: NOW, + }); + expect(today.baseline!.basis).toBe("sameHours"); + expect(today.baseline!.latestDate).toBe("2026-06-02"); + expect(today.baseline!.latestIsToday).toBe(true); + }); +}); diff --git a/src/lib/insights/derived/coach-read-shape.ts b/src/lib/insights/derived/coach-read-shape.ts index 4b977f9a5..359a174aa 100644 --- a/src/lib/insights/derived/coach-read-shape.ts +++ b/src/lib/insights/derived/coach-read-shape.ts @@ -19,8 +19,18 @@ export interface CoachReadBaseline { low: number; /** Robust upper edge of the personal typical range. */ high: number; - /** Today's latest reading (display value, same units as the band). */ + /** + * The latest reading (display value, same units as the band). Today's only + * when `latestIsToday` says so; otherwise it is from `latestDate`. + */ latest: number; + /** Calendar day of the latest reading in the reader's zone, `YYYY-MM-DD`. */ + latestDate: string; + /** + * True when `latestDate` is the reader's today (`isFromToday`). A client + * calls the value "today's" only then, and otherwise names its date. + */ + latestIsToday: boolean; /** Where the latest reading sits relative to the band. */ placement: CoachReadBaselinePlacement; /** Distinct days that fed the band (transparency). */ diff --git a/src/lib/insights/derived/coach-read.ts b/src/lib/insights/derived/coach-read.ts index 60da1bcfa..7af471cd2 100644 --- a/src/lib/insights/derived/coach-read.ts +++ b/src/lib/insights/derived/coach-read.ts @@ -37,6 +37,10 @@ import { readSameHoursStanding, SAME_HOURS_TYPES, } from "@/lib/insights/derived/coincident-deviation"; +import { + dayKeyAgeInDays, + isFromToday, +} from "@/lib/insights/measurement-freshness"; import { userDayKey } from "@/lib/tz/format"; import { readCoachCorrelations } from "@/lib/ai/coach/tools/correlations-read"; import { @@ -59,13 +63,29 @@ export type { CoachReadStripData } from "@/lib/insights/derived/coach-read-shape async function readLatestValue( userId: string, type: MeasurementType, -): Promise { - const row = await prisma.measurement.findFirst({ +): Promise<{ value: number; measuredAt: Date } | null> { + return prisma.measurement.findFirst({ where: { userId, type, deletedAt: null }, orderBy: { measuredAt: "desc" }, - select: { value: true }, + select: { value: true, measuredAt: true }, }); - return row?.value ?? null; +} + +/** + * The day a latest reading belongs to, and whether that is the reader's + * today. Both clients word the value from this: "today's" only when it is, + * the reading's date otherwise. A reading from last week used to be called + * today's on every metric page. + */ +function latestDay( + day: string, + now: Date, + tz: string, +): { latestDate: string; latestIsToday: boolean } { + return { + latestDate: day, + latestIsToday: isFromToday(dayKeyAgeInDays(day, userDayKey(now, tz))), + }; } /** Trailing window the band engine reads; the day-mean placement matches it. */ @@ -101,6 +121,7 @@ async function placeByDayMean( return { ...band, latest: latest.value, + ...latestDay(latest.day, now, tz), placement: placeAgainstBand(latest.value, band.low, band.high), }; } @@ -115,6 +136,7 @@ async function placeByDayMean( low: standing.low, high: standing.high, latest: standing.value, + ...latestDay(latest.day, now, tz), placement: placeAgainstBand(standing.value, standing.low, standing.high), sampleDays: standing.days, basis: "sameHours", @@ -173,8 +195,9 @@ export async function buildCoachReadStrip( baseline = { low, high, - latest, - placement: placeAgainstBand(latest, low, high), + latest: latest.value, + ...latestDay(userDayKey(latest.measuredAt, opts.tz), now, opts.tz), + placement: placeAgainstBand(latest.value, low, high), sampleDays, }; } else { diff --git a/src/lib/insights/signals-of-day.ts b/src/lib/insights/signals-of-day.ts index a55db8b60..b522db563 100644 --- a/src/lib/insights/signals-of-day.ts +++ b/src/lib/insights/signals-of-day.ts @@ -10,12 +10,20 @@ */ import { trendSlope } from "@/lib/analytics/trends"; import { avgInWindow, stdDev, toDataPoints } from "@/lib/insights/features"; +import { + SAME_HOURS_MIN_DAYS, + SAME_HOURS_TYPES, +} from "@/lib/insights/derived/coincident-deviation"; import { dayKeyAgeInDays } from "@/lib/insights/measurement-freshness"; +import type { MeasurementType } from "@/generated/prisma/client"; +import { dayValue } from "@/lib/measurements/day-mean"; +import { isPlausibleMetricValue } from "@/lib/measurements/value-domain"; import { roundToDisplay, vitalDisplayDecimals, } from "@/lib/measurements/vital-precision"; import { userDayKey } from "@/lib/tz/format"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; /** * v1.18.7 — one present-focused signal feeding the daily briefing. Every @@ -30,7 +38,10 @@ export interface SignalOfDay { label: string; /** Unit string when the metric carries one. */ unit?: string; - /** Freshest reading (the "now" value the briefing leads with). */ + /** + * Freshest reading (the "now" value the briefing leads with). Under + * `basis: "sameHours"` it is today's mean so far. + */ latest: number; /** * Calendar days between the freshest reading and today in the reader's @@ -57,6 +68,15 @@ export interface SignalOfDay { value: number; anomalyDaysAgo: number; } | null; + /** + * `"sameHours"` for a type whose day mean moves with the hour (glucose, + * `SAME_HOURS_TYPES`): today so far is compared with the same hours of the + * earlier days, each cut at the local time of today's latest reading. + * `latest` is then today's mean so far, `avg7` / `avg30` / `spread30` are + * over those earlier days' same-hours means, and the anomaly is a day's + * same-hours mean. Absent for the reading-against-window comparison. + */ + basis?: "sameHours"; } /** Compute historical comparison: current 7d avg vs previous 30d avg (days 7-37). */ @@ -101,6 +121,8 @@ function r2(v: number): number { interface SignalRead { /** The reader's zone; "today" is the calendar day in it. */ tz: string; + /** The measurement type; picks the like-for-like path (`SAME_HOURS_TYPES`). */ + type: string; /** For pulse, the zone its day means are read in (`day-mean.ts`). */ day?: { type: string; tz: string }; /** @@ -138,6 +160,9 @@ function buildSignal( userDayKey(new Date(now), read.tz), ); if (latestDaysAgo !== 0) return null; + if (SAME_HOURS_TYPES.has(read.type as MeasurementType)) { + return buildSameHoursSignal(metric, label, records, now, unit, read); + } const { day } = read; const shown = (v: number) => read.decimals === undefined ? r2(v) : roundToDisplay(v, read.decimals); @@ -213,6 +238,134 @@ function buildSignal( }; } +/** Seconds since local midnight of an instant in `tz`. */ +function localSecondOfDay(at: Date, tz: string): number { + const c = wallClockInTz(at, tz); + return c.hour * 3600 + c.minute * 60 + c.second; +} + +function mean(values: readonly number[]): number | null { + return values.length === 0 + ? null + : values.reduce((s, v) => s + v, 0) / values.length; +} + +/** + * The signal for a type whose day mean depends on how much of the day has + * passed (`SAME_HOURS_TYPES`). A fasting glucose reading at seven held against + * the mean of whole days (breakfast, lunch, dinner) reads as low every morning, + * which is no statement about the day at all. So today so far is compared with + * the same hours of the earlier days: every day is cut at the local clock time + * of today's latest reading and reduced to its mean, then today's mean is held + * against those day means. With fewer than `SAME_HOURS_MIN_DAYS` earlier days + * in those hours there is nothing like-for-like to compare with, and no signal. + * The same basis as the coincident-deviation flag and the metric page strip. + */ +function buildSameHoursSignal( + metric: SignalOfDay["metric"], + label: string, + records: Array<{ value: number; measuredAt: Date }>, + now: number, + unit: string | undefined, + read: SignalRead, +): SignalOfDay | null { + const { type } = read; + const { tz } = read; + const usable = records.filter((r) => isPlausibleMetricValue(type, r.value)); + if (usable.length === 0) return null; + const newest = usable.reduce((a, b) => + b.measuredAt.getTime() > a.measuredAt.getTime() ? b : a, + ); + const todayKey = userDayKey(new Date(now), tz); + if (userDayKey(newest.measuredAt, tz) !== todayKey) return null; + const cut = localSecondOfDay(newest.measuredAt, tz); + const since = now - 30 * 24 * 60 * 60 * 1000; + + const byDay = new Map>(); + for (const r of usable) { + const t = r.measuredAt.getTime(); + if (t < since || t > now) continue; + if (localSecondOfDay(r.measuredAt, tz) > cut) continue; + const key = userDayKey(r.measuredAt, tz); + const list = byDay.get(key); + if (list) list.push(r); + else byDay.set(key, [r]); + } + const days: Array<{ key: string; age: number; value: number; at: Date }> = []; + for (const [key, rows] of byDay) { + const value = dayValue(type, rows, tz); + const age = dayKeyAgeInDays(key, todayKey); + if (value === null || age === null || age < 0) continue; + const at = rows.reduce((a, b) => (b.measuredAt > a.measuredAt ? b : a)); + days.push({ key, age, value, at: at.measuredAt }); + } + days.sort((a, b) => a.at.getTime() - b.at.getTime()); + const today = days.find((d) => d.age === 0); + const earlier = days.filter((d) => d.age > 0); + if (!today || earlier.length < SAME_HOURS_MIN_DAYS) return null; + + const shown = (v: number) => + read.decimals === undefined ? r2(v) : roundToDisplay(v, read.decimals); + const shownOrNull = (v: number | null) => (v === null ? null : shown(v)); + + const avg30 = mean(earlier.map((d) => d.value)); + const avg7 = mean(earlier.filter((d) => d.age <= 7).map((d) => d.value)); + const spread30 = stdDev(earlier.map((d) => d.value)); + const latest = today.value; + const outsideNormalSwing = + avg30 !== null && spread30 !== null && spread30 > 0 + ? Math.abs(latest - avg30) > spread30 + : false; + + const slope = trendSlope( + days.map((d) => ({ date: d.at, value: d.value })), + 30, + now, + ); + const emergingTrend: SignalOfDay["emergingTrend"] = slope + ? slope.direction === "up" + ? "rising" + : slope.direction === "down" + ? "falling" + : "flat" + : null; + + let recentAnomaly: SignalOfDay["recentAnomaly"] = null; + let bestAbs = 0; + if (avg30 !== null && spread30 !== null && spread30 > 0) { + for (const d of days) { + if (d.age > 14) continue; + const sd = (d.value - avg30) / spread30; + const abs = Math.abs(d.value - avg30); + if (Math.abs(sd) >= 2 && (recentAnomaly === null || abs > bestAbs)) { + bestAbs = abs; + recentAnomaly = { + kind: sd > 0 ? "peak" : "trough", + value: shown(d.value), + anomalyDaysAgo: d.age, + }; + } + } + } + + return { + metric, + label, + ...(unit ? { unit } : {}), + latest: shown(latest), + latestDaysAgo: 0, + avg7: shownOrNull(avg7), + avg30: shownOrNull(avg30), + deltaVs7: avg7 !== null ? shown(latest - avg7) : null, + deltaVs30: avg30 !== null ? shown(latest - avg30) : null, + spread30: shownOrNull(spread30), + outsideNormalSwing, + emergingTrend, + recentAnomaly, + basis: "sameHours", + }; +} + /** * v1.18.7 — assemble the present-focused "Signals of the day" block from the * in-memory measurement set (no extra DB round-trip). Computes today-vs-7d/30d @@ -231,8 +384,11 @@ export function computeSignalsOfDay( const push = (s: SignalOfDay | null) => { if (s) candidates.push(s); }; + // Every read names its type, so a type joining `SAME_HOURS_TYPES` takes the + // like-for-like path here without a second list to keep in step. const whole = (type: string) => ({ tz, + type, decimals: vitalDisplayDecimals(type, 0), }); @@ -255,9 +411,9 @@ export function computeSignalsOfDay( byType("BLOOD_GLUCOSE"), now, undefined, - { - tz, - }, + // Compared with the same hours of the earlier days, never with whole + // days (see `buildSameHoursSignal`). + { tz, type: "BLOOD_GLUCOSE" }, ), ); push( @@ -280,7 +436,10 @@ export function computeSignalsOfDay( // unit is attached where the signal is converted for the prompt // (`featuresInReaderUnits`), so no canonical symbol can leak into one. push( - buildSignal("weight", "weight", byType("WEIGHT"), now, undefined, { tz }), + buildSignal("weight", "weight", byType("WEIGHT"), now, undefined, { + tz, + type: "WEIGHT", + }), ); // Sleep is stored one row per stage per night, so a raw "latest" point // would mis-sum; the sleep aggregates carry that signal already. Steps diff --git a/src/lib/mcp/__tests__/rich-reads.test.ts b/src/lib/mcp/__tests__/rich-reads.test.ts index a00449a37..08bedaa6d 100644 --- a/src/lib/mcp/__tests__/rich-reads.test.ts +++ b/src/lib/mcp/__tests__/rich-reads.test.ts @@ -500,6 +500,8 @@ describe("get_metric_baseline", () => { latest: 70, placement: "above", sampleDays: 30, + latestDate: "2026-09-30", + latestIsToday: false, }, learning: false, driver: { @@ -512,6 +514,9 @@ describe("get_metric_baseline", () => { expect(res.present).toBe(true); expect(res.baseline).toEqual({ low: 55, high: 65, sampleDays: 30 }); expect(res.latest).toBe(70); + // An older reading carries its day, so the model never calls it today's. + expect(res.latestDate).toBe("2026-09-30"); + expect(res.latestIsToday).toBe(false); expect(res.placement).toBe("above"); expect(res.unit).toBe("bpm"); expect(res.referenceBand).toEqual({ low: 50, high: 100 }); @@ -553,6 +558,8 @@ describe("get_metric_baseline", () => { latest: 38, placement: "within", sampleDays: 30, + latestDate: "2026-10-06", + latestIsToday: true, }, learning: false, driver: null, @@ -575,6 +582,8 @@ describe("get_metric_baseline", () => { latest: 60, placement: "within", sampleDays: 30, + latestDate: "2026-10-06", + latestIsToday: true, }, learning: false, driver: null, diff --git a/src/lib/mcp/rich-reads.ts b/src/lib/mcp/rich-reads.ts index 91c7cc2e1..2dcae91f3 100644 --- a/src/lib/mcp/rich-reads.ts +++ b/src/lib/mcp/rich-reads.ts @@ -1039,8 +1039,12 @@ export interface MetricBaselineResult { unit?: string; /** The user's personal usual range (median ± k·MAD) + sample transparency. */ baseline?: { low: number; high: number; sampleDays: number }; - /** Today's latest reading. */ + /** The latest reading; today's only when `latestIsToday`. */ latest?: number; + /** Calendar day of `latest` in the user's zone, `YYYY-MM-DD`. */ + latestDate?: string; + /** True when `latest` is from the user's today. */ + latestIsToday?: boolean; /** Where the latest reading sits relative to the personal band. */ placement?: "within" | "above" | "below"; /** @@ -1056,7 +1060,7 @@ export interface MetricBaselineResult { } /** - * Return where today's value sits against the user's own usual range. Pure + * Return where the latest value (dated, with whether it is today's) sits against the user's own usual range. Pure * re-export of `buildCoachReadStrip` — the SAME median ± k·MAD baseline engine * (`computeVitalsBaseline`) + lagged-driver pick the metric page renders. Below * the engine's 7-day history floor the band is not asserted (`{ present: false, @@ -1108,6 +1112,8 @@ export async function getMetricBaseline( sampleDays: strip.baseline.sampleDays, }, latest: strip.baseline.latest, + latestDate: strip.baseline.latestDate, + latestIsToday: strip.baseline.latestIsToday, placement: strip.baseline.placement, ...(strip.baseline.basis ? { basis: strip.baseline.basis } : {}), referenceBand: metric.band, diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index 7e68950da..befde5e00 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -964,6 +964,18 @@ const getMetricBaselineOutput: z.ZodRawShape = { .object({ low: z.number(), high: z.number(), sampleDays: z.number() }) .optional(), latest: z.number().optional(), + latestDate: z + .string() + .optional() + .describe( + "Calendar day of `latest` in the user's time zone, `YYYY-MM-DD`.", + ), + latestIsToday: z + .boolean() + .optional() + .describe( + "True when `latest` is from today. When false, `latest` is an older reading: name its date, never call it today's.", + ), placement: z.enum(["within", "above", "below"]).optional(), basis: z .literal("sameHours") @@ -1430,7 +1442,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [ name: "get_metric_baseline", title: "Get a metric's personal baseline", description: - "Fetch where the user's latest reading for ONE metric sits against their own usual range (median ± robust deviation), plus the strongest lagged driver of that metric. Re-exports the same baseline engine the metric page renders. Also resolves the clinical-signal metrics (grip strength, pain 0–10 NRS, waist circumference, waist-to-height ratio). A metric can be named by its alias, its key, or its display name in any language HealthLog ships (e.g. 'poids', 'Blutzucker'). Returns the personal band, today's value + placement (within/above/below), and the population reference band. Returns { present: false } with reason 'insufficient_history' below the 7-day learning floor — never a fabricated range.", + "Fetch where the user's latest reading for ONE metric sits against their own usual range (median ± robust deviation), plus the strongest lagged driver of that metric. Re-exports the same baseline engine the metric page renders. Also resolves the clinical-signal metrics (grip strength, pain 0–10 NRS, waist circumference, waist-to-height ratio). A metric can be named by its alias, its key, or its display name in any language HealthLog ships (e.g. 'poids', 'Blutzucker'). Returns the personal band, the latest value with its date and whether it is from today (latestDate, latestIsToday) + placement (within/above/below), and the population reference band. Returns { present: false } with reason 'insufficient_history' below the 7-day learning floor — never a fabricated range.", inputShape: { metric: z.string().min(1).max(60), }, diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 4715011ac..97c8b955e 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -2654,7 +2654,22 @@ export const coachReadStripResponse = z .object({ low: z.number().describe("Robust lower edge of the personal range."), high: z.number().describe("Robust upper edge."), - latest: z.number().describe("Today's latest reading, same units."), + latest: z + .number() + .describe( + "The latest reading, same units. Today's only when `latestIsToday` is true; otherwise it is from `latestDate`.", + ), + latestDate: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .describe( + "Calendar day of the latest reading in the account's time zone, `YYYY-MM-DD`.", + ), + latestIsToday: z + .boolean() + .describe( + "True when `latestDate` is today in the account's time zone. When false, a client names the reading's date instead of calling it today's.", + ), placement: z.enum(["within", "above", "below"]), sampleDays: z .number()