From aa222f5b02b32404ace597fe8b8dc82f59e58a3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 21:22:57 +0200 Subject: [PATCH 1/2] fix(daily): speak about today only from today's readings A statement that says "today", or compares today against a baseline, now only rests on a reading taken on the current calendar day in the reader's zone. Yesterday morning's pulse read at nine in the evening was narrated as today's: the signals of the day accepted any reading from the last two weeks, and the Today overview, the coincident deviation flag and the health status counted yesterday as current. - computeSignalsOfDay drops a metric with no reading today, so the briefing prompt and its content hash never see a stale value as the day's; the prompt says so in both languages. - The digest serves no cached briefing written on an earlier day and drops a reading-backed signal whose metric was not measured today, so the web hero, the iOS topSignal and the morning push agree. - The Today overview's vitals line and deterministic lead, the coincident flag and the health status deviations use the new isFromToday rule (calendar day, not elapsed hours). Signal deltas now carry each metric's display precision: the prompt input is rounded (whole bpm and mmHg, one decimal of kg), and the delta string under the hero lead is re-read at that precision in the reader's locale ("+34 bpm", not "+33,72 bpm"). --- src/lib/ai/prompts/insight-generator.ts | 8 + .../daily/__tests__/briefing-today.test.ts | 133 +++++++++++++++ .../daily/__tests__/today-overview.test.ts | 46 +++++- src/lib/daily/briefing-today.ts | 151 ++++++++++++++++++ src/lib/daily/load-digest.ts | 34 ++-- src/lib/daily/today-overview.ts | 11 +- .../insights/__tests__/health-status.test.ts | 6 + .../__tests__/signals-of-day-pulse.test.ts | 22 +-- .../__tests__/signals-of-day-today.test.ts | 78 +++++++++ .../__tests__/coincident-deviation.test.ts | 17 ++ .../insights/derived/coincident-deviation.ts | 8 +- src/lib/insights/features-units.ts | 28 +++- src/lib/insights/features.ts | 9 +- src/lib/insights/health-status.ts | 14 +- src/lib/insights/measurement-freshness.ts | 21 +++ src/lib/insights/signals-of-day.ts | 97 ++++++++--- src/lib/measurements/vital-precision.ts | 37 +++++ 17 files changed, 645 insertions(+), 75 deletions(-) create mode 100644 src/lib/daily/__tests__/briefing-today.test.ts create mode 100644 src/lib/daily/briefing-today.ts create mode 100644 src/lib/insights/__tests__/signals-of-day-today.test.ts create mode 100644 src/lib/measurements/vital-precision.ts 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; +} From ff0769f045e0587d00ead7027773ea57ceb53c83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 21:30:29 +0200 Subject: [PATCH 2/2] fix(dashboard): lay the Today hero out for a phone Below md the score ring sat centred under the Today facts as a 168 px dial, where the bottom navigation covered it. The hero is now one grid with two arrangements: on a phone the ring is a compact 80 px dial at the top right beside the lead and signal line, flush with the card's trailing edge, and the Today facts run the full card width under both; from md up the ring spans both rows of the trailing column exactly as before (desktop captures are pixel-identical). Without a lead the facts take the leading cell, so the ring never sits beside an empty row. ScoreRing gains a `responsive` size (80 px, md:168 px) whose label is left out below md; the ring keeps its aria-label and the surrounding link names it. The meta lines under the phone dial are capped to its width, so the lead keeps at least 180 px beside it at 360 px. Each fact now stacks its muted label above its foreground value on the reading edge at every width, instead of a label-left / value-right row that broke long values into a ragged right-aligned block. Rows stay at the 44 px tap floor below md. The Today overview e2e covers 1440, 390 and 360 px in both themes for an AI lead, a long AI lead, an AI-off day and a quiet day, and asserts the dial size, its place beside the lead, symmetric insets, full-width facts and stacked 44 px rows on a phone. --- e2e/today-overview.spec.ts | 277 +++++++++++------ .../daily/__tests__/today-hero.test.tsx | 48 ++- src/components/daily/today-hero.tsx | 282 +++++++++++------- .../insights/derived/score-ring.tsx | 37 ++- 4 files changed, 441 insertions(+), 203 deletions(-) diff --git a/e2e/today-overview.spec.ts b/e2e/today-overview.spec.ts index 459201129..4216f5b82 100644 --- a/e2e/today-overview.spec.ts +++ b/e2e/today-overview.spec.ts @@ -16,11 +16,13 @@ import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures" * The Today overview on the dashboard hero, at a desktop and a phone width. * * What it proves is geometry, because that is what the unit suites cannot - * see: the facts sit beside the ring on a wide screen and above it on a - * phone, a phone shows four facts and a wide screen five, nothing pushes the - * page sideways, the hero keeps the width of the cards around it, and the - * ring keeps its size. Each state is also captured as an element screenshot - * under `test-results/today-overview/` for a visual check. + * see: the facts sit beside the ring on a wide screen; on a phone the ring is + * a compact dial at the top right beside the lead and the facts run the full + * card width under both; a phone shows four facts and a wide screen five, + * nothing pushes the page sideways, the hero keeps the width of the cards + * around it, and the ring keeps its size per breakpoint. Each state is also + * captured as an element screenshot, in both themes, under + * `test-results/today-overview/` for a visual check. * * The digests are what the server would publish for each state. They carry * the pre-overview fields too (`briefingLead`, `line`), with the values the @@ -36,8 +38,17 @@ const SHOTS = join(process.cwd(), "test-results", "today-overview"); const VIEWPORTS = [ { name: "desktop", width: 1440, height: 900 }, { name: "mobile", width: 390, height: 844 }, + { name: "narrow", width: 360, height: 780 }, ] as const; +const THEMES = ["dark", "light"] as const; + +/** The dial's edge per breakpoint: compact on a phone, md from 768 px. */ +const RING_PX = { phone: 80, wide: 168 } as const; + +/** Below this the lead beside the ring would read as a column of words. */ +const MIN_LEAD_WIDTH = 180; + const BASE: DailyDigest = { generatedAt: new Date().toISOString(), ai: DIGEST_AI_AVAILABLE, @@ -158,6 +169,39 @@ const STATES: Record = { }, today: [FACTS[0], FACTS[2], FACTS[4]], }, + // AI on with a long lead and a long sleep value: the phone case the ring + // used to fall under the facts for. + "ai-on-long": { + ...BASE, + score: { + value: 63, + band: "yellow", + delta: -6, + deltaReason: null, + steadyWeeks: null, + }, + lead: { + text: "Your pulse sits well above its usual level this evening, after a shorter night than you normally get; a quieter evening would suit it.", + source: "briefing", + }, + signalLine: { headline: null, delta: "+34 bpm vs your 30-day average" }, + today: [ + FACTS[0], + { + kind: "sleep", + label: "Last night", + value: "5h 45m, 42m less than usual", + href: "/insights/sleep", + moduleKey: "sleep", + }, + { + kind: "vitals", + label: "Vitals", + value: "Pulse above your range", + href: "/insights", + }, + ], + }, // A quiet day: one calm sentence and no facts beyond it. quiet: { ...BASE, @@ -195,96 +239,163 @@ test.describe("Today overview geometry", () => { }); for (const viewport of VIEWPORTS) { - for (const [state, digest] of Object.entries(STATES)) { - test(`${state} at ${viewport.width}px`, async ({ page }) => { - await page.setViewportSize(viewport); - const hero = await openDashboard(page, digest); + for (const theme of THEMES) { + for (const [state, digest] of Object.entries(STATES)) { + test(`${state} at ${viewport.width}px, ${theme}`, async ({ page }) => { + // The app paints from its own stored preference, not the media + // query, so both are set. + await page.emulateMedia({ + colorScheme: theme, + reducedMotion: "reduce", + }); + await page.addInitScript((value: string) => { + window.localStorage.setItem("healthlog-theme", value); + }, theme); + await page.setViewportSize(viewport); + const hero = await openDashboard(page, digest); + const phone = viewport.width < 768; - mkdirSync(SHOTS, { recursive: true }); - await hero.screenshot({ - path: join(SHOTS, `${viewport.name}-${state}.png`), - }); + // Late banners (the key-backup nudge) insert above the hero; let + // the page settle so the capture shows one stable layout. + await page.waitForLoadState("networkidle"); + mkdirSync(SHOTS, { recursive: true }); + await hero.screenshot({ + path: join(SHOTS, `${viewport.name}-${theme}-${state}.png`), + }); + // The viewport as a reader sees it, bottom navigation included. + await page.evaluate(() => window.scrollTo(0, 0)); + await page.screenshot({ + path: join( + SHOTS, + `${viewport.name}-${theme}-${state}-viewport.png`, + ), + }); - // Nothing pushes the page sideways. - const overflow = await page.evaluate( - () => document.documentElement.scrollWidth - window.innerWidth, - ); - expect(overflow).toBeLessThanOrEqual(0); + // Nothing pushes the page sideways. + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - window.innerWidth, + ); + expect(overflow).toBeLessThanOrEqual(0); - // The hero spans the same column as the tile strip below it. - const heroBox = (await hero.boundingBox())!; - const stripBox = (await page - .locator('[data-slot="dashboard-tile-strip"]') - .boundingBox())!; - expect(Math.abs(heroBox.x - stripBox.x)).toBeLessThanOrEqual(1); - expect(Math.abs(heroBox.width - stripBox.width)).toBeLessThanOrEqual(1); + // The hero spans the same column as the tile strip below it. + const heroBox = (await hero.boundingBox())!; + const stripBox = (await page + .locator('[data-slot="dashboard-tile-strip"]') + .boundingBox())!; + expect(Math.abs(heroBox.x - stripBox.x)).toBeLessThanOrEqual(1); + expect(Math.abs(heroBox.width - stripBox.width)).toBeLessThanOrEqual( + 1, + ); - // The ring keeps its size: the same 168 px dial as before. - const ring = hero.locator('[data-slot="score-ring"]').first(); - const ringBox = (await ring.boundingBox())!; - expect(Math.round(ringBox.width)).toBe(168); - expect(Math.round(ringBox.height)).toBe(168); + // The ring keeps its size: a compact dial on a phone, the same + // 168 px dial as before from md up. + const ring = hero.locator('[data-slot="score-ring"]').first(); + const ringBox = (await ring.boundingBox())!; + const edge = phone ? RING_PX.phone : RING_PX.wide; + expect(Math.round(ringBox.width)).toBe(edge); + expect(Math.round(ringBox.height)).toBe(edge); - const lead = hero.locator('[data-slot="today-hero-lead"]'); - await expect(lead).toBeVisible(); - await expect(lead).toHaveAttribute("data-source", digest.lead!.source); - const leadBox = (await lead.boundingBox())!; + const lead = hero.locator('[data-slot="today-hero-lead"]'); + await expect(lead).toBeVisible(); + await expect(lead).toHaveAttribute( + "data-source", + digest.lead!.source, + ); + const leadBox = (await lead.boundingBox())!; - const facts = hero.locator('[data-slot="today-hero-fact"]'); - const expected = digest.today.length; - await expect(facts).toHaveCount(expected); - const visible = await facts.evaluateAll( - (items) => - items.filter((el) => (el as HTMLElement).offsetParent !== null) - .length, - ); - expect(visible).toBe( - viewport.width < 768 ? Math.min(expected, 4) : expected, - ); + // The ring sits at the top right, beside the lead, and the lead + // keeps a readable width next to it. + expect(ringBox.x).toBeGreaterThanOrEqual(leadBox.x + leadBox.width); + expect(ringBox.y).toBeLessThan(leadBox.y + leadBox.height); + expect(Math.abs(ringBox.y - leadBox.y)).toBeLessThanOrEqual(8); + expect(leadBox.width).toBeGreaterThanOrEqual(MIN_LEAD_WIDTH); + // Symmetric insets: the ring's right edge mirrors the lead's left. + const leftInset = leadBox.x - heroBox.x; + const rightInset = + heroBox.x + heroBox.width - (ringBox.x + ringBox.width); + expect(Math.abs(leftInset - rightInset)).toBeLessThanOrEqual(1); - // The all-clear sentence never sits under a lead or facts. - await expect( - hero.locator('[data-slot="today-hero-all-clear"]'), - ).toHaveCount(0); + const facts = hero.locator('[data-slot="today-hero-fact"]'); + const expected = digest.today.length; + await expect(facts).toHaveCount(expected); + const visible = await facts.evaluateAll( + (items) => + items.filter((el) => (el as HTMLElement).offsetParent !== null) + .length, + ); + expect(visible).toBe(phone ? Math.min(expected, 4) : expected); - if (expected === 0) { + // The all-clear sentence never sits under a lead or facts. await expect( - hero.locator('[data-slot="today-hero-today"]'), + hero.locator('[data-slot="today-hero-all-clear"]'), ).toHaveCount(0); - // A quiet day keeps the full composition: lead beside the ring. - await expect(hero).toHaveAttribute("data-layout", "narrative"); - return; - } - const block = hero.locator('[data-slot="today-hero-today"]'); - const blockBox = (await block.boundingBox())!; - // The facts follow the lead. - expect(blockBox.y).toBeGreaterThanOrEqual( - leadBox.y + leadBox.height - 1, - ); - if (viewport.width >= 768) { - // Beside the ring on a wide screen, never under it. - expect(blockBox.x + blockBox.width).toBeLessThanOrEqual( - ringBox.x + 1, - ); - expect(blockBox.y).toBeLessThan(ringBox.y + ringBox.height); - } else { - // Above the ring on a phone, so the day reads before the dial. - expect(blockBox.y + blockBox.height).toBeLessThanOrEqual( - ringBox.y + 1, - ); - } + if (expected === 0) { + await expect( + hero.locator('[data-slot="today-hero-today"]'), + ).toHaveCount(0); + // A quiet day keeps the full composition: lead beside the ring. + await expect(hero).toHaveAttribute("data-layout", "narrative"); + return; + } - // No fact line overflows its own box. - const clipped = await facts.evaluateAll( - (items) => - items.filter((el) => { - const link = el.querySelector("a"); - return link ? link.scrollWidth > link.clientWidth + 1 : false; - }).length, - ); - expect(clipped).toBe(0); - }); + const block = hero.locator('[data-slot="today-hero-today"]'); + const blockBox = (await block.boundingBox())!; + // The facts follow the lead. + expect(blockBox.y).toBeGreaterThanOrEqual( + leadBox.y + leadBox.height - 1, + ); + if (!phone) { + // Beside the ring on a wide screen, never under it. + expect(blockBox.x + blockBox.width).toBeLessThanOrEqual( + ringBox.x + 1, + ); + expect(blockBox.y).toBeLessThan(ringBox.y + ringBox.height); + } else { + // Under the dial on a phone, across the full card width. + expect(blockBox.y).toBeGreaterThanOrEqual( + ringBox.y + ringBox.height - 1, + ); + expect( + Math.abs( + blockBox.x + blockBox.width - (ringBox.x + ringBox.width), + ), + ).toBeLessThanOrEqual(1); + // Each tappable fact keeps the 44 px floor, label over value, + // both on the reading edge. + const rows = await facts.evaluateAll((items) => + items + .filter((el) => (el as HTMLElement).offsetParent !== null) + .map((el) => { + const link = el.querySelector("a")!; + const [label, value] = Array.from(link.children).map((c) => + c.getBoundingClientRect(), + ); + return { + height: link.getBoundingClientRect().height, + stacked: value.top >= label.bottom - 1, + sameEdge: Math.abs(value.left - label.left) <= 1, + }; + }), + ); + for (const row of rows) { + expect(row.height).toBeGreaterThanOrEqual(44); + expect(row.stacked).toBe(true); + expect(row.sameEdge).toBe(true); + } + } + + // No fact line overflows its own box. + const clipped = await facts.evaluateAll( + (items) => + items.filter((el) => { + const link = el.querySelector("a"); + return link ? link.scrollWidth > link.clientWidth + 1 : false; + }).length, + ); + expect(clipped).toBe(0); + }); + } } } diff --git a/src/components/daily/__tests__/today-hero.test.tsx b/src/components/daily/__tests__/today-hero.test.tsx index c0939590f..d8d8640cf 100644 --- a/src/components/daily/__tests__/today-hero.test.tsx +++ b/src/components/daily/__tests__/today-hero.test.tsx @@ -388,7 +388,7 @@ describe("", () => { expect(text.match(/\b82\b/g)).toHaveLength(1); }); - it("keeps the full narrative composition and md score ring when a lead exists", () => { + it("keeps the full narrative composition and the responsive score ring when a lead exists", () => { const html = render( ", () => { expect(html).toContain('data-layout="narrative"'); expect(html).toContain('data-slot="today-hero-lead"'); - expect(html).toContain('style="width:168px;height:168px"'); + // A compact dial on a phone, the md dial (168 px) from md up. + expect(html).toContain("size-20 md:size-42"); expect(html).not.toContain('data-layout="compact-all-clear"'); }); @@ -587,6 +588,49 @@ describe(" Today overview", () => { } }); + it("puts the ring beside the lead and the overview under both on a phone", () => { + const html = render( + , + ); + // The read grid: lead cell, overview cell, ring cell, in that order. + const read = html.slice(html.indexOf('data-slot="today-hero-read"')); + // The cell is the grid child wrapping the slot: the nearest preceding + // div that carries a grid placement. + const cellClass = (slot: string) => { + const before = read.slice(0, read.indexOf(`data-slot="${slot}"`)); + const cells = [ + ...before.matchAll(/
{ + const html = render(); + const links = [ + ...html.matchAll( + /data-slot="today-hero-fact"[^>]*>]*class="([^"]*)"[^>]*>(.*?)<\/a>/g, + ), + ]; + expect(links).toHaveLength(FACTS.length); + for (const [, linkClass, inner] of links) { + expect(linkClass).toContain("flex-col"); + expect(linkClass).not.toContain("justify-between"); + expect(linkClass).toContain("min-h-11"); + expect(inner).not.toContain("text-right"); + // Label muted, value foreground. + expect(inner).toMatch( + /text-muted-foreground[^"]*"[^>]*>[^<]+<\/span> { const html = render(); const items = [ diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index 53b3f9f59..65bbd4f4e 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -268,14 +268,19 @@ export function TodayHero({ i >= TODAY_FACTS_NARROW && "hidden md:block", )} > + {/* Label above value at every width, both on the reading edge. + A label-left / value-right row pushed a long value ("5 h 45 + min, 42 min less than usual") into a ragged right-aligned + block on a phone; stacked, every value reads as one left- + aligned sentence. The row keeps the 44 px tap floor below md. */} - + {fact.label} - + {fact.value} @@ -285,6 +290,109 @@ export function TodayHero({
) : null; + /* The lead — the read leads large in the foreground token, calm and + legible — and, one muted step down, the top signal's present-tense + headline + delta, supporting it without competing. */ + const leadBlock = + lead || signalLine ? ( +
+ {lead ? ( +
+ +
+ ) : null} + {signalLine ? ( +

+ {signalLine.headline} + {signalLine.headline && signalLine.delta ? " · " : null} + {signalLine.delta} +

+ ) : null} +
+ ) : null; + + /* Health score ring — `flat` (no sweep/bloom), server-computed band. A + null score paints the ring's honest provisional face at the same + footprint, so the column never collapses. The meta lines under it are + capped to the dial's width on a phone so the column stays compact. */ + const scoreColumn = (size: "sm" | "responsive") => { + const phoneMeta = size === "responsive" && "max-w-20 md:max-w-none"; + return ( +
+
+ {/* The ring opens the Insights overview — the destination the + health-score card owns — so every hero ring is a door, not a + poster. */} + + + +
+ {deltaShown && digest.score?.delta != null ? ( + + {formatDelta(digest.score.delta, t)} + + ) : null} + {steadyLine ? ( + + {steadyLine} + + ) : null} + {basisLine ? ( + + {basisLine} + + ) : null} +
+ ); + }; + const heroShellClassName = cn( // The tile strip's surface plus the ONE sanctioned Today atmosphere: // `.today-hero-wash` leans a faint `--primary` mix over the theme @@ -336,121 +444,75 @@ export function TodayHero({ gaps let the worth-a-look rail sit close under the lead so the card reads filled, not padded out. */}
- {/* The day's read — the numeric face on the trailing edge, the - narrative lead leading. On md+ the lead sits flush-top with the - score ring (items-start) rather than floating centred beside it. */} -
- {/* The overview is its own section under the lead, so it keeps a - section's distance from it; the compact all-clear stays tight. */} -
- {/* Hero numeric face: the read leads large in the foreground - token, calm and legible — the day's read, not a slogan. */} - {lead || signalLine ? ( -
- {lead ? ( -
- -
- ) : null} - {/* Top signal — present-tense headline + optional delta, one - muted step down so it supports the lead without - competing. */} - {signalLine ? ( -

- {signalLine.headline} - {signalLine.headline && signalLine.delta ? " · " : null} - {signalLine.delta} -

- ) : null} -
- ) : null} - {todayBlock} - {compactAllClear ? ( + {compactAllClear ? ( + /* Score-only digest: the all-clear copy leads and a smaller + version of the same ring stays the one numeric face. */ +
+

{t("daily.today.allClear")}

- ) : null} - - {/* In the compact fallback the quiet freshness tier belongs next - to the smaller ring. That uses the otherwise-empty leading - space and avoids adding a second row below the dial. */} - {compactAllClear ? sleepPendingNote : null} -
- - {/* Health score ring — `flat` (no sweep/bloom), server-computed - band. A null score paints the ring's honest provisional face - at the same footprint, so the column never collapses. */} -
-
- {/* The ring opens the Insights overview — the destination the - health-score card owns — matching the cluster rings' tap - behaviour, so every hero ring is a door, not a poster. */} - - - + {/* The quiet freshness tier belongs next to the smaller ring: + it uses the otherwise-empty leading space and adds no + second row below the dial. */} + {sleepPendingNote}
- {deltaShown && digest.score?.delta != null ? ( - - {formatDelta(digest.score.delta, t)} - - ) : null} - {steadyLine ? ( - - {steadyLine} - - ) : null} - {basisLine ? ( - - {basisLine} - - ) : null} + {scoreColumn("sm")}
-
+ ) : ( + /* The day's read: the narrative lead on the reading edge, the + numeric face on the trailing one, the Today overview under the + lead. + + One grid, two arrangements. Below md the ring is a compact dial + at the top right beside the lead, and the overview runs the full + card width underneath both; a phone has no room for a 168 px + dial under the facts, where it used to sit centred and fall + behind the bottom navigation. From md up the ring spans both + rows of the trailing column and the overview stays in the + leading column, flush-top with the ring, exactly as before. + + Without a lead the overview takes the leading cell itself, so + the ring never floats beside an empty row. */ +
+ {leadBlock ? ( +
+ {leadBlock} +
+ ) : null} + {todayBlock ? ( +
+ {todayBlock} +
+ ) : null} +
+ {scoreColumn("responsive")} +
+
+ )} {/* Meta row — the hero's quiet tier (UI-STANDARDS §text: `text-xs text-muted-foreground` is the meta floor; never an accent, never an diff --git a/src/components/insights/derived/score-ring.tsx b/src/components/insights/derived/score-ring.tsx index 917330473..675cff2d6 100644 --- a/src/components/insights/derived/score-ring.tsx +++ b/src/components/insights/derived/score-ring.tsx @@ -54,11 +54,27 @@ const SPRING_SAFE_MAX = 94; const SIZE: Record< NonNullable, - { px: number; numberClass: string; labelClass: string } + { + /** Fixed edge length; null when `boxClass` sizes the ring per breakpoint. */ + px: number | null; + boxClass?: string; + numberClass: string; + labelClass: string; + } > = { sm: { px: 120, numberClass: "text-3xl", labelClass: "text-2xs" }, md: { px: 168, numberClass: "text-5xl", labelClass: "text-xs" }, lg: { px: 232, numberClass: "text-7xl", labelClass: "text-sm" }, + // The dashboard hero: a compact 80 px dial beside the lead on a phone, the + // `md` dial from the md breakpoint up. Below md the label is left out (the + // number alone is legible at this size; the ring's aria-label and the + // surrounding link still name it), so the centre never crowds. + responsive: { + px: null, + boxClass: "size-20 md:size-42", + numberClass: "text-2xl md:text-5xl", + labelClass: "hidden text-xs md:block", + }, }; export interface ScoreRingProps { @@ -68,8 +84,11 @@ export interface ScoreRingProps { band?: ScoreBand; /** Short label rendered under the number (e.g. "Readiness", "/100"). */ label?: string; - /** Render size. `sm` in a grid tile, `md`/`lg` on the anatomy view. */ - size?: "sm" | "md" | "lg"; + /** + * Render size. `sm` in a grid tile, `md`/`lg` on the anatomy view, + * `responsive` on the dashboard hero (compact on a phone, `md` above). + */ + size?: "sm" | "md" | "lg" | "responsive"; /** Disable the sweep + count-up (e.g. when already revealed this session). */ animate?: boolean; /** @@ -190,12 +209,14 @@ export function ScoreRing({ data-provisional={hasScore ? undefined : "true"} role="img" aria-label={ariaLabel} - className={cn("relative shrink-0", className)} + className={cn("relative shrink-0", dims.boxClass, className)} style={ - { - width: dims.px, - height: dims.px, - } as React.CSSProperties + dims.px === null + ? undefined + : ({ + width: dims.px, + height: dims.px, + } as React.CSSProperties) } > {/* The -90° rotation starts the arc at 12 o'clock so it sweeps clockwise