mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
merge(W1): backend perf, resilience, timezone correctness
startOfLocalDayInTz DST-safe day anchor (fixes the intake UTC-midnight off-by-offset bug) + userDayKey/wallClockInTz dedup across scheduling, consolidation and correlations; illness-insights N+1 collapsed under a concurrency cap; series-batch per-type isolation with degraded_types meta; rest-mode episode read memoised per request; targets response builder extracted to src/lib/targets.
This commit is contained in:
commit
b4a3355fd0
16 files changed
+1795
-1647
No files matched your search
@@ -11,6 +11,7 @@
|
||||
* contributes no gap rather than a wrong one. Born-gated + owner-scoped.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import pLimit from "p-limit";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
@@ -29,6 +30,9 @@ import { resolveUserTimezone } from "@/lib/measurements/consolidation-base";
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
/** Cap the per-episode gap computations so a heavy history can't fan out. */
|
||||
const MAX_GAP_COMPUTATIONS = 24;
|
||||
/** Run the per-episode correlations under a bounded concurrency budget so
|
||||
* the up-to-24 candidates don't serialise into ~500 round-trips. */
|
||||
const EPISODE_GAP_CONCURRENCY = 4;
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -69,19 +73,24 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const gapCandidates = rows
|
||||
.filter((r) => r.resolvedAt !== null && r.lifecycle !== "CHRONIC_ONGOING")
|
||||
.slice(0, MAX_GAP_COMPUTATIONS);
|
||||
const gapById = new Map<string, number | null>();
|
||||
for (const r of gapCandidates) {
|
||||
const derived = await computeEpisodeCorrelation(
|
||||
user.id,
|
||||
{ id: r.id, onsetAt: r.onsetAt, resolvedAt: r.resolvedAt, lifecycle: r.lifecycle },
|
||||
tz,
|
||||
now,
|
||||
);
|
||||
gapById.set(
|
||||
r.id,
|
||||
derived.status === "ok" ? derived.value.recoveryGapDays : null,
|
||||
);
|
||||
}
|
||||
const limit = pLimit(EPISODE_GAP_CONCURRENCY);
|
||||
const gapEntries = await Promise.all(
|
||||
gapCandidates.map((r) =>
|
||||
limit(async (): Promise<[string, number | null]> => {
|
||||
const derived = await computeEpisodeCorrelation(
|
||||
user.id,
|
||||
{ id: r.id, onsetAt: r.onsetAt, resolvedAt: r.resolvedAt, lifecycle: r.lifecycle },
|
||||
tz,
|
||||
now,
|
||||
);
|
||||
return [
|
||||
r.id,
|
||||
derived.status === "ok" ? derived.value.recoveryGapDays : null,
|
||||
];
|
||||
}),
|
||||
),
|
||||
);
|
||||
const gapById = new Map<string, number | null>(gapEntries);
|
||||
|
||||
const episodes: RetrospectiveEpisode[] = rows.map((r) => ({
|
||||
id: r.id,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -117,4 +117,24 @@ describe("GET /api/measurements/series-batch", () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(vi.mocked(readDailySeries)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("isolates a single type's failure to an empty slice, others still return", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const weightRows = [
|
||||
{ type: "WEIGHT", value: 80, measuredAt: FROM, count: 1 },
|
||||
];
|
||||
vi.mocked(readDailySeries).mockImplementation(async ({ type }) => {
|
||||
if (type === "WEIGHT") return weightRows as never;
|
||||
throw new Error("transient db reject");
|
||||
});
|
||||
|
||||
const res = await GET(
|
||||
req(`types=WEIGHT,BLOOD_PRESSURE_SYS&from=${FROM}&to=${TO}`),
|
||||
);
|
||||
// One type rejecting must NOT 500 the whole batch.
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.series.WEIGHT).toEqual(weightRows);
|
||||
expect(body.data.series.BLOOD_PRESSURE_SYS).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -63,18 +63,27 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// so the batched reader doesn't re-query it per type.
|
||||
const priorityJson = await loadUserSourcePriority(user.id);
|
||||
|
||||
// Per-type isolation: a single type's transient DB reject degrades to an
|
||||
// empty slice for THAT type only, never a 500 that blanks every chart in
|
||||
// the row. The failing chart self-fetches through its existing fallback.
|
||||
const limit = pLimit(SERIES_BATCH_CONCURRENCY);
|
||||
let degradedTypes = 0;
|
||||
const entries = await Promise.all(
|
||||
uniqueTypes.map((type) =>
|
||||
limit(async () => {
|
||||
const rows = await readDailySeries({
|
||||
userId: user.id,
|
||||
type,
|
||||
from,
|
||||
to,
|
||||
priorityJson,
|
||||
});
|
||||
return [type, rows] as const;
|
||||
limit(async (): Promise<readonly [MeasurementType, DailySeriesRow[]]> => {
|
||||
try {
|
||||
const rows = await readDailySeries({
|
||||
userId: user.id,
|
||||
type,
|
||||
from,
|
||||
to,
|
||||
priorityJson,
|
||||
});
|
||||
return [type, rows] as const;
|
||||
} catch {
|
||||
degradedTypes += 1;
|
||||
return [type, []] as const;
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -89,6 +98,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
meta: {
|
||||
type_count: uniqueTypes.length,
|
||||
total: entries.reduce((sum, [, rows]) => sum + rows.length, 0),
|
||||
degraded_types: degradedTypes,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
expectedSlotCountForDay,
|
||||
lastNonSkippedTakenAt,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import { getUserTodayBounds } from "@/lib/tz/local-day";
|
||||
import { getUserTodayBounds, startOfLocalDayInTz } from "@/lib/tz/local-day";
|
||||
import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes";
|
||||
import {
|
||||
applyCanonicalSlotWrite,
|
||||
@@ -84,19 +84,6 @@ const updateSchema = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
function startOfDayInTz(date: Date, tz: string): Date {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(date);
|
||||
const y = parts.find((p) => p.type === "year")?.value ?? "1970";
|
||||
const m = parts.find((p) => p.type === "month")?.value ?? "01";
|
||||
const d = parts.find((p) => p.type === "day")?.value ?? "01";
|
||||
return new Date(`${y}-${m}-${d}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
@@ -138,7 +125,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
|
||||
if (scope === "today") {
|
||||
const todayStart = startOfDayInTz(new Date(), userTz);
|
||||
const todayStart = startOfLocalDayInTz(new Date(), userTz);
|
||||
const todayEnd = new Date(todayStart.getTime() + 86_400_000);
|
||||
|
||||
// v1.4.39 W-SERVER-FIX — project pending intake rows for every
|
||||
|
||||
@@ -36,7 +36,7 @@ const CALL_SITES: Array<{ file: string; surface: string }> = [
|
||||
surface: "insight feature extraction (c7/c30/c90)",
|
||||
},
|
||||
{
|
||||
file: "src/app/api/insights/targets/route.ts",
|
||||
file: "src/lib/targets/build-response.ts",
|
||||
surface: "insight targets",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
type CorrelationResult,
|
||||
} from "@/lib/insights/correlations";
|
||||
import { isNearUtc, userDayKey } from "@/lib/tz/resolver";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
|
||||
/**
|
||||
* v1.4.37 W2 — cold-path correlation window. Trim from 30 to 28 days
|
||||
@@ -368,34 +369,8 @@ function dateFromDayKey(key: string): Date {
|
||||
return new Date(`${key}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
const WEEKDAY_FORMATTER_CACHE = new Map<string, Intl.DateTimeFormat>();
|
||||
function getWeekdayFormatter(timeZone: string): Intl.DateTimeFormat {
|
||||
let formatter = WEEKDAY_FORMATTER_CACHE.get(timeZone);
|
||||
if (!formatter) {
|
||||
formatter = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
weekday: "short",
|
||||
});
|
||||
WEEKDAY_FORMATTER_CACHE.set(timeZone, formatter);
|
||||
}
|
||||
return formatter;
|
||||
}
|
||||
|
||||
const ISO_WEEKDAY: Record<string, number> = {
|
||||
Mon: 1,
|
||||
Tue: 2,
|
||||
Wed: 3,
|
||||
Thu: 4,
|
||||
Fri: 5,
|
||||
Sat: 6,
|
||||
Sun: 7,
|
||||
};
|
||||
|
||||
function isoWeekdayInTz(d: Date, timeZone: string): number {
|
||||
const parts = getWeekdayFormatter(timeZone).formatToParts(d);
|
||||
const weekday = parts.find((p) => p.type === "weekday")?.value ?? "Mon";
|
||||
return ISO_WEEKDAY[weekday] ?? 1;
|
||||
// `wallClockInTz` returns 0=Sun..6=Sat; remap to ISO 1=Mon..7=Sun.
|
||||
const weekday = wallClockInTz(d, timeZone).weekday;
|
||||
return weekday === 0 ? 7 : weekday;
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
* Rollup DAY buckets are minted at the UTC midnight of the user-tz day, so
|
||||
* keying their `bucketStart` through `dayKeyForUserTz` is exact.
|
||||
*/
|
||||
import pLimit from "p-limit";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy";
|
||||
@@ -43,6 +44,13 @@ import {
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Bound the per-vital fan-out so a single episode's scan can't pin the
|
||||
* shared Prisma pool. Mirrors the W-POOL `p-limit(4)` discipline the
|
||||
* analytics summaries slice applies.
|
||||
*/
|
||||
const VITAL_SCAN_CONCURRENCY = 4;
|
||||
|
||||
/** The episode shape the read layer needs (a subset of the Prisma row). */
|
||||
export interface EpisodeForCorrelation {
|
||||
id: string;
|
||||
@@ -88,16 +96,30 @@ export async function computeEpisodeCorrelation(
|
||||
// mitigation — the same pattern the derived route uses).
|
||||
const coverage = await probeRollupCoverage(userId);
|
||||
|
||||
// Attempt every scan type under a bounded concurrency cap. The two
|
||||
// windows per type (baseline + episode) are independent reads, so they
|
||||
// run paired rather than serially (halving the per-type round-trips).
|
||||
// The day-log fever read shares the same fan-out budget.
|
||||
const limit = pLimit(VITAL_SCAN_CONCURRENCY);
|
||||
const [perType, dayLogFever] = await Promise.all([
|
||||
Promise.all(
|
||||
ILLNESS_SCAN_TYPES.map((type) =>
|
||||
limit(async () => {
|
||||
const [baseline, episodeWin] = await Promise.all([
|
||||
readWindowMeans(userId, type, baselineStart, baselineEnd, coverage, tz, now),
|
||||
readWindowMeans(userId, type, preOnsetStart, end, coverage, tz, now),
|
||||
]);
|
||||
return { type, baseline, episodeWin };
|
||||
}),
|
||||
),
|
||||
),
|
||||
limit(() => readDayLogFever(episode.id, preOnsetStart, end, tz)),
|
||||
]);
|
||||
|
||||
const series: VitalSeries[] = [];
|
||||
let anyLive = false;
|
||||
let anyDay = false;
|
||||
|
||||
// We attempt every scan type. An untracked vital returns empty points and
|
||||
// is dropped below. Each read is a single windowed query, so attempting all
|
||||
// types is bounded.
|
||||
for (const type of ILLNESS_SCAN_TYPES) {
|
||||
const baseline = await readWindowMeans(userId, type, baselineStart, baselineEnd, coverage, tz, now);
|
||||
const episodeWin = await readWindowMeans(userId, type, preOnsetStart, end, coverage, tz, now);
|
||||
for (const { type, baseline, episodeWin } of perType) {
|
||||
if (baseline.points.length === 0 && episodeWin.points.length === 0) continue;
|
||||
if (baseline.source === "live" || episodeWin.source === "live") anyLive = true;
|
||||
if (baseline.source === "DAY" || episodeWin.source === "DAY") anyDay = true;
|
||||
@@ -111,11 +133,6 @@ export async function computeEpisodeCorrelation(
|
||||
});
|
||||
}
|
||||
|
||||
// Union the canonical journaling-fever path: the illness day-log `feverC`
|
||||
// series over the episode window. A user logging 39.2 °C for days with no
|
||||
// HealthKit thermometer must still escalate. Per-day MAX.
|
||||
const dayLogFever = await readDayLogFever(episode.id, preOnsetStart, end, tz);
|
||||
|
||||
const source: "DAY" | "live" | "none" = anyDay
|
||||
? "DAY"
|
||||
: anyLive
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
import type { PrismaClient, IllnessEpisode } from "@/generated/prisma/client";
|
||||
import { prisma as defaultPrisma } from "@/lib/db";
|
||||
import { isIllnessEnabled } from "@/lib/illness/gate";
|
||||
import { memoizePerRequest } from "@/lib/request-cache";
|
||||
|
||||
/** Minimal shape the Rest Mode resolver needs from an episode row. */
|
||||
export type ActiveIllnessEpisode = Pick<
|
||||
@@ -78,6 +79,24 @@ export async function getActiveIllnessEpisodes(
|
||||
userId: string,
|
||||
asOf: Date = new Date(),
|
||||
client: PrismaClient = defaultPrisma,
|
||||
): Promise<ActiveIllnessEpisode[]> {
|
||||
// Several surfaces resolve Rest Mode for the same `(userId, asOf)` inside
|
||||
// one request — the snapshot builder, the recovery score it builds, the
|
||||
// analytics route, and coincident-deviation — each firing an identical
|
||||
// `findMany`. Dedupe them to a single read per request. An explicit client
|
||||
// (a test stub or a transaction) bypasses the memo so its query is never
|
||||
// served a default-client cached result.
|
||||
if (client !== defaultPrisma) return readActiveIllnessEpisodes(userId, asOf, client);
|
||||
return memoizePerRequest(
|
||||
`illness-active-episodes:${userId}:${asOf.getTime()}`,
|
||||
() => readActiveIllnessEpisodes(userId, asOf, client),
|
||||
);
|
||||
}
|
||||
|
||||
async function readActiveIllnessEpisodes(
|
||||
userId: string,
|
||||
asOf: Date,
|
||||
client: PrismaClient,
|
||||
): Promise<ActiveIllnessEpisode[]> {
|
||||
if (!(await isIllnessEnabled(userId))) return [];
|
||||
return client.illnessEpisode.findMany({
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
* every existing import site keeps working unchanged.
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
// `userDayKey` lives in the pure `@/lib/tz/format` leaf (no Prisma /
|
||||
// node:module pull) so importing it keeps this dependency-free module clean.
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
/**
|
||||
* Canonical grace window shared by the cumulative + mean drains. Rows
|
||||
@@ -52,12 +55,12 @@ export interface PerSampleRow {
|
||||
|
||||
/**
|
||||
* Resolve the user's calendar-day key (`YYYY-MM-DD`) for a given
|
||||
* timestamp + timezone. Reuses the same `sv-SE` Intl formatting choice
|
||||
* the mood-entries path locked in v1.4.25 W7b so iOS-side and
|
||||
* server-side day-keys round-trip byte-identically.
|
||||
* timestamp + timezone. Delegates to the canonical `userDayKey` so the
|
||||
* server-side and iOS-side day-keys round-trip byte-identically against
|
||||
* every other day-bucketed surface.
|
||||
*/
|
||||
export function dayKeyForUserTz(date: Date, tz: string): string {
|
||||
return new Intl.DateTimeFormat("sv-SE", { timeZone: tz }).format(date);
|
||||
return userDayKey(date, tz);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
} from "@/lib/medications/scheduling/recurrence";
|
||||
import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
import { startOfLocalDayInTz } from "@/lib/tz/local-day";
|
||||
|
||||
/**
|
||||
* v1.13.x compliance — opt-in retrospective-rolling expansion.
|
||||
@@ -336,8 +337,7 @@ function hhmmToMinutes(hhmm: string): number | null {
|
||||
|
||||
/** Snap a Date down to the user-local midnight. */
|
||||
export function startOfLocalDay(d: Date, tz: string | undefined): Date {
|
||||
const parts = wallClockInTz(d, tz);
|
||||
return instantInTz(parts.year, parts.month, parts.day, 0, 0, tz);
|
||||
return startOfLocalDayInTz(d, tz);
|
||||
}
|
||||
|
||||
/** Snap a Date down to the Sunday-rooted user-local week. */
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
type RecurrenceContext,
|
||||
} from "./recurrence";
|
||||
import { normaliseDoseWindows } from "./worker-helpers";
|
||||
import { userDayKey } from "@/lib/tz/resolver";
|
||||
|
||||
export interface ComplianceChips {
|
||||
/** 0-100, taken / (taken + missed). Skipped doses are excluded from
|
||||
@@ -62,9 +63,10 @@ const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Day-key in the user's timezone (or system-local when `tz` is
|
||||
* undefined). `Intl.DateTimeFormat` with `en-CA` reliably returns
|
||||
* `YYYY-MM-DD`, which sorts lexically and matches the chart
|
||||
* legend's `day` ticks.
|
||||
* undefined). Routes through the canonical `userDayKey` so the
|
||||
* `YYYY-MM-DD` bucket stays byte-for-byte aligned with every other
|
||||
* day-bucketed surface; the host-local branch covers the pre-per-user
|
||||
* callers that pass no zone.
|
||||
*/
|
||||
function localDayKey(d: Date, tz: string | undefined): string {
|
||||
if (!tz) {
|
||||
@@ -73,12 +75,7 @@ function localDayKey(d: Date, tz: string | undefined): string {
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(d);
|
||||
return userDayKey(d, tz);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -59,6 +59,7 @@ import { RRule } from "rrule";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
import { startOfLocalDayInTz } from "@/lib/tz/local-day";
|
||||
|
||||
/**
|
||||
* v1.7.0 — schedule-type discriminator on the canonical schedule.
|
||||
@@ -303,7 +304,7 @@ export function nextOccurrenceAfter(
|
||||
// keeps a slot the user has not yet acted on visible through the rest
|
||||
// of its due day instead of silently rolling it forward by N.
|
||||
if (schedule.rollingIntervalDays !== null) {
|
||||
const dayFloor = startOfDayInTz(after, ctx.timeZone);
|
||||
const dayFloor = startOfLocalDayInTz(after, ctx.timeZone);
|
||||
// With no intake logged the first dose anchors at `startsOn ?? createdAt`.
|
||||
// When that anchor is on a PRIOR calendar day the dose is overdue by ≥1
|
||||
// day and must still surface ("take now"); flooring only to the start of
|
||||
@@ -434,7 +435,7 @@ function expandOneShot(
|
||||
*/
|
||||
function firstRollingDoseDayFloor(ctx: RecurrenceContext): number {
|
||||
const anchor = ctx.medication.startsOn ?? ctx.medication.createdAt;
|
||||
return startOfDayInTz(anchor, ctx.timeZone).getTime();
|
||||
return startOfLocalDayInTz(anchor, ctx.timeZone).getTime();
|
||||
}
|
||||
|
||||
function expandRolling(
|
||||
@@ -782,16 +783,6 @@ function applyTimeOfDayToDate(day: Date, hhmm: string, tz: string): Date {
|
||||
return guess;
|
||||
}
|
||||
|
||||
/**
|
||||
* The UTC instant of midnight (00:00 wall-clock) on `instant`'s day in
|
||||
* the user's IANA timezone. Used by the rolling-cadence next-due search
|
||||
* to floor the window to the start of the user's current day so an
|
||||
* overdue / due-earlier-today rolling dose still surfaces.
|
||||
*/
|
||||
function startOfDayInTz(instant: Date, tz: string): Date {
|
||||
return applyTimeOfDayToDate(instant, "00:00", tz);
|
||||
}
|
||||
|
||||
function tzOffsetMinutes(date: Date, tz: string): number {
|
||||
const parts = wallClockInTz(date, tz);
|
||||
const asIfUtc = Date.UTC(
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { startOfLocalDayInTz } from "@/lib/tz/local-day";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
|
||||
describe("startOfLocalDayInTz", () => {
|
||||
it("anchors at the user's local midnight, not UTC midnight", () => {
|
||||
// 2026-06-15 22:00 UTC is already 2026-06-16 00:00 in Berlin (UTC+2),
|
||||
// so the local day floor must land on the Berlin 2026-06-16 midnight
|
||||
// instant (2026-06-15T22:00:00Z) — never 2026-06-16T00:00:00Z.
|
||||
const instant = new Date("2026-06-15T23:30:00.000Z");
|
||||
const floor = startOfLocalDayInTz(instant, "Europe/Berlin");
|
||||
const parts = wallClockInTz(floor, "Europe/Berlin");
|
||||
expect(parts.hour).toBe(0);
|
||||
expect(parts.minute).toBe(0);
|
||||
expect(parts.second).toBe(0);
|
||||
expect(floor.toISOString()).toBe("2026-06-15T22:00:00.000Z");
|
||||
});
|
||||
|
||||
it("handles negative offsets (Pacific/Auckland) — late-local reading stays on its day", () => {
|
||||
// 2026-06-15 23:30 NZST (UTC+12) is 2026-06-15T11:30Z; the local day
|
||||
// floor is Auckland 2026-06-15 midnight = 2026-06-14T12:00:00Z.
|
||||
const instant = new Date("2026-06-15T11:30:00.000Z");
|
||||
const floor = startOfLocalDayInTz(instant, "Pacific/Auckland");
|
||||
const parts = wallClockInTz(floor, "Pacific/Auckland");
|
||||
expect(parts.hour).toBe(0);
|
||||
expect(parts.day).toBe(15);
|
||||
});
|
||||
|
||||
it("is DST-safe across the spring-forward boundary", () => {
|
||||
// Europe/Berlin springs forward 2026-03-29 02:00→03:00.
|
||||
const instant = new Date("2026-03-29T10:00:00.000Z");
|
||||
const floor = startOfLocalDayInTz(instant, "Europe/Berlin");
|
||||
const parts = wallClockInTz(floor, "Europe/Berlin");
|
||||
expect(parts.hour).toBe(0);
|
||||
expect(parts.day).toBe(29);
|
||||
});
|
||||
|
||||
it("falls back to host-local day when tz is undefined", () => {
|
||||
const instant = new Date(2026, 5, 15, 14, 30, 0);
|
||||
const floor = startOfLocalDayInTz(instant, undefined);
|
||||
expect(floor.getHours()).toBe(0);
|
||||
expect(floor.getDate()).toBe(15);
|
||||
});
|
||||
});
|
||||
@@ -49,6 +49,58 @@ export function getUserTodayBounds(
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
/**
|
||||
* UTC instant of local midnight (00:00 wall-clock) on the calendar day
|
||||
* `instant` falls on in `tz`. DST-safe: derives the zone offset at the
|
||||
* target local time so the returned instant always represents the user's
|
||||
* local start-of-day, never UTC midnight of that calendar date.
|
||||
*
|
||||
* Canonical start-of-local-day primitive. The medication-scheduling
|
||||
* `startOfLocalDay` (cadence) and `startOfDayInTz` (recurrence) route
|
||||
* through this so the day-floor never drifts between surfaces. When `tz`
|
||||
* is omitted the host's system-local day is used.
|
||||
*/
|
||||
export function startOfLocalDayInTz(
|
||||
instant: Date,
|
||||
tz: string | undefined,
|
||||
): Date {
|
||||
if (!tz) {
|
||||
return new Date(
|
||||
instant.getFullYear(),
|
||||
instant.getMonth(),
|
||||
instant.getDate(),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
const parts = wallClockInTz(instant, tz);
|
||||
// Two-pass converge: treat the wall clock as UTC, then correct by the
|
||||
// zone offset at that approximate instant. The second pass settles the
|
||||
// offset across a DST transition for every IANA zone.
|
||||
let guess = new Date(
|
||||
Date.UTC(parts.year, parts.month - 1, parts.day, 0, 0, 0, 0),
|
||||
);
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const at = wallClockInTz(guess, tz);
|
||||
const asIfUtc = Date.UTC(
|
||||
at.year,
|
||||
at.month - 1,
|
||||
at.day,
|
||||
at.hour,
|
||||
at.minute,
|
||||
at.second,
|
||||
);
|
||||
const offsetMin = Math.round((asIfUtc - guess.getTime()) / 60_000);
|
||||
guess = new Date(
|
||||
Date.UTC(parts.year, parts.month - 1, parts.day, 0, 0, 0, 0) -
|
||||
offsetMin * 60_000,
|
||||
);
|
||||
}
|
||||
return guess;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the day of the week (0=Sun..6=Sat) for a given instant in a specific
|
||||
* timezone.
|
||||
|
||||
Reference in new issue
Block a user