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); }