diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 8252763ab..3e34a56e6 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -59427,6 +59427,13 @@ components: minimum: -9007199254740991 maximum: 9007199254740991 description: Distinct days behind the band, for transparency. + basis: + description: Present when today is still in progress for a metric whose day mean moves with the hour (blood glucose). + `latest` is then today's mean so far and `low` / `high` the usual range for the same hours of the + earlier days, not the whole-day band; a client naming the range says it is the one for this time of + day. Absent for the whole-day comparison. + type: string + const: sameHours required: - low - high diff --git a/messages/de.json b/messages/de.json index 856bbd8e1..71b749fd2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3030,6 +3030,9 @@ "baselineWithin": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; heute liegt {value} darin.", "baselineAbove": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; heute liegt {value} darüber.", "baselineBelow": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; heute liegt {value} darunter.", + "sameHoursWithin": "Bis zu dieser Tageszeit liegst du sonst bei {low}–{high} {unit}; heute bisher {value}, also im Bereich.", + "sameHoursAbove": "Bis zu dieser Tageszeit liegst du sonst bei {low}–{high} {unit}; heute bisher {value}, also darüber.", + "sameHoursBelow": "Bis zu dieser Tageszeit liegst du sonst bei {low}–{high} {unit}; heute bisher {value}, also darunter.", "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.", diff --git a/messages/en.json b/messages/en.json index 758b965b5..0ecbd9bc7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3030,6 +3030,9 @@ "baselineWithin": "Your usual range is {low}–{high} {unit}; today's {value} sits within it.", "baselineAbove": "Your usual range is {low}–{high} {unit}; today's {value} sits above it.", "baselineBelow": "Your usual range is {low}–{high} {unit}; today's {value} sits below it.", + "sameHoursWithin": "By this time of day you usually sit at {low}–{high} {unit}; today so far, {value} is within that.", + "sameHoursAbove": "By this time of day you usually sit at {low}–{high} {unit}; today so far, {value} is above that.", + "sameHoursBelow": "By this time of day you usually sit at {low}–{high} {unit}; today so far, {value} is below that.", "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.", diff --git a/messages/es.json b/messages/es.json index c1793bf8c..7f85b2d83 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3030,6 +3030,9 @@ "baselineWithin": "Tu rango habitual es {low}–{high} {unit}; hoy {value} se sitúa dentro de él.", "baselineAbove": "Tu rango habitual es {low}–{high} {unit}; hoy {value} se sitúa por encima.", "baselineBelow": "Tu rango habitual es {low}–{high} {unit}; hoy {value} se sitúa por debajo.", + "sameHoursWithin": "Hasta esta hora del día sueles estar en {low}–{high} {unit}; hoy, por ahora, {value} está dentro.", + "sameHoursAbove": "Hasta esta hora del día sueles estar en {low}–{high} {unit}; hoy, por ahora, {value} está por encima.", + "sameHoursBelow": "Hasta esta hora del día sueles estar en {low}–{high} {unit}; hoy, por ahora, {value} está por debajo.", "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.", diff --git a/messages/fr.json b/messages/fr.json index 31e54cfe0..773989159 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3030,6 +3030,9 @@ "baselineWithin": "Ta plage habituelle est de {low}–{high} {unit} ; aujourd'hui, {value} s'y situe.", "baselineAbove": "Ta plage habituelle est de {low}–{high} {unit} ; aujourd'hui, {value} se situe au-dessus.", "baselineBelow": "Ta plage habituelle est de {low}–{high} {unit} ; aujourd'hui, {value} se situe en dessous.", + "sameHoursWithin": "À ce moment de la journée, tu es habituellement à {low}–{high} {unit} ; aujourd'hui jusqu'ici, {value} s'y situe.", + "sameHoursAbove": "À ce moment de la journée, tu es habituellement à {low}–{high} {unit} ; aujourd'hui jusqu'ici, {value} se situe au-dessus.", + "sameHoursBelow": "À ce moment de la journée, tu es habituellement à {low}–{high} {unit} ; aujourd'hui jusqu'ici, {value} se situe en dessous.", "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.", diff --git a/messages/it.json b/messages/it.json index d1c2c9047..fde129a22 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3030,6 +3030,9 @@ "baselineWithin": "Il tuo intervallo abituale è {low}–{high} {unit}; oggi {value} rientra al suo interno.", "baselineAbove": "Il tuo intervallo abituale è {low}–{high} {unit}; oggi {value} si colloca al di sopra.", "baselineBelow": "Il tuo intervallo abituale è {low}–{high} {unit}; oggi {value} si colloca al di sotto.", + "sameHoursWithin": "A quest'ora del giorno di solito sei a {low}–{high} {unit}; oggi finora {value} rientra nell'intervallo.", + "sameHoursAbove": "A quest'ora del giorno di solito sei a {low}–{high} {unit}; oggi finora {value} si colloca al di sopra.", + "sameHoursBelow": "A quest'ora del giorno di solito sei a {low}–{high} {unit}; oggi finora {value} si colloca al di sotto.", "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.", diff --git a/messages/ko.json b/messages/ko.json index c792625dd..2d7e5dbac 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -3030,6 +3030,9 @@ "baselineWithin": "평소 범위: {low}–{high} {unit}. 오늘 값 {value} — 범위 안이에요.", "baselineAbove": "평소 범위: {low}–{high} {unit}. 오늘 값 {value} — 범위 위예요.", "baselineBelow": "평소 범위: {low}–{high} {unit}. 오늘 값 {value} — 범위 아래예요.", + "sameHoursWithin": "이 시간대까지 평소 범위: {low}–{high} {unit}. 오늘 지금까지 {value} — 범위 안이에요.", + "sameHoursAbove": "이 시간대까지 평소 범위: {low}–{high} {unit}. 오늘 지금까지 {value} — 범위 위예요.", + "sameHoursBelow": "이 시간대까지 평소 범위: {low}–{high} {unit}. 오늘 지금까지 {value} — 범위 아래예요.", "steadyWithin": "평소 값: {low} {unit}. 오늘 값 {value} — 평소와 같아요.", "steadyAbove": "평소 값: {low} {unit}. 오늘 값 {value} — 평소보다 높아요.", "steadyBelow": "평소 값: {low} {unit}. 오늘 값 {value} — 평소보다 낮아요.", diff --git a/messages/pl.json b/messages/pl.json index 7bdc35f33..b8c56cabb 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3030,6 +3030,9 @@ "baselineWithin": "Twój zwykły zakres to {low}–{high} {unit}; dzisiejsza wartość {value} mieści się w nim.", "baselineAbove": "Twój zwykły zakres to {low}–{high} {unit}; dzisiejsza wartość {value} jest powyżej niego.", "baselineBelow": "Twój zwykły zakres to {low}–{high} {unit}; dzisiejsza wartość {value} jest poniżej niego.", + "sameHoursWithin": "Do tej pory dnia zwykle masz {low}–{high} {unit}; dziś jak dotąd {value}, czyli w zakresie.", + "sameHoursAbove": "Do tej pory dnia zwykle masz {low}–{high} {unit}; dziś jak dotąd {value}, czyli powyżej.", + "sameHoursBelow": "Do tej pory dnia zwykle masz {low}–{high} {unit}; dziś jak dotąd {value}, czyli poniżej.", "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.", diff --git a/src/app/api/insights/coach-read/__tests__/route.test.ts b/src/app/api/insights/coach-read/__tests__/route.test.ts index 0faf29c0d..4425cd2eb 100644 --- a/src/app/api/insights/coach-read/__tests__/route.test.ts +++ b/src/app/api/insights/coach-read/__tests__/route.test.ts @@ -13,9 +13,17 @@ import { NextRequest } from "next/server"; */ const buildCoachReadStrip = vi.fn(); +const zoneSeen = vi.fn(); vi.mock("@/lib/insights/derived/coach-read", () => ({ - buildCoachReadStrip: (userId: string, metric: string, locale: string) => - buildCoachReadStrip(userId, metric, locale), + buildCoachReadStrip: ( + userId: string, + metric: string, + locale: string, + opts: { tz: string }, + ) => { + zoneSeen(opts.tz); + return buildCoachReadStrip(userId, metric, locale); + }, })); vi.mock("@/lib/db", () => ({ @@ -58,7 +66,7 @@ import { GET } from "../route"; import { getSession } from "@/lib/auth/session"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; -function sessionWithLocale(locale: string | null) { +function sessionWithLocale(locale: string | null, timezone?: string) { return { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, user: { @@ -66,6 +74,7 @@ function sessionWithLocale(locale: string | null) { username: "testuser", role: "USER" as const, locale, + ...(timezone ? { timezone } : {}), }, }; } @@ -126,3 +135,19 @@ describe("GET /api/insights/coach-read — the reader's language", () => { ]); }); }); + +describe("GET /api/insights/coach-read — the reader's day", () => { + it("passes the reader's own zone, which decides whether a day is still in progress", async () => { + vi.mocked(getSession).mockResolvedValue( + sessionWithLocale(null, "America/New_York") as unknown as Awaited< + ReturnType + >, + ); + zoneSeen.mockReset(); + + const res = await GET(REQUEST()); + + expect(res.status).toBe(200); + expect(zoneSeen).toHaveBeenCalledWith("America/New_York"); + }); +}); diff --git a/src/app/api/insights/coach-read/route.ts b/src/app/api/insights/coach-read/route.ts index e7917ed51..5b6f27ebb 100644 --- a/src/app/api/insights/coach-read/route.ts +++ b/src/app/api/insights/coach-read/route.ts @@ -24,6 +24,7 @@ import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; import { measurementTypeEnum } from "@/lib/validations/measurement"; import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { resolveStoredTimezone } from "@/lib/tz/resolver"; import type { MeasurementType } from "@/generated/prisma/client"; export const dynamic = "force-dynamic"; @@ -66,7 +67,11 @@ export const GET = apiHandler(async (request: NextRequest) => { // English, one line under the other. const locale = await resolveServerLocale({ userLocale: user.locale ?? null }); - const strip = await buildCoachReadStrip(user.id, metric, locale); + // The reader's zone decides which day is "today": a glucose day still in + // progress is placed against the same hours of the earlier days. + const strip = await buildCoachReadStrip(user.id, metric, locale, { + tz: await resolveStoredTimezone(user.timezone), + }); annotate({ action: { name: "insights.coach-read" }, 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 new file mode 100644 index 000000000..5e5e3184e --- /dev/null +++ b/src/components/insights/derived/__tests__/coach-read-strip-same-hours.test.tsx @@ -0,0 +1,94 @@ +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 { queryKeys } from "@/lib/query-keys"; +import type { CoachReadStripData } from "@/lib/insights/derived/coach-read-shape"; + +/** + * A glucose day still in progress is placed against the same hours of the + * earlier days. The range is then the usual one for this time of day and the + * figure is today's mean so far, and the sentence has to say so: printed as + * "your usual range", a morning range would read as the whole-day one. + */ + +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 = "BLOOD_GLUCOSE"; + +function render(data: CoachReadStripData, locale: "en" | "de" = "en"): string { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + client.setQueryData(queryKeys.insightsCoachRead(METRIC, locale), data); + return renderToStaticMarkup( + + + + + , + ); +} + +function sameHours( + latest: number, + placement: "within" | "above" | "below", +): CoachReadStripData { + return { + baseline: { + low: 80, + high: 90, + latest, + placement, + sampleDays: 14, + basis: "sameHours", + }, + learning: false, + driver: null, + }; +} + +describe("CoachReadStrip — a glucose day in progress", () => { + it("names the range as the one for this time of day", () => { + const markup = render(sameHours(86, "within")); + expect(markup).toContain("By this time of day"); + expect(markup).toContain("today so far, 86 is within that"); + expect(markup).not.toContain("Your usual range"); + }); + + it("keeps the placement", () => { + expect(render(sameHours(95, "above"))).toContain("is above that"); + expect(render(sameHours(70, "below"))).toContain("is below that"); + }); + + it("says it in the reader's language", () => { + expect(render(sameHours(86, "within"), "de")).toContain( + "Bis zu dieser Tageszeit", + ); + }); + + it("keeps the whole-day sentence without the basis", () => { + const markup = render({ + baseline: { + low: 110, + high: 150, + latest: 132, + placement: "within", + sampleDays: 30, + }, + learning: false, + driver: null, + }); + expect(markup).toContain("Your usual range is 110–150"); + }); +}); diff --git a/src/components/insights/derived/coach-read-strip.tsx b/src/components/insights/derived/coach-read-strip.tsx index c529823b8..ebe37a727 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". Below the engine's 7-day history floor it reads + * above / below". 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 * outcome is this metric, stated in the engine's own never-causal voice. @@ -105,21 +106,31 @@ export function CoachReadStrip({ if (data.learning || !data.baseline) { return t("insights.coach.readStrip.insufficient"); } - const { low, high, latest, placement } = data.baseline; + const { low, high, latest, placement, basis } = 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); - const key = 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"; + // 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. + const key = + basis === "sameHours" + ? placement === "above" + ? "insights.coach.readStrip.sameHoursAbove" + : 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"; return t(key, { low: fmt(low), high: fmt(high), diff --git a/src/lib/insights/derived/__tests__/coach-read-glucose.test.ts b/src/lib/insights/derived/__tests__/coach-read-glucose.test.ts new file mode 100644 index 000000000..1cb8a2855 --- /dev/null +++ b/src/lib/insights/derived/__tests__/coach-read-glucose.test.ts @@ -0,0 +1,171 @@ +/** + * The "Usual range" strip on the glucose page holds today against the band of + * whole days only when today is a whole day. + * + * The band is built from day means, and a glucose day mean climbs with every + * meal. At nine in the morning the day holds its fasting reading and nothing + * after, so the strip placed that reading against whole days (fasting plus + * lunch plus dinner) and said "below your range" on an ordinary morning. A + * glucose day still in progress is now held against the earlier days cut at + * the local time of today's latest reading, the same comparison the health + * status already makes. + * + * Drives the real builder over the real band engine; only the database reads + * are faked, through the same day-aggregate fold the SQL reader uses. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/measurements/day-aggregates", async () => ({ + readDayAggregates: ( + await import("@/lib/measurements/__tests__/fake-day-aggregates") + ).fakeReadDayAggregates, +})); + +const findMany = vi.fn(); +const findFirst = vi.fn(); +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { + findMany: (a: unknown) => findMany(a), + findFirst: (a: unknown) => findFirst(a), + }, + }, +})); +vi.mock("@/lib/rollups/measurement-coverage", () => ({ + probeRollupCoverage: vi.fn().mockResolvedValue(new Map()), +})); +vi.mock("@/lib/rollups/measurement-read-wmy", () => ({ + readBestGranularityRollups: vi.fn().mockResolvedValue(null), +})); +vi.mock("@/lib/insights/derived/baseline", async (importOriginal) => ({ + ...(await importOriginal()), + loadBaselineProfile: async () => ({ ageYears: 40, sex: "MALE" }), +})); +vi.mock("@/lib/ai/coach/tools/correlations-read", () => ({ + readCoachCorrelations: async () => ({ present: false }), +})); + +import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read"; + +const TZ = "Europe/Berlin"; +// 09:00 in Berlin (CEST, UTC+2). +const NOW = new Date("2026-06-02T07:00:00Z"); + +interface Row { + value: number; + measuredAt: Date; +} + +/** + * Fourteen whole days, each with a fasting reading at 07:00 local and two + * after meals at 13:00 and 19:00 local: day means near 132. + */ +function wholeDays(): Row[] { + return Array.from({ length: 14 }, (_, i) => { + const date = `2026-05-${String(18 + i).padStart(2, "0")}`; + return [ + { value: 85, measuredAt: new Date(`${date}T05:00:00Z`) }, + { value: 160, measuredAt: new Date(`${date}T11:00:00Z`) }, + { value: 150, measuredAt: new Date(`${date}T17:00:00Z`) }, + ]; + }).flat(); +} + +function serve(rows: Row[]) { + const sorted = [...rows].sort( + (a, b) => b.measuredAt.getTime() - a.measuredAt.getTime(), + ); + findMany.mockImplementation( + async (args: { where: { measuredAt?: { gte?: Date } } }) => { + const gte = args.where.measuredAt?.gte; + return sorted.filter((r) => !gte || r.measuredAt >= gte); + }, + ); + findFirst.mockImplementation(async () => sorted[0] ?? null); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("coach read — glucose on a day still in progress", () => { + it("does not call a fasting morning below the usual range", async () => { + serve([ + ...wholeDays(), + // 07:10 local today: the fasting reading, nothing after it yet. + { value: 86, measuredAt: new Date("2026-06-02T05:10:00Z") }, + ]); + const strip = await buildCoachReadStrip("u1", "BLOOD_GLUCOSE", "en", { + tz: TZ, + now: NOW, + }); + expect(strip.learning).toBe(false); + expect(strip.baseline).not.toBeNull(); + expect(strip.baseline!.placement).toBe("within"); + expect(strip.baseline!.basis).toBe("sameHours"); + expect(strip.baseline!.latest).toBe(86); + // The range is the usual one for the morning, not for whole days. + expect(strip.baseline!.high).toBeLessThan(100); + expect(strip.baseline!.sampleDays).toBe(14); + }); + + it("still says below when the morning is low against earlier mornings", async () => { + serve([ + ...wholeDays(), + { value: 62, measuredAt: new Date("2026-06-02T05:10:00Z") }, + ]); + const strip = await buildCoachReadStrip("u1", "BLOOD_GLUCOSE", "en", { + tz: TZ, + now: NOW, + }); + expect(strip.baseline!.placement).toBe("below"); + expect(strip.baseline!.basis).toBe("sameHours"); + }); + + it("gives no verdict when earlier days have nothing at this hour", async () => { + // Earlier days only ever carry an evening reading, so there is no morning + // to compare this one with. + serve([ + ...wholeDays().filter((r) => r.value === 150), + { value: 86, measuredAt: new Date("2026-06-02T05:10:00Z") }, + ]); + const strip = await buildCoachReadStrip("u1", "BLOOD_GLUCOSE", "en", { + tz: TZ, + now: NOW, + }); + expect(strip.baseline).toBeNull(); + expect(strip.learning).toBe(true); + }); + + it("holds a finished day against whole days by its day mean", async () => { + // Nothing today; the latest day is yesterday, whole. Its mean is set + // against the whole-day band, not its last (evening) reading alone. + serve(wholeDays()); + const strip = await buildCoachReadStrip("u1", "BLOOD_GLUCOSE", "en", { + tz: TZ, + now: NOW, + }); + expect(strip.baseline!.basis).toBeUndefined(); + expect(strip.baseline!.latest).toBeCloseTo((85 + 160 + 150) / 3, 5); + expect(strip.baseline!.placement).toBe("within"); + }); + + it("keeps the single latest reading for a type whose day mean does not move with the hour", async () => { + const rows = Array.from({ length: 10 }, (_, i) => ({ + value: 58, + measuredAt: new Date( + `2026-05-${String(20 + i).padStart(2, "0")}T05:00:00Z`, + ), + })); + serve([ + ...rows, + { value: 59, measuredAt: new Date("2026-06-02T05:00:00Z") }, + ]); + const strip = await buildCoachReadStrip("u1", "RESTING_HEART_RATE", "en", { + tz: TZ, + now: NOW, + }); + expect(strip.baseline!.latest).toBe(59); + expect(strip.baseline!.basis).toBeUndefined(); + }); +}); diff --git a/src/lib/insights/derived/__tests__/coach-read-locale.test.ts b/src/lib/insights/derived/__tests__/coach-read-locale.test.ts index ebaa47e75..0f0b6d761 100644 --- a/src/lib/insights/derived/__tests__/coach-read-locale.test.ts +++ b/src/lib/insights/derived/__tests__/coach-read-locale.test.ts @@ -146,7 +146,9 @@ describe("buildCoachReadStrip — the pattern line speaks the reader's language" }); it("writes the driver note in German for a German reader", async () => { - const strip = await buildCoachReadStrip("u1", "SLEEP_DURATION", "de"); + const strip = await buildCoachReadStrip("u1", "SLEEP_DURATION", "de", { + tz: "UTC", + }); expect(strip.driver).not.toBeNull(); const note = strip.driver!.note; @@ -161,7 +163,9 @@ describe("buildCoachReadStrip — the pattern line speaks the reader's language" }); it("writes the same driver note in English for an English reader", async () => { - const strip = await buildCoachReadStrip("u1", "SLEEP_DURATION", "en"); + const strip = await buildCoachReadStrip("u1", "SLEEP_DURATION", "en", { + tz: "UTC", + }); expect(strip.driver).not.toBeNull(); expect(strip.driver!.note).toBe( @@ -175,8 +179,12 @@ describe("buildCoachReadStrip — the pattern line speaks the reader's language" // Only the sentence is localised. `outcome` is how the strip finds the // driver that belongs to THIS metric, so it has to stay the same string in // every language or a German page would find no driver at all. - const de = await buildCoachReadStrip("u1", "SLEEP_DURATION", "de"); - const en = await buildCoachReadStrip("u1", "SLEEP_DURATION", "en"); + const de = await buildCoachReadStrip("u1", "SLEEP_DURATION", "de", { + tz: "UTC", + }); + const en = await buildCoachReadStrip("u1", "SLEEP_DURATION", "en", { + tz: "UTC", + }); expect(de.driver!.outcome).toBe("sleep duration"); expect(en.driver!.outcome).toBe("sleep duration"); diff --git a/src/lib/insights/derived/coach-read-shape.ts b/src/lib/insights/derived/coach-read-shape.ts index 278aa243c..4b977f9a5 100644 --- a/src/lib/insights/derived/coach-read-shape.ts +++ b/src/lib/insights/derived/coach-read-shape.ts @@ -25,6 +25,14 @@ export interface CoachReadBaseline { placement: CoachReadBaselinePlacement; /** Distinct days that fed the band (transparency). */ sampleDays: number; + /** + * `"sameHours"` when today is still in progress for a type whose day mean + * moves with the hour (glucose): `latest` is today's mean so far and + * `low` / `high` are the usual range for the same hours of the earlier + * days, not the whole-day band. A surface naming the range says so. + * Absent for the whole-day comparison. + */ + basis?: "sameHours"; } /** The resolved lagged-association line (line 2). */ diff --git a/src/lib/insights/derived/coach-read.ts b/src/lib/insights/derived/coach-read.ts index 7467e1f98..60da1bcfa 100644 --- a/src/lib/insights/derived/coach-read.ts +++ b/src/lib/insights/derived/coach-read.ts @@ -6,7 +6,10 @@ * * 1. own-baseline — the user's personal typical range (median ± k·MAD) * from `computeVitalsBaseline`, plus where today's latest reading sits - * relative to it (within / above / below). Below the engine's 7-day + * relative to it (within / above / below). A type whose day mean moves + * with the hour (glucose) is placed by its day mean instead, and a day + * still in progress against the same hours of the earlier days (see + * `placeByDayMean`). Below the engine's 7-day * history floor the band is `insufficient` and the strip says * "still learning your range" — never a fabricated range. * 2. one lagged association — the single strongest discovered driver @@ -29,6 +32,12 @@ import { computeVitalsBaseline, } from "@/lib/insights/derived/baseline"; import { isDerivedOk } from "@/lib/insights/derived"; +import { + readLatestDayMean, + readSameHoursStanding, + SAME_HOURS_TYPES, +} from "@/lib/insights/derived/coincident-deviation"; +import { userDayKey } from "@/lib/tz/format"; import { readCoachCorrelations } from "@/lib/ai/coach/tools/correlations-read"; import { humaniseType, @@ -59,6 +68,59 @@ async function readLatestValue( return row?.value ?? null; } +/** Trailing window the band engine reads; the day-mean placement matches it. */ +const BAND_WINDOW_DAYS = 30; + +/** + * Placement for a type whose day mean depends on how much of the day has + * passed (`SAME_HOURS_TYPES`, glucose). The band is built from whole-day + * means, so the value placed against it has to be a day mean as well, never + * one reading: a fasting reading against whole days reads as low every + * morning. The latest day is placed by its mean. When that day is the + * reader's today it is still in progress, and is held against the earlier + * days cut at the local time of today's latest reading instead of against + * whole days. With no like-for-like basis for today the result is + * `"no-basis"` and the strip gives no verdict. + */ +async function placeByDayMean( + userId: string, + type: MeasurementType, + band: { low: number; high: number; sampleDays: number }, + now: Date, + tz: string, +): Promise { + const latest = await readLatestDayMean( + userId, + type, + BAND_WINDOW_DAYS, + now, + tz, + ); + if (!latest) return null; + if (latest.day !== userDayKey(now, tz)) { + return { + ...band, + latest: latest.value, + placement: placeAgainstBand(latest.value, band.low, band.high), + }; + } + const standing = await readSameHoursStanding(userId, type, { + windowDays: BAND_WINDOW_DAYS, + now, + tz, + lastAt: latest.lastAt, + }); + if (!standing) return "no-basis"; + return { + low: standing.low, + high: standing.high, + latest: standing.value, + placement: placeAgainstBand(standing.value, standing.low, standing.high), + sampleDays: standing.days, + basis: "sameHours", + }; +} + /** * Build the "Coach read" strip payload for one metric. Pure orchestration * over the baseline + correlations engines; never throws on a partial read @@ -76,11 +138,13 @@ export async function buildCoachReadStrip( * up with a German first line and an English second one. */ locale: Locale, + opts: { tz: string; now?: Date }, ): Promise { + const now = opts.now ?? new Date(); const profile = await loadBaselineProfile(prisma, userId); const [baselineDerived, latest, correlations] = await Promise.all([ - computeVitalsBaseline(userId, profile, { type }), + computeVitalsBaseline(userId, profile, { type, now }), readLatestValue(userId, type), // Line 2 is best-effort: a correlation failure must never sink the band. readCoachCorrelations(userId, locale).catch( @@ -91,7 +155,20 @@ export async function buildCoachReadStrip( let baseline: CoachReadBaseline | null = null; let learning = false; - if (isDerivedOk(baselineDerived) && latest !== null) { + const byDayMean = SAME_HOURS_TYPES.has(type); + if (isDerivedOk(baselineDerived) && byDayMean) { + const { low, high, sampleDays } = baselineDerived.value; + const placed = await placeByDayMean( + userId, + type, + { low, high, sampleDays }, + now, + opts.tz, + ); + // Nothing like-for-like to place today against yet: no verdict. + if (placed === "no-basis" || placed === null) learning = true; + else baseline = placed; + } else if (isDerivedOk(baselineDerived) && latest !== null) { const { low, high, sampleDays } = baselineDerived.value; baseline = { low, diff --git a/src/lib/insights/derived/coincident-deviation.ts b/src/lib/insights/derived/coincident-deviation.ts index a9dbe2459..bb6353542 100644 --- a/src/lib/insights/derived/coincident-deviation.ts +++ b/src/lib/insights/derived/coincident-deviation.ts @@ -80,7 +80,7 @@ export const COINCIDENT_MIN_BANDS = 2; * (each day cut at the local time of today's latest reading), never with * whole days. */ -const SAME_HOURS_TYPES: ReadonlySet = new Set([ +export const SAME_HOURS_TYPES: ReadonlySet = new Set([ "BLOOD_GLUCOSE", ]); @@ -97,7 +97,14 @@ export function sameHoursStanding( days: readonly { day: string; n: number; sum: number; dayMean?: number }[], todayKey: string, type: MeasurementType, -): { value: number; low: number; high: number; center: number } | null { +): { + value: number; + low: number; + high: number; + center: number; + /** Earlier days the band was built from. */ + days: number; +} | null { // A type on the hourly-mean reader carries `dayMean` (the mean of its // hours' means); every other type's day is the mean of its readings. const value = (d: { n: number; sum: number; dayMean?: number }) => @@ -113,6 +120,7 @@ export function sameHoursStanding( low: band.low, high: band.high, center: band.center, + days: earlier.length, }; } @@ -148,6 +156,31 @@ async function readSameHoursDays( }); } +/** + * Today's same-hours standing for a type whose latest reading is from the + * reader's local today: today's readings so far against the earlier days cut + * at the local time of `lastAt` (today's latest reading). Null when there is + * no like-for-like basis yet (see {@link sameHoursStanding}). + */ +export async function readSameHoursStanding( + userId: string, + type: MeasurementType, + opts: { windowDays: number; now: Date; tz: string; lastAt: Date }, +) { + return sameHoursStanding( + await readSameHoursDays( + userId, + type, + opts.windowDays, + opts.now, + opts.tz, + opts.lastAt, + ), + userDayKey(opts.now, opts.tz), + type, + ); +} + /** One vital's standing against its personal band today. */ export interface VitalDeviation { type: MeasurementType; @@ -254,7 +287,7 @@ export interface CoincidentDeviationOpts { * coincident flag could compare a vital from the wrong calendar day against * its band and narrate "≥2 vitals out of band TODAY" on the wrong day. */ -async function readLatestDayMean( +export async function readLatestDayMean( userId: string, type: MeasurementType, windowDays: number, @@ -348,18 +381,12 @@ export async function computeCoincidentDeviation( // days, not with their whole-day band (see `SAME_HOURS_TYPES`). const sameHours = latest && latest.day === todayKey && SAME_HOURS_TYPES.has(type) - ? sameHoursStanding( - await readSameHoursDays( - userId, - type, - windowDays, - now, - tz, - latest.lastAt, - ), - todayKey, - type, - ) + ? await readSameHoursStanding(userId, type, { + windowDays, + now, + tz, + lastAt: latest.lastAt, + }) : undefined; return { type, baseline, latest, sameHours }; }), diff --git a/src/lib/mcp/__tests__/rich-reads.test.ts b/src/lib/mcp/__tests__/rich-reads.test.ts index b23f44d6c..a00449a37 100644 --- a/src/lib/mcp/__tests__/rich-reads.test.ts +++ b/src/lib/mcp/__tests__/rich-reads.test.ts @@ -561,7 +561,9 @@ describe("get_metric_baseline", () => { const res = await getMetricBaseline(USER, { metric: "hrv" }); expect(res.present).toBe(true); - expect(buildCoachReadStrip).toHaveBeenCalledWith(USER, "HRV_RMSSD", "de"); + expect(buildCoachReadStrip).toHaveBeenCalledWith(USER, "HRV_RMSSD", "de", { + tz: expect.any(String), + }); }); it("stays on SDNN when the user has any HEART_RATE_VARIABILITY rows", async () => { @@ -585,6 +587,7 @@ describe("get_metric_baseline", () => { USER, "HEART_RATE_VARIABILITY", "de", + { tz: expect.any(String) }, ); }); }); diff --git a/src/lib/mcp/rich-reads.ts b/src/lib/mcp/rich-reads.ts index 05cb1823e..91c7cc2e1 100644 --- a/src/lib/mcp/rich-reads.ts +++ b/src/lib/mcp/rich-reads.ts @@ -38,6 +38,7 @@ import { moduleForMeasurementType } from "@/lib/modules/measurement-scope"; import { readCoachCorrelations } from "@/lib/ai/coach/tools/correlations-read"; import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read"; import { resolveLocaleForUser } from "@/lib/i18n/user-locale"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; import { buildLocalisedLabelIndex, foldLabel, @@ -1042,6 +1043,12 @@ export interface MetricBaselineResult { latest?: number; /** Where the latest reading sits relative to the personal band. */ placement?: "within" | "above" | "below"; + /** + * `"sameHours"` when today is still in progress for a metric whose day + * mean moves with the hour (glucose): `latest` is today's mean so far and + * the band is the usual range for the same hours of earlier days. + */ + basis?: "sameHours"; /** Population reference band, or `null` when none exists for the metric. */ referenceBand?: { low: number; high: number } | null; /** The single strongest lagged driver of this metric, or `null`. */ @@ -1070,6 +1077,7 @@ export async function getMetricBaseline( userId, metric.measurementType, await resolveLocaleForUser(userId), + { tz: await resolveUserTimezone(userId) }, ); if (!strip.baseline) { @@ -1101,6 +1109,7 @@ export async function getMetricBaseline( }, latest: strip.baseline.latest, placement: strip.baseline.placement, + ...(strip.baseline.basis ? { basis: strip.baseline.basis } : {}), referenceBand: metric.band, driver: strip.driver, }; diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index 7d5f04749..7e68950da 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -965,6 +965,12 @@ const getMetricBaselineOutput: z.ZodRawShape = { .optional(), latest: z.number().optional(), placement: z.enum(["within", "above", "below"]).optional(), + basis: z + .literal("sameHours") + .optional() + .describe( + "Present when today is still in progress for a metric whose day mean moves with the hour (blood glucose): `latest` is today's mean so far and `baseline` the usual range for the same hours of earlier days, not the whole-day range.", + ), referenceBand: bandShape, driver: z .object({ note: z.string(), behaviour: z.string(), outcome: z.string() }) diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index af86c029f..4715011ac 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -2660,6 +2660,12 @@ export const coachReadStripResponse = z .number() .int() .describe("Distinct days behind the band, for transparency."), + basis: z + .literal("sameHours") + .optional() + .describe( + "Present when today is still in progress for a metric whose day mean moves with the hour (blood glucose). `latest` is then today's mean so far and `low` / `high` the usual range for the same hours of the earlier days, not the whole-day band; a client naming the range says it is the one for this time of day. Absent for the whole-day comparison.", + ), }) .nullable() .describe(