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:
Marc-André Bombeck committed 2026-06-18 18:35:45 +02:00
commit b4a3355fd0
16 files changed
+1795 -1647

No files matched your search

+22 -13
View File
@@ -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([]);
});
});
+19 -9
View File
@@ -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,
},
});
+2 -15
View File
@@ -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",
},
{
+4 -29
View File
@@ -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;
}
+29 -12
View File
@@ -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
+19
View File
@@ -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({
+7 -4
View File
@@ -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);
}
/**
+2 -2
View File
@@ -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. */
+6 -9
View File
@@ -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);
}
/**
+3 -12
View File
@@ -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
+44
View File
@@ -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);
});
});
+52
View File
@@ -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.