test(tz): pin DST-safe today bounds, user-tz compliance bucketing, greeting zone

- getUserTodayBounds: a 25h fall-back day keeps a 23:30 dose inside today,
  a 23h spring-forward day does not bleed tomorrow's 00:30 dose, ordinary
  days stay 24h, and a negative-offset zone anchors on the local day.
- dailyComplianceRatesFromLedger: a Los-Angeles user's two same-local-day
  doses land in ONE bucket where the old fixed-Berlin key split them.
- classifyIntakeTiming: an 08:00-local dose reads on_time with the user's
  zone and very_late without it.
- hourInTz: reads the wall-clock hour in the given zone.
- hero-strip greeting: resolves the hour in the stored zone, not the
  browser clock; existing greeting fixtures re-pinned to explicit UTC
  instants mapped to Berlin wall-clock.
This commit is contained in:
Marc-André Bombeck committed 2026-07-08 23:50:08 +02:00
1 parent fb6813bcdb
commit 88bff471cc
5 files changed
+179 -11

No files matched your search

@@ -33,11 +33,13 @@ const sampleBriefing: DailyBriefingPayload = {
keyFindings: [],
};
// `getHours()` reads from the local representation. Force a known
// local hour by constructing from a local-time fields tuple.
const morningLocal = new Date(2026, 4, 10, 9, 0, 0); // May 10, 09:00 local
const afternoonLocal = new Date(2026, 4, 10, 14, 0, 0); // May 10, 14:00 local
const eveningLocal = new Date(2026, 4, 10, 20, 0, 0); // May 10, 20:00 local
// The greeting bucket is now computed in the user's configured zone
// (the mocked `Europe/Berlin`), not the host's — so pin explicit UTC
// instants and translate them to the intended Berlin wall-clock hour.
// May 10 2026 is CEST (UTC+2), so Berlin = UTC + 2h.
const morningLocal = new Date("2026-05-10T07:00:00Z"); // 09:00 Berlin
const afternoonLocal = new Date("2026-05-10T12:00:00Z"); // 14:00 Berlin
const eveningLocal = new Date("2026-05-10T18:00:00Z"); // 20:00 Berlin
describe("<HeroStrip>", () => {
it("renders the slot wrapper + Dracula gradient utility", () => {
@@ -178,9 +180,10 @@ describe("<HeroStrip>", () => {
});
it("uses the now override deterministically (smoke for greeting-bucket logic)", () => {
// Five different hours map to four different greeting buckets:
// 09:00 → morning, 14:00 → afternoon, 20:00 → evening, 02:00 → night.
const earlyMorning = new Date(2026, 4, 10, 2, 0, 0);
// Buckets are read in the mocked user's Berlin zone: 09:00 → morning,
// 14:00 → afternoon, 20:00 → evening, 02:00 → night. 00:00 UTC is
// 02:00 Berlin (CEST).
const earlyMorning = new Date("2026-05-10T00:00:00Z"); // 02:00 Berlin
const html = render(<HeroStrip briefing={null} now={earlyMorning} />);
// Night maps to "Good evening" in EN per the resolver mapping.
expect(html).toContain("Good evening");
@@ -188,12 +191,24 @@ describe("<HeroStrip>", () => {
expect(html).not.toContain("Good morning");
});
it("uses the morning bucket at noon-1 (11:59 boundary)", () => {
const justBeforeNoon = new Date(2026, 4, 10, 11, 59, 0);
it("uses the morning bucket at noon-1 (11:59 Berlin boundary)", () => {
const justBeforeNoon = new Date("2026-05-10T09:59:00Z"); // 11:59 Berlin
const html = render(<HeroStrip briefing={null} now={justBeforeNoon} />);
expect(html).toContain("Good morning");
});
it("resolves the greeting hour in the user's stored zone, not the browser", () => {
// 11:30 UTC is still MORNING in UTC but 13:30 (AFTERNOON) in the mocked
// Berlin user's zone. The afternoon greeting proves the hour comes from
// the stored zone — the /insights hero used to read the browser clock.
const utcMorningBerlinAfternoon = new Date("2026-05-10T11:30:00Z");
const html = render(
<HeroStrip briefing={null} now={utcMorningBerlinAfternoon} />,
);
expect(html).toContain("Good afternoon");
expect(html).not.toContain("Good morning");
});
// ── B4 — Weekly-report banner card ─────────────────────────────────
// v1.4.28 retired the weekly-report path. The banner, the report
// route, the schema slot and the i18n keys are gone; the hero
@@ -23,6 +23,8 @@ import {
tallyLedgerRows,
type ComplianceSchedule,
} from "../compliance";
import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history";
import { zonedWallClockToUtc } from "@/lib/tz/wall-clock";
const TZ = "UTC";
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -334,6 +336,41 @@ describe("dailyComplianceRatesFromLedger — cadence-aware per-day series", () =
const aggregateRate = Math.round((taken / (taken + missed)) * 100);
expect(aggregateRate).toBe(bundle.compliance30.rate);
});
it("buckets a non-Berlin user's two same-local-day doses into ONE day", () => {
// America/Los_Angeles user, twice-daily 08:00 + 20:00 LOCAL on the same
// local calendar day (2026-06-15). The 20:00 PDT dose is 03:00 UTC the
// NEXT day — and 05:00 the next Berlin day — so a fixed-Berlin day key
// splits one local day's two doses across two buckets.
const takenOnTime = (h: number, m: number): DoseHistoryRow => {
const at = zonedWallClockToUtc(
{ year: 2026, month: 6, day: 15, hour: h, minute: m },
"America/Los_Angeles",
);
return {
kind: "slot",
at,
timeOfDay: `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`,
status: "taken_on_time",
intake: null,
};
};
const rows: DoseHistoryRow[] = [takenOnTime(8, 0), takenOnTime(20, 0)];
// Bucketed in the user's own zone: one local day, two taken.
const laSeries = dailyComplianceRatesFromLedger(
rows,
"America/Los_Angeles",
);
expect(laSeries.length).toBe(1);
expect(laSeries[0].taken).toBe(2);
expect(laSeries[0].rate).toBe(100);
// Sanity: the fixed-Berlin key (the pre-fix behaviour) would split these
// same two doses across two calendar buckets — the defect the fix closes.
const berlinSeries = dailyComplianceRatesFromLedger(rows, "Europe/Berlin");
expect(berlinSeries.length).toBe(2);
});
});
describe("tallyLedgerRows — windowed tallies over one shared ledger", () => {
@@ -1367,6 +1367,28 @@ describe("classifyIntakeTiming", () => {
"very_late",
);
});
it("interprets the window HH:mm in the supplied tz (non-UTC user)", () => {
// America/Los_Angeles is UTC-7 in summer. An "08:00" slot means 08:00
// LOCAL = 15:00 UTC on 2026-06-15; a dose taken 15 min into the window
// is 15:15 UTC. The slot's canonical instant is the anchor date.
const slotInstant = new Date("2026-06-15T15:00:00Z"); // 08:00 LA
const takenAt = new Date("2026-06-15T15:15:00Z"); // 08:15 LA
// With the user's zone the dose is on time.
expect(
classifyIntakeTiming(takenAt, "08:00", "09:00", slotInstant, {
tz: "America/Los_Angeles",
}),
).toBe("on_time");
// Without a tz the window HH:mm is read as UTC (08:00 UTC), so the very
// same on-time dose reads as 7h15m off — very_late. This is the defect
// the achievements perfect-day check hit for users far from UTC.
expect(classifyIntakeTiming(takenAt, "08:00", "09:00", slotInstant)).toBe(
"very_late",
);
});
});
// v1.8.5 — the cadence-density rule + the per-dose uptime strip.
+70 -1
View File
@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import { startOfLocalDayInTz } from "@/lib/tz/local-day";
import { getUserTodayBounds, startOfLocalDayInTz } from "@/lib/tz/local-day";
import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock";
const HOUR_MS = 60 * 60 * 1000;
describe("zonedWallClockToUtc", () => {
it("resolves a wall clock in a positive-offset zone to the right UTC instant", () => {
// 00:30 CEST (UTC+2) on 2026-05-11 → 22:30 UTC on 2026-05-10.
@@ -82,3 +84,70 @@ describe("startOfLocalDayInTz", () => {
expect(floor.getDate()).toBe(15);
});
});
describe("getUserTodayBounds — DST-safe local-day window", () => {
it("spans a full 24h on an ordinary (non-transition) day", () => {
const now = new Date("2026-06-15T14:30:00.000Z");
const { start, end } = getUserTodayBounds(now, "Europe/Berlin");
// Berlin is CEST (UTC+2) in June: local midnight = 2026-06-14T22:00Z.
expect(start.toISOString()).toBe("2026-06-14T22:00:00.000Z");
// Inclusive end is the last ms before the next local midnight.
expect(end.getTime() - start.getTime()).toBe(24 * HOUR_MS - 1);
});
it("fall-back day is 25h and keeps a 23:30-local dose inside 'today'", () => {
// Europe/Berlin falls back 2026-10-25 03:00→02:00 (a 25-hour local day).
// A read taken at 23:30 local that evening.
const now = zonedWallClockToUtc(
{ year: 2026, month: 10, day: 25, hour: 23, minute: 30 },
"Europe/Berlin",
);
const { start, end } = getUserTodayBounds(now, "Europe/Berlin");
// The window covers the real 25-hour local day, not a hardcoded 24h.
expect(end.getTime() - start.getTime()).toBe(25 * HOUR_MS - 1);
// The 23:30 dose — which the old 24h window dropped off the med cards —
// now falls inside [start, end].
const dose2330 = zonedWallClockToUtc(
{ year: 2026, month: 10, day: 25, hour: 23, minute: 30 },
"Europe/Berlin",
);
expect(dose2330.getTime()).toBeGreaterThanOrEqual(start.getTime());
expect(dose2330.getTime()).toBeLessThanOrEqual(end.getTime());
// Both bounds sit at local midnight of their respective calendar days.
expect(wallClockInTz(start, "Europe/Berlin").hour).toBe(0);
expect(wallClockInTz(start, "Europe/Berlin").day).toBe(25);
});
it("spring-forward day is 23h and does not bleed into tomorrow's 00:30 dose", () => {
// Europe/Berlin springs forward 2026-03-29 02:00→03:00 (a 23-hour day).
const now = zonedWallClockToUtc(
{ year: 2026, month: 3, day: 29, hour: 20, minute: 0 },
"Europe/Berlin",
);
const { start, end } = getUserTodayBounds(now, "Europe/Berlin");
// The window covers only the real 23-hour local day.
expect(end.getTime() - start.getTime()).toBe(23 * HOUR_MS - 1);
// Tomorrow's 00:30 dose belongs to 2026-03-30, so it must fall OUTSIDE
// today — the old fixed-24h window double-counted it as "today".
const tomorrow0030 = zonedWallClockToUtc(
{ year: 2026, month: 3, day: 30, hour: 0, minute: 30 },
"Europe/Berlin",
);
expect(tomorrow0030.getTime()).toBeGreaterThan(end.getTime());
});
it("is correct for a negative-offset zone (America/Los_Angeles)", () => {
// 2026-06-15 20:00 PDT (UTC-7) → 2026-06-16T03:00Z. The local day is
// still the 15th, so the window must anchor on LA's 2026-06-15 midnight.
const now = new Date("2026-06-16T03:00:00.000Z");
const { start, end } = getUserTodayBounds(now, "America/Los_Angeles");
expect(start.toISOString()).toBe("2026-06-15T07:00:00.000Z");
expect(end.getTime() - start.getTime()).toBe(24 * HOUR_MS - 1);
expect(wallClockInTz(start, "America/Los_Angeles").day).toBe(15);
});
});
+25
View File
@@ -23,6 +23,7 @@ import {
formatInUserTz,
invalidateServerDefaultTimezone,
invalidateUserTimezone,
hourInTz,
isNearUtc,
isValidTimezone,
resolveServerDefaultTimezone,
@@ -262,6 +263,30 @@ describe("userDayKey", () => {
});
});
describe("hourInTz", () => {
it("reads the wall-clock hour in the user's zone, not UTC", () => {
// 2026-06-15T03:00Z is 20:00 the previous day in Los Angeles (UTC-7),
// 05:00 in Berlin (UTC+2), 12:00 in Tokyo (UTC+9).
const instant = new Date("2026-06-15T03:00:00.000Z");
expect(hourInTz(instant, "UTC")).toBe(3);
expect(hourInTz(instant, "America/Los_Angeles")).toBe(20);
expect(hourInTz(instant, "Europe/Berlin")).toBe(5);
expect(hourInTz(instant, "Asia/Tokyo")).toBe(12);
});
it("normalises local midnight to 0, never 24", () => {
const instant = new Date("2026-06-14T22:00:00.000Z"); // 00:00 Berlin
expect(hourInTz(instant, "Europe/Berlin")).toBe(0);
});
it("falls back to the default zone for an unusable tz string", () => {
// Mirrors formatInUserTz / userDayKey: an invalid zone degrades to
// DEFAULT_TIMEZONE (Europe/Berlin), which is UTC+2 in summer.
const instant = new Date("2026-06-15T09:00:00.000Z");
expect(hourInTz(instant, "Mars/Olympus")).toBe(11);
});
});
describe("isNearUtc", () => {
// Pin a summer-side instant so DST is on for Berlin (+2) and off for
// most southern-hemisphere zones — keeps the assertions readable.