mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge branch 'fix/tz-correctness' into release/v1.27.30
This commit is contained in:
commit
9ce88f01df
14 files changed
+278
-62
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
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useModuleEnabled } from "@/hooks/use-module-enabled";
|
||||
import { hourInTz, DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { ProseBlocks } from "@/components/insights/prose-blocks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
@@ -138,8 +139,7 @@ interface HeroStripProps {
|
||||
* rare to see /insights at 03:00 and "Guten
|
||||
* Morgen" pre-5am reads odd)
|
||||
*/
|
||||
function resolveGreetingKey(now: Date): string {
|
||||
const hour = now.getHours();
|
||||
function resolveGreetingKey(hour: number): string {
|
||||
if (hour >= 5 && hour < 12) return "insights.heroGreetingMorning";
|
||||
if (hour >= 12 && hour < 18) return "insights.heroGreetingAfternoon";
|
||||
// 18:00-22:59 maps to "Good evening"; 23:00-04:59 also maps to
|
||||
@@ -221,7 +221,14 @@ export function HeroStrip({
|
||||
// pillar from the number itself). SSR-safe + default-on, matching the
|
||||
// disabled-allowlist gate.
|
||||
const moodEnabled = useModuleEnabled("mood");
|
||||
const greetingKey = resolveGreetingKey(now ?? new Date());
|
||||
// Greeting hour in the user's configured zone, not the browser's — a
|
||||
// traveller / VPN user / wrong device clock otherwise sees the wrong
|
||||
// salutation. Mirrors the dashboard hero (`hourInTimezone(now, userTz)`).
|
||||
const greetingHour = hourInTz(
|
||||
now ?? new Date(),
|
||||
user?.timezone ?? DEFAULT_TIMEZONE,
|
||||
);
|
||||
const greetingKey = resolveGreetingKey(greetingHour);
|
||||
const greetingBase = t(greetingKey);
|
||||
const greeting = userName ? `${greetingBase}, ${userName}` : greetingBase;
|
||||
const subtitle = briefing?.paragraph ?? t("insights.heroFallbackSubtitle");
|
||||
|
||||
@@ -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;
|
||||
@@ -273,7 +275,7 @@ describe("dailyComplianceRatesFromLedger — cadence-aware per-day series", () =
|
||||
(r) =>
|
||||
r.at.getTime() >= from.getTime() && r.at.getTime() <= NOW.getTime(),
|
||||
);
|
||||
const series = dailyComplianceRatesFromLedger(windowRows);
|
||||
const series = dailyComplianceRatesFromLedger(windowRows, TZ);
|
||||
|
||||
// Only the Mondays in the window produce a point — Tuesday…Sunday emit
|
||||
// nothing, so a 7×-denser daily denominator can never drag the rate down.
|
||||
@@ -319,7 +321,7 @@ describe("dailyComplianceRatesFromLedger — cadence-aware per-day series", () =
|
||||
(r) =>
|
||||
r.at.getTime() >= from.getTime() && r.at.getTime() <= NOW.getTime(),
|
||||
);
|
||||
const series = dailyComplianceRatesFromLedger(windowRows);
|
||||
const series = dailyComplianceRatesFromLedger(windowRows, TZ);
|
||||
|
||||
// Still one point per due Monday — never a point on a non-due weekday.
|
||||
// Five Mondays in the window; the skipped take leaves one as a miss.
|
||||
@@ -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.
|
||||
|
||||
@@ -52,7 +52,8 @@ import {
|
||||
type RecurrenceContext,
|
||||
type ScheduleType,
|
||||
} from "@/lib/medications/scheduling/recurrence";
|
||||
import { toBerlinDayKey } from "@/lib/tz/resolver";
|
||||
import { userDayKey } from "@/lib/tz/resolver";
|
||||
import { localHmAsUtc } from "@/lib/tz/local-day";
|
||||
|
||||
export interface IntakeEvent {
|
||||
takenAt: Date | null;
|
||||
@@ -126,10 +127,19 @@ function parseHHmm(time: string): { hours: number; minutes: number } {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Date for a given "HH:mm" on a specific date.
|
||||
* Build the UTC instant of local "HH:mm" on the calendar day `day` falls on.
|
||||
*
|
||||
* When `tz` is supplied the window `HH:mm` is interpreted in the user's zone
|
||||
* (via {@link localHmAsUtc}) on the local day of `day` — so an "08:00" slot is
|
||||
* 08:00 LOCAL, not 08:00 UTC. Without `tz` it keeps the legacy UTC anchoring
|
||||
* (`setUTCHours`), which is only correct for a UTC user; a caller far from UTC
|
||||
* must pass its zone or the punctuality band is offset by the zone's offset.
|
||||
*/
|
||||
function toDateOnDay(time: string, day: Date): Date {
|
||||
function toDateOnDay(time: string, day: Date, tz?: string): Date {
|
||||
const { hours, minutes } = parseHHmm(time);
|
||||
if (tz) {
|
||||
return localHmAsUtc(day, tz, hours, minutes);
|
||||
}
|
||||
const d = new Date(day);
|
||||
d.setUTCHours(hours, minutes, 0, 0);
|
||||
return d;
|
||||
@@ -157,18 +167,21 @@ function toDateOnDay(time: string, day: Date): Date {
|
||||
* @param options.lateMinutes Width of the `late` band beyond the 3-hour
|
||||
* on-time tolerance, in minutes. Defaults to 120. Doses past this
|
||||
* tail land in `very_late`.
|
||||
* @param options.tz IANA zone the window `HH:mm` is interpreted in. Supply
|
||||
* the user's zone so an "08:00" slot means 08:00 LOCAL; omitting it keeps
|
||||
* the legacy UTC interpretation (correct only for a UTC user).
|
||||
*/
|
||||
export function classifyIntakeTiming(
|
||||
takenAt: Date | null,
|
||||
windowStart: string, // "HH:mm"
|
||||
windowEnd: string, // "HH:mm"
|
||||
scheduledDate: Date, // the date this was scheduled
|
||||
options?: { lateMinutes?: number },
|
||||
options?: { lateMinutes?: number; tz?: string },
|
||||
): IntakeTimingClass {
|
||||
if (takenAt === null) return "missed";
|
||||
|
||||
const start = toDateOnDay(windowStart, scheduledDate);
|
||||
let end = toDateOnDay(windowEnd, scheduledDate);
|
||||
const start = toDateOnDay(windowStart, scheduledDate, options?.tz);
|
||||
let end = toDateOnDay(windowEnd, scheduledDate, options?.tz);
|
||||
|
||||
// Handle overnight windows (e.g. windowStart="23:00", windowEnd="01:00")
|
||||
if (end <= start) {
|
||||
@@ -1967,9 +1980,16 @@ export interface DailyComplianceRate {
|
||||
* rows (the window hasn't opened) are dropped before grouping, exactly as the
|
||||
* window tally excludes them. A day whose only ledger rows are skips yields no
|
||||
* point (zero denominator) rather than a misleading 0%.
|
||||
*
|
||||
* Days are bucketed in the USER's timezone (`tz`). Passing the caller's real
|
||||
* zone keeps an evening dose on the local day it was actually due: a fixed
|
||||
* Berlin key filed a `20:00 America/Los_Angeles` dose (04:00 UTC next day)
|
||||
* into the following Berlin day, splitting one local day's two doses across
|
||||
* two buckets and skewing every per-day rate for non-Berlin users.
|
||||
*/
|
||||
export function dailyComplianceRatesFromLedger(
|
||||
ledgerRows: DoseHistoryRow[],
|
||||
tz: string,
|
||||
): DailyComplianceRate[] {
|
||||
const byDay = new Map<
|
||||
string,
|
||||
@@ -1987,7 +2007,7 @@ export function dailyComplianceRatesFromLedger(
|
||||
// counter — they never enter the day's denominator.
|
||||
if (!isTaken && !isMissed) continue;
|
||||
|
||||
const dayKey = toBerlinDayKey(row.at);
|
||||
const dayKey = userDayKey(row.at, tz);
|
||||
const bucket = byDay.get(dayKey) ?? { taken: 0, missed: 0, date: row.at };
|
||||
if (isTaken) bucket.taken += 1;
|
||||
else bucket.missed += 1;
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
buildComplianceMedicationContext,
|
||||
expectedSlotCountForDay,
|
||||
lastNonSkippedTakenAt,
|
||||
SCHEDULE_COMPLIANCE_SELECT,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import { getUserTodayBounds } from "@/lib/tz/local-day";
|
||||
import { userDayKey } from "@/lib/tz/resolver";
|
||||
@@ -50,7 +51,9 @@ export async function buildScheduleAnchoredComplianceBuckets(
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
include: {
|
||||
schedules: true,
|
||||
// v1.15.20 — the shared compliance select so a future engine column
|
||||
// reaches this surface the moment it joins SCHEDULE_COMPLIANCE_SELECT.
|
||||
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
|
||||
// v1.16.3 — archived schedule eras for era-aware expected counts.
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
// v1.25 H-MED1 — pause eras so paused days drop out of the denominator.
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
buildComplianceMedicationContext,
|
||||
lastNonSkippedTakenAt,
|
||||
tallyComplianceFromLedger,
|
||||
SCHEDULE_COMPLIANCE_SELECT,
|
||||
type ComplianceSchedule,
|
||||
type IntakeEvent,
|
||||
type MedicationPauseEraLike,
|
||||
@@ -828,7 +829,10 @@ export async function collectDoctorReportData(
|
||||
prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
include: {
|
||||
schedules: true,
|
||||
// v1.15.20 — the shared compliance select (plus `label`, which the
|
||||
// FHIR schedule mapping below needs) so a future engine column added
|
||||
// to SCHEDULE_COMPLIANCE_SELECT reaches the doctor-report surface.
|
||||
schedules: { select: { ...SCHEDULE_COMPLIANCE_SELECT, label: true } },
|
||||
// v1.17 W1a — archived schedule eras so the ledger compliance
|
||||
// builder segments expected-slot expansion against the schedule
|
||||
// that was live on each past day (matches the detail page).
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
classifyPulse,
|
||||
} from "@/lib/analytics/classifications";
|
||||
import { classifyIntakeTiming } from "@/lib/analytics/compliance";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
|
||||
export type AuthedUser = Awaited<ReturnType<typeof requireAuth>>["user"];
|
||||
|
||||
@@ -270,6 +271,7 @@ function getHealthGreenDaySeries(
|
||||
function getOnTimePerfectDaySeries(
|
||||
intakeEvents: IntakeEventRecord[],
|
||||
schedulesByMedicationId: Map<string, MedicationScheduleRecord[]>,
|
||||
tz: string,
|
||||
) {
|
||||
const eventsByDay = new Map<string, IntakeEventRecord[]>();
|
||||
|
||||
@@ -285,7 +287,6 @@ function getOnTimePerfectDaySeries(
|
||||
for (const [dayKey, events] of eventsByDay.entries()) {
|
||||
if (events.length === 0) continue;
|
||||
|
||||
const scheduledDate = dayKeyToDate(dayKey);
|
||||
let isPerfectDay = true;
|
||||
|
||||
for (const event of events) {
|
||||
@@ -299,9 +300,11 @@ function getOnTimePerfectDaySeries(
|
||||
continue;
|
||||
}
|
||||
|
||||
const eventMinutes =
|
||||
event.scheduledFor.getUTCHours() * 60 +
|
||||
event.scheduledFor.getUTCMinutes();
|
||||
// The scheduled minute-of-day must be read in the user's zone: the raw
|
||||
// `getUTCHours()` would push a non-UTC user's on-time dose past the 3h
|
||||
// grace and misread a genuinely-perfect day as imperfect.
|
||||
const scheduledWall = wallClockInTz(event.scheduledFor, tz);
|
||||
const eventMinutes = scheduledWall.hour * 60 + scheduledWall.minute;
|
||||
|
||||
let bestSchedule = schedules[0];
|
||||
let bestDistance = Infinity;
|
||||
@@ -316,11 +319,15 @@ function getOnTimePerfectDaySeries(
|
||||
}
|
||||
}
|
||||
|
||||
// Classify punctuality against the slot's real local day: pass the
|
||||
// canonical instant as the anchor and the user's zone so the window
|
||||
// `HH:mm` resolves to the correct local wall time, not 08:00 UTC.
|
||||
const timing = classifyIntakeTiming(
|
||||
event.takenAt,
|
||||
bestSchedule.windowStart,
|
||||
bestSchedule.windowEnd,
|
||||
scheduledDate,
|
||||
event.scheduledFor,
|
||||
{ tz },
|
||||
);
|
||||
|
||||
// v1.4.34 IW-C — `early` is also a compliant bucket (dose taken
|
||||
@@ -722,6 +729,7 @@ export async function buildAchievementsResult(
|
||||
const onTimeSeries = getOnTimePerfectDaySeries(
|
||||
intakeEvents,
|
||||
schedulesByMedicationId,
|
||||
user.timezone,
|
||||
);
|
||||
const complianceSeries = getCompliance80DaySeries(
|
||||
intakeEvents,
|
||||
|
||||
@@ -297,12 +297,13 @@ export async function prepareMedicationComplianceStatusForUser(
|
||||
// calendar day as fully dose-due — which made a weekly-cadence med's
|
||||
// per-day series contradict its own compliance30. Reusing the canonical
|
||||
// ledger fixes that by construction.
|
||||
const perDayRecords = dailyComplianceRatesFromLedger(bundle.ledgerRows).map(
|
||||
(day) => ({
|
||||
measuredAt: day.date,
|
||||
value: round(day.rate, 1),
|
||||
}),
|
||||
);
|
||||
const perDayRecords = dailyComplianceRatesFromLedger(
|
||||
bundle.ledgerRows,
|
||||
userTz,
|
||||
).map((day) => ({
|
||||
measuredAt: day.date,
|
||||
value: round(day.rate, 1),
|
||||
}));
|
||||
|
||||
// `applyPayloadBudget` gives the latest-day focus; the compact
|
||||
// graded series is what reaches the prompt.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -191,6 +191,18 @@ export function userDayKey(date: Date, tz: string): string {
|
||||
return formatInUserTz(date, tz, "date");
|
||||
}
|
||||
|
||||
/**
|
||||
* Wall-clock hour (0–23) an observer in `tz` reads off the clock at
|
||||
* `date`. Used for the time-of-day greeting so a traveller whose device
|
||||
* clock differs from their configured HealthLog zone still sees the right
|
||||
* salutation. Falls back to the UTC hour if the zone is unusable.
|
||||
*/
|
||||
export function hourInTz(date: Date, tz: string): number {
|
||||
const safeTz = isValidTimezone(tz) ? tz : DEFAULT_TIMEZONE;
|
||||
const parsed = parseInt(wallClockParts(date, safeTz).hour, 10);
|
||||
return Number.isFinite(parsed) ? parsed % 24 : date.getUTCHours();
|
||||
}
|
||||
|
||||
interface WallClockParts {
|
||||
year: string;
|
||||
month: string;
|
||||
|
||||
+18
-26
@@ -13,38 +13,30 @@ import { wallClockInTz } from "./wall-clock";
|
||||
/**
|
||||
* Get the start (midnight) and end (23:59:59.999) of "today" in the user's
|
||||
* timezone, returned as UTC Date objects suitable for database queries.
|
||||
*
|
||||
* DST-safe. Both bounds route through {@link startOfLocalDayInTz}, whose
|
||||
* two-pass solver settles the zone offset AT the target local midnight —
|
||||
* not at `now`. The window therefore spans the user's real local day even
|
||||
* when it is 23 h (spring-forward) or 25 h (fall-back): the previous
|
||||
* implementation sampled the offset at `now` and hardcoded a 24 h length,
|
||||
* so on a fall-back day the last local hour (e.g. a 23:30 dose) fell
|
||||
* OUTSIDE `[midnight, midnight+24h)` and on a spring-forward day the window
|
||||
* bled one hour into the next local day.
|
||||
*/
|
||||
export function getUserTodayBounds(
|
||||
now: Date,
|
||||
tz: string,
|
||||
): { start: Date; end: Date } {
|
||||
const parts = wallClockInTz(now, tz);
|
||||
|
||||
// Build a UTC date that represents the user's local midnight
|
||||
// by computing the offset between the real UTC time and the local
|
||||
// representation.
|
||||
const localMidnightAsUtc = new Date(
|
||||
Date.UTC(parts.year, parts.month - 1, parts.day, 0, 0, 0, 0),
|
||||
const start = startOfLocalDayInTz(now, tz);
|
||||
// Start of the NEXT local day. Advancing 25 h always crosses into the next
|
||||
// calendar day (a real day is 23–25 h, never ≤ 12.5 h, so 25 h can neither
|
||||
// fall short of the next day nor skip past it), then floor back to that
|
||||
// day's local midnight. `end` is the inclusive last millisecond of today.
|
||||
const nextDayStart = startOfLocalDayInTz(
|
||||
new Date(start.getTime() + 25 * 60 * 60 * 1000),
|
||||
tz,
|
||||
);
|
||||
|
||||
// Compute offset: how far ahead (positive) or behind (negative) the tz is
|
||||
// from UTC. localMidnightAsUtc represents "midnight in tz as if it were
|
||||
// UTC"; we need the UTC instant that corresponds to midnight in the tz.
|
||||
const localNowAsUtc = new Date(
|
||||
Date.UTC(
|
||||
parts.year,
|
||||
parts.month - 1,
|
||||
parts.day,
|
||||
parts.hour,
|
||||
parts.minute,
|
||||
parts.second,
|
||||
),
|
||||
);
|
||||
const offsetMs =
|
||||
Math.round((localNowAsUtc.getTime() - now.getTime()) / 60000) * 60000;
|
||||
|
||||
const start = new Date(localMidnightAsUtc.getTime() - offsetMs);
|
||||
const end = new Date(start.getTime() + 24 * 60 * 60 * 1000 - 1);
|
||||
const end = new Date(nextDayStart.getTime() - 1);
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ export {
|
||||
detectBrowserTimezone,
|
||||
formatInUserTz,
|
||||
userDayKey,
|
||||
hourInTz,
|
||||
type FormatInUserTzShape,
|
||||
} from "./format";
|
||||
|
||||
|
||||
Reference in new issue
Block a user