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