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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-03 05:49:18 +02:00
1 parent fc1ebeae9e
commit 4aaed5c312
9 files changed
+159 -55

No files matched your search

-5
View File
@@ -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
-1
View File
@@ -258,7 +258,6 @@ export function buildMockSnapshot(
gender: "MALE",
glucoseUnit: "mg/dL",
onboardingTourCompleted: true,
greetingHour: 9,
},
layout: DEFAULT_DASHBOARD_LAYOUT,
layoutCatalogue: buildLayoutCatalogue(),
@@ -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());
});
});
@@ -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");
});
@@ -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 =
@@ -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,
+5 -3
View File
@@ -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);
+8 -23
View File
@@ -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<string, { lastSeenAt: string } | null>,
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),
@@ -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