diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index b21305370..1850562a2 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -70875,9 +70875,10 @@ components:
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
- description: Instant behind `latestValue`, falling back to the metric's newest reading. For `mood` this is the day START
- of the latest daily bucket, not a reading time — the series pins a day, so inventing an instant would be a
- lie. For `bmi` it is the WEIGHT metadata, since BMI moves exactly when weight does.
+ description: Instant behind `latestValue`, falling back to the metric's newest reading. For `mood` this is the first
+ instant of the latest daily bucket's day in the account's time zone, not a reading time (the series pins a
+ local day, not an instant); formatted in that zone it always lands on the day the entry was logged. For
+ `bmi` it is the WEIGHT metadata, since BMI moves exactly when weight does.
allTimeCount:
type: integer
minimum: -9007199254740991
diff --git a/eslint-plugins/healthlog/__tests__/no-utc-day-key.test.mjs b/eslint-plugins/healthlog/__tests__/no-utc-day-key.test.mjs
new file mode 100644
index 000000000..ea2c96e9b
--- /dev/null
+++ b/eslint-plugins/healthlog/__tests__/no-utc-day-key.test.mjs
@@ -0,0 +1,154 @@
+/**
+ * Unit tests for the `healthlog/no-utc-day-key` rule: the UTC-day idioms are
+ * refused in application source, allowed in `src/lib/tz/` and in tests, and a
+ * disable directive for the rule must be next-line-only and carry a reason.
+ */
+import { describe, expect, it } from "vitest";
+import { Linter, RuleTester } from "eslint";
+import rule from "../no-utc-day-key.js";
+
+RuleTester.describe = describe;
+RuleTester.it = it;
+RuleTester.itOnly = it.only;
+
+const ruleTester = new RuleTester({
+ languageOptions: {
+ ecmaVersion: 2023,
+ sourceType: "module",
+ parserOptions: { ecmaFeatures: { jsx: true } },
+ },
+});
+
+const APP = "/repo/src/lib/insights/some-reader.ts";
+
+ruleTester.run("no-utc-day-key", rule, {
+ valid: [
+ { code: "const day = userDayKey(at, tz);", filename: APP },
+ { code: "const day = dateOnlyKey(row.documentDate);", filename: APP },
+ // A month key is not a day key.
+ { code: "const month = at.toISOString().slice(0, 7);", filename: APP },
+ // A full instant is fine.
+ { code: "const iso = at.toISOString();", filename: APP },
+ // Noon UTC is the date-only storage anchor.
+ { code: "const d = new Date(`${key}T12:00:00.000Z`);", filename: APP },
+ // A fixed instant literal is not day parsing.
+ { code: 'const d = new Date("2026-06-10T08:00:00.000Z");', filename: APP },
+ // The time zone module owns the idiom.
+ {
+ code: "export const k = (d) => d.toISOString().slice(0, 10);",
+ filename: "/repo/src/lib/tz/date-only.ts",
+ },
+ // Fixtures pick concrete days on purpose.
+ {
+ code: 'const d = new Date("2026-06-10"); const k = d.toISOString().slice(0, 10);',
+ filename: "/repo/src/lib/insights/__tests__/reader.test.ts",
+ },
+ // Outside src/ entirely.
+ {
+ code: "const k = d.toISOString().slice(0, 10);",
+ filename: "/repo/scripts/backfill.ts",
+ },
+ ],
+ invalid: [
+ {
+ code: "const day = at.toISOString().slice(0, 10);",
+ filename: APP,
+ errors: [{ messageId: "utcSlice" }],
+ },
+ {
+ code: "const day = at.toISOString().substring(0, 10);",
+ filename: APP,
+ errors: [{ messageId: "utcSlice" }],
+ },
+ {
+ code: 'const day = at.toISOString().split("T")[0];',
+ filename: APP,
+ errors: [{ messageId: "utcSlice" }],
+ },
+ {
+ code: "const day = new Date(now - 86400000)\n .toISOString()\n .slice(0, 10);",
+ filename: APP,
+ errors: [{ messageId: "utcSlice" }],
+ },
+ {
+ code: 'const d = new Date("2026-06-10");',
+ filename: APP,
+ errors: [{ messageId: "utcMidnightParse" }],
+ },
+ {
+ code: "const d = new Date(`${key}T00:00:00.000Z`);",
+ filename: APP,
+ errors: [{ messageId: "utcMidnightParse" }],
+ },
+ {
+ code: "const ms = Date.parse(`${key}T00:00:00Z`);",
+ filename: APP,
+ errors: [{ messageId: "utcMidnightParse" }],
+ },
+ {
+ code: ";",
+ filename: "/repo/src/components/settings/form.tsx",
+ errors: [{ messageId: "utcSlice" }],
+ },
+ ],
+});
+
+/**
+ * Disable directives name the rule under the plugin's own prefix, which the
+ * RuleTester does not use, so they run through a Linter with the plugin
+ * registered as `healthlog` — the way the flat config loads it.
+ */
+describe("no-utc-day-key disable directives", () => {
+ const linter = new Linter({ configType: "flat" });
+ const config = [
+ {
+ files: ["**/*.ts"],
+ plugins: { healthlog: { rules: { "no-utc-day-key": rule } } },
+ rules: { "healthlog/no-utc-day-key": "error" },
+ },
+ ];
+ const lint = (code) =>
+ linter
+ .verify(code, config, { filename: "src/lib/insights/reader.ts" })
+ .map((m) => m.messageId ?? m.message);
+
+ it("accepts a next-line directive that gives a reason", () => {
+ expect(
+ lint(
+ "// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp\nconst s = new Date().toISOString().slice(0, 10);",
+ ),
+ ).toEqual([]);
+ });
+
+ it("refuses a directive without a reason", () => {
+ expect(
+ lint(
+ "// eslint-disable-next-line healthlog/no-utc-day-key\nconst s = d.toISOString().slice(0, 10);",
+ ),
+ ).toEqual(["missingReason"]);
+ });
+
+ it("refuses a reason too short to say anything", () => {
+ expect(
+ lint(
+ "// eslint-disable-next-line healthlog/no-utc-day-key -- ok\nconst s = d.toISOString().slice(0, 10);",
+ ),
+ ).toEqual(["missingReason"]);
+ });
+
+ it("refuses a same-line or whole-file disable, which would hide the next site too", () => {
+ // The site itself is suppressed by the directive, so the only report is
+ // the directive's own, placed at the top of the file where the directive
+ // does not reach.
+ expect(
+ lint(
+ "const a = 1;\nconst s = d.toISOString().slice(0, 10); // eslint-disable-line healthlog/no-utc-day-key -- UTC by design: file name stamp",
+ ),
+ ).toEqual(["nextLineOnly"]);
+ expect(
+ lint(
+ "const a = 1;\n/* eslint-disable healthlog/no-utc-day-key -- UTC by design: file name stamp */\nconst s = d.toISOString().slice(0, 10);",
+ ),
+ ).toEqual(["nextLineOnly"]);
+ });
+});
diff --git a/eslint-plugins/healthlog/index.js b/eslint-plugins/healthlog/index.js
index 961372c8b..da764d680 100644
--- a/eslint-plugins/healthlog/index.js
+++ b/eslint-plugins/healthlog/index.js
@@ -12,6 +12,8 @@
* whose handler returns a JobOutcome.
* - `no-default-zone-literal` — the default zone is DEFAULT_TIMEZONE from
* src/lib/tz/format, never written out.
+ * - `no-utc-day-key` — a day is cut through the tz module, not by
+ * slicing an ISO string or parsing UTC midnight.
*/
"use strict";
@@ -23,6 +25,7 @@ const noRawPaletteColor = require("./no-raw-palette-color.js");
const spacingScale = require("./spacing-scale.js");
const jobHandlerOutcome = require("./job-handler-outcome.js");
const noDefaultZoneLiteral = require("./no-default-zone-literal.js");
+const noUtcDayKey = require("./no-utc-day-key.js");
module.exports = {
rules: {
@@ -38,5 +41,6 @@ module.exports = {
"no-dracula-utility": noRawPaletteColor,
"job-handler-outcome": jobHandlerOutcome,
"no-default-zone-literal": noDefaultZoneLiteral,
+ "no-utc-day-key": noUtcDayKey,
},
};
diff --git a/eslint-plugins/healthlog/no-utc-day-key.js b/eslint-plugins/healthlog/no-utc-day-key.js
new file mode 100644
index 000000000..ea1f300fe
--- /dev/null
+++ b/eslint-plugins/healthlog/no-utc-day-key.js
@@ -0,0 +1,226 @@
+/**
+ * @fileoverview ESLint rule — a day is cut in a named way, not by slicing UTC.
+ *
+ * `instant.toISOString().slice(0, 10)` is the UTC day of an instant. For a
+ * person that is the wrong day for part of every day anywhere off UTC: the
+ * evening west of it, the small hours east of it. The same goes for parsing a
+ * day key back as UTC midnight (`new Date("2026-06-10")`,
+ * `` new Date(`${key}T00:00:00Z`) ``), which is the previous evening west of
+ * UTC. The idiom kept coming back because it looks harmless, and each copy
+ * was a place where an entry landed on the neighbouring day.
+ *
+ * The time zone module names every legitimate form:
+ *
+ * - the day an instant fell on for a person: `userDayKey(instant, tz)`
+ * - a date-only value or a `@db.Date` column read back: `dateOnlyKey(value)`
+ * - a date-only value to store: `dateOnlyAtNoonUtc(key)`
+ * - a `@db.Date` column value to compare with: `dayKeyAsUtcMidnight(key)`
+ * - the first instant of a person's day: `startOfLocalDayKey(key, tz)`
+ *
+ * FLAGGED, in `src/` outside `src/lib/tz/` and outside test files:
+ *
+ * - `.toISOString().slice(0, 10)` and `.substring(0, 10)`
+ * - `.toISOString().split("T")[0]`
+ * - `new Date("YYYY-MM-DD")` / `Date.parse("YYYY-MM-DD")`
+ * - `` new Date(`${key}T00:00:00Z`) `` (also `T00:00Z`, `T00:00:00.000Z`)
+ * and the same through `Date.parse`
+ *
+ * A site that means the UTC day on purpose (a file name, a key that is UTC by
+ * design and documented as such) stays, with the reason on the line above:
+ *
+ * // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design:
+ *
+ * The sites that were already there when the rule arrived and should move to
+ * the user's day carry `-- baseline: ` instead. The guard
+ * `src/__tests__/utc-day-key-allowlist-guard.test.ts` requires one of the two
+ * prefixes on every directive and holds the baseline count to a ceiling that
+ * only goes down.
+ *
+ * The rule refuses a disable directive for itself that carries no reason, or
+ * that uses any form other than `eslint-disable-next-line` (a whole-file or
+ * same-line disable would hide the next occurrence too).
+ *
+ * WHAT THIS RULE DOES NOT CATCH
+ *
+ * The UTC day assembled another way: `getUTCFullYear()` / `getUTCMonth()` /
+ * `getUTCDate()` arithmetic, `toISOString().slice(0, 7)` month keys, an
+ * `Intl.DateTimeFormat` pinned to `timeZone: "UTC"`, a slice through a
+ * variable (`const iso = d.toISOString(); iso.slice(0, 10)`), or SQL
+ * `date_trunc` without `AT TIME ZONE`. It removes the idiom, not every way to
+ * reach the UTC calendar.
+ */
+
+"use strict";
+
+const RULE_NAME = "healthlog/no-utc-day-key";
+const EXEMPT_ROOTS = ["src/lib/tz/"];
+const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
+const UTC_MIDNIGHT_TAIL_RE = /^T00:00(?::00(?:\.0{1,3})?)?Z$/;
+const MIN_REASON_CHARS = 8;
+
+function toPosix(filename) {
+ return filename.replace(/\\/g, "/");
+}
+
+function isTestFile(posix) {
+ return (
+ /\.test\.[cm]?[jt]sx?$/.test(posix) ||
+ /\.spec\.[cm]?[jt]sx?$/.test(posix) ||
+ posix.includes("/__tests__/") ||
+ posix.includes("/__mocks__/")
+ );
+}
+
+function isEnforced(filename) {
+ const posix = toPosix(filename);
+ if (!posix.includes("/src/") && !posix.startsWith("src/")) return false;
+ if (EXEMPT_ROOTS.some((root) => posix.includes(root))) return false;
+ if (isTestFile(posix)) return false;
+ return true;
+}
+
+function propertyName(member) {
+ if (!member || member.type !== "MemberExpression") return null;
+ if (!member.computed && member.property.type === "Identifier") {
+ return member.property.name;
+ }
+ if (member.computed && member.property.type === "Literal") {
+ return String(member.property.value);
+ }
+ return null;
+}
+
+/** `.toISOString()` with no arguments. */
+function isToIsoStringCall(node) {
+ return (
+ node &&
+ node.type === "CallExpression" &&
+ node.arguments.length === 0 &&
+ propertyName(node.callee) === "toISOString"
+ );
+}
+
+function isNumberLiteral(node, value) {
+ return node && node.type === "Literal" && node.value === value;
+}
+
+/** A day key parsed as UTC midnight: a date-only string or `${k}T00:00Z`. */
+function isUtcMidnightDayArg(arg) {
+ if (!arg) return false;
+ if (arg.type === "Literal" && typeof arg.value === "string") {
+ return DATE_ONLY_RE.test(arg.value);
+ }
+ if (arg.type === "TemplateLiteral" && arg.expressions.length >= 1) {
+ const last = arg.quasis[arg.quasis.length - 1];
+ const tail = last.value.cooked ?? last.value.raw;
+ return typeof tail === "string" && UTC_MIDNIGHT_TAIL_RE.test(tail);
+ }
+ return false;
+}
+
+/** @type {import("eslint").Rule.RuleModule} */
+const noUtcDayKeyRule = {
+ meta: {
+ type: "problem",
+ docs: {
+ description:
+ "Cut a day through the time zone module (userDayKey, dateOnlyKey, dateOnlyAtNoonUtc, dayKeyAsUtcMidnight) instead of slicing or parsing UTC.",
+ },
+ schema: [],
+ messages: {
+ utcSlice:
+ "This is the UTC day of an instant. For a person's day use userDayKey(instant, tz); for a date-only value or a @db.Date column use dateOnlyKey(value) (both in @/lib/tz). If UTC is meant, say why: `// eslint-disable-next-line healthlog/no-utc-day-key -- `.",
+ utcMidnightParse:
+ "This parses a day as UTC midnight, the previous evening west of UTC. Store a date-only value with dateOnlyAtNoonUtc(key), compare a @db.Date column with dayKeyAsUtcMidnight(key), or take a person's day start with startOfLocalDayKey(key, tz). If UTC midnight is meant, say why: `// eslint-disable-next-line healthlog/no-utc-day-key -- `.",
+ missingReason:
+ "A disable directive for healthlog/no-utc-day-key must say why the UTC day is right here: `-- ` after the rule name.",
+ nextLineOnly:
+ "Disable healthlog/no-utc-day-key only with `eslint-disable-next-line`, one site at a time, so the next occurrence is still caught.",
+ },
+ },
+ create(context) {
+ const filename = context.filename ?? context.getFilename?.();
+ if (!filename || !isEnforced(filename)) {
+ return {};
+ }
+ const sourceCode = context.sourceCode ?? context.getSourceCode();
+
+ function checkDirectives() {
+ for (const comment of sourceCode.getAllComments()) {
+ const text = comment.value.trim();
+ const match = /^eslint-disable(-next-line|-line)?\b([\s\S]*)$/.exec(
+ text,
+ );
+ if (!match || !match[2].includes(RULE_NAME)) continue;
+ if (match[1] !== "-next-line") {
+ // A same-line or block directive suppresses reports at its own
+ // position, so the refusal is placed at the top of the file. One
+ // sitting on the very first line still hides it; the allowlist
+ // guard test (src/__tests__/utc-day-key-allowlist-guard.test.ts)
+ // reads every directive and catches that case.
+ context.report({
+ loc: { line: 1, column: 0 },
+ messageId: "nextLineOnly",
+ });
+ continue;
+ }
+ const reason = match[2].split(/\s--\s/)[1];
+ if (!reason || reason.trim().length < MIN_REASON_CHARS) {
+ context.report({ loc: comment.loc, messageId: "missingReason" });
+ }
+ }
+ }
+
+ function checkDateParse(node) {
+ if (isUtcMidnightDayArg(node.arguments[0])) {
+ context.report({ node, messageId: "utcMidnightParse" });
+ }
+ }
+
+ return {
+ "Program:exit": checkDirectives,
+ CallExpression(node) {
+ const name = propertyName(node.callee);
+ if (
+ (name === "slice" || name === "substring") &&
+ node.arguments.length === 2 &&
+ isNumberLiteral(node.arguments[0], 0) &&
+ isNumberLiteral(node.arguments[1], 10) &&
+ isToIsoStringCall(node.callee.object)
+ ) {
+ context.report({ node, messageId: "utcSlice" });
+ return;
+ }
+ if (
+ name === "parse" &&
+ node.callee.object.type === "Identifier" &&
+ node.callee.object.name === "Date"
+ ) {
+ checkDateParse(node);
+ }
+ },
+ MemberExpression(node) {
+ // `.toISOString().split("T")[0]`
+ if (
+ node.computed &&
+ isNumberLiteral(node.property, 0) &&
+ node.object.type === "CallExpression" &&
+ propertyName(node.object.callee) === "split" &&
+ node.object.arguments.length >= 1 &&
+ node.object.arguments[0].type === "Literal" &&
+ node.object.arguments[0].value === "T" &&
+ isToIsoStringCall(node.object.callee.object)
+ ) {
+ context.report({ node, messageId: "utcSlice" });
+ }
+ },
+ NewExpression(node) {
+ if (node.callee.type === "Identifier" && node.callee.name === "Date") {
+ checkDateParse(node);
+ }
+ },
+ };
+ },
+};
+
+module.exports = noUtcDayKeyRule;
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 13102ce22..897750959 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -108,6 +108,13 @@ const eslintConfig = defineConfig([
// literal anywhere else is where a user outside that zone got someone
// else's calendar. src/lib/tz/ and test files are exempt; see the rule.
"healthlog/no-default-zone-literal": "error",
+ // A day is cut through src/lib/tz (userDayKey, dateOnlyKey,
+ // dateOnlyAtNoonUtc, dayKeyAsUtcMidnight), never by slicing an ISO string or by
+ // parsing a key as UTC midnight: each copy put an entry on the
+ // neighbouring day for someone off UTC. A site where UTC is meant
+ // carries `eslint-disable-next-line ... -- `; the rule refuses
+ // a directive without one. See the rule header.
+ "healthlog/no-utc-day-key": "error",
},
},
// v1.28.17 — every recharts-rendering component funnels through the
diff --git a/src/__tests__/tz-day-paths-harness.test.ts b/src/__tests__/tz-day-paths-harness.test.ts
new file mode 100644
index 000000000..7adf653da
--- /dev/null
+++ b/src/__tests__/tz-day-paths-harness.test.ts
@@ -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();
+ 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;
+ 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);
+ });
+ });
+});
diff --git a/src/__tests__/utc-day-key-allowlist-guard.test.ts b/src/__tests__/utc-day-key-allowlist-guard.test.ts
new file mode 100644
index 000000000..08ec93095
--- /dev/null
+++ b/src/__tests__/utc-day-key-allowlist-guard.test.ts
@@ -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 -- ` 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);
+ });
+});
diff --git a/src/app/api/admin/backups/[id]/download/route.ts b/src/app/api/admin/backups/[id]/download/route.ts
index 31b703a7e..d79aca81d 100644
--- a/src/app/api/admin/backups/[id]/download/route.ts
+++ b/src/app/api/admin/backups/[id]/download/route.ts
@@ -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`;
diff --git a/src/app/api/cycle/calendar/route.ts b/src/app/api/cycle/calendar/route.ts
index 7d3b07dda..247f0341a 100644
--- a/src/app/api/cycle/calendar/route.ts
+++ b/src/app/api/cycle/calendar/route.ts
@@ -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" },
diff --git a/src/app/api/cycle/insights/route.ts b/src/app/api/cycle/insights/route.ts
index fb9d6c1c8..49c3be777 100644
--- a/src/app/api/cycle/insights/route.ts
+++ b/src/app/api/cycle/insights/route.ts
@@ -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 },
diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts
index 57fb570ad..e61286e5b 100644
--- a/src/app/api/dashboard/summary/__tests__/route.test.ts
+++ b/src/app/api/dashboard/summary/__tests__/route.test.ts
@@ -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 () => {
diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts
index 084b12087..26d76552f 100644
--- a/src/app/api/dashboard/summary/route.ts
+++ b/src/app/api/dashboard/summary/route.ts
@@ -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",
diff --git a/src/app/api/documents/inbound/[id]/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/__tests__/route.test.ts
index 3b1f74874..b82bd9d65 100644
--- a/src/app/api/documents/inbound/[id]/__tests__/route.test.ts
+++ b/src/app/api/documents/inbound/[id]/__tests__/route.test.ts
@@ -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);
diff --git a/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts
index 267c366bd..ccb39338b 100644
--- a/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts
+++ b/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts
@@ -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({
diff --git a/src/app/api/documents/inbound/[id]/extract/route.ts b/src/app/api/documents/inbound/[id]/extract/route.ts
index f6bd96a3b..930d8c895 100644
--- a/src/app/api/documents/inbound/[id]/extract/route.ts
+++ b/src/app/api/documents/inbound/[id]/extract/route.ts
@@ -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) => ({
diff --git a/src/app/api/documents/inbound/[id]/route.ts b/src/app/api/documents/inbound/[id]/route.ts
index 8876d279c..b6f512a27 100644
--- a/src/app/api/documents/inbound/[id]/route.ts
+++ b/src/app/api/documents/inbound/[id]/route.ts
@@ -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;
}
diff --git a/src/app/api/export/encrypted/route.ts b/src/app/api/export/encrypted/route.ts
index 70acef73a..3cc347b00 100644
--- a/src/app/api/export/encrypted/route.ts
+++ b/src/app/api/export/encrypted/route.ts
@@ -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
diff --git a/src/app/api/export/full-backup/route.ts b/src/app/api/export/full-backup/route.ts
index a0581850a..5a6235b43 100644
--- a/src/app/api/export/full-backup/route.ts
+++ b/src/app/api/export/full-backup/route.ts
@@ -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
diff --git a/src/app/api/export/health-record/route.ts b/src/app/api/export/health-record/route.ts
index 282da0731..067c04888 100644
--- a/src/app/api/export/health-record/route.ts
+++ b/src/app/api/export/health-record/route.ts
@@ -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);
diff --git a/src/app/api/export/measurements/route.ts b/src/app/api/export/measurements/route.ts
index 8fb5c199d..809bc14f2 100644
--- a/src/app/api/export/measurements/route.ts
+++ b/src/app/api/export/measurements/route.ts
@@ -208,6 +208,7 @@ function csvResponse(
body: ReadableStream,
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,
diff --git a/src/app/api/export/medications/route.ts b/src/app/api/export/medications/route.ts
index 00d2142cb..8ec77cd18 100644
--- a/src/app/api/export/medications/route.ts
+++ b/src/app/api/export/medications/route.ts
@@ -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,
diff --git a/src/app/api/export/mood/route.ts b/src/app/api/export/mood/route.ts
index 6feb26b35..b9b9ce83b 100644
--- a/src/app/api/export/mood/route.ts
+++ b/src/app/api/export/mood/route.ts
@@ -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,
diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts
index 0e26e7560..4020fc835 100644
--- a/src/app/api/export/route.ts
+++ b/src/app/api/export/route.ts
@@ -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") {
diff --git a/src/app/api/insights/cards/route.ts b/src/app/api/insights/cards/route.ts
index 499171ebe..61b69805c 100644
--- a/src/app/api/insights/cards/route.ts
+++ b/src/app/api/insights/cards/route.ts
@@ -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,
}));
diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts
index cfe291dd4..6309dfb22 100644
--- a/src/app/api/insights/comprehensive/route.ts
+++ b/src/app/api/insights/comprehensive/route.ts
@@ -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();
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),
diff --git a/src/app/api/insights/glp1-timeline/__tests__/route.test.ts b/src/app/api/insights/glp1-timeline/__tests__/route.test.ts
index 966d55e94..eeeee3c4b 100644
--- a/src/app/api/insights/glp1-timeline/__tests__/route.test.ts
+++ b/src/app/api/insights/glp1-timeline/__tests__/route.test.ts
@@ -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"]),
diff --git a/src/app/api/insights/glp1-timeline/route.ts b/src/app/api/insights/glp1-timeline/route.ts
index 513cb76ef..67af7ff9c 100644
--- a/src/app/api/insights/glp1-timeline/route.ts
+++ b/src/app/api/insights/glp1-timeline/route.ts
@@ -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);
diff --git a/src/app/api/labs/ocr/commit/__tests__/route.test.ts b/src/app/api/labs/ocr/commit/__tests__/route.test.ts
index ed84c475c..e77e0b794 100644
--- a/src/app/api/labs/ocr/commit/__tests__/route.test.ts
+++ b/src/app/api/labs/ocr/commit/__tests__/route.test.ts
@@ -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()),
+ 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);
+ });
+});
diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts
index 5fd00dc2b..6f193ef36 100644
--- a/src/app/api/labs/ocr/commit/route.ts
+++ b/src/app/api/labs/ocr/commit/route.ts
@@ -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 {
- 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();
+ 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;
}
diff --git a/src/app/api/managed-profiles/route.ts b/src/app/api/managed-profiles/route.ts
index 693a7af47..9cb3fe433 100644
--- a/src/app/api/managed-profiles/route.ts
+++ b/src/app/api/managed-profiles/route.ts
@@ -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,
diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts
index ecd12aeba..2ae0d8840 100644
--- a/src/app/api/medications/[id]/intake/[eventId]/route.ts
+++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts
@@ -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: {
diff --git a/src/app/api/record-settings/[family]/route.ts b/src/app/api/record-settings/[family]/route.ts
index f72f62af9..c899bd3ba 100644
--- a/src/app/api/record-settings/[family]/route.ts
+++ b/src/app/api/record-settings/[family]/route.ts
@@ -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;
diff --git a/src/app/api/sleep/night/route.ts b/src/app/api/sleep/night/route.ts
index fc8c429ba..9a856f88f 100644
--- a/src/app/api/sleep/night/route.ts
+++ b/src/app/api/sleep/night/route.ts
@@ -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(),
});
diff --git a/src/app/c/[token]/fhir/route.ts b/src/app/c/[token]/fhir/route.ts
index 82a25f1d6..ac882d340 100644
--- a/src/app/c/[token]/fhir/route.ts
+++ b/src/app/c/[token]/fhir/route.ts
@@ -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,
diff --git a/src/app/c/[token]/report.pdf/route.ts b/src/app/c/[token]/report.pdf/route.ts
index eafbc2d29..c02777e3d 100644
--- a/src/app/c/[token]/report.pdf/route.ts
+++ b/src/app/c/[token]/report.pdf/route.ts
@@ -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,
diff --git a/src/components/cycle/bbt-chart.tsx b/src/components/cycle/bbt-chart.tsx
index 88528dc5f..75471ce7d 100644
--- a/src/components/cycle/bbt-chart.tsx
+++ b/src/components/cycle/bbt-chart.tsx
@@ -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(() => {
diff --git a/src/components/links/vault-document-options.ts b/src/components/links/vault-document-options.ts
index 1c2832320..91aee8604 100644
--- a/src/components/links/vault-document-options.ts
+++ b/src/components/links/vault-document-options.ts
@@ -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;
}
/**
diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx
index f1085e2c5..40ff9afdd 100644
--- a/src/components/measurement-reminders/vorsorge-section.tsx
+++ b/src/components/measurement-reminders/vorsorge-section.tsx
@@ -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(
diff --git a/src/components/medications/sections/data-portability-row.tsx b/src/components/medications/sections/data-portability-row.tsx
index 4eae729f0..2ad68d5f5 100644
--- a/src/components/medications/sections/data-portability-row.tsx
+++ b/src/components/medications/sections/data-portability-row.tsx
@@ -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}`,
diff --git a/src/components/mood/use-linked-day-context.ts b/src/components/mood/use-linked-day-context.ts
index 00cc3c71d..a89e60e2a 100644
--- a/src/components/mood/use-linked-day-context.ts
+++ b/src/components/mood/use-linked-day-context.ts
@@ -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());
}
/**
diff --git a/src/components/onboarding/baseline-fields.tsx b/src/components/onboarding/baseline-fields.tsx
index c543b42ed..bd83c1e77 100644
--- a/src/components/onboarding/baseline-fields.tsx
+++ b/src/components/onboarding/baseline-fields.tsx
@@ -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={
diff --git a/src/components/records/allergy-form.tsx b/src/components/records/allergy-form.tsx
index cc1dfb472..77544005b 100644
--- a/src/components/records/allergy-form.tsx
+++ b/src/components/records/allergy-form.tsx
@@ -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();
}
/**
diff --git a/src/components/settings/__tests__/environment-section-dates.test.tsx b/src/components/settings/__tests__/environment-section-dates.test.tsx
new file mode 100644
index 000000000..a37b6c670
--- /dev/null
+++ b/src/components/settings/__tests__/environment-section-dates.test.tsx
@@ -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(
+
+
+
+
+ ,
+ );
+ return /id="env-backfill-end"[^>]*value="([^"]*)"/.exec(html)?.[1];
+}
+
+beforeEach(() => {
+ vi.useFakeTimers({ toFake: ["Date"] });
+});
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe(" 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");
+ });
+});
diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx
index 359be334f..a4cde390e 100644
--- a/src/components/settings/account-section/index.tsx
+++ b/src/components/settings/account-section/index.tsx
@@ -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
diff --git a/src/components/settings/environment-section.tsx b/src/components/settings/environment-section.tsx
index 0a9de3eb6..ff6a31342 100644
--- a/src/components/settings/environment-section.tsx
+++ b/src/components/settings/environment-section.tsx
@@ -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 (
diff --git a/src/components/settings/export-section.tsx b/src/components/settings/export-section.tsx
index 3fe5f5954..7ee5b7b13 100644
--- a/src/components/settings/export-section.tsx
+++ b/src/components/settings/export-section.tsx
@@ -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(
diff --git a/src/components/settings/report-selection/report-visit-offer.tsx b/src/components/settings/report-selection/report-visit-offer.tsx
index 5108a1588..b31708730 100644
--- a/src/components/settings/report-selection/report-visit-offer.tsx
+++ b/src/components/settings/report-selection/report-visit-offer.tsx
@@ -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({
diff --git a/src/components/settings/report-selection/selection-panel.tsx b/src/components/settings/report-selection/selection-panel.tsx
index 2f9f1875c..4fb1830cb 100644
--- a/src/components/settings/report-selection/selection-panel.tsx
+++ b/src/components/settings/report-selection/selection-panel.tsx
@@ -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(
@@ -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}`;
diff --git a/src/components/vaccinations/vaccination-document-suggestion.tsx b/src/components/vaccinations/vaccination-document-suggestion.tsx
index ea07169aa..79628668a 100644
--- a/src/components/vaccinations/vaccination-document-suggestion.tsx
+++ b/src/components/vaccinations/vaccination-document-suggestion.tsx
@@ -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) => {
diff --git a/src/components/vaccinations/vaccination-form.tsx b/src/components/vaccinations/vaccination-form.tsx
index a000a3f39..f65b2ac37 100644
--- a/src/components/vaccinations/vaccination-form.tsx
+++ b/src/components/vaccinations/vaccination-form.tsx
@@ -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? */
diff --git a/src/components/vaccinations/vaccination-list.tsx b/src/components/vaccinations/vaccination-list.tsx
index 8f84e14f7..9f7c39c26 100644
--- a/src/components/vaccinations/vaccination-list.tsx
+++ b/src/components/vaccinations/vaccination-list.tsx
@@ -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["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}
- {format.date(record.occurredAt)}
+
+ {/* 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,
+ )}
+
{record.lotNumber ? (
{t("vaccinations.row.lot", { lot: record.lotNumber })}
diff --git a/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts b/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts
index 51a1485b5..4836ac307 100644
--- a/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts
+++ b/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts
@@ -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,
+ });
+ });
+});
diff --git a/src/lib/ai/coach/budget.ts b/src/lib/ai/coach/budget.ts
index b96f6d7e1..5e77dcc94 100644
--- a/src/lib/ai/coach/budget.ts
+++ b/src/lib/ai/coach/budget.ts
@@ -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);
}
diff --git a/src/lib/ai/coach/cycle-snapshot.ts b/src/lib/ai/coach/cycle-snapshot.ts
index 1edd20a02..510638c23 100644
--- a/src/lib/ai/coach/cycle-snapshot.ts
+++ b/src/lib/ai/coach/cycle-snapshot.ts
@@ -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({
diff --git a/src/lib/ai/coach/follow-ups/derive.ts b/src/lib/ai/coach/follow-ups/derive.ts
index 6bf30bee9..34f596af5 100644
--- a/src/lib/ai/coach/follow-ups/derive.ts
+++ b/src/lib/ai/coach/follow-ups/derive.ts
@@ -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`. */
diff --git a/src/lib/ai/coach/glp1-snapshot.ts b/src/lib/ai/coach/glp1-snapshot.ts
index 0a17ba5ca..4256e827d 100644
--- a/src/lib/ai/coach/glp1-snapshot.ts
+++ b/src/lib/ai/coach/glp1-snapshot.ts
@@ -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 {
// 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).
diff --git a/src/lib/ai/coach/results/metric-table-tool.ts b/src/lib/ai/coach/results/metric-table-tool.ts
index 68a4ba84c..23d230ed9 100644
--- a/src/lib/ai/coach/results/metric-table-tool.ts
+++ b/src/lib/ai/coach/results/metric-table-tool.ts
@@ -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);
}
diff --git a/src/lib/ai/coach/results/projections.ts b/src/lib/ai/coach/results/projections.ts
index 04586b1bb..9d8bd4930 100644
--- a/src/lib/ai/coach/results/projections.ts
+++ b/src/lib/ai/coach/results/projections.ts
@@ -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));
}
/**
diff --git a/src/lib/ai/coach/snapshot-blocks/core-metrics-block.ts b/src/lib/ai/coach/snapshot-blocks/core-metrics-block.ts
index 6049a5986..2a3e23493 100644
--- a/src/lib/ai/coach/snapshot-blocks/core-metrics-block.ts
+++ b/src/lib/ai/coach/snapshot-blocks/core-metrics-block.ts
@@ -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
? {
diff --git a/src/lib/ai/coach/snapshot-series.ts b/src/lib/ai/coach/snapshot-series.ts
index cc5752508..4df20de6e 100644
--- a/src/lib/ai/coach/snapshot-series.ts
+++ b/src/lib/ai/coach/snapshot-series.ts
@@ -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();
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;
diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts
index 665925363..be95be3fc 100644
--- a/src/lib/ai/coach/snapshot.ts
+++ b/src/lib/ai/coach/snapshot.ts
@@ -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;
diff --git a/src/lib/analytics/bp-in-target-fast-path.ts b/src/lib/analytics/bp-in-target-fast-path.ts
index 431f7def9..b5ebcbcc9 100644
--- a/src/lib/analytics/bp-in-target-fast-path.ts
+++ b/src/lib/analytics/bp-in-target-fast-path.ts
@@ -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);
}
diff --git a/src/lib/analytics/correlations-fast-path.ts b/src/lib/analytics/correlations-fast-path.ts
index 1779e4df9..e3999a58b 100644
--- a/src/lib/analytics/correlations-fast-path.ts
+++ b/src/lib/analytics/correlations-fast-path.ts
@@ -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 {
diff --git a/src/lib/analytics/intraday-pulse-io.ts b/src/lib/analytics/intraday-pulse-io.ts
index d271aeebc..30bdafdb7 100644
--- a/src/lib/analytics/intraday-pulse-io.ts
+++ b/src/lib/analytics/intraday-pulse-io.ts
@@ -183,6 +183,7 @@ export async function loadIntradayPulse(
dateKey: string,
): Promise {
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);
diff --git a/src/lib/analytics/mood-prognosis/load.ts b/src/lib/analytics/mood-prognosis/load.ts
index 58376eb85..833dd4a1a 100644
--- a/src/lib/analytics/mood-prognosis/load.ts
+++ b/src/lib/analytics/mood-prognosis/load.ts
@@ -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);
}
diff --git a/src/lib/analytics/range-delta.ts b/src/lib/analytics/range-delta.ts
index eba1c4abe..667cf37c9 100644
--- a/src/lib/analytics/range-delta.ts
+++ b/src/lib/analytics/range-delta.ts
@@ -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> {
- 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,
}));
}
diff --git a/src/lib/analytics/score/lipids.ts b/src/lib/analytics/score/lipids.ts
index 062db9a6b..0c1a3ddca 100644
--- a/src/lib/analytics/score/lipids.ts
+++ b/src/lib/analytics/score/lipids.ts
@@ -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 ?? ""}`;
}
diff --git a/src/lib/clinician-share/share-view-data.ts b/src/lib/clinician-share/share-view-data.ts
index 1d0eb370a..69d899033 100644
--- a/src/lib/clinician-share/share-view-data.ts
+++ b/src/lib/clinician-share/share-view-data.ts
@@ -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,
diff --git a/src/lib/cycle/prediction-refresh.ts b/src/lib/cycle/prediction-refresh.ts
index 02641f861..ca18119d6 100644
--- a/src/lib/cycle/prediction-refresh.ts
+++ b/src/lib/cycle/prediction-refresh.ts
@@ -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,
),
},
},
diff --git a/src/lib/daily/milestones.ts b/src/lib/daily/milestones.ts
index 7cfa2d81b..c6df8e160 100644
--- a/src/lib/daily/milestones.ts
+++ b/src/lib/daily/milestones.ts
@@ -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. */
diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts
index 83dd06f32..53579cd84 100644
--- a/src/lib/dashboard/snapshot.ts
+++ b/src/lib/dashboard/snapshot.ts
@@ -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());
diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts
index d991edbac..a92c0b886 100644
--- a/src/lib/doctor-report/collect.ts
+++ b/src/lib/doctor-report/collect.ts
@@ -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)
diff --git a/src/lib/documents/__tests__/auto-stage-labs.test.ts b/src/lib/documents/__tests__/auto-stage-labs.test.ts
index 8f522f927..ebcde0792 100644
--- a/src/lib/documents/__tests__/auto-stage-labs.test.ts
+++ b/src/lib/documents/__tests__/auto-stage-labs.test.ts
@@ -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 }[];
};
diff --git a/src/lib/documents/__tests__/commit.test.ts b/src/lib/documents/__tests__/commit.test.ts
index 1e858b531..4a54f40d9 100644
--- a/src/lib/documents/__tests__/commit.test.ts
+++ b/src/lib/documents/__tests__/commit.test.ts
@@ -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");
});
});
diff --git a/src/lib/documents/auto-stage-labs.ts b/src/lib/documents/auto-stage-labs.ts
index c66b24b13..ef9133ee8 100644
--- a/src/lib/documents/auto-stage-labs.ts
+++ b/src/lib/documents/auto-stage-labs.ts
@@ -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,
},
});
diff --git a/src/lib/documents/commit.ts b/src/lib/documents/commit.ts
index e22f3d349..1bc271dfd 100644
--- a/src/lib/documents/commit.ts
+++ b/src/lib/documents/commit.ts
@@ -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();
}
diff --git a/src/lib/documents/extract.ts b/src/lib/documents/extract.ts
index 3d02c342c..04477338f 100644
--- a/src/lib/documents/extract.ts
+++ b/src/lib/documents/extract.ts
@@ -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 {
diff --git a/src/lib/documents/ingest.ts b/src/lib/documents/ingest.ts
index 0932ba813..9af617538 100644
--- a/src/lib/documents/ingest.ts
+++ b/src/lib/documents/ingest.ts
@@ -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,
diff --git a/src/lib/documents/store.ts b/src/lib/documents/store.ts
index 268504ed7..41849833b 100644
--- a/src/lib/documents/store.ts
+++ b/src/lib/documents/store.ts
@@ -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,
diff --git a/src/lib/environment/service.ts b/src/lib/environment/service.ts
index 3c2fb43ed..a38f95d00 100644
--- a/src/lib/environment/service.ts
+++ b/src/lib/environment/service.ts
@@ -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;
}
diff --git a/src/lib/export/records-backup.ts b/src/lib/export/records-backup.ts
index 2bb4cd278..75fdfb71d 100644
--- a/src/lib/export/records-backup.ts
+++ b/src/lib/export/records-backup.ts
@@ -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 =
@@ -543,10 +544,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,
diff --git a/src/lib/fitbit/client.ts b/src/lib/fitbit/client.ts
index 3d39eea8c..b7be1b503 100644
--- a/src/lib/fitbit/client.ts
+++ b/src/lib/fitbit/client.ts
@@ -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);
}
diff --git a/src/lib/google-health/client.ts b/src/lib/google-health/client.ts
index f9cc7ea90..923968b2a 100644
--- a/src/lib/google-health/client.ts
+++ b/src/lib/google-health/client.ts
@@ -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":
diff --git a/src/lib/illness/correlation.ts b/src/lib/illness/correlation.ts
index 7dd52abcc..95f47e60b 100644
--- a/src/lib/illness/correlation.ts
+++ b/src/lib/illness/correlation.ts
@@ -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);
}
diff --git a/src/lib/insights/briefing-failure-marker.ts b/src/lib/insights/briefing-failure-marker.ts
index 71abc304a..025a3abe4 100644
--- a/src/lib/insights/briefing-failure-marker.ts
+++ b/src/lib/insights/briefing-failure-marker.ts
@@ -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);
}
diff --git a/src/lib/insights/comprehensive-aggregator.ts b/src/lib/insights/comprehensive-aggregator.ts
index 9f10da2e0..548f89268 100644
--- a/src/lib/insights/comprehensive-aggregator.ts
+++ b/src/lib/insights/comprehensive-aggregator.ts
@@ -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.
diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts
index 0009faab5..79d81f52d 100644
--- a/src/lib/insights/comprehensive-generate.ts
+++ b/src/lib/insights/comprehensive-generate.ts
@@ -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);
}
diff --git a/src/lib/insights/correlation-channel-series.ts b/src/lib/insights/correlation-channel-series.ts
index 9efd60145..aaed68cfa 100644
--- a/src/lib/insights/correlation-channel-series.ts
+++ b/src/lib/insights/correlation-channel-series.ts
@@ -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 {
+ // 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;
diff --git a/src/lib/insights/correlation-discovery.ts b/src/lib/insights/correlation-discovery.ts
index 49852dd93..0fd87e959 100644
--- a/src/lib/insights/correlation-discovery.ts
+++ b/src/lib/insights/correlation-discovery.ts
@@ -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);
}
/**
diff --git a/src/lib/insights/derived/__tests__/adherence-storyline-engine-parity.test.ts b/src/lib/insights/derived/__tests__/adherence-storyline-engine-parity.test.ts
index ff5fc0e81..c8c43c269 100644
--- a/src/lib/insights/derived/__tests__/adherence-storyline-engine-parity.test.ts
+++ b/src/lib/insights/derived/__tests__/adherence-storyline-engine-parity.test.ts
@@ -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());
+ });
+});
diff --git a/src/lib/insights/derived/adherence-storyline.ts b/src/lib/insights/derived/adherence-storyline.ts
index 893ff68f0..27a5c3962 100644
--- a/src/lib/insights/derived/adherence-storyline.ts
+++ b/src/lib/insights/derived/adherence-storyline.ts
@@ -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 {
- // 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 },
});
diff --git a/src/lib/insights/derived/baseline.ts b/src/lib/insights/derived/baseline.ts
index 821ab52d6..c267e44c4 100644
--- a/src/lib/insights/derived/baseline.ts
+++ b/src/lib/insights/derived/baseline.ts
@@ -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" };
diff --git a/src/lib/insights/derived/trajectory.ts b/src/lib/insights/derived/trajectory.ts
index 244a06e63..5707ca590 100644
--- a/src/lib/insights/derived/trajectory.ts
+++ b/src/lib/insights/derived/trajectory.ts
@@ -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,
diff --git a/src/lib/insights/derived/wellness-scores.ts b/src/lib/insights/derived/wellness-scores.ts
index fb486bbef..4e0830d4b 100644
--- a/src/lib/insights/derived/wellness-scores.ts
+++ b/src/lib/insights/derived/wellness-scores.ts
@@ -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 },
diff --git a/src/lib/insights/glp1-plateau.ts b/src/lib/insights/glp1-plateau.ts
index 3f4cb2768..3bdc36ea3 100644
--- a/src/lib/insights/glp1-plateau.ts
+++ b/src/lib/insights/glp1-plateau.ts
@@ -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,
diff --git a/src/lib/insights/labs-changes.ts b/src/lib/insights/labs-changes.ts
index a16ea22cc..c3afb2cfe 100644
--- a/src/lib/insights/labs-changes.ts
+++ b/src/lib/insights/labs-changes.ts
@@ -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);
}
diff --git a/src/lib/insights/measurement-freshness.ts b/src/lib/insights/measurement-freshness.ts
index 54068b1ba..2fcf162fa 100644
--- a/src/lib/insights/measurement-freshness.ts
+++ b/src/lib/insights/measurement-freshness.ts
@@ -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;
}
diff --git a/src/lib/insights/mood-crosstab.ts b/src/lib/insights/mood-crosstab.ts
index 8e42fc9ff..2e59b2ce5 100644
--- a/src/lib/insights/mood-crosstab.ts
+++ b/src/lib/insights/mood-crosstab.ts
@@ -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. */
diff --git a/src/lib/insights/score-row.ts b/src/lib/insights/score-row.ts
index 98d63211a..c82b07487 100644
--- a/src/lib/insights/score-row.ts
+++ b/src/lib/insights/score-row.ts
@@ -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);
}
diff --git a/src/lib/insights/strain-score.ts b/src/lib/insights/strain-score.ts
index 00c2bc400..3cfee08f0 100644
--- a/src/lib/insights/strain-score.ts
+++ b/src/lib/insights/strain-score.ts
@@ -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 {
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 {
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
diff --git a/src/lib/insights/streak-detector.ts b/src/lib/insights/streak-detector.ts
index bb21c1ffb..0b0db4ac3 100644
--- a/src/lib/insights/streak-detector.ts
+++ b/src/lib/insights/streak-detector.ts
@@ -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;
}
diff --git a/src/lib/insights/stress-score.ts b/src/lib/insights/stress-score.ts
index 887eed35f..04d572859 100644
--- a/src/lib/insights/stress-score.ts
+++ b/src/lib/insights/stress-score.ts
@@ -133,6 +133,7 @@ async function readIntradaySdnn(
now: Date,
): Promise {
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({
diff --git a/src/lib/jobs/coach-plan-review.ts b/src/lib/jobs/coach-plan-review.ts
index acf500618..9ea17eff0 100644
--- a/src/lib/jobs/coach-plan-review.ts
+++ b/src/lib/jobs/coach-plan-review.ts
@@ -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[] = [];
diff --git a/src/lib/jobs/offhost-backup.ts b/src/lib/jobs/offhost-backup.ts
index e1c89d864..f48ab0b17 100644
--- a/src/lib/jobs/offhost-backup.ts
+++ b/src/lib/jobs/offhost-backup.ts
@@ -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({
diff --git a/src/lib/jobs/offhost-purge.ts b/src/lib/jobs/offhost-purge.ts
index a42fcb78f..c7089b98a 100644
--- a/src/lib/jobs/offhost-purge.ts
+++ b/src/lib/jobs/offhost-purge.ts
@@ -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);
diff --git a/src/lib/jobs/reminder/cleanup-handlers.ts b/src/lib/jobs/reminder/cleanup-handlers.ts
index 5795aae78..14c86bb90 100644
--- a/src/lib/jobs/reminder/cleanup-handlers.ts
+++ b/src/lib/jobs/reminder/cleanup-handlers.ts
@@ -104,6 +104,7 @@ export async function handleMoodReminderCleanup(
try {
const cutoff = new Date();
cutoff.setUTCDate(cutoff.getUTCDate() - MOOD_REMINDER_RETENTION_DAYS);
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: retention cutoff; a day either way does not matter
const cutoffIso = cutoff.toISOString().slice(0, 10);
const deleted = await p.moodReminderDispatch.deleteMany({
where: { date: { lt: cutoffIso } },
diff --git a/src/lib/jobs/restore-drill.ts b/src/lib/jobs/restore-drill.ts
index 4571af414..efd0baf6e 100644
--- a/src/lib/jobs/restore-drill.ts
+++ b/src/lib/jobs/restore-drill.ts
@@ -167,6 +167,7 @@ export async function runRestoreDrill(
}
const ageDays = Math.floor(
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: age of a UTC-dated off-host object key
(now.getTime() - Date.parse(`${dateKey}T00:00:00Z`)) /
(24 * 60 * 60 * 1000),
);
diff --git a/src/lib/jobs/step-consolidation-repair.ts b/src/lib/jobs/step-consolidation-repair.ts
index a0b1db8d2..09c8a3bc3 100644
--- a/src/lib/jobs/step-consolidation-repair.ts
+++ b/src/lib/jobs/step-consolidation-repair.ts
@@ -119,6 +119,7 @@ export async function runStepConsolidationRepairForUser(
// in different UTC days, so both instants are tracked).
const touchedByUtcDay = new Map();
const markTouched = (instant: Date) => {
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the rollup tier keys DAY buckets by UTC day (see the comment above)
touchedByUtcDay.set(instant.toISOString().slice(0, 10), instant);
};
diff --git a/src/lib/labs/__tests__/reading-day.test.ts b/src/lib/labs/__tests__/reading-day.test.ts
new file mode 100644
index 000000000..e0d2c452f
--- /dev/null
+++ b/src/lib/labs/__tests__/reading-day.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest";
+import { labReadingDay, labReadingDaySearchRange } from "../reading-day";
+
+describe("labReadingDay", () => {
+ it("reads a date-only reading as its stated date in every zone", () => {
+ const noon = new Date("2026-06-10T12:00:00.000Z");
+ const legacyMidnight = new Date("2026-06-10T00:00:00.000Z");
+ for (const tz of ["America/Los_Angeles", "Pacific/Kiritimati", "UTC"]) {
+ expect(labReadingDay(noon, tz)).toBe("2026-06-10");
+ expect(labReadingDay(legacyMidnight, tz)).toBe("2026-06-10");
+ }
+ });
+
+ it("reads a hand-entered instant on the user's own day", () => {
+ const draw = new Date("2026-06-09T23:00:00.000Z"); // 08:00 on the 10th in Tokyo
+ expect(labReadingDay(draw, "Asia/Tokyo")).toBe("2026-06-10");
+ expect(labReadingDay(draw, "America/Los_Angeles")).toBe("2026-06-09");
+ });
+
+ it("searches a range that holds the day's readings in any zone", () => {
+ const range = labReadingDaySearchRange("2026-06-10");
+ const kiritimatiMorning = new Date("2026-06-09T10:30:00.000Z"); // 00:30 on the 10th at UTC+14
+ const utcMinus12Late = new Date("2026-06-11T10:30:00.000Z"); // 22:30 on the 10th at UTC-12
+ expect(kiritimatiMorning >= range.gte && kiritimatiMorning < range.lt).toBe(
+ true,
+ );
+ expect(utcMinus12Late >= range.gte && utcMinus12Late < range.lt).toBe(true);
+ });
+});
diff --git a/src/lib/labs/ocr-extract.ts b/src/lib/labs/ocr-extract.ts
index 4329211b8..af2926a09 100644
--- a/src/lib/labs/ocr-extract.ts
+++ b/src/lib/labs/ocr-extract.ts
@@ -36,6 +36,12 @@ import {
type OcrExtractedRowDto,
OCR_MAX_ROWS,
} from "@/lib/validations/labs-ocr";
+import {
+ labReadingDay,
+ labReadingDaySearchRange,
+} from "@/lib/labs/reading-day";
+import { resolveUserTimezone } from "@/lib/tz/resolver";
+import { statedDateKey } from "@/lib/tz/date-only";
const SYSTEM_PROMPT = `You transcribe a photograph or PDF of a laboratory test report into structured data.
@@ -90,12 +96,8 @@ Respond ONLY with a JSON object of this exact shape:
/** 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;
// Keep only the calendar day — the lab reports a date, not an instant.
- return parsed.toISOString().slice(0, 10);
+ return statedDateKey(raw);
}
/**
@@ -133,6 +135,7 @@ async function annotateRow(
userId: string,
row: ExtractedRow,
reportDate: string | null,
+ tz: string,
): Promise {
const analyte = row.analyte.trim();
const takenAt = normaliseDate(row.takenAt) ?? reportDate;
@@ -149,18 +152,21 @@ async function annotateRow(
// a likely re-scan. Uses the existing (userId, analyte, takenAt) index.
let duplicateOf: string | null = null;
if (takenAt) {
- const dayStart = new Date(`${takenAt}T00:00:00.000Z`);
- const dayEnd = new Date(`${takenAt}T23:59:59.999Z`);
- const candidates = await prisma.labResult.findMany({
+ // The reading's calendar day, the same rule the commit applies, so the
+ // review screen flags exactly the rows the commit will skip.
+ const rows = await prisma.labResult.findMany({
where: {
userId,
deletedAt: null,
analyte: { equals: analyte, mode: "insensitive" },
- takenAt: { gte: dayStart, lte: dayEnd },
+ takenAt: labReadingDaySearchRange(takenAt),
},
- select: { id: true, value: true, valueText: true },
- take: 25,
+ select: { id: true, value: true, valueText: true, takenAt: true },
+ take: 100,
});
+ const candidates = rows.filter(
+ (c) => labReadingDay(c.takenAt, tz) === takenAt,
+ );
for (const c of candidates) {
if (row.value !== null && c.value !== null && c.value === row.value) {
duplicateOf = c.id;
@@ -279,11 +285,12 @@ export async function runOcrExtraction(
// noticing that a report layout or a language is unsupported and never
// hearing about it. A count only: the string is the user's document.
let unreadableRanges = 0;
+ const tz = await resolveUserTimezone(args.userId);
for (const row of envelope.rows.slice(0, OCR_MAX_ROWS)) {
if (isUnreadableRange(parseReferenceRange(row.referenceText, row.unit))) {
unreadableRanges += 1;
}
- rows.push(await annotateRow(args.userId, row, reportDate));
+ rows.push(await annotateRow(args.userId, row, reportDate, tz));
}
annotate({
diff --git a/src/lib/labs/reading-day.ts b/src/lib/labs/reading-day.ts
new file mode 100644
index 000000000..47937c191
--- /dev/null
+++ b/src/lib/labs/reading-day.ts
@@ -0,0 +1,37 @@
+/**
+ * The calendar day a lab reading belongs to.
+ *
+ * Two kinds of reading share `LabResult.takenAt`. A scanned or document
+ * reading states a date only, stored at noon UTC (rows from before the noon
+ * anchor sit at UTC midnight); its day is that date. A reading entered by
+ * hand carries the real instant of the draw; its day is the user's day of
+ * that instant. Cutting both on the UTC day, as the duplicate checks did,
+ * put a morning draw east of UTC on the previous day, so a scan of the same
+ * report wrote the reading a second time.
+ */
+import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
+import { shiftDateKey, userDayKey } from "@/lib/tz/format";
+
+const DAY_MS = 86_400_000;
+const NOON_MS = 43_200_000;
+
+export function labReadingDay(takenAt: Date, tz: string): string {
+ const msOfDay = ((takenAt.getTime() % DAY_MS) + DAY_MS) % DAY_MS;
+ if (msOfDay === 0 || msOfDay === NOON_MS) return dateOnlyKey(takenAt);
+ return userDayKey(takenAt, tz);
+}
+
+/**
+ * A `takenAt` range wide enough to hold every reading whose
+ * {@link labReadingDay} is `day`, in any zone from UTC-12 to UTC+14. It
+ * over-selects by design; filter the rows with `labReadingDay` afterwards.
+ */
+export function labReadingDaySearchRange(day: string): {
+ gte: Date;
+ lt: Date;
+} {
+ return {
+ gte: dayKeyAsUtcMidnight(shiftDateKey(day, -1)),
+ lt: dayKeyAsUtcMidnight(shiftDateKey(day, 2)),
+ };
+}
diff --git a/src/lib/labs/vault-link.ts b/src/lib/labs/vault-link.ts
index 4d4a42d5d..936efd781 100644
--- a/src/lib/labs/vault-link.ts
+++ b/src/lib/labs/vault-link.ts
@@ -28,6 +28,7 @@ import { encryptFactData, encryptFactProvenance } from "@/lib/documents/store";
import { annotate } from "@/lib/logging/context";
import { isModuleEnabled } from "@/lib/modules/gate";
import type { ObservationFactData } from "@/lib/validations/inbound-documents";
+import { dateOnlyKey } from "@/lib/tz/date-only";
/** One inserted lab row to cross-link back to its source vault document. */
export interface InsertedLabForLink {
@@ -54,7 +55,7 @@ function observationFromLab(lab: InsertedLabForLink): ObservationFactData {
unit: lab.unit,
referenceLow: lab.referenceLow,
referenceHigh: lab.referenceHigh,
- effectiveDate: lab.takenAt.toISOString().slice(0, 10),
+ effectiveDate: dateOnlyKey(lab.takenAt),
};
}
diff --git a/src/lib/managed-profiles/lifecycle.ts b/src/lib/managed-profiles/lifecycle.ts
index 1160825e5..5ab65a432 100644
--- a/src/lib/managed-profiles/lifecycle.ts
+++ b/src/lib/managed-profiles/lifecycle.ts
@@ -5,6 +5,7 @@ import {
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import type { Prisma } from "@/generated/prisma/client";
+import { dayKeyAsUtcMidnight, dateOnlyKey } from "@/lib/tz/date-only";
type Transaction = Prisma.TransactionClient;
@@ -266,7 +267,7 @@ export function toManagedProfileView(row: {
return {
id: row.id,
displayName: row.displayName,
- dateOfBirth: row.dateOfBirth?.toISOString().slice(0, 10) ?? null,
+ dateOfBirth: row.dateOfBirth ? dateOnlyKey(row.dateOfBirth) : null,
gender: row.gender,
locale: row.locale,
timezone: row.timezone,
@@ -373,7 +374,7 @@ export async function updateManagedProfile(input: {
...(patch.dateOfBirth !== undefined
? {
dateOfBirth: patch.dateOfBirth
- ? new Date(`${patch.dateOfBirth}T00:00:00.000Z`)
+ ? dayKeyAsUtcMidnight(patch.dateOfBirth)
: null,
}
: {}),
diff --git a/src/lib/mcp/record-search.ts b/src/lib/mcp/record-search.ts
index 3084f5b12..bb1b69ffc 100644
--- a/src/lib/mcp/record-search.ts
+++ b/src/lib/mcp/record-search.ts
@@ -453,6 +453,7 @@ function clip(text: string, max: number): { text: string; truncated: boolean } {
}
function dateOnly(value: Date | null): string | null {
+ // eslint-disable-next-line healthlog/no-utc-day-key -- baseline: text label mixing date-only columns and instants, read without a zone
return value ? value.toISOString().slice(0, 10) : null;
}
diff --git a/src/lib/measurements/dense-intraday-retention.ts b/src/lib/measurements/dense-intraday-retention.ts
index 032b173b8..e5e939613 100644
--- a/src/lib/measurements/dense-intraday-retention.ts
+++ b/src/lib/measurements/dense-intraday-retention.ts
@@ -708,6 +708,7 @@ export async function runDenseIntradayRetention(
// The fold's canonical timestamp is local-noon of the day; reuse
// its UTC date for the resting row's `stats:` key so a re-run
// upserts the same row instead of minting a sibling.
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: stable stats: externalId key; changing it would mint a sibling row on re-run
canonicalTimestamp.toISOString().slice(0, 10),
);
await pc.measurement.upsert({
diff --git a/src/lib/measurements/intraday-cumulative-profile.ts b/src/lib/measurements/intraday-cumulative-profile.ts
index 6d527017a..f05e95cd0 100644
--- a/src/lib/measurements/intraday-cumulative-profile.ts
+++ b/src/lib/measurements/intraday-cumulative-profile.ts
@@ -250,6 +250,7 @@ export async function pruneIntradayCumulativeProfiles(
retentionDays: number = CUMULATIVE_PROFILE_RETENTION_DAYS,
): Promise {
const cutoffKey = shiftDateKey(
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: 400-day retention cutoff; no zone moves it by more than a day (see the doc comment)
now.toISOString().slice(0, 10),
-retentionDays,
);
diff --git a/src/lib/medications/scheduling/project-today-intakes.ts b/src/lib/medications/scheduling/project-today-intakes.ts
index 940162cc9..1ab61da58 100644
--- a/src/lib/medications/scheduling/project-today-intakes.ts
+++ b/src/lib/medications/scheduling/project-today-intakes.ts
@@ -42,6 +42,7 @@ import {
LIVE_ERA_REVISION_ARGS,
liveEraStart,
} from "@/lib/medications/scheduling/live-era";
+import { dayKeyForScheduledFor } from "@/lib/rollups/medication-compliance-rollups";
export interface ProjectTodayIntakesResult {
projected: number;
@@ -238,7 +239,9 @@ export async function projectTodayIntakesAndRecompute(input: {
const seenDayKeys = new Set();
const recomputeJobs: Array> = [];
for (const m of missing) {
- const key = `${m.medicationId}|${m.scheduledFor.toISOString().slice(0, 10)}`;
+ // The compliance rollup's own day for the slot: two slots on different
+ // local days that share a UTC date must each get their recompute.
+ const key = `${m.medicationId}|${dayKeyForScheduledFor(m.scheduledFor, userTz)}`;
if (seenDayKeys.has(key)) continue;
seenDayKeys.add(key);
recomputeJobs.push(
diff --git a/src/lib/mood/linked-context.ts b/src/lib/mood/linked-context.ts
index a39910892..ce632e10b 100644
--- a/src/lib/mood/linked-context.ts
+++ b/src/lib/mood/linked-context.ts
@@ -236,6 +236,7 @@ function localDayWindow(day: string): { from: Date; to: Date } {
// helper agrees belongs to this day. Cheaper and far safer than arithmetic
// on `new Date(y, m, d)`, which slips by an hour twice a year and has
// already been solved once in this codebase.
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: anchor of a superset window; rows are kept only when their own local day matches
const naive = new Date(`${day}T00:00:00.000Z`);
const from = new Date(naive.getTime() - 26 * 60 * 60 * 1000);
const to = new Date(naive.getTime() + 50 * 60 * 60 * 1000);
diff --git a/src/lib/nutrients/day-bounds.ts b/src/lib/nutrients/day-bounds.ts
index ee440c155..64b6c816e 100644
--- a/src/lib/nutrients/day-bounds.ts
+++ b/src/lib/nutrients/day-bounds.ts
@@ -16,5 +16,6 @@
*/
export function maxAcceptableNutrientDay(now: Date): string {
const limit = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000);
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: upper sanity bound with two days of slack for every real zone (see the doc comment)
return limit.toISOString().slice(0, 10);
}
diff --git a/src/lib/openapi/routes/dashboard.ts b/src/lib/openapi/routes/dashboard.ts
index e71ded325..2779f7448 100644
--- a/src/lib/openapi/routes/dashboard.ts
+++ b/src/lib/openapi/routes/dashboard.ts
@@ -223,7 +223,7 @@ const dashboardMetricCard = z
.datetime({ offset: true })
.nullable()
.describe(
- "Instant behind `latestValue`, falling back to the metric's newest reading. For `mood` this is the day START of the latest daily bucket, not a reading time — the series pins a day, so inventing an instant would be a lie. For `bmi` it is the WEIGHT metadata, since BMI moves exactly when weight does.",
+ "Instant behind `latestValue`, falling back to the metric's newest reading. For `mood` this is the first instant of the latest daily bucket's day in the account's time zone, not a reading time (the series pins a local day, not an instant); formatted in that zone it always lands on the day the entry was logged. For `bmi` it is the WEIGHT metadata, since BMI moves exactly when weight does.",
),
allTimeCount: z
.number()
diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts
index cc087bbc2..194aaa626 100644
--- a/src/lib/oura/sync.ts
+++ b/src/lib/oura/sync.ts
@@ -181,6 +181,7 @@ export async function recordOuraSyncFailure(
}
function ymd(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);
}
diff --git a/src/lib/polar/sync.ts b/src/lib/polar/sync.ts
index db71ff320..23db25dbf 100644
--- a/src/lib/polar/sync.ts
+++ b/src/lib/polar/sync.ts
@@ -140,6 +140,7 @@ function toUpsert(
// A timed row keys on its instant's UTC date, as it always has.
externalId:
m.externalId ??
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: timed rows keep their historical UTC-date externalId so re-syncs upsert (see the comment above)
`${resourcePrefix}:${m.day ?? m.measuredAt.toISOString().slice(0, 10)}:${m.fieldTag}`,
sleepStage: m.sleepStage ?? null,
}));
diff --git a/src/lib/rollups/mood-rollups.ts b/src/lib/rollups/mood-rollups.ts
index 9f9986bd0..a1ebd21e9 100644
--- a/src/lib/rollups/mood-rollups.ts
+++ b/src/lib/rollups/mood-rollups.ts
@@ -55,6 +55,7 @@ import type {
PrismaClient,
RollupGranularity,
} from "@/generated/prisma/client";
+import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
/** Granularities that recompute asynchronously via pg-boss. */
const ASYNC_GRANULARITIES: RollupGranularity[] = ["WEEK", "MONTH", "YEAR"];
@@ -250,7 +251,7 @@ export async function recomputeMoodBucketsForEntry(
// on `entry.date`) and puts a late-evening / after-midnight local
// mood on the calendar day the user logged it in their own timezone.
// `bucket_start` encodes the label as its UTC midnight instant.
- const bucketStart = new Date(`${dateLabel}T00:00:00.000Z`);
+ const bucketStart = dayKeyAsUtcMidnight(dateLabel);
await client.$executeRaw`
WITH aggregate AS (
SELECT
@@ -718,7 +719,7 @@ function bucketSpan(
): { from: Date; to: Date } {
// Anchor at the label's UTC midnight; its UTC calendar parts ARE the
// label's parts, so the existing UTC date math below is exact.
- const anchor = new Date(`${dateLabel}T00:00:00.000Z`);
+ const anchor = dayKeyAsUtcMidnight(dateLabel);
switch (granularity) {
case "DAY": {
return {
diff --git a/src/lib/tz/__tests__/date-only.test.ts b/src/lib/tz/__tests__/date-only.test.ts
new file mode 100644
index 000000000..6c09a06b2
--- /dev/null
+++ b/src/lib/tz/__tests__/date-only.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from "vitest";
+import {
+ dateOnlyAtNoonUtc,
+ dateOnlyKey,
+ dayKeyAsUtcMidnight,
+ isCalendarDateKey,
+ isDateOnlyKey,
+ statedDateKey,
+} from "../date-only";
+import { daysBetweenDateKeys, weekdayOfDateKey } from "../format";
+
+describe("date-only values", () => {
+ it("stores a date at noon UTC and reads it back", () => {
+ const stored = dateOnlyAtNoonUtc("2026-06-10");
+ expect(stored.toISOString()).toBe("2026-06-10T12:00:00.000Z");
+ expect(dateOnlyKey(stored)).toBe("2026-06-10");
+ });
+
+ it("reads a row stored at UTC midnight as the same date", () => {
+ expect(dateOnlyKey(dayKeyAsUtcMidnight("2026-06-10"))).toBe("2026-06-10");
+ });
+
+ it("tells a shape from a real calendar date", () => {
+ expect(isDateOnlyKey("2026-02-30")).toBe(true);
+ expect(isCalendarDateKey("2026-02-30")).toBe(false);
+ expect(isCalendarDateKey("2028-02-29")).toBe(true);
+ expect(isCalendarDateKey("2026-6-1")).toBe(false);
+ });
+
+ it("reads the date a string states", () => {
+ expect(statedDateKey("2026-06-10")).toBe("2026-06-10");
+ expect(statedDateKey("2026-06-10T23:30:00-07:00")).toBe("2026-06-10");
+ expect(statedDateKey(" 2026-06-10 ")).toBe("2026-06-10");
+ expect(statedDateKey("June 10, 2026")).toBe("2026-06-10");
+ expect(statedDateKey("2026-02-30")).toBeNull();
+ expect(statedDateKey("not a date")).toBeNull();
+ });
+});
+
+describe("calendar key arithmetic", () => {
+ it("counts whole days between keys across a DST change", () => {
+ expect(daysBetweenDateKeys("2026-03-28", "2026-03-30")).toBe(2);
+ expect(daysBetweenDateKeys("2026-03-30", "2026-03-28")).toBe(-2);
+ expect(daysBetweenDateKeys("2025-12-31", "2026-01-01")).toBe(1);
+ expect(daysBetweenDateKeys("bad", "2026-01-01")).toBeNaN();
+ });
+
+ it("names the weekday of a key", () => {
+ expect(weekdayOfDateKey("2026-10-01")).toBe(4); // Thursday
+ expect(weekdayOfDateKey("2026-10-04")).toBe(0); // Sunday
+ });
+});
diff --git a/src/lib/tz/date-only.ts b/src/lib/tz/date-only.ts
new file mode 100644
index 000000000..589fee3c5
--- /dev/null
+++ b/src/lib/tz/date-only.ts
@@ -0,0 +1,82 @@
+/**
+ * Date-only values: a calendar date (`YYYY-MM-DD`) with no time of day — a
+ * report's stated date, a document's filing date, a backup's label for a
+ * `@db.Date` column.
+ *
+ * Such a value is stored in a timestamp column as noon UTC. Noon keeps the
+ * date on the same calendar day whether it is read back as a UTC label
+ * ({@link dateOnlyKey}) or formatted in any zone from UTC-11 to UTC+11. The
+ * older anchor, UTC midnight, sits on the previous evening everywhere west
+ * of UTC, which is how a stated report date came to show a day early.
+ *
+ * Rows written at UTC midnight before the switch still read back to the same
+ * key through {@link dateOnlyKey}, so the two anchors coexist without a
+ * migration.
+ */
+
+const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/u;
+
+/** True for a strict `YYYY-MM-DD` string. */
+export function isDateOnlyKey(value: string): boolean {
+ return DATE_ONLY_RE.test(value);
+}
+
+/**
+ * True for a `YYYY-MM-DD` string that names a real calendar date.
+ * `2026-02-30` passes the shape check but overflows to 2 March when parsed,
+ * so a reader would return a different day than the one asked for.
+ */
+export function isCalendarDateKey(value: string): boolean {
+ if (!DATE_ONLY_RE.test(value)) return false;
+ const parsed = new Date(`${value}T00:00:00.000Z`);
+ return (
+ !Number.isNaN(parsed.getTime()) && parsed.toISOString().startsWith(value)
+ );
+}
+
+/**
+ * The calendar date a free-form date string states, or `null`. An ISO string
+ * (`2026-06-10`, `2026-06-10T08:00:00+09:00`) states the date it starts with.
+ * Anything else (`June 10, 2026`) is parsed the way `Date` parses it, as local
+ * time in the process zone, so the date is read back in that same zone;
+ * reading it as UTC put it on the previous day for a process east of UTC.
+ */
+export function statedDateKey(raw: string): string | null {
+ const trimmed = raw.trim();
+ const isoPrefix = /^(\d{4}-\d{2}-\d{2})(?:$|T|\s)/u.exec(trimmed);
+ if (isoPrefix) {
+ return isCalendarDateKey(isoPrefix[1]) ? isoPrefix[1] : null;
+ }
+ const parsed = new Date(trimmed);
+ if (Number.isNaN(parsed.getTime())) return null;
+ const pad = (n: number) => String(n).padStart(2, "0");
+ return `${String(parsed.getFullYear()).padStart(4, "0")}-${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())}`;
+}
+
+/** The instant a date-only value is stored as: 12:00 UTC on that date. */
+export function dateOnlyAtNoonUtc(key: string): Date {
+ return new Date(`${key}T12:00:00.000Z`);
+}
+
+/**
+ * The calendar date of a stored date-only value (or of a `@db.Date` column,
+ * which Prisma hands back as UTC midnight). Only for values that ARE dates:
+ * the day an instant fell on for a person is `userDayKey(instant, tz)`.
+ */
+export function dateOnlyKey(value: Date): string {
+ return value.toISOString().slice(0, 10);
+}
+
+/**
+ * Calendar date `key` as UTC midnight. A LABEL, never the instant the day
+ * began for anyone: it is how a `@db.Date` column holds a date (Prisma reads
+ * and writes those as UTC midnight), how the day rollups key `bucketStart`,
+ * and how a day-keyed series places a point on a time axis. Use it to compare
+ * such a column with a day (`endsOn >= dayKeyAsUtcMidnight(todayKey)` keeps a
+ * course through its last day, where comparing with the instant `now` dropped
+ * it) or to build such a label. The first instant of a person's day is
+ * `startOfLocalDayKey(key, tz)`.
+ */
+export function dayKeyAsUtcMidnight(key: string): Date {
+ return new Date(`${key}T00:00:00.000Z`);
+}
diff --git a/src/lib/tz/format.ts b/src/lib/tz/format.ts
index 8f026c41d..5e6bde119 100644
--- a/src/lib/tz/format.ts
+++ b/src/lib/tz/format.ts
@@ -233,6 +233,27 @@ export function shiftDateKey(dateKey: string, deltaDays: number): string {
return dt.toISOString().slice(0, 10);
}
+/**
+ * Whole calendar days from `fromKey` to `toKey` (negative when `toKey` is
+ * earlier). Pure key arithmetic like {@link shiftDateKey}: both keys are
+ * already resolved calendar dates, so no zone takes part. `NaN` when either
+ * key is malformed, so a caller decides what a bad key means.
+ */
+export function daysBetweenDateKeys(fromKey: string, toKey: string): number {
+ const a = Date.parse(`${fromKey}T00:00:00.000Z`);
+ const b = Date.parse(`${toKey}T00:00:00.000Z`);
+ return Math.round((b - a) / 86_400_000);
+}
+
+/**
+ * Weekday of a calendar date key, 0 = Sunday … 6 = Saturday (the
+ * `getDay()` / schedule `daysOfWeek` convention). A calendar date has the
+ * same weekday in every zone, so this needs none.
+ */
+export function weekdayOfDateKey(dateKey: string): number {
+ return new Date(`${dateKey}T00:00:00.000Z`).getUTCDay();
+}
+
/**
* Wall-clock hour (0–23) an observer in `tz` reads off the clock at
* `date`. Used for the time-of-day greeting so a traveller whose device
diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts
index 0fe13f33d..8f8483c47 100644
--- a/src/lib/validations/measurement.ts
+++ b/src/lib/validations/measurement.ts
@@ -1,6 +1,7 @@
import { z } from "zod/v4";
import { EXTERNAL_SOURCE } from "@/lib/measurements/external-source";
import { validateEntryInstant } from "./entry-instant";
+import { isCalendarDateKey } from "@/lib/tz/date-only";
export const measurementTypeEnum = z.enum([
"WEIGHT",
@@ -869,11 +870,10 @@ export const listMeasurementsSchema = z
// the admin drain route, so a malformed CLI invocation has the
// same blast radius. Reject the impossible shapes at the
// validator instead.
- .refine((s) => {
- const parsed = new Date(`${s}T00:00:00Z`);
- if (Number.isNaN(parsed.getTime())) return false;
- return s === parsed.toISOString().slice(0, 10);
- }, "dayKey must be a real calendar date (YYYY-MM-DD)")
+ .refine(
+ isCalendarDateKey,
+ "dayKey must be a real calendar date (YYYY-MM-DD)",
+ )
.optional(),
})
.refine(
diff --git a/src/lib/withings/sync-sleep.ts b/src/lib/withings/sync-sleep.ts
index d95cf4302..db58a44b9 100644
--- a/src/lib/withings/sync-sleep.ts
+++ b/src/lib/withings/sync-sleep.ts
@@ -191,6 +191,7 @@ export interface WithingsSleepSegment {
* fetch derives from the same `start` / `now` Dates.
*/
function ymdUtc(d: Date): string {
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: day-grained summary window matching the unix-second segment window (see the doc comment)
return d.toISOString().slice(0, 10);
}