Merge remote-tracking branch 'origin/main' into fix/v1397-meta

This commit is contained in:
Marc-André Bombeck committed 2026-10-01 11:29:08 +02:00
commit 383323b542
128 files changed
+1676 -256

No files matched your search

+290
View File
@@ -0,0 +1,290 @@
/**
* Core day paths under a host zone far from UTC.
*
* The suite pins the host to UTC (`vitest.config.mts`), and the production
* image to Europe/Berlin. Both sit close enough to UTC that a UTC day and a
* local day agree for most of every day, which is how day-boundary defects
* kept passing: the evening west of UTC and the small hours east of it are
* exactly the hours no fixture lived in. This harness sets the HOST zone
* (`process.env.TZ`, in code; the `TZ=` prefix on the command line does not
* reach Vitest's workers here) to one zone at each edge, gives the account
* the same zone, and checks that a handful of the paths people hit name the
* local day at the edge of it:
*
* - a medication schedule slot, and a course on its last day
* - a mood entry's day
* - the dashboard summary's mood instant (the iOS home tile)
* - a document date from the stated string to the stored instant to the
* DTO
*
* Each zone is tested at the instant where its local day and the UTC day
* differ: 23:30 in Los Angeles (already tomorrow in UTC) and 00:30 on
* Kiritimati (still yesterday in UTC).
*/
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/modules/gate", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/modules/gate")>();
return {
...actual,
resolveModuleMap: vi.fn(async () =>
Object.fromEntries(actual.MODULE_KEYS.map((k) => [k, true])),
),
};
});
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { groupBy: vi.fn(), findMany: vi.fn() },
medicationIntakeEvent: {
findMany: vi.fn(),
createMany: vi.fn(),
groupBy: vi.fn(),
},
medication: { findMany: vi.fn() },
medicationScheduleRevision: { groupBy: vi.fn() },
user: { findUnique: vi.fn() },
appSettings: { findUnique: vi.fn(async () => null) },
$queryRaw: vi.fn(),
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/rollups/measurement-read", () => ({
loadUserSourcePriority: vi.fn(async () => null),
}));
vi.mock("@/lib/analytics/mood-series", () => ({
buildMoodDailySeries: vi.fn(),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET as dashboardSummary } from "@/app/api/dashboard/summary/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { buildMoodDailySeries } from "@/lib/analytics/mood-series";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
import { serialiseDocument } from "@/lib/documents/store";
import { resolveIntakeActionability } from "@/lib/medications/intake-actionable";
import { moodDateKey } from "@/lib/mood/date-key";
import { dayKeyForScheduledFor } from "@/lib/rollups/medication-compliance-rollups";
import {
dateOnlyAtNoonUtc,
dayKeyAsUtcMidnight,
statedDateKey,
} from "@/lib/tz/date-only";
import { userDayKey } from "@/lib/tz/format";
import { localHmAsUtc } from "@/lib/tz/local-day";
interface EdgeCase {
zone: string;
/** An instant where the local day and the UTC day differ. */
now: Date;
/** The local calendar day at `now`. */
localDay: string;
/** The UTC calendar day at `now`, for the record. */
utcDay: string;
}
const EDGES: EdgeCase[] = [
{
zone: "America/Los_Angeles",
now: new Date("2026-07-03T06:30:00.000Z"), // 2 July, 23:30 local
localDay: "2026-07-02",
utcDay: "2026-07-03",
},
{
zone: "Pacific/Kiritimati",
now: new Date("2026-07-02T10:30:00.000Z"), // 3 July, 00:30 local
localDay: "2026-07-03",
utcDay: "2026-07-02",
},
];
describe.each(EDGES)("day paths with the host in $zone", (edge) => {
let previousTz: string | undefined;
beforeAll(() => {
previousTz = process.env.TZ;
process.env.TZ = edge.zone;
});
afterAll(() => {
if (previousTz === undefined) delete process.env.TZ;
else process.env.TZ = previousTz;
});
it("runs with the host clock in the zone (the harness itself)", () => {
// If the zone did not take, every assertion below would be testing UTC.
expect(edge.now.getDate()).toBe(Number(edge.localDay.slice(8, 10)));
expect(edge.localDay).not.toBe(edge.utcDay);
});
describe("medication schedule day", () => {
it("keys a slot on the local day it falls on", () => {
expect(dayKeyForScheduledFor(edge.now, edge.zone)).toBe(edge.localDay);
});
it("places today's 08:00 slot on the local day", () => {
const slot = localHmAsUtc(edge.now, edge.zone, 8, 0);
expect(userDayKey(slot, edge.zone)).toBe(edge.localDay);
});
it("keeps a course running through its last local day", () => {
const status = resolveIntakeActionability(
{
active: true,
trackIntake: true,
startsOn: null,
// `endsOn` is a @db.Date column: the calendar date as UTC midnight.
endsOn: dayKeyAsUtcMidnight(edge.localDay),
},
edge.now,
edge.zone,
);
expect(status.courseStatus).toBe("CURRENT");
expect(status.intakeActionable).toBe(true);
});
});
describe("mood day", () => {
it("files an entry under the local day it was logged", () => {
expect(moodDateKey(edge.now, edge.zone)).toBe(edge.localDay);
});
});
describe("dashboard summary day", () => {
beforeEach(() => {
vi.resetAllMocks();
__resetAllCachesForTests();
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.createMany).mockResolvedValue({
count: 0,
} as never);
vi.mocked(prisma.medicationIntakeEvent.groupBy).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.medicationScheduleRevision.groupBy).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
gender: null,
heightCm: null,
} as never);
vi.mocked(getSession).mockResolvedValue({
session: { id: "s", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "tester",
role: "USER",
displayName: null,
timezone: edge.zone,
},
} as never);
});
it("puts the mood card's instant on the entry's local day", async () => {
vi.mocked(buildMoodDailySeries).mockResolvedValue({
entries: [{ date: edge.localDay, score: 4, samples: 1 }],
summary: null,
entryCount: 1,
source: "rollup",
} as never);
const call = dashboardSummary as unknown as (
req: NextRequest,
) => Promise<Response>;
const res = await call(
new NextRequest("http://localhost/api/dashboard/summary"),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { metrics: Array<{ kind: string; updatedAt: string | null }> };
};
const mood = body.data.metrics.find((m) => m.kind === "mood");
expect(mood?.updatedAt).toBeTruthy();
const at = new Date(mood!.updatedAt!);
// Read the way a client in the zone reads it: the host clock and the
// account zone agree here.
expect(userDayKey(at, edge.zone)).toBe(edge.localDay);
expect(
`${at.getFullYear()}-${String(at.getMonth() + 1).padStart(2, "0")}-${String(at.getDate()).padStart(2, "0")}`,
).toBe(edge.localDay);
});
});
describe("document date round trip", () => {
function documentRow(documentDate: Date) {
return {
id: "doc-1",
userId: "user-1",
kind: "OTHER",
title: null,
filename: "report.pdf",
mimeType: "application/pdf",
byteSize: 1,
status: "STORED",
providerType: null,
reportDate: documentDate,
documentDate,
errorReason: null,
lastIndexAttemptAt: null,
lastIndexOutcome: null,
sourceSystem: null,
sourceId: null,
createdAt: edge.now,
updatedAt: edge.now,
} as never;
}
it("reads a written-out date as the date it states", () => {
// Parsed as local time in the host zone; read back in UTC this was the
// previous day east of UTC.
expect(statedDateKey("July 3, 2026")).toBe("2026-07-03");
expect(statedDateKey("2026-07-03")).toBe("2026-07-03");
expect(statedDateKey("2026-07-03T08:00:00+09:00")).toBe("2026-07-03");
});
it("stores a stated date and serves the same date back", () => {
const stored = dateOnlyAtNoonUtc(edge.localDay);
const dto = serialiseDocument(documentRow(stored), {
factCount: 0,
pendingCount: 0,
});
expect(dto.documentDate).toBe(edge.localDay);
expect(dto.reportDate).toBe(edge.localDay);
});
it("serves a row written before the noon anchor on its date too", () => {
const legacy = dayKeyAsUtcMidnight(edge.localDay);
const dto = serialiseDocument(documentRow(legacy), {
factCount: 0,
pendingCount: 0,
});
expect(dto.documentDate).toBe(edge.localDay);
});
});
});
@@ -0,0 +1,80 @@
/**
* The allowlist of `healthlog/no-utc-day-key`.
*
* The lint rule refuses the UTC-day idioms in application source; a site
* where the UTC day is right keeps it with an
* `eslint-disable-next-line healthlog/no-utc-day-key -- <reason>` directive.
* This guard reads every such directive and holds three lines the rule alone
* cannot:
*
* - every directive is the next-line form (a same-line or block disable on
* the first line of a file hides the rule's own refusal);
* - every reason says which kind of exception it is: `UTC by design:` (the
* UTC day is the correct answer) or `baseline:` (a known site that should
* move to the user's day and has not yet);
* - the `baseline:` set can only shrink. A new UTC-day site is fixed or
* argued as UTC by design, never parked.
*
* Mutation, done once by hand: a bare directive on a new line in
* `src/lib/insights/score-row.ts` fails the reason check; an eleventh
* `baseline:` directive fails the ceiling.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(__dirname, "..");
const RULE = "healthlog/no-utc-day-key";
/** Known sites still on the UTC day that should not be. Lower it, never raise it. */
const BASELINE_CEILING = 10;
interface Directive {
file: string;
line: number;
text: string;
}
function directives(): Directive[] {
const out: Directive[] = [];
for (const file of walkSourceFiles(SRC, { floor: 2000 })) {
// The rule does not run on tests, so no directive there means anything
// (this file names the directive in its own documentation).
if (/(^|\/)__tests__\/|\.test\.tsx?$/.test(file)) continue;
const lines = readFileSync(join(SRC, file), "utf8").split("\n");
lines.forEach((text, i) => {
if (/eslint-disable/.test(text) && text.includes(RULE)) {
out.push({ file, line: i + 1, text: text.trim() });
}
});
}
return out;
}
describe("no-utc-day-key allowlist", () => {
const found = directives();
it("finds the allowlist it guards", () => {
// A walk that finds nothing would agree with every assertion below.
expect(found.length).toBeGreaterThanOrEqual(40);
});
it("uses only next-line directives", () => {
const offenders = found.filter(
(d) => !d.text.includes(`eslint-disable-next-line ${RULE}`),
);
expect(offenders).toEqual([]);
});
it("gives every directive a classified reason", () => {
const offenders = found.filter(
(d) => !/ -- (UTC by design|baseline): \S.{8,}/.test(d.text),
);
expect(offenders).toEqual([]);
});
it("never grows the baseline", () => {
const baseline = found.filter((d) => d.text.includes(" -- baseline: "));
expect(baseline.length).toBeLessThanOrEqual(BASELINE_CEILING);
});
});
@@ -132,6 +132,7 @@ export const GET = apiHandler(
return apiError("Backup payload failed schema validation", 500);
}
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const isoDate = backup.createdAt.toISOString().slice(0, 10); // YYYY-MM-DD
const filename = `healthlog-backup-${backup.userId}-${isoDate}.json`;
+2 -1
View File
@@ -43,6 +43,7 @@ import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
const DEFAULT_PAST_DAYS = 90;
const DEFAULT_FORWARD_DAYS = 180;
@@ -148,7 +149,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
deletedAt: null,
type: "WRIST_TEMPERATURE",
measuredAt: {
gte: new Date(Date.parse(`${addDays(today, -90)}T00:00:00Z`)),
gte: startOfLocalDayKey(addDays(today, -90), tz),
},
},
orderBy: { measuredAt: "asc" },
+4 -3
View File
@@ -46,6 +46,7 @@ import { addDays } from "@/lib/cycle/day-math";
import { readSourceDayAggregates } from "@/lib/measurements/day-aggregates";
import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
/** Trailing window the phase-contrast walks (days). */
const WINDOW_DAYS = 365;
@@ -125,7 +126,7 @@ export const GET = apiHandler(async () => {
deletedAt: null,
type: "WRIST_TEMPERATURE",
measuredAt: {
gte: new Date(Date.parse(`${addDays(today, -90)}T00:00:00Z`)),
gte: startOfLocalDayKey(addDays(today, -90), tz),
},
},
orderBy: { measuredAt: "asc" },
@@ -142,7 +143,7 @@ export const GET = apiHandler(async () => {
? readSourceDayAggregates({
userId: user.id,
types: PHASE_CROSSTAB_METRIC_TYPES,
since: new Date(Date.parse(`${from}T00:00:00Z`)),
since: startOfLocalDayKey(from, tz),
timeZone: tz,
})
: [],
@@ -159,7 +160,7 @@ export const GET = apiHandler(async () => {
where: {
userId: user.id,
deletedAt: null,
moodLoggedAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) },
moodLoggedAt: { gte: startOfLocalDayKey(from, tz) },
},
orderBy: { moodLoggedAt: "asc" },
select: { score: true, moodLoggedAt: true },
@@ -105,6 +105,7 @@ import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
import { buildMoodDailySeries } from "@/lib/analytics/mood-series";
import { userDayKey } from "@/lib/tz/format";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -1167,7 +1168,32 @@ describe("GET /api/dashboard/summary — mood + BMI cards", () => {
expect(mood?.allTimeCount).toBe(3);
expect(mood?.titleKey).toBe("dashboard.metric.title.mood");
expect(mood?.unitKey).toBe("dashboard.metric.unit.mood");
expect(mood?.lastSeenAt).toBe("2026-07-03T00:00:00.000Z");
// The day key is the account's local day (default zone, summer time):
// its first instant, not the key written out as UTC midnight.
expect(mood?.lastSeenAt).toBe("2026-07-02T22:00:00.000Z");
expect(mood?.updatedAt).toBe("2026-07-02T22:00:00.000Z");
});
it("puts the mood instant on the logged day for a zone west of UTC", async () => {
// The day key as UTC midnight is the previous evening in Los Angeles,
// so a client formatting the instant in its own zone showed today's
// entry as yesterday's.
vi.mocked(getSession).mockResolvedValue({
...SESSION_OK,
user: { ...SESSION_OK.user, timezone: "America/Los_Angeles" },
} as never);
vi.mocked(buildMoodDailySeries).mockResolvedValue({
entries: [{ date: "2026-07-03", score: 4, samples: 1 }],
summary: null,
entryCount: 1,
source: "rollup",
} as never);
const res = await callGet(makeReq());
const mood = cardsOf(await res.json()).find((c) => c.kind === "mood");
const at = new Date(mood?.updatedAt as string);
expect(userDayKey(at, "America/Los_Angeles")).toBe("2026-07-03");
expect(mood?.updatedAt).toBe("2026-07-03T07:00:00.000Z");
});
it("omits the mood card when the account has never logged a mood", async () => {
+8 -4
View File
@@ -65,6 +65,7 @@ import { type Locale } from "@/lib/i18n/config";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { validTimezoneOr } from "@/lib/tz/format";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today";
import { computeStreak } from "@/lib/dashboard/streak";
@@ -879,11 +880,14 @@ async function buildDashboardSummary(
if (moodSeries.entryCount > 0) {
const spark = moodSeries.entries.slice(-SPARK_DAYS).map((e) => e.score);
const latestEntry = moodSeries.entries[moodSeries.entries.length - 1];
// The series carries UTC day labels, not timestamps — the entry's day is
// the only instant the source actually pins, so it is emitted as the
// day's start rather than inventing a reading time.
// The series carries the user's LOCAL day keys (`MoodEntry.date`), not
// timestamps, so the only honest instant is the first instant of that
// day in the user's zone. Writing the key as `T00:00Z` instead put the
// instant on the previous calendar day for anyone west of UTC, and a
// client formatting it in its own zone showed today's entry as
// yesterday's.
const latestAt = latestEntry
? new Date(`${latestEntry.date}T00:00:00.000Z`).toISOString()
? startOfLocalDayKey(latestEntry.date, userTz).toISOString()
: null;
metrics.push({
id: "mood",
@@ -232,6 +232,29 @@ describe("PATCH /api/documents/inbound/[id]", () => {
expect("documentDate" in arg.data).toBe(false);
});
it("stores an edited filing date at noon UTC so it reads back on that day everywhere", async () => {
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
id: "doc-1",
} as never);
vi.mocked(prisma.inboundDocument.update).mockResolvedValue(
docRow() as never,
);
vi.mocked(prisma.inboundDocument.findFirstOrThrow).mockResolvedValue(
docRow() as never,
);
const res = await PATCH(
mkReq("doc-1", { documentDate: "2026-05-03" }) as never,
ctx("doc-1") as never,
);
expect(res.status).toBe(200);
const arg = vi.mocked(prisma.inboundDocument.update).mock.calls[0][0];
// UTC midnight is the previous evening west of UTC.
expect((arg.data.documentDate as Date).toISOString()).toBe(
"2026-05-03T12:00:00.000Z",
);
});
it("422s on an empty update", async () => {
const res = await PATCH(mkReq("doc-1", {}) as never, ctx("doc-1") as never);
expect(res.status).toBe(422);
@@ -288,6 +288,14 @@ describe("POST /api/documents/inbound/[id]/extract — stored mode", () => {
data: expect.objectContaining({ status: "EXTRACTED" }),
}),
);
// The stated report date is a date-only value: stored at noon UTC, not
// at UTC midnight (the previous evening west of UTC).
const staged = tx.inboundDocument.update.mock.calls[0]![0] as {
data: { reportDate: Date };
};
expect(staged.data.reportDate.toISOString()).toBe(
"2026-08-01T12:00:00.000Z",
);
expect(auditLog).toHaveBeenCalledWith(
"documents.inbound.extract",
expect.objectContaining({
@@ -76,6 +76,7 @@ import {
inboundStoredExtractSchema,
inboundTextExtractSchema,
} from "@/lib/validations/inbound-documents";
import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only";
export const dynamic = "force-dynamic";
@@ -111,7 +112,7 @@ async function stageExtraction(
status: "EXTRACTED",
providerType: result.providerType,
reportDate: result.reportDate
? new Date(`${result.reportDate}T00:00:00.000Z`)
? dateOnlyAtNoonUtc(result.reportDate)
: null,
facts: {
create: result.facts.map((f) => ({
+2 -1
View File
@@ -41,6 +41,7 @@ import {
toContentIndexSource,
} from "@/lib/validations/inbound-documents";
import type { Prisma } from "@/generated/prisma/client";
import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only";
type RouteParams = { params: Promise<{ id: string }> };
@@ -232,7 +233,7 @@ export const PATCH = apiHandler(
if (parsed.data.kind !== undefined) data.kind = parsed.data.kind;
if (parsed.data.documentDate !== undefined) {
data.documentDate = parsed.data.documentDate
? new Date(`${parsed.data.documentDate}T00:00:00.000Z`)
? dateOnlyAtNoonUtc(parsed.data.documentDate)
: null;
}
+1
View File
@@ -172,6 +172,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
);
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const stamp = new Date().toISOString().slice(0, 10);
// Return the raw binary archive (NOT the apiSuccess envelope). The file is a
// self-contained `.hlx` archive openable with the user's passphrase via
+1
View File
@@ -88,6 +88,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
},
);
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const stamp = new Date().toISOString().slice(0, 10);
// Stream the JSON directly (NOT wrapped in the apiSuccess envelope) so
// the file is a self-contained backup — admin upload + restore expect
@@ -228,6 +228,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
}
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const stamp = new Date().toISOString().slice(0, 10);
const { t } = getServerTranslator(locale);
+1
View File
@@ -208,6 +208,7 @@ function csvResponse(
body: ReadableStream<Uint8Array>,
prefix: string,
): NextResponse {
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const stamp = new Date().toISOString().slice(0, 10);
return new NextResponse(body, {
status: 200,
+1
View File
@@ -145,6 +145,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
},
});
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const stamp = new Date().toISOString().slice(0, 10);
return new NextResponse(body, {
status: 200,
+1
View File
@@ -83,6 +83,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
},
});
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const stamp = new Date().toISOString().slice(0, 10);
return new NextResponse(csv, {
status: 200,
+1
View File
@@ -155,6 +155,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
: formatMoodEntriesForExport(decryptedMood, userTz);
}
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const filename = `healthlog-export-${new Date().toISOString().slice(0, 10)}`;
if (measurementChunks && format === "csv") {
+2 -1
View File
@@ -35,6 +35,7 @@ import {
import type { MeasurementType } from "@/generated/prisma/client";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
type Severity = "alert" | "caution" | "info" | "good";
@@ -135,7 +136,7 @@ export const GET = apiHandler(async () => {
rollupCoverage,
);
const pulseData: DataPoint[] = pulseDayMeans.map((p) => ({
date: new Date(`${p.day}T00:00:00.000Z`),
date: dayKeyAsUtcMidnight(p.day),
value: p.mean,
}));
+4 -2
View File
@@ -44,6 +44,7 @@ import {
} from "@/lib/rollups/mood-rollups";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
import { dayKeyAsUtcMidnight, dateOnlyKey } from "@/lib/tz/date-only";
export const dynamic = "force-dynamic";
@@ -176,7 +177,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
let moodEntryCount: number;
if (moodRollupDayRows.length > 0) {
dailyMoodEntries = moodRollupDayRows.map((r) => ({
day: r.bucketStart.toISOString().slice(0, 10),
day: dateOnlyKey(r.bucketStart),
value: Math.round(r.mean * 100) / 100,
}));
moodDataPoints = moodRollupDayRows.map((r) => ({
@@ -526,6 +527,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
// the scatter and the coefficient rather than dragging continuity down.
const loggedDays = new Set<string>();
for (const event of bpMedicationEvents) {
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: pairs intake days with the DAY rollup buckets in sysByDay, which are UTC days
const dayKey = event.scheduledFor.toISOString().slice(0, 10);
loggedDays.add(dayKey);
if (event.skipped || !event.takenAt) continue;
@@ -540,7 +542,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
pairs.push({
a: continuity,
b: avgSys,
date: new Date(`${dayKey}T00:00:00.000Z`),
date: dayKeyAsUtcMidnight(dayKey),
});
bpMedicationScatterData.push({
continuityPct: Math.round(continuity * 100),
@@ -54,6 +54,7 @@ function medication() {
function moodDay(day: number, tags: string[]) {
return {
date: `2026-04-${String(day).padStart(2, "0")}`,
moodLoggedAt: new Date(Date.UTC(2026, 3, day, 9, 0, 0)),
tags: JSON.stringify(tags),
};
@@ -99,6 +100,20 @@ describe("GET /api/insights/glp1-timeline side-effect days", () => {
]);
});
it("files a side effect under the entry's own day, not its UTC day", async () => {
// Logged 2026-04-10 at 20:00 in Los Angeles: already the 11th in UTC.
const out = await timeline([
{
date: "2026-04-10",
moodLoggedAt: new Date("2026-04-11T03:00:00.000Z"),
tags: JSON.stringify(["nausea"]),
},
]);
expect(out.entries).toEqual([
{ date: "2026-04-10T12:00:00Z", kind: "side-effect", tags: ["nausea"] },
]);
});
it("recognises the chip label of every shipped locale", async () => {
const out = await timeline([
moodDay(10, ["Übelkeit"]),
+5 -2
View File
@@ -80,7 +80,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
deletedAt: null,
moodLoggedAt: { gte: ninetyDaysAgo },
},
select: { moodLoggedAt: true, tags: true },
select: { date: true, tags: true },
orderBy: { moodLoggedAt: "desc" },
});
@@ -140,7 +140,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { matched, unresolvedCount } = matchGlp1SideEffectTags(mood.tags);
unresolvedTagCount += unresolvedCount;
if (matched.length === 0) continue;
const dayKey = mood.moodLoggedAt.toISOString().slice(0, 10);
// `date` is the day the entry belongs to in the writer's zone. The UTC
// day of `moodLoggedAt` moved an evening entry west of UTC (a morning
// one east of it) onto the neighbouring day.
const dayKey = mood.date;
const existing = sideEffectByDay.get(dayKey) ?? [];
for (const t of matched) {
if (!existing.includes(t)) existing.push(t);
@@ -21,6 +21,11 @@ vi.mock("@/lib/db", () => ({
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
const tzMock = vi.hoisted(() => ({ zone: "UTC" }));
vi.mock("@/lib/tz/resolver", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/tz/resolver")>()),
resolveUserTimezone: vi.fn(async () => tzMock.zone),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
@@ -84,6 +89,7 @@ function postReq(body: unknown): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
tzMock.zone = "UTC";
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(enqueueReminderSatisfy).mockResolvedValue(undefined as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
@@ -133,3 +139,62 @@ describe("POST /api/labs/ocr/commit dedup", () => {
expect(body.data.skipped).toHaveLength(0);
});
});
describe("POST /api/labs/ocr/commit dedup across the user's day", () => {
/**
* A database stand-in that honours the `takenAt` range the route asks for,
* so the test sees what the real query would return.
*/
function liveReadings(rows: Array<{ value: number; takenAt: Date }>) {
vi.mocked(prisma.labResult.findMany).mockImplementation(((args: {
where: { takenAt: { gte?: Date; lt?: Date; lte?: Date } };
}) => {
const { gte, lt, lte } = args.where.takenAt;
return Promise.resolve(
rows
.filter(
(r) =>
(!gte || r.takenAt >= gte) &&
(!lt || r.takenAt < lt) &&
(!lte || r.takenAt <= lte),
)
.map((r) => ({ ...r, valueText: null })),
);
}) as never);
}
it("skips a scanned reading already entered by hand on the same local day east of UTC", async () => {
tzMock.zone = "Asia/Tokyo";
// Entered by hand: 2026-06-10 08:00 in Tokyo, the 9th in UTC.
liveReadings([
{ value: 95, takenAt: new Date("2026-06-09T23:00:00.000Z") },
]);
// The scan of the same report states the 10th (stored at noon UTC).
const res = await POST(
postReq({
rows: [{ ...numericRow(95), takenAt: "2026-06-10T12:00:00.000Z" }],
}),
);
const body = (await res.json()) as {
data: { skipped: Array<{ reason: string }> };
};
expect(body.data.skipped).toEqual([
{ analyte: "Glucose", reason: "duplicate" },
]);
expect(prisma.labResult.create).not.toHaveBeenCalled();
});
it("writes a reading from the neighbouring local day", async () => {
tzMock.zone = "Asia/Tokyo";
// 2026-06-09 08:00 in Tokyo: a different day from the scanned 10th.
liveReadings([
{ value: 95, takenAt: new Date("2026-06-08T23:00:00.000Z") },
]);
await POST(
postReq({
rows: [{ ...numericRow(95), takenAt: "2026-06-10T12:00:00.000Z" }],
}),
);
expect(prisma.labResult.create).toHaveBeenCalledTimes(1);
});
});
+25 -16
View File
@@ -47,29 +47,37 @@ import {
type OcrCommitRow,
type OcrSkippedRowDto,
} from "@/lib/validations/labs-ocr";
import {
labReadingDay,
labReadingDaySearchRange,
} from "@/lib/labs/reading-day";
import { resolveUserTimezone } from "@/lib/tz/resolver";
export const POST = apiHandler(withIdempotency<[NextRequest]>(commitOcrRows));
/** True when a live reading already records this analyte+day+value. */
/**
* True when a live reading already records this analyte+day+value. The day
* is the reading's calendar day (`labReadingDay`), not its UTC day: a
* hand-entered morning draw east of UTC sits on the previous UTC day, and the
* UTC window let the scan of the same report write it again.
*/
async function isDuplicate(
userId: string,
row: OcrCommitRow,
tz: string,
): Promise<boolean> {
const dayStart = new Date(row.takenAt);
dayStart.setUTCHours(0, 0, 0, 0);
const dayEnd = new Date(row.takenAt);
dayEnd.setUTCHours(23, 59, 59, 999);
const candidates = await prisma.labResult.findMany({
const day = labReadingDay(new Date(row.takenAt), tz);
const rows = await prisma.labResult.findMany({
where: {
userId,
deletedAt: null,
analyte: { equals: row.analyte.trim(), mode: "insensitive" },
takenAt: { gte: dayStart, lte: dayEnd },
takenAt: labReadingDaySearchRange(day),
},
select: { value: true, valueText: true },
take: 25,
select: { value: true, valueText: true, takenAt: true },
take: 100,
});
const candidates = rows.filter((c) => labReadingDay(c.takenAt, tz) === day);
for (const c of candidates) {
if (row.value !== undefined && c.value !== null && c.value === row.value) {
return true;
@@ -86,15 +94,15 @@ async function isDuplicate(
}
/**
* Stable in-batch dedup key for a confirmed row: analyte (lower+trim) + UTC day
* + the value dimension that `isDuplicate` matches on. Two rows in ONE document
* Stable in-batch dedup key for a confirmed row: analyte (lower+trim) + the
* reading's calendar day + the value dimension that `isDuplicate` matches on. Two rows in ONE document
* that resolve to the same key are the same reading; only the first is written.
* Mirrors the `isDuplicate` match semantics so in-batch dedup no longer depends
* on a prior row autocommitting before the next row's live query runs.
*/
function inBatchKey(row: OcrCommitRow): string {
function inBatchKey(row: OcrCommitRow, tz: string): string {
const analyte = row.analyte.trim().toLowerCase();
const dayKey = new Date(row.takenAt).toISOString().slice(0, 10);
const dayKey = labReadingDay(new Date(row.takenAt), tz);
const valuePart =
row.value !== undefined
? `n:${row.value}`
@@ -145,9 +153,10 @@ async function commitOcrRows(request: NextRequest) {
// is benign and self-healing — the manual lab-write path mints the same way.
const writtenInBatch = new Set<string>();
const tz = await resolveUserTimezone(user.id);
for (const row of parsed.data.rows) {
const key = inBatchKey(row);
if (writtenInBatch.has(key) || (await isDuplicate(user.id, row))) {
const key = inBatchKey(row, tz);
if (writtenInBatch.has(key) || (await isDuplicate(user.id, row, tz))) {
skipped.push({ analyte: row.analyte.trim(), reason: "duplicate" });
continue;
}
+2 -1
View File
@@ -17,6 +17,7 @@ import { toManagedProfileView } from "@/lib/managed-profiles/lifecycle";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { createManagedProfileSchema } from "@/lib/validations/managed-profiles";
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
/**
* The same ceiling its guardian sibling carries, and for the same reason.
@@ -59,7 +60,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
creatorId: user.id,
displayName: parsed.data.displayName,
dateOfBirth: parsed.data.dateOfBirth
? new Date(`${parsed.data.dateOfBirth}T00:00:00.000Z`)
? dayKeyAsUtcMidnight(parsed.data.dateOfBirth)
: null,
locale: parsed.data.locale,
timezone: parsed.data.timezone,
@@ -28,6 +28,7 @@ import {
resolveSlotForWriteByBand,
} from "@/lib/medications/scheduling/slot-upsert";
import { NextRequest } from "next/server";
import { dateOnlyKey } from "@/lib/tz/date-only";
type RouteParams = { params: Promise<{ id: string; eventId: string }> };
@@ -100,7 +101,7 @@ export const PUT = apiHandler(
});
if (medication?.startsOn) {
const takenDay = dayKeyForUserTz(data.takenAt, user.timezone);
const startsOnDay = medication.startsOn.toISOString().slice(0, 10);
const startsOnDay = dateOnlyKey(medication.startsOn);
if (takenDay < startsOnDay) {
annotate({
action: {
@@ -41,6 +41,7 @@ import {
parseNotificationPrefs,
resolveNotificationPrefs,
} from "@/lib/validations/notification-prefs";
import { dayKeyAsUtcMidnight, dateOnlyKey } from "@/lib/tz/date-only";
import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh";
export const dynamic = "force-dynamic";
@@ -94,7 +95,9 @@ export const GET = apiHandler(
settings = {
displayName: record.displayName,
heightCm: record.heightCm,
dateOfBirth: record.dateOfBirth?.toISOString().slice(0, 10) ?? null,
dateOfBirth: record.dateOfBirth
? dateOnlyKey(record.dateOfBirth)
: null,
gender: record.gender,
locale: record.locale,
timezone: record.timezone,
@@ -196,7 +199,7 @@ export const PATCH = apiHandler(
...(patch.dateOfBirth !== undefined
? {
dateOfBirth: patch.dateOfBirth
? new Date(`${patch.dateOfBirth}T00:00:00.000Z`)
? dayKeyAsUtcMidnight(patch.dateOfBirth)
: null,
}
: {}),
@@ -239,7 +242,9 @@ export const PATCH = apiHandler(
}
settings = {
...record,
dateOfBirth: record.dateOfBirth?.toISOString().slice(0, 10) ?? null,
dateOfBirth: record.dateOfBirth
? dateOnlyKey(record.dateOfBirth)
: null,
};
changed = Object.keys(patch);
break;
+2 -5
View File
@@ -39,6 +39,7 @@ import {
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { isCalendarDateKey } from "@/lib/tz/date-only";
/** Sleep is read row-per-stage, so the window is bounded to one year. */
const SLEEP_NIGHT_MAX_DAYS = 365;
@@ -48,11 +49,7 @@ const querySchema = z.object({
date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD")
.refine((s) => {
const parsed = new Date(`${s}T00:00:00Z`);
if (Number.isNaN(parsed.getTime())) return false;
return s === parsed.toISOString().slice(0, 10);
}, "date must be a real calendar date (YYYY-MM-DD)")
.refine(isCalendarDateKey, "date must be a real calendar date (YYYY-MM-DD)")
.optional(),
});
+1
View File
@@ -33,6 +33,7 @@ export const GET = apiHandler(
const bundle = buildFhirDocumentBundle(resolved.report, {
insuranceNumber: null,
});
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const stamp = new Date().toISOString().slice(0, 10);
return new NextResponse(JSON.stringify(bundle), {
status: 200,
+1
View File
@@ -46,6 +46,7 @@ export const GET = apiHandler(
insuranceNumber: null,
includeCharts: true,
});
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const stamp = new Date().toISOString().slice(0, 10);
return new NextResponse(bytes.slice().buffer, {
status: 200,
+2 -3
View File
@@ -44,6 +44,7 @@ import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { cn } from "@/lib/utils";
import type { CalendarDay, CervicalMucus, OvulationTest } from "./types";
import { PHASE_HUE, OVULATION_HUE } from "./phase-tokens";
import { shiftDateKey } from "@/lib/tz/format";
/** Trailing fallback window (days) when no MENSTRUAL-anchored cycle is active. */
const FALLBACK_WINDOW_DAYS = 35;
@@ -97,9 +98,7 @@ export function BbtChart({
// recent temperatures.
const fromDate = useMemo(() => {
if (cycleStartDate) return cycleStartDate;
const fallback = new Date(ymdToMs(today));
fallback.setUTCDate(fallback.getUTCDate() - FALLBACK_WINDOW_DAYS);
return fallback.toISOString().slice(0, 10);
return shiftDateKey(today, -FALLBACK_WINDOW_DAYS);
}, [cycleStartDate, today]);
const points = useMemo<BbtPoint[]>(() => {
@@ -31,6 +31,7 @@ import {
DOCUMENT_LIST_MAX_LIMIT,
type InboundDocumentDto,
} from "@/lib/validations/inbound-documents";
import { daysBetweenDateKeys } from "@/lib/tz/format";
/**
* How many pages the picker walks. At the route's ceiling of 100 that is a
@@ -38,8 +39,6 @@ import {
*/
const VAULT_PICKER_MAX_PAGES = 10;
const DAY_MS = 24 * 60 * 60 * 1000;
/** The fields the option list reads off a document. */
export type VaultDocument = Pick<
InboundDocumentDto,
@@ -82,10 +81,12 @@ export function isNearAnchor(
anchorIso: string | null,
): boolean {
if (!documentDay || !anchorIso) return false;
const day = Date.parse(`${documentDay.slice(0, 10)}T00:00:00.000Z`);
const anchor = Date.parse(`${anchorIso.slice(0, 10)}T00:00:00.000Z`);
if (Number.isNaN(day) || Number.isNaN(anchor)) return false;
return Math.abs(day - anchor) <= ENCOUNTER_SUGGEST_WINDOW_DAYS * DAY_MS;
const gap = daysBetweenDateKeys(
anchorIso.slice(0, 10),
documentDay.slice(0, 10),
);
if (Number.isNaN(gap)) return false;
return Math.abs(gap) <= ENCOUNTER_SUGGEST_WINDOW_DAYS;
}
/**
@@ -98,6 +98,11 @@ import {
useMeasurementReminderMutations,
type MeasurementReminder,
} from "@/hooks/use-measurement-reminders";
import {
detectBrowserTimezone,
shiftDateKey,
userDayKey,
} from "@/lib/tz/format";
// v1.18.1 (V3) — the active-measurement set, grouped so the dropdown
// doesn't read as a flat 15-item list. Mirrors the Zod allow-list in
@@ -177,8 +182,7 @@ function toDateInputValue(iso: string | null): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const offset = d.getTimezoneOffset();
return new Date(d.getTime() - offset * 60 * 1000).toISOString().slice(0, 10);
return userDayKey(d, detectBrowserTimezone());
}
/**
@@ -839,12 +843,11 @@ export function postponeOffsetTargets(
// in between moves it an hour, which flips the date for anyone acting near
// midnight. Anchoring today's local date at UTC noon and stepping whole
// UTC days keeps every offset on the day its label names.
const anchor = new Date(`${isoDayInTz(nowMs, tz)}T12:00:00.000Z`);
return SNOOZE_CHIP_DAYS.map((days) => {
const target = new Date(anchor);
target.setUTCDate(target.getUTCDate() + days);
return { days, date: target.toISOString().slice(0, 10) };
});
const today = isoDayInTz(nowMs, tz);
return SNOOZE_CHIP_DAYS.map((days) => ({
days,
date: shiftDateKey(today, days),
}));
}
function intervalProgress(
@@ -66,6 +66,7 @@ export function DataPortabilityRow({
intake: includeIntake ? "true" : "false",
medicationId,
}).toString();
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const stamp = new Date().toISOString().slice(0, 10);
await downloadFromUrl(
`/api/export/medications?${query}`,
@@ -30,6 +30,7 @@ import { useQuery } from "@tanstack/react-query";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type { LinkedDayContext } from "@/lib/mood/linked-context";
import { userDayKey } from "@/lib/tz/format";
/**
* The browser's own zone — the right answer for an entry that does not exist
@@ -42,9 +43,7 @@ export function browserTimezone(): string {
/** `YYYY-MM-DD` for a `datetime-local` value, or today when it is empty. */
export function dayOfLocalInput(value: string): string {
if (value.length >= 10) return value.slice(0, 10);
const now = new Date();
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60 * 1000);
return local.toISOString().slice(0, 10);
return userDayKey(new Date(), browserTimezone());
}
/**
@@ -16,6 +16,7 @@ import type {
HeightDraft,
HeightUnitAdapter,
} from "@/lib/profile/height-unit-display";
import { detectBrowserTimezone, userDayKey } from "@/lib/tz/format";
/**
* The four profile inputs of the onboarding baseline step.
@@ -184,7 +185,7 @@ export function BaselineFields({
id={IDS.dateOfBirth}
value={value.dateOfBirth}
onChange={(next) => onChange("dateOfBirth", next)}
max={new Date().toISOString().slice(0, 10)}
max={userDayKey(new Date(), detectBrowserTimezone())}
autoComplete="bday"
aria-invalid={errors.dateOfBirth ? true : undefined}
aria-describedby={
+8 -4
View File
@@ -27,6 +27,7 @@ import {
import { useTranslations } from "@/lib/i18n/context";
import type { AllergyDTO } from "@/lib/records/dto";
import { dateOnlyAtNoonUtc, isCalendarDateKey } from "@/lib/tz/date-only";
const TEXT_MAX_LENGTH = 2000;
/** Select sentinel for the optional "not assessed" severity. */
@@ -41,11 +42,14 @@ interface AllergyFormProps {
footerSlot?: HTMLElement | null;
}
/** ISO date input (YYYY-MM-DD) → start-of-day UTC instant, or undefined. */
/**
* ISO date input (YYYY-MM-DD) → the instant a date-only value is stored at
* (noon UTC), or undefined. UTC midnight read back as the previous day west
* of UTC.
*/
function toInstant(value: string): string | undefined {
if (!value) return undefined;
const d = new Date(`${value}T00:00:00.000Z`);
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
if (!isCalendarDateKey(value)) return undefined;
return dateOnlyAtNoonUtc(value).toISOString();
}
/**
@@ -0,0 +1,58 @@
/**
* The environment settings default their date fields to today. "Today" is
* the user's day: the UTC day was yesterday west of UTC in the evening and
* tomorrow east of it in the small hours.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
const auth = vi.hoisted(() => ({ timezone: "America/Los_Angeles" }));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { timezone: auth.timezone, modules: { environment: true } },
isAuthenticated: true,
}),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/settings/environment",
useSearchParams: () => new URLSearchParams(""),
}));
import { EnvironmentSection } from "../environment-section";
function backfillEnd(): string | undefined {
const client = new QueryClient({ defaultOptions: { queries: { retry: 0 } } });
const html = renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<EnvironmentSection />
</I18nProvider>
</QueryClientProvider>,
);
return /id="env-backfill-end"[^>]*value="([^"]*)"/.exec(html)?.[1];
}
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date"] });
});
afterEach(() => {
vi.useRealTimers();
});
describe("<EnvironmentSection> date defaults", () => {
it("defaults to the user's day west of UTC", () => {
auth.timezone = "America/Los_Angeles";
vi.setSystemTime(new Date("2026-07-03T03:00:00.000Z")); // 2 July, 20:00
expect(backfillEnd()).toBe("2026-07-02");
});
it("defaults to the user's day east of UTC", () => {
auth.timezone = "Pacific/Kiritimati";
vi.setSystemTime(new Date("2026-07-02T12:00:00.000Z")); // 3 July, 02:00
expect(backfillEnd()).toBe("2026-07-03");
});
});
@@ -33,7 +33,11 @@ import { TimeFormatSelect } from "@/components/settings/time-format-select";
import { DateFormatSelect } from "@/components/settings/date-format-select";
import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select";
import { GlucoseUnitSelect } from "@/components/settings/glucose-unit-select";
import { detectBrowserTimezone, DEFAULT_TIMEZONE } from "@/lib/tz/format";
import {
detectBrowserTimezone,
DEFAULT_TIMEZONE,
userDayKey,
} from "@/lib/tz/format";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import {
recentProofErrorMessage,
@@ -53,6 +57,7 @@ import {
} from "./account-section-utils";
import { AvatarSection } from "./avatar-section";
import { SetupCard } from "./setup-card";
import { dateOnlyKey } from "@/lib/tz/date-only";
export { resolveInitialTimezone } from "./account-section-utils";
@@ -145,7 +150,7 @@ export function AccountSection() {
email: user.email ?? "",
height: heightAdapter.toDraft(user.heightCm),
dateOfBirth: user.dateOfBirth
? new Date(user.dateOfBirth).toISOString().slice(0, 10)
? dateOnlyKey(new Date(user.dateOfBirth))
: "",
gender: user.gender ?? "",
timezone: resolveInitialTimezone(user.timezone, detectBrowserTimezone()),
@@ -528,7 +533,7 @@ export function AccountSection() {
setDateOfBirth(next);
clearFieldError("dateOfBirth");
}}
max={new Date().toISOString().slice(0, 10)}
max={userDayKey(new Date(), detectBrowserTimezone())}
aria-invalid={fieldErrors.dateOfBirth ? true : undefined}
aria-describedby={
fieldErrors.dateOfBirth ? "dob-error" : undefined
@@ -32,6 +32,12 @@ import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import {
DEFAULT_TIMEZONE,
detectBrowserTimezone,
userDayKey,
validTimezoneOr,
} from "@/lib/tz/format";
interface GeocodeResult {
lat: number;
@@ -72,14 +78,18 @@ interface EnvironmentOverview {
attribution: string;
}
function todayKey(): string {
return new Date().toISOString().slice(0, 10);
}
export function EnvironmentSection() {
const { t, tCount } = useTranslations();
const { user } = useAuth();
const queryClient = useQueryClient();
// Date defaults are the user's own days. The UTC day was tomorrow for
// anyone east of UTC in the small hours and yesterday west of it in the
// evening.
const tz = validTimezoneOr(
user?.timezone ?? detectBrowserTimezone(),
DEFAULT_TIMEZONE,
);
const todayKey = () => userDayKey(new Date(), tz);
const enabled = user?.modules?.environment === true;
@@ -217,6 +227,9 @@ export function EnvironmentSection() {
// user can still type an earlier start, but pre-home days resolve to SKIP on
// the server — to fill the deep past they add an explicit location period.
const effectiveBackfillStart =
// `since` stays on the server's day for the home (a UTC day key in the
// environment module), so the default start matches what the worker
// compares against.
backfillStart || home?.since?.slice(0, 10) || "";
return (
@@ -246,6 +246,7 @@ function CsvCard({
since: since || undefined,
until: until || undefined,
});
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const stamp = new Date().toISOString().slice(0, 10);
const filename = `${filenamePrefix}-${stamp}.csv`;
await downloadFromUrl(`${endpoint}${query}`, filename);
@@ -329,6 +330,7 @@ function MedicationsCsvCard() {
until: until || undefined,
intake: includeIntake ? "true" : "false",
});
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const stamp = new Date().toISOString().slice(0, 10);
await downloadFromUrl(
`/api/export/medications${query}`,
@@ -447,6 +449,7 @@ function FullBackupCard() {
}
async function downloadOnce() {
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const stamp = new Date().toISOString().slice(0, 10);
if (encrypt) {
const res = await throwIfReproofRequired(
@@ -25,6 +25,7 @@ import { Button } from "@/components/ui/button";
import { useEncounters } from "@/hooks/use-encounters";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { EncounterKind } from "@/generated/prisma/client";
import { detectBrowserTimezone, userDayKey } from "@/lib/tz/format";
/**
* How far ahead a booked visit is treated as the reason for this report.
@@ -36,9 +37,7 @@ export const REPORT_VISIT_HORIZON_DAYS = 14;
/** The local `yyyy-MM-dd` a `DateField` takes, in the reader's own day. */
function localDay(iso: string): string {
const at = new Date(iso);
const offset = at.getTimezoneOffset();
return new Date(at.getTime() - offset * 60_000).toISOString().slice(0, 10);
return userDayKey(new Date(iso), detectBrowserTimezone());
}
export function ReportVisitOffer({
@@ -66,6 +66,13 @@ import { orderLeaves } from "@/lib/report-selection/selection";
import { ReportScopePicker } from "./report-scope-picker";
import { ScopeSummary } from "./scope-summary";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
import {
DEFAULT_TIMEZONE,
detectBrowserTimezone,
shiftDateKey,
validTimezoneOr,
} from "@/lib/tz/format";
type ExportFormat = "pdf" | "fhir" | "package";
@@ -78,6 +85,12 @@ class ReportRequestError extends Error {}
export function HealthRecordExportPanel() {
const { t, locale } = useTranslations();
const { user } = useAuth();
// The report reads its window in the profile zone (the server's
// `reportTz`), so the picked days are cut there too.
const reportTz = validTimezoneOr(
user?.timezone ?? detectBrowserTimezone(),
DEFAULT_TIMEZONE,
);
const queryClient = useQueryClient();
const [format, setFormat] = useState<ExportFormat>(
@@ -155,8 +168,15 @@ export function HealthRecordExportPanel() {
const range =
customRange && startDate && endDate
? {
startDate: new Date(`${startDate}T00:00:00Z`).toISOString(),
endDate: new Date(`${endDate}T23:59:59Z`).toISOString(),
// The picked days are the user's days: from the first instant
// of the first to the last instant of the last, in their zone.
// UTC midnight started the window the previous evening west of
// UTC, and the report read it as one day more.
startDate: startOfLocalDayKey(startDate, reportTz).toISOString(),
endDate: new Date(
startOfLocalDayKey(shiftDateKey(endDate, 1), reportTz).getTime() -
1,
).toISOString(),
}
: { days };
const res = await throwIfReproofRequired(
@@ -190,6 +210,7 @@ export function HealthRecordExportPanel() {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
a.download = `healthlog-health-record-${new Date()
.toISOString()
.slice(0, 10)}.${ext}`;
@@ -22,12 +22,13 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
import {
useLinkDocumentToVaccination,
useVaccinationSuggestion,
} from "./use-vaccinations";
import type { VaccinationSuggestion } from "@/lib/vaccinations/document-suggestion";
import { formatDate as formatCalendarDate } from "@/lib/date-format";
export function VaccinationDocumentSuggestion({
anchor,
@@ -39,8 +40,8 @@ export function VaccinationDocumentSuggestion({
documentId: string;
disabled?: boolean;
}) {
const { t } = useTranslations();
const format = useFormatters();
const { t, locale } = useTranslations();
const dateFormat = useDateFormatPreference();
// Lift the date-only anchor to noon UTC — the rule is a ±7-day window, so the
// time never changes the verdict, and noon keeps a viewer's local midnight
// from rounding the day either way. The bare date would 422 the endpoint.
@@ -60,7 +61,9 @@ export function VaccinationDocumentSuggestion({
const name = candidate.antigenSlug
? t(`vaccinations.catalog.${candidate.antigenSlug}`)
: (candidate.vaccineName ?? t("vaccinations.suggestion.unnamed"));
return `${name} · ${format.date(candidate.occurredAt)}`;
// A date stored at UTC midnight: render the calendar date, not the
// instant (the previous evening west of UTC).
return `${name} · ${formatCalendarDate(candidate.occurredAt.slice(0, 10), dateFormat, locale)}`;
};
const onPick = (candidate: VaccinationSuggestion) => {
@@ -35,6 +35,7 @@ import type { Practitioner } from "@/hooks/use-practitioners";
import type { Vaccination, VaccinationWriteBody } from "./use-vaccinations";
import { CatalogPicker } from "./catalog-picker";
import { VaccinationDocumentPicker } from "./vaccination-document-picker";
import { dayKeyAsUtcMidnight, isCalendarDateKey } from "@/lib/tz/date-only";
/** The seven anatomical sites, mirroring the Prisma `VaccinationSite` enum. */
export const VACCINATION_SITES = [
@@ -116,8 +117,10 @@ export function draftFromVaccination(row: Vaccination): VaccinationDraft {
/** The day, at UTC midnight — an Impfpass carries dates, never times. */
export function draftInstant(day: string): string | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return null;
const parsed = new Date(`${day}T00:00:00.000Z`);
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
if (!isCalendarDateKey(day)) return null;
// Stored at UTC midnight by the column's own convention; every reader
// takes the date part.
return dayKeyAsUtcMidnight(day).toISOString();
}
/** Does the draft carry at least one identity arm? */
@@ -33,12 +33,17 @@ import { AlarmClock, Syringe } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { InfoPopover } from "@/components/ui/info-popover";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import {
useDateFormatPreference,
useFormatters,
useTranslations,
} from "@/lib/i18n/context";
import type { SeriesPosition } from "@/lib/vaccinations/series";
import type { VaccinationRenewalDTO } from "@/lib/vaccinations/renewal";
import { cn } from "@/lib/utils";
import type { Vaccination } from "./use-vaccinations";
import { CatalogInfo, catalogInfoAvailable } from "./catalog-info";
import { formatDate as formatCalendarDate } from "@/lib/date-format";
type Translate = ReturnType<typeof useTranslations>["t"];
@@ -148,8 +153,8 @@ function DoseRow({
row: GroupRow;
onEdit?: (record: Vaccination) => void;
}) {
const { t } = useTranslations();
const format = useFormatters();
const { t, locale } = useTranslations();
const dateFormat = useDateFormatPreference();
const { record, position } = row;
const series = seriesLabel(t, position);
const identity = recordIdentity(t, record);
@@ -198,7 +203,15 @@ function DoseRow({
) : null}
</div>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs">
<span>{format.date(record.occurredAt)}</span>
<span>
{/* A date, stored at UTC midnight: render the calendar date,
not the instant (the previous evening west of UTC). */}
{formatCalendarDate(
record.occurredAt.slice(0, 10),
dateFormat,
locale,
)}
</span>
{record.lotNumber ? (
<span data-slot="vaccination-lot">
{t("vaccinations.row.lot", { lot: record.lotNumber })}
@@ -278,3 +278,57 @@ describe("buildGlp1SnapshotBlock side-effect tags across locales", () => {
]);
});
});
/**
* Injection days are the user's calendar days. The snapshot used to cut
* them on UTC days and compare `getUTCDay()` with the schedule's local
* weekday, so an evening injection west of UTC was reported on the next
* day and the projection counted from the wrong day.
*/
describe("buildGlp1SnapshotBlock injection days in the user's zone", () => {
it("dates a Friday-evening injection in Los Angeles on Friday", async () => {
prismaMock.medication.findMany.mockResolvedValue([
fakeMedication({
schedules: [
{ daysOfWeek: "5", windowStart: "19:00", windowEnd: "21:00" },
],
// Friday 2026-09-25, 20:00 in Los Angeles — Saturday in UTC.
intakeEvents: [
{
takenAt: new Date("2026-09-26T03:00:00.000Z"),
injectionSite: null,
},
],
}),
]);
// Monday 2026-09-28, 11:00 in Los Angeles.
const now = new Date("2026-09-28T18:00:00.000Z");
const out = await buildGlp1SnapshotBlock(
"user-1",
now,
"America/Los_Angeles",
);
const med = out?.medications[0];
expect(med?.lastInjection?.date).toBe("2026-09-25");
expect(med?.nextInjection).toEqual({ date: "2026-10-02", daysAway: 4 });
});
it("names tomorrow from the local day east of UTC", async () => {
prismaMock.medication.findMany.mockResolvedValue([
fakeMedication({
schedules: [{ daysOfWeek: null, windowStart: null, windowEnd: null }],
}),
]);
// 2026-09-28 12:00 UTC is already 2026-09-29 02:00 on Kiritimati.
const now = new Date("2026-09-28T12:00:00.000Z");
const out = await buildGlp1SnapshotBlock(
"user-1",
now,
"Pacific/Kiritimati",
);
expect(out?.medications[0].nextInjection).toEqual({
date: "2026-09-30",
daysAway: 1,
});
});
});
+1
View File
@@ -235,6 +235,7 @@ export async function readDailySpend(
* the local meter to the same boundary keeps reasoning trivial.
*/
export function buildDateKey(at: Date = new Date()): string {
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the spend meter follows the provider's UTC billing day (see the doc comment)
return at.toISOString().slice(0, 10);
}
+3 -2
View File
@@ -43,6 +43,7 @@ import { addDays } from "@/lib/cycle/day-math";
import { readSourceDayAggregates } from "@/lib/measurements/day-aggregates";
import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
/** Trailing window the phase contrast walks (days). Mirrors the read route. */
const WINDOW_DAYS = 365;
@@ -191,7 +192,7 @@ export async function buildCycleSnapshotBlock(
deletedAt: null,
type: "WRIST_TEMPERATURE",
measuredAt: {
gte: new Date(Date.parse(`${addDays(today, -90)}T00:00:00Z`)),
gte: startOfLocalDayKey(addDays(today, -90), tz),
},
},
orderBy: { measuredAt: "asc" },
@@ -206,7 +207,7 @@ export async function buildCycleSnapshotBlock(
readSourceDayAggregates({
userId,
types: PHASE_CROSSTAB_METRIC_TYPES,
since: new Date(Date.parse(`${from}T00:00:00Z`)),
since: startOfLocalDayKey(from, tz),
timeZone: tz,
}),
prisma.user.findUnique({
+2 -4
View File
@@ -30,7 +30,7 @@
*/
import type { Locale } from "@/lib/i18n/config";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { userDayKey } from "@/lib/tz/format";
import { userDayKey, shiftDateKey } from "@/lib/tz/format";
import { annotate } from "@/lib/logging/context";
import type {
CoachFollowUp,
@@ -93,9 +93,7 @@ export function followUpChipsEnabled(prefs: CoachPrefs): boolean {
/** `YYYY-MM-DD` minus `days`, as a day key. */
function dayKeyMinus(dayKey: string, days: number): string {
const at = new Date(`${dayKey}T00:00:00Z`);
at.setUTCDate(at.getUTCDate() - days);
return at.toISOString().slice(0, 10);
return shiftDateKey(dayKey, -days);
}
/** True when the record's first reading lies before `dayKey`. */
+26 -24
View File
@@ -25,6 +25,13 @@ import { matchGlp1SideEffectTags } from "@/lib/medications/glp1-side-effect-tag-
import type { Glp1SideEffectTag } from "@/lib/medications/glp1-side-effect-tags";
import { isRecordOnly } from "@/lib/medications/intake-tracking";
import { floorWhole } from "@/lib/medications/units-per-dose";
import {
DEFAULT_TIMEZONE,
daysBetweenDateKeys,
shiftDateKey,
userDayKey,
weekdayOfDateKey,
} from "@/lib/tz/format";
/**
* Recommended generic name for the canonical GLP-1 drug brand. The Coach
@@ -159,13 +166,15 @@ function weeksBetween(from: Date, to: Date): number {
return Math.max(0, Math.round(ms / (7 * 24 * 60 * 60 * 1000)));
}
function isoDate(d: Date): string {
return d.toISOString().slice(0, 10);
}
/**
* Predict next injection from cadence + last intake.
*
* Works on the user's calendar days throughout: the last injection's day,
* today and the schedule's `daysOfWeek` are all local, so the projection
* walks local day keys. Walking UTC days and testing `getUTCDay()` against
* a local weekday named the wrong day for anyone whose injection time sits
* on the other side of UTC midnight.
*
* - Weekly cadence with a single weekday: project to the next matching
* weekday after the last intake (or after today if no last intake).
* - Daily / unset cadence: next is tomorrow (the existing reminder
@@ -176,26 +185,20 @@ function predictNextInjection(
schedule: ScheduleEntry | null,
lastInjection: LastInjection | null,
now: Date,
tz: string,
): NextInjection | null {
if (!schedule) return null;
const todayKey = userDayKey(now, tz);
if (schedule.cadence === "weekly" && schedule.daysOfWeek.length > 0) {
const targetDow = schedule.daysOfWeek[0];
const anchor = lastInjection
? new Date(lastInjection.date + "T00:00:00Z")
: now;
// Project forward from the anchor (lastInjection if known, otherwise
// today) until we land on the target weekday.
const cursor = new Date(anchor.getTime());
const anchorKey = lastInjection ? lastInjection.date : todayKey;
for (let i = 1; i <= 14; i += 1) {
cursor.setUTCDate(cursor.getUTCDate() + 1);
if (cursor.getUTCDay() === targetDow) {
const daysAway = Math.max(
0,
Math.round(
(cursor.getTime() - now.getTime()) / (24 * 60 * 60 * 1000),
),
);
return { date: isoDate(cursor), daysAway };
const key = shiftDateKey(anchorKey, i);
if (weekdayOfDateKey(key) === targetDow) {
const daysAway = Math.max(0, daysBetweenDateKeys(todayKey, key));
return { date: key, daysAway };
}
}
}
@@ -203,9 +206,7 @@ function predictNextInjection(
// depending on what's already been logged. Surfacing "tomorrow" keeps
// the Coach from making confident "today is your injection day"
// claims on partial data.
const tomorrow = new Date(now.getTime());
tomorrow.setUTCDate(tomorrow.getUTCDate() + 1);
return { date: isoDate(tomorrow), daysAway: 1 };
return { date: shiftDateKey(todayKey, 1), daysAway: 1 };
}
/**
@@ -216,6 +217,7 @@ function predictNextInjection(
export async function buildGlp1SnapshotBlock(
userId: string,
now: Date = new Date(),
tz: string = DEFAULT_TIMEZONE,
): Promise<Glp1SnapshotBlock | null> {
// Test environments mock parts of prisma (only `measurement` +
// `moodEntry`) and leave `medication` undefined. Treat the absence
@@ -295,7 +297,7 @@ export async function buildGlp1SnapshotBlock(
return {
value: dc.doseValue,
unit: sanitizeForPrompt(dc.doseUnit, 20),
effectiveFrom: isoDate(dc.effectiveFrom),
effectiveFrom: userDayKey(dc.effectiveFrom, tz),
note:
decryptedNote === null ? null : sanitizeForPrompt(decryptedNote, 200),
};
@@ -305,7 +307,7 @@ export async function buildGlp1SnapshotBlock(
? {
value: latestChange.doseValue,
unit: sanitizeForPrompt(latestChange.doseUnit, 20),
since: isoDate(latestChange.effectiveFrom),
since: userDayKey(latestChange.effectiveFrom, tz),
weeksOnDose: weeksBetween(latestChange.effectiveFrom, now),
}
: null;
@@ -334,7 +336,7 @@ export async function buildGlp1SnapshotBlock(
const lastInjection: LastInjection | null =
last && last.takenAt
? {
date: isoDate(last.takenAt),
date: userDayKey(last.takenAt, tz),
site: last.injectionSite ?? null,
weeksAgo: weeksBetween(last.takenAt, now),
}
@@ -344,7 +346,7 @@ export async function buildGlp1SnapshotBlock(
// information but no injection is predicted as due.
const nextInjection = isRecordOnly(med)
? null
: predictNextInjection(schedule, lastInjection, now);
: predictNextInjection(schedule, lastInjection, now, tz);
// Inventory math over the per-item entities (v1.16.10 — the same
// rows the Bestand tab and the consumption hook move).
+10 -7
View File
@@ -33,7 +33,7 @@ import { prisma } from "@/lib/db";
import type { MeasurementType } from "@/generated/prisma/client";
import type { Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
import { shiftDateKey, userDayKey, weekdayOfDateKey } from "@/lib/tz/format";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
import {
readDailySeries,
@@ -66,6 +66,7 @@ import type {
import { aggregationKind } from "./chart-spec";
import { RESULT_TABLE_MAX_ROWS } from "./project";
import { dateOnlyKey } from "@/lib/tz/date-only";
/**
* Sources the table tool leaves to their own tools: glucose needs its
@@ -167,16 +168,14 @@ export function resolveTableRange(args: {
// ── Period keys ─────────────────────────────────────────────────────────
/** `YYYY-MM-DD` of a UTC-midnight bucket timestamp. */
/** `YYYY-MM-DD` of a UTC-midnight bucket timestamp (a day label). */
function dayKeyOfUtc(ms: number): string {
return new Date(ms).toISOString().slice(0, 10);
return dateOnlyKey(new Date(ms));
}
/** Monday (`YYYY-MM-DD`) of the ISO week `dayKey` falls in. */
function weekKeyOf(dayKey: string): string {
const ms = Date.parse(`${dayKey}T00:00:00Z`);
const dow = (new Date(ms).getUTCDay() + 6) % 7;
return dayKeyOfUtc(ms - dow * 86_400_000);
return shiftDateKey(dayKey, -((weekdayOfDateKey(dayKey) + 6) % 7));
}
function monthKeyOf(dayKey: string): string {
@@ -416,8 +415,12 @@ async function readMeasurementDays(args: {
return { series: ordered, counts, suffixes };
}
/** `YYYY-MM-DD` of an instant in UTC. */
/**
* `YYYY-MM-DD` of an instant in UTC. Only for the all-time month read: the
* MONTH rollup tier buckets UTC months, so its range edges are UTC days.
*/
function utcDayKey(date: Date): string {
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the MONTH rollup tier buckets UTC months, so this read keys its edges on UTC days
return date.toISOString().slice(0, 10);
}
+4 -8
View File
@@ -12,7 +12,7 @@
*/
import type { Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
import { shiftDateKey, userDayKey, weekdayOfDateKey } from "@/lib/tz/format";
import type {
CoachResultCell,
CoachResultTable,
@@ -189,17 +189,13 @@ function mondayOfIsoWeek(weekIso: string): string | null {
const year = Number(match[1]);
const week = Number(match[2]);
// Week 1 holds January 4th; its Monday is the ISO year's first Monday.
const jan4 = Date.UTC(year, 0, 4);
const jan4Dow = (new Date(jan4).getUTCDay() + 6) % 7;
const monday = jan4 - jan4Dow * 86_400_000 + (week - 1) * 7 * 86_400_000;
return new Date(monday).toISOString().slice(0, 10);
const jan4 = `${String(year).padStart(4, "0")}-01-04`;
return shiftDateKey(mondayOfDay(jan4), (week - 1) * 7);
}
/** Monday (`YYYY-MM-DD`) of the ISO week a day key falls in. */
function mondayOfDay(dayKey: string): string {
const ms = Date.parse(`${dayKey}T00:00:00Z`);
const dow = (new Date(ms).getUTCDay() + 6) % 7;
return shiftDateKey(new Date(ms).toISOString().slice(0, 10), -dow);
return shiftDateKey(dayKey, -((weekdayOfDateKey(dayKey) + 6) % 7));
}
/**
@@ -59,8 +59,8 @@ export function buildCoreMetricsBlocks(
);
const olderSys = sysRows.filter((row) => row.measuredAt < ctx.recentCutoff);
const olderDia = diaRows.filter((row) => row.measuredAt < ctx.recentCutoff);
const sysBand = bpBandFromRows(sysRows, "BLOOD_PRESSURE_SYS");
const diaBand = bpBandFromRows(diaRows, "BLOOD_PRESSURE_DIA");
const sysBand = bpBandFromRows(sysRows, "BLOOD_PRESSURE_SYS", ctx.userTz);
const diaBand = bpBandFromRows(diaRows, "BLOOD_PRESSURE_DIA", ctx.userTz);
const usualRange =
sysBand || diaBand
? {
+3 -1
View File
@@ -297,10 +297,12 @@ export function buildDailyBpRows(
export function bpBandFromRows(
rows: ReadonlyArray<{ measuredAt: Date; value: number }>,
type: "BLOOD_PRESSURE_SYS" | "BLOOD_PRESSURE_DIA",
tz: string,
): { low: number; high: number } | null {
const byDay = new Map<string, { sum: number; count: number }>();
for (const r of rows) {
const day = r.measuredAt.toISOString().slice(0, 10);
// The user's day, like every other day mean in the snapshot.
const day = tzDayKey(r.measuredAt, tz);
const acc = byDay.get(day) ?? { sum: 0, count: 0 };
acc.sum += r.value;
acc.count += 1;
+1 -1
View File
@@ -744,7 +744,7 @@ async function buildCoachSnapshotImpl(
: null;
const glp1BlockPromise = excludesMedications
? null
: buildGlp1SnapshotBlock(userId, now);
: buildGlp1SnapshotBlock(userId, now, userTz);
const derivedBlockPromise = derivedActive
? buildDerivedSnapshotBlock(userId, derivedProfile, now, userTz)
: null;
@@ -468,6 +468,7 @@ function bucketWindow(
* safe for every code path that reaches this helper.
*/
function bucketDayKey(d: Date): string {
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: rollup bucketStart key; non-near-UTC users are routed to the live path (see the doc comment)
return d.toISOString().slice(0, 10);
}
+2 -1
View File
@@ -66,6 +66,7 @@ import { wallClockInTz } from "@/lib/tz/wall-clock";
import { defaultLocale, type Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking";
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
/**
* v1.4.37 W2 — cold-path correlation window. Trim from 30 to 28 days
@@ -413,7 +414,7 @@ async function fetchSeriesChunked(
}
function dateFromDayKey(key: string): Date {
return new Date(`${key}T00:00:00.000Z`);
return dayKeyAsUtcMidnight(key);
}
function isoWeekdayInTz(d: Date, timeZone: string): number {
+1
View File
@@ -183,6 +183,7 @@ export async function loadIntradayPulse(
dateKey: string,
): Promise<IntradayPulseResult> {
const localOf = makeLocalResolver(timezone);
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: anchor of a superset read window; rows are cut to the exact local day afterwards
const anchorMs = new Date(`${dateKey}T00:00:00.000Z`).getTime();
const start = new Date(anchorMs - WINDOW_LEAD_MS);
const end = new Date(anchorMs + WINDOW_TRAIL_MS);
+1
View File
@@ -60,6 +60,7 @@ export const MAX_PROGNOSIS_ROWS = 5000;
/** `YYYY-MM-DD`, this many days before the given instant, in UTC terms. */
export function dayKeyBefore(now: Date, days: number): string {
// eslint-disable-next-line healthlog/no-utc-day-key -- baseline: far edge of the fit window on stored day keys; a day either way does not change the fit
return new Date(now.getTime() - days * 86_400_000).toISOString().slice(0, 10);
}
+5 -5
View File
@@ -60,6 +60,7 @@ import {
type RangeDeltaResult,
type WindowAggregate,
} from "@/lib/analytics/range-shared";
import { dayKeyAsUtcMidnight, dateOnlyKey } from "@/lib/tz/date-only";
// The range constants + result shapes live in the client-safe
// `range-shared` module so the insights client bundle never pulls the
@@ -156,6 +157,7 @@ function splitPerPeriod(
export function dailyTotalsEndKey(now: number, timezone: string): string {
const localYesterday = shiftDateKey(userDayKey(new Date(now), timezone), -1);
const utcYesterday = shiftDateKey(
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the DAY rollups are UTC buckets, so the earlier of the local and the UTC yesterday wins (see the doc comment)
new Date(now).toISOString().slice(0, 10),
-1,
);
@@ -193,13 +195,11 @@ async function readDailyTotals(
windowDays: number,
endKey: string,
): Promise<Array<{ day: string; value: number }>> {
const from = new Date(
`${shiftDateKey(endKey, -(2 * windowDays - 1))}T00:00:00.000Z`,
);
const to = new Date(`${shiftDateKey(endKey, 1)}T00:00:00.000Z`);
const from = dayKeyAsUtcMidnight(shiftDateKey(endKey, -(2 * windowDays - 1)));
const to = dayKeyAsUtcMidnight(shiftDateKey(endKey, 1));
const rows = await readRollupBuckets(userId, type, "DAY", from, to);
return rows.map((r) => ({
day: r.bucketStart.toISOString().slice(0, 10),
day: dateOnlyKey(r.bucketStart),
value: r.mean * r.count,
}));
}
+1
View File
@@ -16,6 +16,7 @@ export const LIPIDS_WINDOW_DAYS = 365;
export const LIPIDS_MIN_PANELS = 1;
function panelKey(row: LipidReading): string {
// eslint-disable-next-line healthlog/no-utc-day-key -- baseline: grouping key only, never shown; the readings of one panel share one takenAt, so any consistent day partition groups them alike
return `${row.at.toISOString().slice(0, 10)}|${row.panel ?? ""}`;
}
+2 -1
View File
@@ -31,6 +31,7 @@ import {
type ReportSelection,
} from "@/lib/report-selection/selection";
import type { ShareContext } from "@/lib/clinician-share/resolve-share-token";
import { dateOnlyKey } from "@/lib/tz/date-only";
/**
* v1.28 — metadata for one document on the share's frozen set. NEVER carries
@@ -207,7 +208,7 @@ async function loadShareDocuments(
title: document.title,
kind: document.kind,
documentDate: document.documentDate
? document.documentDate.toISOString().slice(0, 10)
? dateOnlyKey(document.documentDate)
: null,
byteSize: document.byteSize,
mimeType: document.mimeType,
+4 -4
View File
@@ -27,6 +27,7 @@ import { addDays } from "@/lib/cycle/day-math";
import { BBT_WINDOW } from "@/lib/cycle/types";
import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
/** Day-log lookback: the calendar's default past span or the symptothermal
* window, whichever reaches further back. */
@@ -97,10 +98,9 @@ export async function refreshPredictionCacheForUser(
deletedAt: null,
type: "WRIST_TEMPERATURE",
measuredAt: {
gte: new Date(
Date.parse(
`${addDays(today, -TEMPERATURE_LOOKBACK_DAYS)}T00:00:00Z`,
),
gte: startOfLocalDayKey(
addDays(today, -TEMPERATURE_LOOKBACK_DAYS),
tz,
),
},
},
+2 -1
View File
@@ -30,6 +30,7 @@
import type { MeasurementType } from "@/generated/prisma/client";
import type { ServerTranslator } from "@/lib/i18n/server-translator";
import { MIN_IN_RUN, type StreakResult } from "@/lib/insights/streak-detector";
import { shiftDateKey } from "@/lib/tz/format";
/** Closed set of durable-state kinds. Grows by PR, never with a counter field. */
export const MILESTONE_KINDS = [
@@ -101,7 +102,7 @@ function dayKeyToSerial(key: string): number {
}
function serialToDayKey(serial: number): string {
return new Date(serial * 86_400_000).toISOString().slice(0, 10);
return shiftDateKey("1970-01-01", serial);
}
/** Shift a UTC-ISO day key by a whole number of days. */
+3 -1
View File
@@ -120,6 +120,7 @@ import {
toProfileSex,
type ProfileSex,
} from "@/lib/profile/sex";
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
/** Briefing freshness window — mirrors the 24 h TTL on the advisor cache. */
const BRIEFING_TTL_MS = 24 * 60 * 60 * 1000;
@@ -602,6 +603,7 @@ async function buildNutrientWaterBlock(
prisma: PrismaClient,
userId: string,
): Promise<{ summary: DataSummary; lastSeenAt: string } | null> {
// eslint-disable-next-line healthlog/no-utc-day-key -- baseline: 30-day lower bound on stored water day keys; a day either way at the far edge does not change the tile
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
@@ -617,7 +619,7 @@ async function buildNutrientWaterBlock(
}
const points: DataPoint[] = [...sumByDay.entries()]
.map(([day, amount]) => ({
date: new Date(`${day}T00:00:00.000Z`),
date: dayKeyAsUtcMidnight(day),
value: amount,
}))
.sort((a, b) => a.date.getTime() - b.date.getTime());
+4 -3
View File
@@ -73,6 +73,7 @@ import {
loadSurgicalHistory,
loadLabResults,
} from "./clinical-records";
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
const DENSE_REPORT_RAW_WINDOW_DAYS = 90;
@@ -179,8 +180,8 @@ export async function collectDoctorReportData(
// they compare against the window's first and last day in the report zone,
// not its instants: west of UTC the window starts hours after UTC midnight,
// and a course that ended on the first day read as over before it began.
const firstDay = new Date(`${userDayKey(start, reportTz)}T00:00:00.000Z`);
const lastDay = new Date(`${userDayKey(end, reportTz)}T00:00:00.000Z`);
const firstDay = dayKeyAsUtcMidnight(userDayKey(start, reportTz));
const lastDay = dayKeyAsUtcMidnight(userDayKey(end, reportTz));
const aggregateDenseTypes = days > DENSE_REPORT_RAW_WINDOW_DAYS;
const includedDenseStatTypes = DENSE_STAT_TYPES.filter(
@@ -607,7 +608,7 @@ export async function collectDoctorReportData(
emergency,
] = await Promise.all([
gate.admits("CYCLE")
? buildCycleExportSummary(userId, end.toISOString().slice(0, 10))
? buildCycleExportSummary(userId, userDayKey(end, reportTz))
: Promise.resolve(null),
gate.admits("LAB_RESULTS")
? loadLabResults(userId, start, end)
@@ -159,6 +159,13 @@ describe("maybeAutoStageLabFacts", () => {
data: expect.objectContaining({ status: "EXTRACTED" }),
}),
);
// The stated report date is stored at noon UTC, not UTC midnight.
const moved = updateMany.mock.calls[0]![0] as {
data: { reportDate: Date };
};
expect(moved.data.reportDate.toISOString()).toBe(
"2026-07-10T12:00:00.000Z",
);
const created = createMany.mock.calls[0]![0] as {
data: { status: string }[];
};
@@ -28,6 +28,7 @@ import { prisma } from "@/lib/db";
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
import { decryptFactData } from "@/lib/documents/store";
import type { ExtractedFact } from "@/generated/prisma/client";
import { userDayKey } from "@/lib/tz/format";
function observationFact(): ExtractedFact {
return {
@@ -94,5 +95,12 @@ describe("commitApprovedFact — OBSERVATION unit integrity", () => {
const ref = await commitApprovedFact("user-1", observationFact());
expect(ref).toEqual({ recordType: "labResult", recordId: "lr-1" });
expect(prisma.labResult.create).toHaveBeenCalledOnce();
// The stated date is stored at noon UTC: it reads back on the stated day
// as a UTC label and formatted in a zone west of UTC alike. UTC midnight
// showed the reading a day early there.
const takenAt = vi.mocked(prisma.labResult.create).mock.calls[0]![0].data
.takenAt as Date;
expect(takenAt.toISOString()).toBe("2025-01-01T12:00:00.000Z");
expect(userDayKey(takenAt, "America/Los_Angeles")).toBe("2025-01-01");
});
});
+2 -1
View File
@@ -48,6 +48,7 @@ import { localisedValues } from "@/lib/i18n/shared-resolve";
import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog";
import { annotate } from "@/lib/logging/context";
import { isModuleEnabled } from "@/lib/modules/gate";
import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only";
/**
* Lab-report signals used to decide whether a transcribed document is worth an
@@ -215,7 +216,7 @@ async function stageFactsIfStored(
status: "EXTRACTED",
providerType: result.providerType,
reportDate: result.reportDate
? new Date(`${result.reportDate}T00:00:00.000Z`)
? dateOnlyAtNoonUtc(result.reportDate)
: null,
},
});
+7 -4
View File
@@ -39,6 +39,7 @@ import type {
MedicationStatementFactData,
ObservationFactData,
} from "@/lib/validations/inbound-documents";
import { dateOnlyAtNoonUtc, isDateOnlyKey } from "@/lib/tz/date-only";
/** A per-fact commit failure the caller maps to a per-fact 422 entry. */
export class FactCommitError extends Error {
@@ -55,11 +56,13 @@ export interface CommittedRecordRef {
recordId: string;
}
/** Parse a stated YYYY-MM-DD into a UTC instant, or fall back to now. */
/**
* A stated YYYY-MM-DD as the instant a date-only value is stored at (noon
* UTC), or now when the document states none. UTC midnight read back as the
* previous day anywhere west of UTC.
*/
function statedDateOrNow(date: string | null): Date {
if (date && /^\d{4}-\d{2}-\d{2}$/u.test(date)) {
return new Date(`${date}T00:00:00.000Z`);
}
if (date && isDateOnlyKey(date)) return dateOnlyAtNoonUtc(date);
return new Date();
}
+2 -5
View File
@@ -52,6 +52,7 @@ import {
type MedicationStatementFactData,
type ObservationFactData,
} from "@/lib/validations/inbound-documents";
import { statedDateKey } from "@/lib/tz/date-only";
const SYSTEM_PROMPT = `You transcribe a photograph, PDF, or scan of a CLINICAL DOCUMENT (a doctor's report or a hospital discharge letter) into structured facts.
@@ -103,11 +104,7 @@ NOTE: the document below was produced by automatic OCR, so it may contain garble
/** A normalised ISO date string (YYYY-MM-DD) or null. */
function normaliseDate(raw: string | null): string | null {
if (!raw) return null;
const trimmed = raw.trim();
if (!trimmed) return null;
const parsed = new Date(trimmed);
if (Number.isNaN(parsed.getTime())) return null;
return parsed.toISOString().slice(0, 10);
return statedDateKey(raw);
}
export class InboundExtractError extends Error {
+17 -5
View File
@@ -58,6 +58,9 @@ import type {
DocumentSourceSystemValue,
InboundDocumentKindValue,
} from "@/lib/validations/inbound-documents";
import { dateOnlyAtNoonUtc, dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
import { userDayKey } from "@/lib/tz/format";
import { resolveUserTimezone } from "@/lib/tz/resolver";
/**
* The person's own uploads per hour, from the web or the phone, and the
@@ -74,9 +77,14 @@ export function personalUploadBucket(userId: string): string {
return `documents-upload:${userId}`;
}
/** Parse a YYYY-MM-DD value into a UTC midnight Date. */
/**
* A YYYY-MM-DD as UTC midnight: the lower bound of that date for a range
* filter over stored date-only values (which sit at noon UTC, or at UTC
* midnight for rows written before the noon anchor). Not a storage anchor;
* a stored date goes through `dateOnlyAtNoonUtc`.
*/
export function isoDateToUtc(value: string): Date {
return new Date(`${value}T00:00:00.000Z`);
return dayKeyAsUtcMidnight(value);
}
export interface IngestInput {
@@ -297,9 +305,13 @@ export async function ingestDocument(
contentCodec: codec,
contentSha256,
status: "STORED",
documentDate: input.documentDate
? isoDateToUtc(input.documentDate)
: new Date(),
// The upload day when none is stated is the uploader's own day,
// not the UTC one (just after midnight east of UTC that was
// yesterday).
documentDate: dateOnlyAtNoonUtc(
input.documentDate ??
userDayKey(new Date(), await resolveUserTimezone(input.userId)),
),
sourceSystem,
sourceId,
sourceInstance,
+3 -6
View File
@@ -35,6 +35,7 @@ import type {
InboundDocumentKindValue,
InboundFactType,
} from "@/lib/validations/inbound-documents";
import { dateOnlyKey } from "@/lib/tz/date-only";
/**
* The two storage codecs `InboundDocument.contentEncrypted` may carry,
@@ -226,12 +227,8 @@ export function serialiseDocument(
byteSize: doc.byteSize,
status: doc.status as InboundDocumentStatus,
providerType: doc.providerType,
reportDate: doc.reportDate
? doc.reportDate.toISOString().slice(0, 10)
: null,
documentDate: doc.documentDate
? doc.documentDate.toISOString().slice(0, 10)
: null,
reportDate: doc.reportDate ? dateOnlyKey(doc.reportDate) : null,
documentDate: doc.documentDate ? dateOnlyKey(doc.documentDate) : null,
errorReason: doc.errorReason,
factCount: counts.factCount,
pendingCount: counts.pendingCount,
+4 -8
View File
@@ -28,8 +28,8 @@ import {
type DailyEnvironmentObservation,
} from "@/lib/environment/open-meteo";
import type { EnvironmentLocationSource } from "@/generated/prisma/client";
import { shiftDateKey } from "@/lib/tz/format";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/** Default lookback window (days) for the nightly fetch — absorbs the archive
* settling lag and any days missed across worker reboots. */
export const ENVIRONMENT_LOOKBACK_DAYS = 7;
@@ -63,19 +63,15 @@ interface TravelOverride {
/** UTC YYYY-MM-DD for `date` (used to bound the default lookback window). */
export function utcDayKey(date: Date): string {
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the environment module keys homeSince and the backfill window on UTC days throughout, and the worker compares against the same keys
return date.toISOString().slice(0, 10);
}
/** Inclusive list of YYYY-MM-DD keys from `start` to `end` (date arithmetic). */
export function enumerateDays(start: string, end: string): string[] {
const out: string[] = [];
const [sy, sm, sd] = start.split("-").map(Number);
const [ey, em, ed] = end.split("-").map(Number);
let cur = Date.UTC(sy, sm - 1, sd);
const last = Date.UTC(ey, em - 1, ed);
while (cur <= last) {
out.push(new Date(cur).toISOString().slice(0, 10));
cur += MS_PER_DAY;
for (let cur = start; cur <= end; cur = shiftDateKey(cur, 1)) {
out.push(cur);
}
return out;
}
+3 -4
View File
@@ -32,6 +32,7 @@ import {
type AllergyDTO,
type FamilyHistoryEntryDTO,
} from "@/lib/records/dto";
import { dateOnlyKey } from "@/lib/tz/date-only";
/** Disclosed in the payload manifest AND mirrored in the export UI copy. */
export const DOCUMENTS_MANIFEST_NOTE =
@@ -546,10 +547,8 @@ export async function buildRecordsBackupSection(
mimeType: d.mimeType,
byteSize: d.byteSize,
status: d.status,
reportDate: d.reportDate ? d.reportDate.toISOString().slice(0, 10) : null,
documentDate: d.documentDate
? d.documentDate.toISOString().slice(0, 10)
: null,
reportDate: d.reportDate ? dateOnlyKey(d.reportDate) : null,
documentDate: d.documentDate ? dateOnlyKey(d.documentDate) : null,
sourceSystem: d.sourceSystem,
sourceId: d.sourceId,
sourceInstance: d.sourceInstance,
+1
View File
@@ -386,6 +386,7 @@ export const FITBIT_SLEEP_RANGE_DAYS = 30;
/** `YYYY-MM-DD` (UTC) for a Fitbit date-path segment. */
export function fitbitDate(d: Date): string {
// eslint-disable-next-line healthlog/no-utc-day-key -- baseline: request window edge for the provider API, not a displayed day; rows carry their own dates
return d.toISOString().slice(0, 10);
}
+1
View File
@@ -646,6 +646,7 @@ export function incrementalFilter(
case "date":
return {
field: `${dateStyle === "camel" ? dataType.key : dataType.filter}.date`,
// eslint-disable-next-line healthlog/no-utc-day-key -- baseline: request window edge for the provider API, not a displayed day; rows carry their own dates
bound: start.toISOString().slice(0, 10),
};
case "rollup":
+2 -3
View File
@@ -44,6 +44,7 @@ import {
nowProvenanceTimestamp,
} from "@/lib/insights/derived/coverage";
import type { Derived } from "@/lib/insights/derived/types";
import { daysBetweenDateKeys } from "@/lib/tz/format";
/* ── tunables (documented invariants) ────────────────────────────────── */
@@ -1050,7 +1051,5 @@ function runFlag(
/** Whole-day signed difference `to − from` for two `YYYY-MM-DD` keys. */
export function dayDiff(from: string, to: string): number {
const a = Date.parse(`${from}T00:00:00Z`);
const b = Date.parse(`${to}T00:00:00Z`);
return Math.round((b - a) / (24 * 60 * 60 * 1000));
return daysBetweenDateKeys(from, to);
}
@@ -61,6 +61,7 @@ export function classifyBriefingFailure(args: {
/** UTC YYYY-MM-DD calendar-day key for the marker payload. */
function dateKey(at: Date = new Date()): string {
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: internal once-per-day marker key, written and read by this module only, never shown
return at.toISOString().slice(0, 10);
}
+2 -1
View File
@@ -123,6 +123,7 @@ import {
isFullyCovered,
probeRollupCoverage,
} from "@/lib/rollups/measurement-coverage";
import { dateOnlyKey } from "@/lib/tz/date-only";
/**
* Heavy aggregate row — count/min/max/mean alongside the non-composable
@@ -939,7 +940,7 @@ function buildDailyByType(
// `bucketStart` is the UTC midnight of the bucketed day — the
// same boundary Postgres' `date_trunc('day', ...)` would pick
// when the session timezone is UTC (the container default).
day: b.day.toISOString().slice(0, 10),
day: dateOnlyKey(b.day),
// Match the legacy `ROUND(AVG, 2)::double precision` semantics
// so correlation thresholds tuned against the v1.4.34 output
// don't drift on the third decimal.
@@ -155,6 +155,7 @@ const BRIEFING_REROLL_TEMPERATURE = 0.6;
/** UTC YYYY-MM-DD calendar-day key gating the once-per-day briefing re-roll. */
function buildRerollDateKey(at: Date = new Date()): string {
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: internal once-per-day re-roll gate key, never shown
return at.toISOString().slice(0, 10);
}
@@ -60,6 +60,7 @@ import type {
MeasurementType,
} from "@/generated/prisma/client";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
import { dateOnlyKey } from "@/lib/tz/date-only";
/**
* v1.21.0 (FDREXTEND) — build the user's MEDICATION_COMPLIANCE daily series.
@@ -223,6 +224,7 @@ export async function fetchEnvironmentSeries(
userId: string,
since: Date,
): Promise<NamedSeries[]> {
// eslint-disable-next-line healthlog/no-utc-day-key -- baseline: lower bound on stored environment day keys; a day either way at the far edge does not change the series
const sinceKey = since.toISOString().slice(0, 10);
const rows = await prisma.environmentContext.findMany({
where: { userId, date: { gte: sinceKey } },
@@ -758,7 +760,7 @@ function composeRollupDailyMeans(
// DAY buckets are minted at UTC midnight; the UTC date IS the bucket's
// day key (the same convention graded-series and the derived baselines
// read the tier under).
const day = r.bucketStart.toISOString().slice(0, 10);
const day = dateOnlyKey(r.bucketStart);
const acc = byDay.get(day) ?? { sum: 0, count: 0 };
acc.sum += sum;
acc.count += r.count;
+2 -4
View File
@@ -41,6 +41,7 @@
import { pearson, MIN_PAIRED_N } from "@/lib/insights/correlations";
import { type Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { shiftDateKey } from "@/lib/tz/format";
/**
* Locale-aware string builder — the same signature the server translator
@@ -238,10 +239,7 @@ export interface CorrelationDiscoveryResult {
/** Add `lagDays` to a YYYY-MM-DD day key, returning the shifted key. */
function shiftDay(day: string, lagDays: number): string {
const [y, m, d] = day.split("-").map(Number);
const dt = new Date(Date.UTC(y, m - 1, d));
dt.setUTCDate(dt.getUTCDate() + lagDays);
return dt.toISOString().slice(0, 10);
return shiftDateKey(day, lagDays);
}
/**
@@ -194,3 +194,20 @@ describe("adherence storyline ↔ dashboard-tile engine parity", () => {
expect(story).toBeNull();
});
});
describe("adherence storyline course window", () => {
it("keeps a course on its last day in the user's zone", async () => {
// 2026-06-21 20:00 in Los Angeles, already the 22nd in UTC. A course
// whose `endsOn` (a calendar date) is the 21st is still running.
const now = new Date("2026-06-22T03:00:00.000Z");
await buildAdherenceStoryline("user-1", "America/Los_Angeles", now);
const where = mocks.medicationFindMany.mock.calls[0]![0].where as {
OR: [unknown, { endsOn: { gte: Date } }];
};
const lastDay = new Date("2026-06-21T00:00:00.000Z"); // @db.Date value
expect(where.OR[1].endsOn.gte.getTime()).toBeLessThanOrEqual(
lastDay.getTime(),
);
expect(where.OR[1].endsOn.gte.toISOString()).toBe(lastDay.toISOString());
});
});
@@ -28,6 +28,7 @@ import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
import { readDayMeanSeries } from "@/lib/insights/derived/baseline";
import { buildScheduleAnchoredComplianceBuckets } from "@/lib/analytics/schedule-anchored-compliance";
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { userDayKey } from "@/lib/tz/format";
import { sanitizeForPrompt } from "@/lib/insights/sanitize";
import {
inferMedTargetClass,
@@ -35,6 +36,7 @@ import {
type MedTargetClass,
} from "@/lib/medications/med-target-map";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const MAD_TO_SIGMA = 1.4826;
@@ -160,7 +162,11 @@ export async function buildAdherenceStoryline(
timezone: string | null,
now: Date = new Date(),
): Promise<AdherenceStoryline | null> {
// 1. Active medications with a confidently-known target class.
// 1. Active medications with a confidently-known target class. `endsOn` is
// a calendar date, so a course is over only once the user's own day has
// passed it; comparing the column against the instant `now` dropped the
// course's last day.
const todayKey = userDayKey(now, timezone ?? DEFAULT_TIMEZONE);
const meds = await prisma.medication.findMany({
where: {
userId,
@@ -168,7 +174,10 @@ export async function buildAdherenceStoryline(
// v1.39.1 (#1033) — the adherence figure below counts tracked
// medications only, so a record-only one cannot be its subject.
...TRACKED_INTAKE_WHERE,
OR: [{ endsOn: null }, { endsOn: { gte: now } }],
OR: [
{ endsOn: null },
{ endsOn: { gte: dayKeyAsUtcMidnight(todayKey) } },
],
},
select: { name: true, treatmentClass: true },
});
+2 -1
View File
@@ -54,6 +54,7 @@ import {
type Derived,
type DerivedProvenanceSource,
} from "./types";
import { dateOnlyKey } from "@/lib/tz/date-only";
/** k for the median ± k·MAD band — ≈3σ-equivalent for normal data. */
const DEFAULT_MAD_K = 3;
@@ -255,7 +256,7 @@ export async function readDayMeanSeries(
const points = resolved.rows
.filter((row) => isPlausibleMetricValue(type, row.mean))
.map((row) => ({
day: row.bucketStart.toISOString().slice(0, 10),
day: dateOnlyKey(row.bucketStart),
mean: row.mean,
}));
if (points.length > 0) return { points, source: "DAY" };
+4 -9
View File
@@ -52,6 +52,7 @@ import {
} from "./coverage";
import { readDayMeanSeries, type BaselineProfile } from "./baseline";
import type { Derived, DerivedProvenanceSource } from "./types";
import { daysBetweenDateKeys, shiftDateKey } from "@/lib/tz/format";
/** Trailing window the fit reads (days). Matches the baseline engine. */
const DEFAULT_WINDOW_DAYS = 30;
@@ -266,11 +267,7 @@ export async function computeTrajectory(
}
// x = days since the FIRST in-window day; y = the per-day mean.
const dayMs = 24 * 60 * 60 * 1000;
const startMs = new Date(`${points[0].day}T00:00:00Z`).getTime();
const xs = points.map(
(p) => (new Date(`${p.day}T00:00:00Z`).getTime() - startMs) / dayMs,
);
const xs = points.map((p) => daysBetweenDateKeys(points[0].day, p.day));
const ys = points.map((p) => p.mean);
const fit = fitOls(xs, ys);
@@ -286,9 +283,7 @@ export async function computeTrajectory(
// Project from the last observed day-offset forward over the horizon,
// each point carrying the widening prediction band.
const lastX = xs[xs.length - 1];
const lastDayMs = new Date(
`${points[points.length - 1].day}T00:00:00Z`,
).getTime();
const lastDay = points[points.length - 1].day;
const projection: TrajectoryPoint[] = [];
for (let h = 1; h <= horizonDays; h++) {
const x = lastX + h;
@@ -296,7 +291,7 @@ export async function computeTrajectory(
const half = predictionIntervalHalfWidth(fit, x);
projection.push({
dayOffset: h,
date: new Date(lastDayMs + h * dayMs).toISOString().slice(0, 10),
date: shiftDateKey(lastDay, h),
projected,
bandLow: projected - half,
bandHigh: projected + half,
+2 -1
View File
@@ -23,6 +23,7 @@ import type { StrainAnchor } from "@/lib/insights/strain-score";
import { computeReadiness, type ReadinessComponent } from "./readiness";
import { resolveCanonicalRecovery } from "./recovery-resolve";
import { SPARKLINE_MAX_POINTS, type Derived } from "./types";
import { dateOnlyKey } from "@/lib/tz/date-only";
/** A 0–100 wellness score band. Higher is better for recovery; for stress a
* higher score is worse, so the band direction flips (see `WELLNESS_DIR`). */
@@ -207,7 +208,7 @@ export async function computeWellnessScore(
// truth is the cache row, not a re-derivation here.
let anchor: StrainAnchor | null = null;
if (type === "STRAIN_SCORE") {
const day = latest.measuredAt.toISOString().slice(0, 10);
const day = dateOnlyKey(latest.measuredAt);
const cache = await prisma.strainTrimpCache.findUnique({
where: { userId_day: { userId, day } },
select: { anchor: true },
+1
View File
@@ -104,6 +104,7 @@ export async function detectGlp1Plateau(
drug,
doseValue: latestDose.doseValue,
doseUnit: latestDose.doseUnit,
// eslint-disable-next-line healthlog/no-utc-day-key -- baseline: dose-start label in a prompt; this builder has no zone yet
doseSince: latestDose.effectiveFrom.toISOString().slice(0, 10),
daysOnDose,
weightDeltaKg: Math.round(delta * 10) / 10,
+1
View File
@@ -84,6 +84,7 @@ const ABSENT: LabChangesSummary = {
/** UTC calendar-day key (YYYY-MM-DD) for a sample instant. */
function dayKey(d: Date): string {
// eslint-disable-next-line healthlog/no-utc-day-key -- baseline: groups draws by UTC day; this summariser has no zone yet (labReadingDay is the target)
return d.toISOString().slice(0, 10);
}
+3 -4
View File
@@ -1,3 +1,4 @@
import { daysBetweenDateKeys } from "@/lib/tz/format";
/**
* How old a reading may be before "today" stops being true about it.
*
@@ -50,8 +51,6 @@ export function isCurrentForTodayClaim(
* answers `null` rather than a fabricated distance.
*/
export function dayKeyAgeInDays(then: string, today: string): number | null {
const a = Date.parse(`${then}T00:00:00.000Z`);
const b = Date.parse(`${today}T00:00:00.000Z`);
if (!Number.isFinite(a) || !Number.isFinite(b)) return null;
return Math.round((b - a) / 86_400_000);
const days = daysBetweenDateKeys(then, today);
return Number.isFinite(days) ? days : null;
}
+2 -5
View File
@@ -17,7 +17,7 @@ import { welchTTest } from "@/lib/insights/correlations";
import { round } from "@/lib/insights/status-shared";
import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum";
import { MS_PER_DAY } from "@/lib/time-constants";
import { userDayKey } from "@/lib/tz/format";
import { userDayKey, shiftDateKey } from "@/lib/tz/format";
import type {
CrossMetricMeasurement,
MoodAggregateEntry,
@@ -254,10 +254,7 @@ export function metricDayMap(
/** Add `lagDays` to a YYYY-MM-DD day key (UTC-anchored, DST-immune). */
export function shiftDayKey(day: string, lagDays: number): string {
const [y, m, d] = day.split("-").map(Number);
const dt = new Date(Date.UTC(y, m - 1, d));
dt.setUTCDate(dt.getUTCDate() + lagDays);
return dt.toISOString().slice(0, 10);
return shiftDateKey(day, lagDays);
}
/** Convert a raw metric value to the row's display unit. */
+1
View File
@@ -34,6 +34,7 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000;
* `YYYY-MM-DD`. Resolved in ONE place so all three engines agree.
*/
export function scoreDayKey(now: Date): string {
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the derived-score engines score UTC days, resolved here in one place (see the doc comment)
return new Date(now.getTime() - MS_PER_DAY).toISOString().slice(0, 10);
}
+3 -6
View File
@@ -71,6 +71,7 @@ import {
scoreMeasuredAt,
upsertScoreRow,
} from "@/lib/insights/score-row";
import { shiftDateKey } from "@/lib/tz/format";
/** The per-day idempotency-key prefix for a stored Strain score row. */
export const STRAIN_SCORE_EXTERNAL_ID_PREFIX = "strain:";
@@ -395,6 +396,7 @@ export async function computeStrainScore(
},
): Promise<StrainComputeResult> {
const dayKey = strainDayKey(now);
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: UTC day window of the UTC-day score engines (scoreDayKey)
const dayStart = new Date(`${dayKey}T00:00:00.000Z`);
const dayEnd = new Date(dayStart.getTime() + MS_PER_DAY);
@@ -548,12 +550,7 @@ export async function loadStrainAnchorHistory(
now: Date,
): Promise<StrainAnchorHistory> {
const dayKey = strainDayKey(now);
const windowStart = new Date(
new Date(`${dayKey}T00:00:00.000Z`).getTime() -
STRAIN_CHRONIC_WINDOW_DAYS * MS_PER_DAY,
)
.toISOString()
.slice(0, 10);
const windowStart = shiftDateKey(dayKey, -STRAIN_CHRONIC_WINDOW_DAYS);
// The chronic window of cached rows, excluding the scored day itself (its
// TRIMP is added at compute time so the distribution always reflects the
+3 -4
View File
@@ -30,6 +30,7 @@
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { buildBaselineBand } from "@/lib/insights/derived/baseline";
import { daysBetweenDateKeys } from "@/lib/tz/format";
/** One DAY-bucket point: a calendar day key (YYYY-MM-DD) + that day's mean. */
export interface StreakPoint {
@@ -220,8 +221,6 @@ function buildBandFromSeries(
/** Whole-day gap between two YYYY-MM-DD keys (>=1; 1 = adjacent). */
function dayGap(earlier: string, later: string): number {
const a = Date.parse(`${earlier}T00:00:00Z`);
const b = Date.parse(`${later}T00:00:00Z`);
if (Number.isNaN(a) || Number.isNaN(b)) return Number.POSITIVE_INFINITY;
return Math.round((b - a) / (24 * 60 * 60 * 1000));
const gap = daysBetweenDateKeys(earlier, later);
return Number.isNaN(gap) ? Number.POSITIVE_INFINITY : gap;
}
+1
View File
@@ -133,6 +133,7 @@ async function readIntradaySdnn(
now: Date,
): Promise<number[]> {
const dayKey = stressDayKey(now);
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: UTC day window of the UTC-day score engines (scoreDayKey)
const dayStart = new Date(`${dayKey}T00:00:00.000Z`);
const dayEnd = new Date(dayStart.getTime() + MS_PER_DAY);
const rows = await prisma.measurement.findMany({
+1
View File
@@ -293,6 +293,7 @@ export async function runCoachPlanReviewTick(
timeZone: "UTC",
db: prisma,
});
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the day series above is read with timeZone UTC, so the plan start is cut on the same calendar
const startKey = plan.createdAt.toISOString().slice(0, 10);
const before: number[] = [];
const after: number[] = [];
+1
View File
@@ -906,6 +906,7 @@ export async function runOffhostBackup(
}
const s3 = s3Override ?? (await getS3Client(cfg));
const runStartedAt = options.runStartedAt;
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: off-host object key date, the name the restore drill and the purge look up
const dateKey = (runStartedAt ?? now).toISOString().slice(0, 10);
const users = await prisma.user.findMany({
+2 -1
View File
@@ -211,7 +211,8 @@ export async function processOffhostPurges(
const subject = request.subjectId;
const day =
request.reason === "data_wiped" && live.has(subject)
? request.requestedAt.toISOString().slice(0, 10)
? // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: matches the UTC dates in the off-host object keys
request.requestedAt.toISOString().slice(0, 10)
: null;
const prior = lastDayToDelete.get(subject);
if (prior === undefined) lastDayToDelete.set(subject, day);
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff. Show more