diff --git a/src/lib/ai/prompts/insight-generator.ts b/src/lib/ai/prompts/insight-generator.ts index b4f36a865..a17c28c14 100644 --- a/src/lib/ai/prompts/insight-generator.ts +++ b/src/lib/ai/prompts/insight-generator.ts @@ -293,6 +293,10 @@ gib die obige Verweigerung zurück.`, briefing is stronger with one or two sharp signals than three weak ones. When "signalsOfDay" is absent or flat, omit the field (or set null) — do NOT manufacture a signal from a quiet metric. + Every "signalsOfDay" entry comes from a reading taken TODAY in the + 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. - 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 @@ -504,6 +508,10 @@ gib die obige Verweigerung zurück.`, zwei scharfe Signale sind stärker als drei schwache. Fehlt "signalsOfDay" oder ist flach, lass das Feld weg (oder setze null) — erfinde KEIN Signal aus einer ruhigen Metrik. + Jeder "signalsOfDay"-Eintrag stammt aus einer Messung von HEUTE in + 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. - 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/daily/__tests__/briefing-today.test.ts b/src/lib/daily/__tests__/briefing-today.test.ts new file mode 100644 index 000000000..7a9ff8f00 --- /dev/null +++ b/src/lib/daily/__tests__/briefing-today.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; + +import type { DailyBriefing } from "@/lib/ai/schema"; +import { + briefingForToday, + formatSignalDelta, +} from "@/lib/daily/briefing-today"; + +describe("formatSignalDelta", () => { + it("reads a pulse difference in whole beats (de)", () => { + expect( + formatSignalDelta("+33,72 bpm vs. dein 30-Tage-Mittel", "pulse", "de"), + ).toBe("+34 bpm vs. dein 30-Tage-Mittel"); + }); + + it("reads a pulse difference in whole beats (en)", () => { + expect( + formatSignalDelta("+33.72 bpm vs your 30-day average", "pulse", "en"), + ).toBe("+34 bpm vs your 30-day average"); + }); + + it("reads weight at one decimal in both languages", () => { + expect( + formatSignalDelta("−0,37 kg seit letzter Woche", "weight", "de"), + ).toBe("−0,4 kg seit letzter Woche"); + expect(formatSignalDelta("-0.37 kg since last week", "weight", "en")).toBe( + "-0.4 kg since last week", + ); + }); + + it("leaves a figure already at its precision, and a grouped thousand, alone", () => { + expect( + formatSignalDelta("+6 mmHg vs your 30-day average", "bp", "en"), + ).toBe("+6 mmHg vs your 30-day average"); + expect(formatSignalDelta("+1,2 kg", "weight", "de")).toBe("+1,2 kg"); + // "1.234" is a thousand in German, never a decimal to round. + expect(formatSignalDelta("1.234 bpm", "pulse", "de")).toBe("1.234 bpm"); + }); + + it("drops the sign of a difference that rounds to nothing", () => { + expect(formatSignalDelta("+0.3 bpm vs your normal", "pulse", "en")).toBe( + "0 bpm vs your normal", + ); + }); + + it("keeps a metric that is not a single reading verbatim", () => { + expect(formatSignalDelta("−42,5 Min.", "sleep", "de")).toBe("−42,5 Min."); + }); +}); + +const BRIEFING: DailyBriefing = { + paragraph: "Dein Puls ist heute deutlich erhöht.", + signalsOfDay: [ + { + sourceMetric: "pulse", + tone: "watch", + headline: "Puls ist heute deutlich erhöht", + nudge: "Gönn dir heute etwas Ruhe.", + delta: "+33,72 bpm vs. dein 30-Tage-Mittel", + }, + { + sourceMetric: "sleep", + tone: "info", + headline: "Kürzere Nacht", + nudge: "Heute etwas früher ins Bett.", + delta: null, + }, + ], + keyFindings: [], +}; + +/** 2026-10-05 21:00 in Berlin, the time the defect was seen. */ +const BERLIN = { + timezone: "Europe/Berlin", + todayLocalDate: "2026-10-05", + language: "de" as const, +}; + +describe("briefingForToday", () => { + it("drops a pulse signal whose last reading was yesterday at 08:50", () => { + const out = briefingForToday(BRIEFING, { + ...BERLIN, + updatedAt: "2026-10-05T04:00:00Z", + lastSeenAt: (type) => (type === "PULSE" ? "2026-10-04T06:50:00Z" : null), + }); + expect(out?.signalsOfDay?.map((s) => s.sourceMetric)).toEqual(["sleep"]); + }); + + it("keeps a pulse signal measured today, with its delta in whole beats", () => { + const out = briefingForToday(BRIEFING, { + ...BERLIN, + updatedAt: "2026-10-05T04:00:00Z", + lastSeenAt: (type) => (type === "PULSE" ? "2026-10-05T06:50:00Z" : null), + }); + expect(out?.signalsOfDay?.[0]?.delta).toBe( + "+34 bpm vs. dein 30-Tage-Mittel", + ); + }); + + it("serves no briefing written on an earlier day, just after midnight", () => { + // Written 23:30 on the 4th, read 00:10 on the 5th (Berlin). + expect( + briefingForToday(BRIEFING, { + ...BERLIN, + updatedAt: "2026-10-04T21:30:00Z", + lastSeenAt: () => "2026-10-04T21:00:00Z", + }), + ).toBeNull(); + }); + + it("decides the day in the reader's zone", () => { + // 05:00 UTC on the 5th is the evening of the 4th in Los Angeles. + const ctx = { + updatedAt: "2026-10-05T05:00:00Z", + lastSeenAt: () => "2026-10-05T05:00:00Z", + language: "en" as const, + }; + expect( + briefingForToday(BRIEFING, { + ...ctx, + timezone: "America/Los_Angeles", + todayLocalDate: "2026-10-05", + }), + ).toBeNull(); + expect( + briefingForToday(BRIEFING, { + ...ctx, + timezone: "UTC", + todayLocalDate: "2026-10-05", + })?.signalsOfDay?.length, + ).toBe(2); + }); +}); diff --git a/src/lib/daily/__tests__/today-overview.test.ts b/src/lib/daily/__tests__/today-overview.test.ts index 6e25dcc1a..060451c31 100644 --- a/src/lib/daily/__tests__/today-overview.test.ts +++ b/src/lib/daily/__tests__/today-overview.test.ts @@ -343,7 +343,51 @@ describe("deterministic lead — strongest signal", () => { expect(o.today).toEqual([]); }); - it("ignores a vital that is not from today or yesterday", () => { + it("says nothing about today from yesterday's reading", () => { + // A pulse from yesterday morning, read at nine in the evening: neither + // the lead nor the vitals line may call it today's. + const o = buildTodayOverview( + input({ + vitals: [ + vital({ + type: "PULSE", + daysAgo: 1, + direction: "above", + value: 98, + high: 72, + valueLabel: "98 bpm", + rangeLabel: "58 to 72 bpm", + }), + ], + }), + t, + ); + expect(o.lead).toBeNull(); + expect(o.today.some((f) => f.kind === "vitals")).toBe(false); + }); + + it("speaks about a vital measured today", () => { + const o = buildTodayOverview( + input({ + vitals: [ + vital({ + type: "PULSE", + daysAgo: 0, + direction: "above", + value: 98, + high: 72, + valueLabel: "98 bpm", + rangeLabel: "58 to 72 bpm", + }), + ], + }), + t, + ); + expect(o.lead?.source).toBe("signal"); + expect(o.lead?.text).toContain("98 bpm"); + }); + + it("ignores a vital from days ago", () => { const o = buildTodayOverview( input({ vitals: [vital({ daysAgo: 4, direction: "above", value: 70 })], diff --git a/src/lib/daily/briefing-today.ts b/src/lib/daily/briefing-today.ts new file mode 100644 index 000000000..bce8c7be8 --- /dev/null +++ b/src/lib/daily/briefing-today.ts @@ -0,0 +1,151 @@ +/** + * The cached briefing, reduced to what is still true about today. + * + * The briefing is model text written at one moment and served for up to a + * day, and the content-hash gate may keep the same text across several. The + * hero reads it as the day's lead, its top signal as "pulse is up today", and + * the morning push repeats both. Two things made that wrong in practice: + * + * - A briefing written on an earlier calendar day speaks about that day. + * Served after midnight it narrates yesterday's readings as today's. + * - A signal whose metric has no reading today is not a signal of the day, + * whatever the text says. The prompt input is filtered the same way + * (`computeSignalsOfDay`), so this only catches text cached before that, + * or a cache that outlived the reading it was written from. + * + * Both are decided here, on the server, so every client (the web hero, the + * iOS app reading `topSignal`, the push line) gets the same answer. The + * delta string is model text as well: its figures are re-read at the + * metric's display precision, so a pulse difference reads "+34 bpm", never + * "+33.72 bpm". + */ +import type { DailyBriefing, DailyBriefingSignal } from "@/lib/ai/schema"; +import type { Locale } from "@/lib/i18n/config"; +import { resolveIntlLocale } from "@/lib/format-locale"; +import { vitalDisplayDecimals } from "@/lib/measurements/vital-precision"; +import { userDayKey } from "@/lib/tz/format"; + +/** + * The measurement type a briefing signal is a reading of. Metrics absent + * here (sleep, steps, mood, compliance, derived scores) are not a single + * dated reading and keep their signal; the prompt input already decides + * those. + */ +export const SIGNAL_READING_TYPE: Partial< + Record +> = { + bp: "BLOOD_PRESSURE_SYS", + weight: "WEIGHT", + pulse: "PULSE", + resting_hr: "RESTING_HEART_RATE", + hrv: "HEART_RATE_VARIABILITY", + body_temp: "BODY_TEMPERATURE", +}; + +/** The locale's decimal separator ("," for de, "." for en). */ +function decimalSeparator(locale: Locale): string { + return ( + new Intl.NumberFormat(resolveIntlLocale(locale)) + .formatToParts(1.5) + .find((part) => part.type === "decimal")?.value ?? "." + ); +} + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Re-read the figures in a signal's delta string at the metric's display + * precision. + * + * Only a number written with the locale's own decimal separator is touched, + * and only when it carries more fraction digits than the metric is shown + * with: "1.234" in German is a thousand, not a decimal, and is left alone. + * A figure that rounds to zero loses its sign ("+0.3 bpm" reads "0 bpm"). + * A metric with no reading type keeps its delta verbatim. + */ +export function formatSignalDelta( + delta: string, + sourceMetric: DailyBriefingSignal["sourceMetric"], + locale: Locale, +): string { + const type = SIGNAL_READING_TYPE[sourceMetric]; + if (!type) return delta; + // Every reading type above reads at the same precision in both unit + // systems (whole bpm / mmHg / ms, one decimal of kg / lb / °C / °F). + const decimals = vitalDisplayDecimals(type, 1); + const sep = decimalSeparator(locale); + const number = new Intl.NumberFormat(resolveIntlLocale(locale), { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + useGrouping: false, + }); + const pattern = new RegExp( + `([+\\-−±]?)(\\d+)${escapeRegExp(sep)}(\\d+)(?![\\d])`, + "g", + ); + return delta.replace(pattern, (match, sign: string, int: string, frac) => { + if ((frac as string).length <= decimals) return match; + const value = Number(`${int}.${frac as string}`); + const rounded = Math.round(value * 10 ** decimals) / 10 ** decimals; + return `${rounded === 0 ? "" : sign}${number.format(rounded)}`; + }); +} + +export interface BriefingForTodayContext { + /** When the cached briefing was written (ISO), or null when unknown. */ + updatedAt: string | null; + /** The newest reading of a measurement type (ISO), or null when none. */ + lastSeenAt: (type: string) => string | null; + /** The reader's zone and today's day key in it. */ + timezone: string; + todayLocalDate: string; + /** The reader's language, already resolved; deltas are re-read in it. */ + language: Locale; +} + +/** + * The briefing as the Today surfaces may use it: null when it was written on + * an earlier calendar day, otherwise with every reading-backed signal whose + * metric was not measured today removed and every remaining delta re-read at + * its metric's precision. + */ +export function briefingForToday( + briefing: DailyBriefing | null, + ctx: BriefingForTodayContext, +): DailyBriefing | null { + if (!briefing) return null; + if ( + ctx.updatedAt !== null && + userDayKey(new Date(ctx.updatedAt), ctx.timezone) !== ctx.todayLocalDate + ) { + return null; + } + if (!briefing.signalsOfDay) return briefing; + const measuredToday = (type: string) => { + const at = ctx.lastSeenAt(type); + return ( + at !== null && + userDayKey(new Date(at), ctx.timezone) === ctx.todayLocalDate + ); + }; + const signalsOfDay = briefing.signalsOfDay + .filter((signal) => { + const type = SIGNAL_READING_TYPE[signal.sourceMetric]; + return type === undefined || measuredToday(type); + }) + .map((signal) => + signal.delta + ? { + ...signal, + delta: formatSignalDelta( + signal.delta, + signal.sourceMetric, + ctx.language, + ), + } + : signal, + ); + return { ...briefing, signalsOfDay }; +} diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index db39186f9..a786cbbf1 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -82,6 +82,8 @@ import { resolveRestMode } from "@/lib/illness/rest-mode"; import { readTodayCycle, type TodayCycleRead } from "@/lib/cycle/today-verdict"; import { addDays, dayDiff } from "@/lib/cycle/day-math"; import { makeFormatters, resolveIntlLocale } from "@/lib/format-locale"; +import { vitalDisplayDecimals } from "@/lib/measurements/vital-precision"; +import { briefingForToday } from "@/lib/daily/briefing-today"; import type { DateFormatPreference, TimeFormatPreference, @@ -328,24 +330,6 @@ async function gatherScoreDays( } } -/** - * Display decimals per vital. The transform's own `decimals` covers the - * converted units (lb, °F, mmol/L); the rest are whole numbers on every - * device and in every chart that shows them. - */ -function vitalDecimals(type: string, transformDecimals: number): number { - switch (type) { - case "BODY_TEMPERATURE": - case "SKIN_TEMPERATURE": - case "WEIGHT": - return 1; - case "BLOOD_GLUCOSE": - return transformDecimals; - default: - return 0; - } -} - /** Render one vital's value and range in the reader's units and locale. */ function toTodayVital( vital: ExtrasVital, @@ -354,7 +338,7 @@ function toTodayVital( t: (key: string, params?: Record) => string, ): TodayVital { const transform = getReadingTransform(vital.type, units); - const decimals = vitalDecimals(vital.type, transform.decimals); + const decimals = vitalDisplayDecimals(vital.type, transform.decimals); const number = new Intl.NumberFormat(resolveIntlLocale(locale), { maximumFractionDigits: decimals, minimumFractionDigits: decimals, @@ -950,7 +934,17 @@ export async function loadDailyDigest( snapshot.layout.enabledHeroItemKinds ?? PRIORITY_ITEM_KINDS, score, - briefing: snapshot.briefing, + // Only what is still true about today: a briefing written on an + // earlier day is not served as today's read, and a signal whose + // metric has no reading today is dropped (`briefing-today.ts`). + briefing: briefingForToday(snapshot.briefing, { + updatedAt: snapshot.briefingUpdatedAt, + lastSeenAt: (type) => + snapshot.tiles.lastSeenByType[type]?.lastSeenAt ?? null, + timezone: user.timezone, + todayLocalDate, + language: resolvedLocale, + }), medsToday: snapshot.medsToday, sleepLastSeenDaysAgo, morningRefreshedToday, diff --git a/src/lib/daily/today-overview.ts b/src/lib/daily/today-overview.ts index 726b14983..105eec8ef 100644 --- a/src/lib/daily/today-overview.ts +++ b/src/lib/daily/today-overview.ts @@ -24,7 +24,7 @@ import type { MedsTodayBlock } from "@/lib/dashboard/meds-today"; import type { Locale } from "@/lib/i18n/config"; import { pluralKey } from "@/lib/i18n/plural"; import { formatDurationMinutes } from "@/lib/i18n/duration"; -import { isCurrentForTodayClaim } from "@/lib/insights/measurement-freshness"; +import { isFromToday } from "@/lib/insights/measurement-freshness"; import type { ModuleKey } from "@/lib/modules/registry"; import type { PriorityItem } from "@/lib/daily/priority-item"; @@ -486,11 +486,16 @@ function buildSignalLine( return keptDelta ? { headline: null, delta: keptDelta } : null; } -/** Vitals that may speak about today: module on, reading current. */ +/** + * Vitals that may speak about today: module on, and a reading taken today in + * the reader's zone. Yesterday's reading is not today's, however recent: at + * nine in the evening a pulse from the previous morning would be narrated as + * the day's, on a day with no reading at all. + */ function currentVitals(input: TodayOverviewInput): TodayVital[] { return input.vitals.filter( (v) => - isCurrentForTodayClaim(v.daysAgo) && + isFromToday(v.daysAgo) && (v.moduleKey === null || moduleOn(input.modules, v.moduleKey)), ); } diff --git a/src/lib/insights/__tests__/health-status.test.ts b/src/lib/insights/__tests__/health-status.test.ts index b5ad57d9c..0bf8b49c8 100644 --- a/src/lib/insights/__tests__/health-status.test.ts +++ b/src/lib/insights/__tests__/health-status.test.ts @@ -57,6 +57,12 @@ describe("summariseHealthStatus", () => { expect(s.shifts[0]).toMatchObject({ metric: "WEIGHT", direction: "up" }); }); + it("does not present yesterday's reading as now", () => { + const s = summariseHealthStatus([vital(true, "above", 1)], []); + expect(s.present).toBe(false); + expect(s.deviations).toHaveLength(0); + }); + it("drops a vital flagged outside but with an 'in' direction", () => { const s = summariseHealthStatus([vital(true, "in")], []); expect(s.present).toBe(false); diff --git a/src/lib/insights/__tests__/signals-of-day-pulse.test.ts b/src/lib/insights/__tests__/signals-of-day-pulse.test.ts index 7aea4e5d1..a4417ef3b 100644 --- a/src/lib/insights/__tests__/signals-of-day-pulse.test.ts +++ b/src/lib/insights/__tests__/signals-of-day-pulse.test.ts @@ -7,16 +7,20 @@ const DAY = 86_400_000; /** * A day of pulse is the mean of its hours' means and a window the mean of its * days (`day-mean.ts`). Two days: a workout hour of twelve readings at 150 and - * three resting hours at 60 (day value 82.5), then a day with one reading at - * 60. The 7- and 30-day means are 71.25, not the 127.5 of the readings. + * three resting hours at 60 (day value 82.5), then today with one reading at + * 60. The 7- and 30-day means are 71.25, not the 127.5 of the readings; the + * pulse signal states them at its display precision, whole beats. */ + +/** 21:00 UTC: late enough that this morning's reading is today's. */ +const NOW = Date.UTC(2026, 9, 5, 21, 0); function rows(now: number) { const today = new Date(now); const midnight = (daysAgo: number) => Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()) - daysAgo * DAY; const out: Array<{ value: number; measuredAt: Date }> = []; - const workout = midnight(3); + const workout = midnight(1); for (let i = 0; i < 12; i += 1) { out.push({ value: 150, @@ -26,13 +30,13 @@ function rows(now: number) { for (const h of [12, 14, 16]) { out.push({ value: 60, measuredAt: new Date(workout + h * 3_600_000) }); } - out.push({ value: 60, measuredAt: new Date(midnight(2) + 8 * 3_600_000) }); + out.push({ value: 60, measuredAt: new Date(midnight(0) + 8 * 3_600_000) }); return out; } describe("the pulse signal of the day", () => { it("weighs each day once in its 7- and 30-day means", () => { - const now = Date.now(); + const now = NOW; const pulse = rows(now); const [signal] = computeSignalsOfDay( (type) => (type === "PULSE" ? pulse : []), @@ -40,13 +44,13 @@ describe("the pulse signal of the day", () => { "UTC", ); expect(signal.metric).toBe("pulse"); - expect(signal.avg7).toBe(71.25); - expect(signal.avg30).toBe(71.25); - expect(signal.deltaVs7).toBe(-11.25); + expect(signal.avg7).toBe(71); + expect(signal.avg30).toBe(71); + expect(signal.deltaVs7).toBe(-11); }); it("leaves every other metric on the plain mean of its readings", () => { - const now = Date.now(); + const now = NOW; const weight = rows(now); const [signal] = computeSignalsOfDay( (type) => (type === "WEIGHT" ? weight : []), diff --git a/src/lib/insights/__tests__/signals-of-day-today.test.ts b/src/lib/insights/__tests__/signals-of-day-today.test.ts new file mode 100644 index 000000000..d03b066a8 --- /dev/null +++ b/src/lib/insights/__tests__/signals-of-day-today.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +import { computeSignalsOfDay } from "../signals-of-day"; + +const HOUR = 3_600_000; +const DAY = 24 * HOUR; + +/** + * Thirty days of pulse around 64 bpm, one reading a day at 08:50 local, the + * newest one at `newest`. Enough history for a signal; the only question is + * whether the newest reading is today's. + */ +function pulseUpTo(newest: Date) { + const out: Array<{ value: number; measuredAt: Date }> = []; + for (let d = 20; d >= 1; d -= 1) { + out.push({ + value: 62 + (d % 5), + measuredAt: new Date(newest.getTime() - d * DAY), + }); + } + out.push({ value: 98, measuredAt: newest }); + return out; +} + +function signals(newest: Date, now: Date, tz: string) { + const pulse = pulseUpTo(newest); + return computeSignalsOfDay( + (type) => (type === "PULSE" ? pulse : []), + now.getTime(), + tz, + ); +} + +describe("signals of the day are about today", () => { + it("drops a pulse last measured yesterday morning, read at 21:00", () => { + // 2026-10-04 08:50 and 2026-10-05 21:00 in Berlin (UTC+2). + const newest = new Date("2026-10-04T06:50:00Z"); + const now = new Date("2026-10-05T19:00:00Z"); + expect(signals(newest, now, "Europe/Berlin")).toEqual([]); + }); + + it("keeps a pulse measured this morning", () => { + const newest = new Date("2026-10-05T06:50:00Z"); + const now = new Date("2026-10-05T19:00:00Z"); + const [signal] = signals(newest, now, "Europe/Berlin"); + expect(signal?.metric).toBe("pulse"); + expect(signal?.latestDaysAgo).toBe(0); + // Whole beats, as pulse is shown everywhere else. + expect(Number.isInteger(signal?.deltaVs30)).toBe(true); + expect(Number.isInteger(signal?.avg30)).toBe(true); + }); + + it("treats 23:50 as yesterday at 00:10, and 00:05 as today", () => { + const now = new Date("2026-10-05T22:10:00Z"); // 00:10 on the 6th in Berlin + expect( + signals(new Date("2026-10-05T21:50:00Z"), now, "Europe/Berlin"), + ).toEqual([]); + expect( + signals(new Date("2026-10-05T22:05:00Z"), now, "Europe/Berlin")[0] + ?.metric, + ).toBe("pulse"); + }); + + it("reads today in the reader's zone, not in UTC", () => { + // 05:00 UTC is 22:00 the previous evening in Los Angeles; 18:00 UTC the + // same calendar day in UTC, but the next one in Los Angeles. + const newest = new Date("2026-10-05T05:00:00Z"); + const now = new Date("2026-10-05T18:00:00Z"); + expect(signals(newest, now, "UTC")[0]?.metric).toBe("pulse"); + expect(signals(newest, now, "America/Los_Angeles")).toEqual([]); + }); + + it("is quiet when there is nothing today, even on a sizeable deviation", () => { + const newest = new Date(Date.UTC(2026, 9, 3, 8)); + const now = new Date(newest.getTime() + 2 * DAY); + expect(signals(newest, now, "UTC")).toEqual([]); + }); +}); diff --git a/src/lib/insights/derived/__tests__/coincident-deviation.test.ts b/src/lib/insights/derived/__tests__/coincident-deviation.test.ts index 6b08af942..76bdf0548 100644 --- a/src/lib/insights/derived/__tests__/coincident-deviation.test.ts +++ b/src/lib/insights/derived/__tests__/coincident-deviation.test.ts @@ -145,6 +145,23 @@ describe("computeCoincidentDeviation", () => { } }); + it("does not call yesterday's outliers today's, read the next evening", async () => { + // The outliers were taken 2026-06-02 07:00 UTC; read on the 3rd at + // 21:00 in Berlin there is no reading today, so nothing contributes. + findMany.mockImplementation(firingRows); + const result = await computeCoincidentDeviation("u1", PROFILE, { + now: new Date("2026-06-03T19:00:00Z"), + tz: "Europe/Berlin", + }); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.value.contributing).toEqual([]); + expect(result.value.fired).toBe(false); + // The vitals keep their standing and their age for the anatomy view. + expect(result.value.vitals.every((v) => v.daysAgo === 1)).toBe(true); + } + }); + it("does not fire when vitals stay inside their bands", async () => { const flat = (base: number): Array<{ value: number; measuredAt: Date }> => Array.from({ length: 10 }, (_, i) => ({ diff --git a/src/lib/insights/derived/coincident-deviation.ts b/src/lib/insights/derived/coincident-deviation.ts index 235a514fa..a9dbe2459 100644 --- a/src/lib/insights/derived/coincident-deviation.ts +++ b/src/lib/insights/derived/coincident-deviation.ts @@ -52,7 +52,7 @@ import { } from "@/lib/measurements/value-domain"; import { dayKeyAgeInDays, - isCurrentForTodayClaim, + isFromToday, } from "@/lib/insights/measurement-freshness"; import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format"; @@ -422,14 +422,14 @@ export async function computeCoincidentDeviation( } // Everything this flag says is about TODAY — the card is literally called - // "signals of the day" and every line it renders is present tense. A vital - // whose freshest reading is older than that cannot contribute to it: the + // "signals of the day" and its lines say "today". A vital whose freshest + // reading is from an earlier calendar day cannot contribute to it: the // reading describes the day it was taken on, and saying otherwise tells the // reader something about a day on which nothing was measured. Stale vitals // stay in `vitals` with their age, so the anatomy view still lists them and // nothing silently disappears. const contributing = vitals.filter( - (v) => v.outside && isCurrentForTodayClaim(v.daysAgo), + (v) => v.outside && isFromToday(v.daysAgo), ); const fired = contributing.length >= COINCIDENT_FIRE_THRESHOLD; diff --git a/src/lib/insights/features-units.ts b/src/lib/insights/features-units.ts index 874142a82..1e06fb3ee 100644 --- a/src/lib/insights/features-units.ts +++ b/src/lib/insights/features-units.ts @@ -16,6 +16,10 @@ * factor alone. A metric reader on mg/dL gets every number back unchanged, * with a unit label added. */ +import { + roundToDisplay, + vitalDisplayDecimals, +} from "@/lib/measurements/vital-precision"; import { applyDisplayTransform, applyDisplayTransformDelta, @@ -61,19 +65,29 @@ export function signalInReaderUnits( const type = SIGNAL_TYPE[signal.metric]; if (!type) return signal; const t = getReadingTransform(type, units); + // A signal is a statement the model repeats to the reader, so its figures + // carry the precision the reading is shown at. The metric branch of a + // transform is the identity and does not round, which handed the model + // "+0.37 kg" to restate. + const decimals = vitalDisplayDecimals(type, t.decimals); + const round = (value: Num): Num => + typeof value === "number" ? roundToDisplay(value, decimals) : value; return { ...signal, unit: t.displayUnit, - latest: applyDisplayTransform(signal.latest, t), - avg7: abs(signal.avg7, t), - avg30: abs(signal.avg30, t), - deltaVs7: delta(signal.deltaVs7, t), - deltaVs30: delta(signal.deltaVs30, t), - spread30: delta(signal.spread30, t), + latest: roundToDisplay(applyDisplayTransform(signal.latest, t), decimals), + avg7: round(abs(signal.avg7, t)), + avg30: round(abs(signal.avg30, t)), + deltaVs7: round(delta(signal.deltaVs7, t)), + deltaVs30: round(delta(signal.deltaVs30, t)), + spread30: round(delta(signal.spread30, t)), recentAnomaly: signal.recentAnomaly ? { ...signal.recentAnomaly, - value: applyDisplayTransform(signal.recentAnomaly.value, t), + value: roundToDisplay( + applyDisplayTransform(signal.recentAnomaly.value, t), + decimals, + ), } : null, }; diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index 08e790466..091714530 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -1901,7 +1901,14 @@ export async function extractFeatures( // in-memory measurement set (no extra DB round-trip). Lands inside the // compacted features payload, so it feeds both the briefing prompt AND the // content-hash gate — a fresh daily signal forces the briefing to refresh. - const signalsOfDay = computeSignalsOfDay(byType, now, pulseTz); + // Each signal is a statement about today, which is a calendar day in the + // reader's own zone, so the zone is resolved for every account here, not + // only for the ones with pulse. + const signalsOfDay = computeSignalsOfDay( + byType, + now, + await resolveUserTimezone(userId), + ); if (signalsOfDay.length > 0) { features.signalsOfDay = signalsOfDay; } diff --git a/src/lib/insights/health-status.ts b/src/lib/insights/health-status.ts index 358059b6e..6cf8e718d 100644 --- a/src/lib/insights/health-status.ts +++ b/src/lib/insights/health-status.ts @@ -17,7 +17,7 @@ import type { MeasurementType } from "@/generated/prisma/client"; import type { VitalDeviation } from "@/lib/insights/derived/coincident-deviation"; import type { ChangepointSignal } from "@/lib/insights/derived/changepoint"; -import { isCurrentForTodayClaim } from "@/lib/insights/measurement-freshness"; +import { isFromToday } from "@/lib/insights/measurement-freshness"; /** One vital sitting outside its personal band today. */ export interface HealthStatusDeviation { @@ -66,14 +66,12 @@ export function summariseHealthStatus( ): HealthStatusSummary { const deviations: HealthStatusDeviation[] = vitals // Present tense, so present readings only. "Your pulse is above your usual - // range" describes now; a reading from last week describes last week, and - // the card carried no date to say which. A vital whose freshest reading is - // outside the claim window is left out rather than dressed as current — + // range ... now 95" describes today; a reading from yesterday describes + // yesterday, and the card carries no date to say which. A vital whose + // freshest reading is not from today is left out rather than dressed as + // current — // the changepoint shifts below are dated by construction and unaffected. - .filter( - (v) => - v.outside && v.direction !== "in" && isCurrentForTodayClaim(v.daysAgo), - ) + .filter((v) => v.outside && v.direction !== "in" && isFromToday(v.daysAgo)) .map((v) => ({ type: v.type, value: v.value, diff --git a/src/lib/insights/measurement-freshness.ts b/src/lib/insights/measurement-freshness.ts index 2fcf162fa..7063d74e9 100644 --- a/src/lib/insights/measurement-freshness.ts +++ b/src/lib/insights/measurement-freshness.ts @@ -42,6 +42,27 @@ export function isCurrentForTodayClaim( ); } +/** + * Whether a reading was taken today, on the reader's own calendar. + * + * Stricter than {@link isCurrentForTodayClaim}, and the rule for every + * sentence that says "today" in so many words or compares today against a + * baseline: the Today overview on the dashboard, the signals of the day, + * the "vitals outside their range today" card. A reading from yesterday + * morning, read at nine in the evening, is not today's pulse, and saying so + * describes a day on which nothing was measured. A metric with no reading + * today is left out of those statements rather than worded with its age: + * the quieter of the two answers, and the one that never asks for a + * measurement nobody planned to take. + * + * `daysAgo` is the calendar distance in the reader's zone (as + * {@link dayKeyAgeInDays} computes it), never elapsed hours: 23:40 and + * 00:10 are different days, 00:10 and 23:50 the same one. + */ +export function isFromToday(daysAgo: number | null | undefined): boolean { + return daysAgo === 0; +} + /** * Whole days between two `YYYY-MM-DD` local-day keys (`then` before `today`). * diff --git a/src/lib/insights/signals-of-day.ts b/src/lib/insights/signals-of-day.ts index 6f6c620e0..a55db8b60 100644 --- a/src/lib/insights/signals-of-day.ts +++ b/src/lib/insights/signals-of-day.ts @@ -10,6 +10,12 @@ */ import { trendSlope } from "@/lib/analytics/trends"; import { avgInWindow, stdDev, toDataPoints } from "@/lib/insights/features"; +import { dayKeyAgeInDays } from "@/lib/insights/measurement-freshness"; +import { + roundToDisplay, + vitalDisplayDecimals, +} from "@/lib/measurements/vital-precision"; +import { userDayKey } from "@/lib/tz/format"; /** * v1.18.7 — one present-focused signal feeding the daily briefing. Every @@ -26,7 +32,10 @@ export interface SignalOfDay { unit?: string; /** Freshest reading (the "now" value the briefing leads with). */ latest: number; - /** Days since the freshest reading. */ + /** + * Calendar days between the freshest reading and today in the reader's + * zone. Always 0: a metric with no reading today yields no signal. + */ latestDaysAgo: number; /** Trailing-7d mean. */ avg7: number | null; @@ -88,26 +97,51 @@ function r2(v: number): number { return Math.round(v * 100) / 100; } +/** How one signal is read: its zone, and the precision its figures carry. */ +interface SignalRead { + /** The reader's zone; "today" is the calendar day in it. */ + tz: string; + /** For pulse, the zone its day means are read in (`day-mean.ts`). */ + day?: { type: string; tz: string }; + /** + * Display precision of a unit-insensitive metric (bpm, mmHg), applied + * here. Weight and glucose stay canonical and are rounded where they are + * converted into the reader's unit (`signalInReaderUnits`). + */ + decimals?: number; +} + /** * Build one signal from a single metric's measurement records. Returns null - * when there is no fresh reading or fewer than three points in 30 days — a - * sparse metric cannot carry an honest "today vs your normal" read. + * when the metric has no reading today, or fewer than three points in 30 + * days — a sparse metric cannot carry an honest "today vs your normal" read. + * + * "Today" is the calendar day in the reader's zone. Every signal is a + * present-tense statement about the day ("pulse is up today"), so a reading + * from yesterday morning read at nine in the evening is not one: it would + * narrate a day on which nothing was measured. A metric that was not + * measured today simply has no signal; the briefing's trend sections still + * carry its history, dated. */ function buildSignal( metric: SignalOfDay["metric"], label: string, records: Array<{ value: number; measuredAt: Date }>, now: number, - unit?: string, - day?: { type: string; tz: string }, + unit: string | undefined, + read: SignalRead, ): SignalOfDay | null { if (records.length === 0) return null; const newest = records[records.length - 1]; - const latestDaysAgo = Math.round( - (now - newest.measuredAt.getTime()) / (24 * 60 * 60 * 1000), + const latestDaysAgo = dayKeyAgeInDays( + userDayKey(newest.measuredAt, read.tz), + userDayKey(new Date(now), read.tz), ); - // A reading older than two weeks is not a "signal of the day". - if (latestDaysAgo > 14) return null; + if (latestDaysAgo !== 0) return null; + const { day } = read; + const shown = (v: number) => + read.decimals === undefined ? r2(v) : roundToDisplay(v, read.decimals); + const shownOrNull = (v: number | null) => (v === null ? null : shown(v)); const win30 = records.filter( (rec) => rec.measuredAt.getTime() >= now - 30 * 24 * 60 * 60 * 1000, @@ -119,8 +153,8 @@ function buildSignal( const avg7 = avgInWindow(records, now, 7, 0, day); const avg30 = avgInWindow(records, now, 30, 0, day); const spread30 = stdDev(win30.map((rec) => rec.value)); - const deltaVs7 = avg7 !== null ? r2(newest.value - avg7) : null; - const deltaVs30 = avg30 !== null ? r2(newest.value - avg30) : null; + const deltaVs7 = avg7 !== null ? shown(newest.value - avg7) : null; + const deltaVs30 = avg30 !== null ? shown(newest.value - avg30) : null; const outsideNormalSwing = avg30 !== null && spread30 !== null && spread30 > 0 ? Math.abs(newest.value - avg30) > spread30 @@ -152,7 +186,7 @@ function buildSignal( bestAbs = abs; recentAnomaly = { kind: (sd > 0 ? "peak" : "trough") as "peak" | "trough", - value: r2(rec.value), + value: shown(rec.value), anomalyDaysAgo: Math.round( (now - rec.measuredAt.getTime()) / (24 * 60 * 60 * 1000), ), @@ -166,13 +200,13 @@ function buildSignal( metric, label, ...(unit ? { unit } : {}), - latest: r2(newest.value), + latest: shown(newest.value), latestDaysAgo, - avg7, - avg30, + avg7: shownOrNull(avg7), + avg30: shownOrNull(avg30), deltaVs7, deltaVs30, - spread30, + spread30: shownOrNull(spread30), outsideNormalSwing, emergingTrend, recentAnomaly, @@ -190,13 +224,17 @@ function buildSignal( export function computeSignalsOfDay( byType: (type: string) => Array<{ value: number; measuredAt: Date }>, now: number, - /** The zone pulse days are read in (`day-mean.ts`). */ - pulseTz: string = "UTC", + /** The reader's zone: today's calendar day, and pulse's day means. */ + tz: string = "UTC", ): SignalOfDay[] { const candidates: SignalOfDay[] = []; const push = (s: SignalOfDay | null) => { if (s) candidates.push(s); }; + const whole = (type: string) => ({ + tz, + decimals: vitalDisplayDecimals(type, 0), + }); // Systolic carries the BP signal (the headline number clinicians read first). push( @@ -206,10 +244,22 @@ export function computeSignalsOfDay( byType("BLOOD_PRESSURE_SYS"), now, "mmHg", + whole("BLOOD_PRESSURE_SYS"), ), ); // Absent data simply produces no signal. - push(buildSignal("glucose", "blood glucose", byType("BLOOD_GLUCOSE"), now)); + push( + buildSignal( + "glucose", + "blood glucose", + byType("BLOOD_GLUCOSE"), + now, + undefined, + { + tz, + }, + ), + ); push( buildSignal( "resting_hr", @@ -217,18 +267,21 @@ export function computeSignalsOfDay( byType("RESTING_HEART_RATE"), now, "bpm", + whole("RESTING_HEART_RATE"), ), ); push( buildSignal("pulse", "pulse", byType("PULSE"), now, "bpm", { - type: "PULSE", - tz: pulseTz, + ...whole("PULSE"), + day: { type: "PULSE", tz }, }), ); // Weight and glucose are canonical here and carry no unit: the reader's // 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)); + push( + buildSignal("weight", "weight", byType("WEIGHT"), now, undefined, { tz }), + ); // 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 // ingest as many intraday `stats:`-prefixed samples, so the newest raw row diff --git a/src/lib/measurements/vital-precision.ts b/src/lib/measurements/vital-precision.ts new file mode 100644 index 000000000..d46dfacf1 --- /dev/null +++ b/src/lib/measurements/vital-precision.ts @@ -0,0 +1,37 @@ +/** + * Decimal places a vital reading is shown at, in the unit it is shown in. + * + * The transform's own `decimals` covers the converted units (lb, °F, + * mmol/L); body temperature and weight read at one decimal; the rest + * (pulse, blood pressure, HRV, SpO2, respiratory rate, the cumulative + * same-hours metrics) are whole numbers on every device and in every chart + * that shows them. + * + * One answer for every server-side text that prints a vital or a difference + * of one: the Today overview's value and range labels, the signals of the + * day the briefing prompt is given, and the delta under the hero lead. A + * difference is read at the precision of the readings it was taken from, so + * "+34 bpm", never "+33.72 bpm". + */ +export function vitalDisplayDecimals( + type: string, + transformDecimals: number, +): number { + switch (type) { + case "BODY_TEMPERATURE": + case "SKIN_TEMPERATURE": + case "WEIGHT": + return 1; + case "BLOOD_GLUCOSE": + return transformDecimals; + default: + return 0; + } +} + +/** Round to `decimals` fraction digits. Non-finite input passes through. */ +export function roundToDisplay(value: number, decimals: number): number { + if (!Number.isFinite(value)) return value; + const scale = 10 ** decimals; + return Math.round(value * scale) / scale; +}