mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(insights): place a glucose day in progress against the same hours
The usual-range strip on a metric page placed the latest single reading against a band built from whole-day means. For glucose the day mean climbs with every meal, so at nine in the morning a fasting reading sat under a band of whole days and the strip said "below your range" on an ordinary morning; the MCP baseline read reported the same. Glucose is now placed by its day mean. A day still in progress, in the reader's own zone, is held against the earlier days cut at the local time of today's latest reading, the comparison the health status already makes; with fewer than seven earlier days that have readings in those hours there is no verdict. The baseline carries `basis: "sameHours"` on the coach-read route and the MCP read, and the strip names the range as the one for this time of day, in all seven locales. Other metrics keep their placement.
This commit is contained in:
1 parent
362818418e
commit
3e968f03a9
21 files changed
+518
-40
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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} — 평소보다 낮아요.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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<typeof getSession>
|
||||
>,
|
||||
);
|
||||
zoneSeen.mockReset();
|
||||
|
||||
const res = await GET(REQUEST());
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(zoneSeen).toHaveBeenCalledWith("America/New_York");
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
|
||||
@@ -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(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<CoachReadStrip metricType={METRIC} unit="mg/dL" />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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<typeof import("@/lib/insights/derived/baseline")>()),
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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<CoachReadBaseline | "no-basis" | null> {
|
||||
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<CoachReadStripData> {
|
||||
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,
|
||||
|
||||
@@ -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<MeasurementType> = new Set([
|
||||
export const SAME_HOURS_TYPES: ReadonlySet<MeasurementType> = 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 };
|
||||
}),
|
||||
|
||||
@@ -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) },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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() })
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user