From 4aaed5c3121026531bf3556e999fa47f86dc66cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 3 Sep 2026 05:19:52 +0200 Subject: [PATCH] fix(dashboard): give the time-of-day greeting one clock Three pieces of code answered "what hour is it" for the salutation. The snapshot builder computed a `greetingHour` in the user's zone and put it on the payload so clients would not have to; no client ever read it. The insights hero derived one through the shared `hourInTz`. The dashboard header derived one through a local helper that fell back to the DEVICE clock whenever the runtime could not resolve the configured zone, which is what happens to an IANA entry the browser's data does not yet carry. At one instant, for one account, those three returned 20, 21 and 05. Keep the client end and drop the server one. The hour is a clock reading, not a fact about the record, and the snapshot body serves stale for up to an hour past its fresh TTL, so a stored hour can name a salutation the clock has left behind. What the server IS authoritative for is the zone, and that already travels on the same payload and is already what both surfaces read. Both greetings now share `hourInTz`, so an unresolvable zone falls back to the same default the rest of the app uses instead of to the device. The header's private helper is gone; it had no other caller. Two tests pinned the old shape by asserting the payload carried an hour. Inverted rather than deleted: the payload must now be shown NOT to carry one, which is the property that was missing when the field went unread for months. `greetingHour` leaves the response contract, so a native client decoding it as required needs the field made optional or dropped. --- docs/api/openapi.yaml | 5 - e2e/utils/mock-dashboard-snapshot.ts | 1 - .../greeting-hour-single-source.test.ts | 131 ++++++++++++++++++ .../snapshot/__tests__/route.test.ts | 8 +- src/components/dashboard/dashboard-header.tsx | 11 +- src/components/dashboard/range-display.tsx | 18 --- src/lib/dashboard/__tests__/snapshot.test.ts | 8 +- src/lib/dashboard/snapshot.ts | 31 ++--- src/lib/openapi/routes/insights/schemas.ts | 1 - 9 files changed, 159 insertions(+), 55 deletions(-) create mode 100644 src/__tests__/greeting-hour-single-source.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 168c050e7..c08418792 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -48650,10 +48650,6 @@ components: - type: "null" onboardingTourCompleted: type: boolean - greetingHour: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 required: - username - timezone @@ -48662,7 +48658,6 @@ components: - gender - glucoseUnit - onboardingTourCompleted - - greetingHour additionalProperties: false layout: type: object diff --git a/e2e/utils/mock-dashboard-snapshot.ts b/e2e/utils/mock-dashboard-snapshot.ts index 77e4a11dd..c8df7de9c 100644 --- a/e2e/utils/mock-dashboard-snapshot.ts +++ b/e2e/utils/mock-dashboard-snapshot.ts @@ -258,7 +258,6 @@ export function buildMockSnapshot( gender: "MALE", glucoseUnit: "mg/dL", onboardingTourCompleted: true, - greetingHour: 9, }, layout: DEFAULT_DASHBOARD_LAYOUT, layoutCatalogue: buildLayoutCatalogue(), diff --git a/src/__tests__/greeting-hour-single-source.test.ts b/src/__tests__/greeting-hour-single-source.test.ts new file mode 100644 index 000000000..98766ce71 --- /dev/null +++ b/src/__tests__/greeting-hour-single-source.test.ts @@ -0,0 +1,131 @@ +/** + * The time-of-day greeting has ONE evaluator. + * + * The dashboard header and the insights hero both open with a salutation + * bucketed by the wall-clock hour. For a long time three different pieces + * of code answered "what hour is it": + * + * - the dashboard snapshot builder, which computed a `greetingHour` in + * the user's zone and shipped it on the payload "so the client never + * has to run its own Intl" — and which no client ever read; + * - the insights hero, through the shared `hourInTz` (profile zone, + * Europe/Berlin when the zone will not resolve); + * - the dashboard header, through a local helper that fell back to the + * DEVICE clock whenever the zone would not resolve. + * + * At one instant, for one account, those three returned 20, 21 and 05. + * + * The server value lost. It is not a derived fact about the record, it is + * a clock read, and the snapshot body is served stale-while-revalidate for + * up to an hour — long enough for a baked-in hour to name a salutation the + * clock had left behind. What the server IS authoritative for is the zone, + * and that already reaches every client on the same payload. + * + * This guard fails if either end drifts back: + * T1 — the snapshot builder or its OpenAPI schema starts carrying a + * greeting hour again. + * T2 — a greeting surface reads a clock that is not `hourInTz` — the + * device zone via `Date#getHours`, or a hand-rolled `Intl` walk. + * T3 — behaviourally, the shared helper stays on the profile zone even + * when the device sits elsewhere and the zone will not resolve. + * + * T1/T2 are grep-shaped and assert a non-zero match count, so an empty + * sweep fails loudly instead of passing silently. + */ +import { describe, it, expect, afterAll, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { hourInTz, DEFAULT_TIMEZONE } from "@/lib/tz/format"; + +const SRC = join(process.cwd(), "src"); +const read = (rel: string) => readFileSync(join(SRC, rel), "utf8"); + +const SERVER_FILES = [ + "lib/dashboard/snapshot.ts", + "lib/openapi/routes/insights/schemas.ts", +] as const; + +/** Every surface that paints a time-of-day salutation. */ +const GREETING_SURFACES = [ + "components/dashboard/dashboard-header.tsx", + "components/insights/hero-strip.tsx", +] as const; + +describe("T1 — the snapshot payload carries no wall-clock hour", () => { + it.each(SERVER_FILES)("%s declares no greeting hour", (rel) => { + const src = read(rel); + expect(src.length).toBeGreaterThan(0); + expect(src).not.toMatch(/greetingHour/); + expect(src).not.toMatch(/hourInTimezone/); + }); + + it("still resolves the zone the clients read", () => { + const src = read("lib/dashboard/snapshot.ts"); + expect(src).toMatch(/timezone:\s*userTz/); + }); +}); + +describe("T2 — every greeting surface reads the shared helper", () => { + it.each(GREETING_SURFACES)("%s derives its hour from hourInTz", (rel) => { + const src = read(rel); + expect(src.length).toBeGreaterThan(0); + // Imported from the shared module, not re-implemented next door. + expect(src).toMatch( + /import[\s\S]*?\bhourInTz\b[\s\S]*?from "@\/lib\/tz\/format"/, + ); + const calls = src.match(/\bhourInTz\s*\(/g) ?? []; + expect(calls.length).toBeGreaterThan(0); + }); + + it.each(GREETING_SURFACES)("%s reads no device-local clock", (rel) => { + const src = read(rel); + // `getHours()` is the device zone by definition; a hand-rolled + // `Intl.DateTimeFormat` for the hour is the second evaluator this + // guard exists to prevent. + expect(src).not.toMatch(/\.getHours\s*\(/); + expect(src).not.toMatch(/Intl\.DateTimeFormat/); + expect(src).not.toMatch(/getHourForTimeZone/); + }); + + it("the retired header helper is gone from the tree", () => { + const src = read("components/dashboard/range-display.tsx"); + expect(src.length).toBeGreaterThan(0); + expect(src).not.toMatch(/getHourForTimeZone/); + }); +}); + +describe("T3 — the profile zone beats the device zone", () => { + const INSTANT = new Date("2026-03-01T20:30:00Z"); + const originalTz = process.env.TZ; + + afterAll(() => { + vi.useRealTimers(); + process.env.TZ = originalTz; + }); + + it("follows the configured zone while the device sits ten hours away", () => { + process.env.TZ = "Asia/Tokyo"; + vi.useFakeTimers(); + vi.setSystemTime(INSTANT); + + // The device says 05:30 (morning). The account is configured for + // Berlin, where it is 21:30 (evening). + expect(new Date().getHours()).toBe(5); + expect(hourInTz(new Date(), "Europe/Berlin")).toBe(21); + }); + + it("falls back to the shared default, never to the device clock", () => { + process.env.TZ = "Asia/Tokyo"; + vi.useFakeTimers(); + vi.setSystemTime(INSTANT); + + // A zone this runtime cannot resolve — a recently added IANA entry on + // an older engine is the realistic case. The header helper used to + // answer with the device hour here, splitting the two greetings by + // sixteen hours for the same account at the same instant. + const unresolvable = hourInTz(new Date(), "Europe/Atlantis"); + expect(unresolvable).toBe(hourInTz(new Date(), DEFAULT_TIMEZONE)); + expect(unresolvable).not.toBe(new Date().getHours()); + }); +}); diff --git a/src/app/api/dashboard/snapshot/__tests__/route.test.ts b/src/app/api/dashboard/snapshot/__tests__/route.test.ts index 634de7dc8..7d68a97f1 100644 --- a/src/app/api/dashboard/snapshot/__tests__/route.test.ts +++ b/src/app/api/dashboard/snapshot/__tests__/route.test.ts @@ -76,7 +76,6 @@ const SNAPSHOT_BODY = { gender: "MALE", glucoseUnit: "mg/dL", onboardingTourCompleted: true, - greetingHour: 9, }, layout: { version: 1, widgets: [] }, tiles: { @@ -121,7 +120,12 @@ describe("GET /api/dashboard/snapshot", () => { expect(json.data.briefingState).toBe("preparing"); expect(json.data.tiles).toBeDefined(); expect(json.data.extras).toBeNull(); - expect(json.data.user.greetingHour).toBe(9); + // Inverted 2026-09: the payload no longer carries a wall-clock hour. + // The body is served stale for up to an hour, so a baked-in hour could + // name a salutation the clock had long left behind. Clients read the + // resolved `timezone` and their own live clock instead. + expect(json.data.user).not.toHaveProperty("greetingHour"); + expect(json.data.user.timezone).toBe("Europe/Berlin"); // bfcache-friendly directive present. expect(res.headers.get("Cache-Control")).toContain("private"); }); diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx index e502f9456..4440bab28 100644 --- a/src/components/dashboard/dashboard-header.tsx +++ b/src/components/dashboard/dashboard-header.tsx @@ -25,7 +25,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; -import { getHourForTimeZone } from "@/components/dashboard/range-display"; +import { hourInTz } from "@/lib/tz/format"; import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets"; export function DashboardHeader({ @@ -47,9 +47,16 @@ export function DashboardHeader({ // hydrates — the text must match the name-less SSR output during // hydration (React #418) and may only personalise from the first // client re-render. + // + // The hour comes from the shared `hourInTz`, the same helper the + // insights hero greeting uses. Its predecessor fell back to the + // DEVICE clock whenever the runtime could not resolve the profile + // zone, so a zone this browser's ICU does not carry (a recently added + // one, an older engine) split the two greetings by hours while every + // other date on the page stayed on the profile zone. const userTimezone = mounted ? user?.timezone : undefined; const hour = useMemo( - () => (userTimezone ? getHourForTimeZone(userTimezone) : null), + () => (userTimezone ? hourInTz(new Date(), userTimezone) : null), [userTimezone], ); const timeGreeting = diff --git a/src/components/dashboard/range-display.tsx b/src/components/dashboard/range-display.tsx index 66256bac1..58268184f 100644 --- a/src/components/dashboard/range-display.tsx +++ b/src/components/dashboard/range-display.tsx @@ -46,24 +46,6 @@ export function toHoursSummary(s: DataSummary): DataSummary { }; } -export function getHourForTimeZone(timeZone?: string): number { - const now = new Date(); - if (!timeZone) return now.getHours(); - - try { - const parts = new Intl.DateTimeFormat("en-US", { - hour: "2-digit", - hour12: false, - timeZone, - }).formatToParts(now); - const hourPart = parts.find((part) => part.type === "hour")?.value; - const parsed = hourPart ? Number(hourPart) : Number.NaN; - return Number.isNaN(parsed) ? now.getHours() : parsed; - } catch { - return now.getHours(); - } -} - export function getRangeColorClass( value: number | null | undefined, config: RangeDisplayConfig, diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 257da8fdd..f98613d9b 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -258,9 +258,11 @@ describe("buildDashboardSnapshot — envelope shape", () => { expect(snap.user.timezone).toBe("Europe/Berlin"); expect(snap.user.heightCm).toBe(180); expect(snap.user.gender).toBe("MALE"); - expect(typeof snap.user.greetingHour).toBe("number"); - expect(snap.user.greetingHour).toBeGreaterThanOrEqual(0); - expect(snap.user.greetingHour).toBeLessThan(24); + // Inverted 2026-09: the builder used to compute a wall-clock hour for + // the greeting that no client ever read, and that a stale-while- + // revalidate serve could carry an hour past its bucket. The zone is + // the resolved value; the hour belongs to whoever holds a live clock. + expect(snap.user).not.toHaveProperty("greetingHour"); // tiles (fast phase) always present expect(snap.tiles.summaries.WEIGHT.latest).toBe(80); diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index dc22484f6..ea56aa949 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -212,6 +212,14 @@ export type BriefingState = "ready" | "preparing" | "disabled" | "no-provider"; export interface DashboardSnapshotUser { username: string; + /** + * The user's configured zone — the ONE server-resolved input the + * greeting needs. The wall-clock hour itself is deliberately not on + * this payload: the body is served stale-while-revalidate for up to an + * hour, so a baked-in hour can name the wrong salutation long after + * the boundary passed, and every client already reads a live clock. + * Resolve the zone here, read the hour there. + */ timezone: string; heightCm: number | null; dateOfBirth: string | null; @@ -223,12 +231,6 @@ export interface DashboardSnapshotUser { gender: ProfileSex; glucoseUnit: string | null; onboardingTourCompleted: boolean; - /** - * Server-computed wall-clock hour in the user's timezone so the - * client greeting never has to run its own `Intl.DateTimeFormat` - * before first paint. - */ - greetingHour: number; } /** @@ -530,22 +532,6 @@ export interface SnapshotUserInput { healthScoreConfigJson: unknown; } -/** Wall-clock hour in `tz`, used for the server-side greeting. */ -function hourInTimezone(now: Date, tz: string): number { - try { - const fmt = new Intl.DateTimeFormat("en-US", { - hour: "numeric", - hour12: false, - timeZone: tz, - }); - const parsed = parseInt(fmt.format(now), 10); - // Intl emits "24" for midnight under hour12:false on some ICU builds. - return Number.isFinite(parsed) ? parsed % 24 : now.getUTCHours(); - } catch { - return now.getUTCHours(); - } -} - function enrichLastSeen( raw: Record, nowMs: number, @@ -1457,7 +1443,6 @@ export async function buildDashboardSnapshot( gender, glucoseUnit: user.glucoseUnit ?? null, onboardingTourCompleted: user.onboardingTourCompleted, - greetingHour: hourInTimezone(now, userTz), }, layout, layoutCatalogue: buildLayoutCatalogue(layout), diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 98c96f8aa..6e76f57ee 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -1030,7 +1030,6 @@ export const dashboardSnapshotResponse = z gender: z.enum(["MALE", "FEMALE", "OTHER"]).nullable(), glucoseUnit: z.string().nullable(), onboardingTourCompleted: z.boolean(), - greetingHour: z.number().int(), }), layout: z.record(z.string(), z.unknown()), // v1.7.0 — full 27-id widget catalogue (16 server-known + 11