From 7c5ff89deff8e882abccea51f8aa9db3e7f2d98d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:59:57 +0200 Subject: [PATCH] feat(lint): refuse UTC day keys outside the time zone module `instant.toISOString().slice(0, 10)` and parsing a key back as UTC midnight are the idioms behind every day-boundary fix this year: the evening west of UTC and the small hours east of it land on the neighbouring day. A new rule, healthlog/no-utc-day-key, refuses them in src/ outside src/lib/tz and tests, and refuses a disable directive for itself that is not next-line or gives no reason. The time zone module now names every legitimate form: userDayKey for a person's day, dateOnlyKey / dateOnlyAtNoonUtc / dayKeyAsUtcMidnight for date-only values and day labels, daysBetweenDateKeys / weekdayOfDateKey / isCalendarDateKey / statedDateKey for key arithmetic and parsing. Of the 133 sites the rule found, 84 are gone. Most moved onto a named helper with no change in behaviour; these changed behaviour: - cycle calendar, insights, Coach snapshot and prediction refresh start their lookbacks at the first instant of the user's day - a model-transcribed date like "June 10, 2026" no longer lands a day early in a process east of UTC (documents and lab scans) - a custom report range covers exactly the picked days in the profile zone instead of starting the evening before west of UTC - date-of-birth pickers cap at the user's today, not the UTC one - an allergy onset date is stored at noon UTC like other date-only values - the report's cycle summary, the Coach's usual BP range and the intake projector's rollup recompute use the user's day - vaccination dates render as dates instead of the previous evening 49 remain, each with its reason on the line above: 39 UTC by design (15 file name stamps, rollup and object keys, the UTC-day score engines) and 10 marked baseline. A guard test requires a classified reason on every directive and holds the baseline to a ceiling that only goes down. --- .../__tests__/no-utc-day-key.test.mjs | 154 ++++++++++++ eslint-plugins/healthlog/index.js | 4 + eslint-plugins/healthlog/no-utc-day-key.js | 226 ++++++++++++++++++ eslint.config.mjs | 7 + .../utc-day-key-allowlist-guard.test.ts | 80 +++++++ .../api/admin/backups/[id]/download/route.ts | 1 + src/app/api/cycle/calendar/route.ts | 3 +- src/app/api/cycle/insights/route.ts | 7 +- src/app/api/export/encrypted/route.ts | 1 + src/app/api/export/full-backup/route.ts | 1 + src/app/api/export/health-record/route.ts | 1 + src/app/api/export/measurements/route.ts | 1 + src/app/api/export/medications/route.ts | 1 + src/app/api/export/mood/route.ts | 1 + src/app/api/export/route.ts | 1 + src/app/api/insights/cards/route.ts | 3 +- src/app/api/insights/comprehensive/route.ts | 6 +- src/app/api/managed-profiles/route.ts | 3 +- .../[id]/intake/[eventId]/route.ts | 3 +- src/app/api/record-settings/[family]/route.ts | 11 +- src/app/api/sleep/night/route.ts | 7 +- src/app/c/[token]/fhir/route.ts | 1 + src/app/c/[token]/report.pdf/route.ts | 1 + src/components/cycle/bbt-chart.tsx | 5 +- .../links/vault-document-options.ts | 13 +- .../vorsorge-section.tsx | 19 +- .../sections/data-portability-row.tsx | 1 + src/components/mood/use-linked-day-context.ts | 5 +- src/components/onboarding/baseline-fields.tsx | 3 +- src/components/records/allergy-form.tsx | 12 +- .../settings/account-section/index.tsx | 11 +- .../settings/environment-section.tsx | 5 +- src/components/settings/export-section.tsx | 3 + .../report-selection/report-visit-offer.tsx | 5 +- .../report-selection/selection-panel.tsx | 25 +- .../vaccination-document-suggestion.tsx | 3 +- .../vaccinations/vaccination-form.tsx | 7 +- .../vaccinations/vaccination-list.tsx | 6 +- src/lib/ai/coach/budget.ts | 1 + src/lib/ai/coach/cycle-snapshot.ts | 5 +- src/lib/ai/coach/follow-ups/derive.ts | 6 +- src/lib/ai/coach/glp1-snapshot.ts | 24 +- src/lib/ai/coach/results/metric-table-tool.ts | 17 +- src/lib/ai/coach/results/projections.ts | 12 +- .../snapshot-blocks/core-metrics-block.ts | 4 +- src/lib/ai/coach/snapshot-series.ts | 4 +- src/lib/analytics/bp-in-target-fast-path.ts | 1 + src/lib/analytics/correlations-fast-path.ts | 3 +- src/lib/analytics/intraday-pulse-io.ts | 1 + src/lib/analytics/mood-prognosis/load.ts | 1 + src/lib/analytics/range-delta.ts | 10 +- src/lib/analytics/score/lipids.ts | 1 + src/lib/clinician-share/share-view-data.ts | 3 +- src/lib/cycle/prediction-refresh.ts | 8 +- src/lib/daily/milestones.ts | 3 +- src/lib/dashboard/snapshot.ts | 4 +- src/lib/doctor-report/collect.ts | 7 +- src/lib/documents/extract.ts | 7 +- src/lib/documents/ingest.ts | 4 +- src/lib/documents/store.ts | 9 +- src/lib/environment/service.ts | 12 +- src/lib/export/records-backup.ts | 7 +- src/lib/fitbit/client.ts | 1 + src/lib/google-health/client.ts | 1 + src/lib/illness/correlation.ts | 5 +- src/lib/insights/briefing-failure-marker.ts | 1 + src/lib/insights/comprehensive-aggregator.ts | 3 +- src/lib/insights/comprehensive-generate.ts | 1 + .../insights/correlation-channel-series.ts | 4 +- src/lib/insights/correlation-discovery.ts | 6 +- .../insights/derived/adherence-storyline.ts | 7 +- src/lib/insights/derived/baseline.ts | 3 +- src/lib/insights/derived/trajectory.ts | 13 +- src/lib/insights/derived/wellness-scores.ts | 3 +- src/lib/insights/glp1-plateau.ts | 1 + src/lib/insights/labs-changes.ts | 1 + src/lib/insights/measurement-freshness.ts | 7 +- src/lib/insights/mood-crosstab.ts | 7 +- src/lib/insights/score-row.ts | 1 + src/lib/insights/strain-score.ts | 9 +- src/lib/insights/streak-detector.ts | 7 +- src/lib/insights/stress-score.ts | 1 + src/lib/jobs/coach-plan-review.ts | 1 + src/lib/jobs/offhost-backup.ts | 1 + src/lib/jobs/offhost-purge.ts | 3 +- src/lib/jobs/reminder/cleanup-handlers.ts | 1 + src/lib/jobs/restore-drill.ts | 1 + src/lib/jobs/step-consolidation-repair.ts | 1 + src/lib/labs/ocr-extract.ts | 7 +- src/lib/labs/reading-day.ts | 6 +- src/lib/labs/vault-link.ts | 3 +- src/lib/managed-profiles/lifecycle.ts | 5 +- src/lib/mcp/record-search.ts | 1 + .../measurements/dense-intraday-retention.ts | 1 + .../intraday-cumulative-profile.ts | 1 + .../scheduling/project-today-intakes.ts | 5 +- src/lib/mood/linked-context.ts | 1 + src/lib/nutrients/day-bounds.ts | 1 + src/lib/oura/sync.ts | 1 + src/lib/polar/sync.ts | 1 + src/lib/rollups/mood-rollups.ts | 5 +- src/lib/tz/date-only.ts | 46 +++- src/lib/tz/format.ts | 21 ++ src/lib/validations/measurement.ts | 10 +- src/lib/withings/sync-sleep.ts | 1 + 105 files changed, 807 insertions(+), 200 deletions(-) create mode 100644 eslint-plugins/healthlog/__tests__/no-utc-day-key.test.mjs create mode 100644 eslint-plugins/healthlog/no-utc-day-key.js create mode 100644 src/__tests__/utc-day-key-allowlist-guard.test.ts 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__/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 4fc89c5b3..d33978122 100644 --- a/src/app/api/cycle/insights/route.ts +++ b/src/app/api/cycle/insights/route.ts @@ -45,6 +45,7 @@ import { import { addDays } from "@/lib/cycle/day-math"; 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; @@ -124,7 +125,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" }, @@ -140,7 +141,7 @@ export const GET = apiHandler(async () => { deletedAt: null, type: { in: PHASE_CROSSTAB_METRIC_TYPES }, measuredAt: { - gte: new Date(Date.parse(`${from}T00:00:00Z`)), + gte: startOfLocalDayKey(from, tz), }, }, orderBy: { measuredAt: "asc" }, @@ -166,7 +167,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/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/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 5b91fa3de..e45bcecb6 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"; export const dynamic = "force-dynamic"; @@ -93,7 +94,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, @@ -195,7 +198,7 @@ export const PATCH = apiHandler( ...(patch.dateOfBirth !== undefined ? { dateOfBirth: patch.dateOfBirth - ? new Date(`${patch.dateOfBirth}T00:00:00.000Z`) + ? dayKeyAsUtcMidnight(patch.dateOfBirth) : null, } : {}), @@ -235,7 +238,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/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 166500a69..ff6a31342 100644 --- a/src/components/settings/environment-section.tsx +++ b/src/components/settings/environment-section.tsx @@ -227,7 +227,10 @@ 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 = - backfillStart || (home?.since ? userDayKey(new Date(home.since), tz) : ""); + // `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..0648aab7f 100644 --- a/src/components/vaccinations/vaccination-document-suggestion.tsx +++ b/src/components/vaccinations/vaccination-document-suggestion.tsx @@ -60,7 +60,8 @@ 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 date, not the instant. + return `${name} · ${format.date(`${candidate.occurredAt.slice(0, 10)}T12:00:00.000Z`)}`; }; 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..1f373c61f 100644 --- a/src/components/vaccinations/vaccination-list.tsx +++ b/src/components/vaccinations/vaccination-list.tsx @@ -198,7 +198,11 @@ function DoseRow({ ) : null}
- {format.date(record.occurredAt)} + + {/* A date, stored at UTC midnight: render its date part, not + the instant (the previous evening west of UTC). */} + {format.date(`${record.occurredAt.slice(0, 10)}T12:00:00.000Z`)} + {record.lotNumber ? ( {t("vaccinations.row.lot", { lot: record.lotNumber })} 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 6cb529d11..4256e827d 100644 --- a/src/lib/ai/coach/glp1-snapshot.ts +++ b/src/lib/ai/coach/glp1-snapshot.ts @@ -25,7 +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, shiftDateKey, userDayKey } from "@/lib/tz/format"; +import { + DEFAULT_TIMEZONE, + daysBetweenDateKeys, + shiftDateKey, + userDayKey, + weekdayOfDateKey, +} from "@/lib/tz/format"; /** * Recommended generic name for the canonical GLP-1 drug brand. The Coach @@ -160,11 +166,6 @@ function weeksBetween(from: Date, to: Date): number { return Math.max(0, Math.round(ms / (7 * 24 * 60 * 60 * 1000))); } -/** Midnight UTC of a calendar-day key: a label for day arithmetic only. */ -function keyEpochDays(key: string): number { - return Math.round(Date.parse(`${key}T00:00:00.000Z`) / 86_400_000); -} - /** * Predict next injection from cadence + last intake. * @@ -191,17 +192,12 @@ function predictNextInjection( if (schedule.cadence === "weekly" && schedule.daysOfWeek.length > 0) { const targetDow = schedule.daysOfWeek[0]; // Project forward from the anchor (lastInjection if known, otherwise - // today) until we land on the target weekday. The weekday of a - // calendar date does not depend on any zone, so the key's UTC label - // answers it. + // today) until we land on the target weekday. const anchorKey = lastInjection ? lastInjection.date : todayKey; for (let i = 1; i <= 14; i += 1) { const key = shiftDateKey(anchorKey, i); - if (new Date(`${key}T00:00:00.000Z`).getUTCDay() === targetDow) { - const daysAway = Math.max( - 0, - keyEpochDays(key) - keyEpochDays(todayKey), - ); + if (weekdayOfDateKey(key) === targetDow) { + const daysAway = Math.max(0, daysBetweenDateKeys(todayKey, key)); return { date: key, daysAway }; } } 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/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 6d0f074a7..9aa25a588 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 fe2154b0a..53385ae25 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; @@ -166,8 +167,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 densePulse = @@ -579,7 +580,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/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 ece28bae6..9af617538 100644 --- a/src/lib/documents/ingest.ts +++ b/src/lib/documents/ingest.ts @@ -58,7 +58,7 @@ import type { DocumentSourceSystemValue, InboundDocumentKindValue, } from "@/lib/validations/inbound-documents"; -import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only"; +import { dateOnlyAtNoonUtc, dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; import { userDayKey } from "@/lib/tz/format"; import { resolveUserTimezone } from "@/lib/tz/resolver"; @@ -84,7 +84,7 @@ export function personalUploadBucket(userId: string): string { * 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 { 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/adherence-storyline.ts b/src/lib/insights/derived/adherence-storyline.ts index fd06e1a8b..27a5c3962 100644 --- a/src/lib/insights/derived/adherence-storyline.ts +++ b/src/lib/insights/derived/adherence-storyline.ts @@ -36,7 +36,7 @@ import { type MedTargetClass, } from "@/lib/medications/med-target-map"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; -import { dbDate } from "@/lib/tz/date-only"; +import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; const MS_PER_DAY = 24 * 60 * 60 * 1000; const MAD_TO_SIGMA = 1.4826; @@ -174,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: dbDate(todayKey) } }], + 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 a2f2e34fa..aad173ceb 100644 --- a/src/lib/jobs/offhost-backup.ts +++ b/src/lib/jobs/offhost-backup.ts @@ -897,6 +897,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/ocr-extract.ts b/src/lib/labs/ocr-extract.ts index bd1872833..af2926a09 100644 --- a/src/lib/labs/ocr-extract.ts +++ b/src/lib/labs/ocr-extract.ts @@ -41,6 +41,7 @@ import { 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. @@ -95,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); } /** diff --git a/src/lib/labs/reading-day.ts b/src/lib/labs/reading-day.ts index 8c3318c4d..47937c191 100644 --- a/src/lib/labs/reading-day.ts +++ b/src/lib/labs/reading-day.ts @@ -9,7 +9,7 @@ * 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, dbDate } from "@/lib/tz/date-only"; +import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; import { shiftDateKey, userDayKey } from "@/lib/tz/format"; const DAY_MS = 86_400_000; @@ -31,7 +31,7 @@ export function labReadingDaySearchRange(day: string): { lt: Date; } { return { - gte: dbDate(shiftDateKey(day, -1)), - lt: dbDate(shiftDateKey(day, 2)), + 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/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/date-only.ts b/src/lib/tz/date-only.ts index 92526a625..589fee3c5 100644 --- a/src/lib/tz/date-only.ts +++ b/src/lib/tz/date-only.ts @@ -21,6 +21,38 @@ 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`); @@ -36,11 +68,15 @@ export function dateOnlyKey(value: Date): string { } /** - * The value a `@db.Date` column holds for calendar date `key` (Prisma reads - * and writes those columns as UTC midnight). Use it to compare such a column - * with a day: `endsOn >= dbDate(todayKey)` keeps a course through its last - * day, where comparing against the instant `now` dropped that day. + * 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 dbDate(key: string): Date { +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); }