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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-01 09:59:57 +02:00
1 parent 5820078008
commit 7c5ff89def
105 files changed
+807 -200

No files matched your search

@@ -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: "<input max={new Date().toISOString().slice(0, 10)} />;",
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"]);
});
});
+4
View File
@@ -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,
},
};
+226
View File
@@ -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:
*
* - `<x>.toISOString().slice(0, 10)` and `.substring(0, 10)`
* - `<x>.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: <why>
*
* The sites that were already there when the rule arrived and should move to
* the user's day carry `-- baseline: <what is missing>` 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;
}
/** `<x>.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 -- <reason>`.",
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 -- <reason>`.",
missingReason:
"A disable directive for healthlog/no-utc-day-key must say why the UTC day is right here: `-- <reason>` 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) {
// `<x>.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;
+7
View File
@@ -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 ... -- <reason>`; 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
@@ -0,0 +1,80 @@
/**
* The allowlist of `healthlog/no-utc-day-key`.
*
* The lint rule refuses the UTC-day idioms in application source; a site
* where the UTC day is right keeps it with an
* `eslint-disable-next-line healthlog/no-utc-day-key -- <reason>` directive.
* This guard reads every such directive and holds three lines the rule alone
* cannot:
*
* - every directive is the next-line form (a same-line or block disable on
* the first line of a file hides the rule's own refusal);
* - every reason says which kind of exception it is: `UTC by design:` (the
* UTC day is the correct answer) or `baseline:` (a known site that should
* move to the user's day and has not yet);
* - the `baseline:` set can only shrink. A new UTC-day site is fixed or
* argued as UTC by design, never parked.
*
* Mutation, done once by hand: a bare directive on a new line in
* `src/lib/insights/score-row.ts` fails the reason check; an eleventh
* `baseline:` directive fails the ceiling.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(__dirname, "..");
const RULE = "healthlog/no-utc-day-key";
/** Known sites still on the UTC day that should not be. Lower it, never raise it. */
const BASELINE_CEILING = 10;
interface Directive {
file: string;
line: number;
text: string;
}
function directives(): Directive[] {
const out: Directive[] = [];
for (const file of walkSourceFiles(SRC, { floor: 2000 })) {
// The rule does not run on tests, so no directive there means anything
// (this file names the directive in its own documentation).
if (/(^|\/)__tests__\/|\.test\.tsx?$/.test(file)) continue;
const lines = readFileSync(join(SRC, file), "utf8").split("\n");
lines.forEach((text, i) => {
if (/eslint-disable/.test(text) && text.includes(RULE)) {
out.push({ file, line: i + 1, text: text.trim() });
}
});
}
return out;
}
describe("no-utc-day-key allowlist", () => {
const found = directives();
it("finds the allowlist it guards", () => {
// A walk that finds nothing would agree with every assertion below.
expect(found.length).toBeGreaterThanOrEqual(40);
});
it("uses only next-line directives", () => {
const offenders = found.filter(
(d) => !d.text.includes(`eslint-disable-next-line ${RULE}`),
);
expect(offenders).toEqual([]);
});
it("gives every directive a classified reason", () => {
const offenders = found.filter(
(d) => !/ -- (UTC by design|baseline): \S.{8,}/.test(d.text),
);
expect(offenders).toEqual([]);
});
it("never grows the baseline", () => {
const baseline = found.filter((d) => d.text.includes(" -- baseline: "));
expect(baseline.length).toBeLessThanOrEqual(BASELINE_CEILING);
});
});
@@ -132,6 +132,7 @@ export const GET = apiHandler(
return apiError("Backup payload failed schema validation", 500);
}
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const isoDate = backup.createdAt.toISOString().slice(0, 10); // YYYY-MM-DD
const filename = `healthlog-backup-${backup.userId}-${isoDate}.json`;
+2 -1
View File
@@ -43,6 +43,7 @@ import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
const DEFAULT_PAST_DAYS = 90;
const DEFAULT_FORWARD_DAYS = 180;
@@ -148,7 +149,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
deletedAt: null,
type: "WRIST_TEMPERATURE",
measuredAt: {
gte: new Date(Date.parse(`${addDays(today, -90)}T00:00:00Z`)),
gte: startOfLocalDayKey(addDays(today, -90), tz),
},
},
orderBy: { measuredAt: "asc" },
+4 -3
View File
@@ -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 },
+1
View File
@@ -172,6 +172,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
);
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const stamp = new Date().toISOString().slice(0, 10);
// Return the raw binary archive (NOT the apiSuccess envelope). The file is a
// self-contained `.hlx` archive openable with the user's passphrase via
+1
View File
@@ -88,6 +88,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
},
);
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const stamp = new Date().toISOString().slice(0, 10);
// Stream the JSON directly (NOT wrapped in the apiSuccess envelope) so
// the file is a self-contained backup — admin upload + restore expect
@@ -228,6 +228,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
}
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const stamp = new Date().toISOString().slice(0, 10);
const { t } = getServerTranslator(locale);
+1
View File
@@ -208,6 +208,7 @@ function csvResponse(
body: ReadableStream<Uint8Array>,
prefix: string,
): NextResponse {
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const stamp = new Date().toISOString().slice(0, 10);
return new NextResponse(body, {
status: 200,
+1
View File
@@ -145,6 +145,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
},
});
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const stamp = new Date().toISOString().slice(0, 10);
return new NextResponse(body, {
status: 200,
+1
View File
@@ -83,6 +83,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
},
});
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const stamp = new Date().toISOString().slice(0, 10);
return new NextResponse(csv, {
status: 200,
+1
View File
@@ -155,6 +155,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
: formatMoodEntriesForExport(decryptedMood, userTz);
}
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const filename = `healthlog-export-${new Date().toISOString().slice(0, 10)}`;
if (measurementChunks && format === "csv") {
+2 -1
View File
@@ -35,6 +35,7 @@ import {
import type { MeasurementType } from "@/generated/prisma/client";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
type Severity = "alert" | "caution" | "info" | "good";
@@ -135,7 +136,7 @@ export const GET = apiHandler(async () => {
rollupCoverage,
);
const pulseData: DataPoint[] = pulseDayMeans.map((p) => ({
date: new Date(`${p.day}T00:00:00.000Z`),
date: dayKeyAsUtcMidnight(p.day),
value: p.mean,
}));
+4 -2
View File
@@ -44,6 +44,7 @@ import {
} from "@/lib/rollups/mood-rollups";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
import { dayKeyAsUtcMidnight, dateOnlyKey } from "@/lib/tz/date-only";
export const dynamic = "force-dynamic";
@@ -176,7 +177,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
let moodEntryCount: number;
if (moodRollupDayRows.length > 0) {
dailyMoodEntries = moodRollupDayRows.map((r) => ({
day: r.bucketStart.toISOString().slice(0, 10),
day: dateOnlyKey(r.bucketStart),
value: Math.round(r.mean * 100) / 100,
}));
moodDataPoints = moodRollupDayRows.map((r) => ({
@@ -526,6 +527,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
// the scatter and the coefficient rather than dragging continuity down.
const loggedDays = new Set<string>();
for (const event of bpMedicationEvents) {
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: pairs intake days with the DAY rollup buckets in sysByDay, which are UTC days
const dayKey = event.scheduledFor.toISOString().slice(0, 10);
loggedDays.add(dayKey);
if (event.skipped || !event.takenAt) continue;
@@ -540,7 +542,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
pairs.push({
a: continuity,
b: avgSys,
date: new Date(`${dayKey}T00:00:00.000Z`),
date: dayKeyAsUtcMidnight(dayKey),
});
bpMedicationScatterData.push({
continuityPct: Math.round(continuity * 100),
+2 -1
View File
@@ -17,6 +17,7 @@ import { toManagedProfileView } from "@/lib/managed-profiles/lifecycle";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { createManagedProfileSchema } from "@/lib/validations/managed-profiles";
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
/**
* The same ceiling its guardian sibling carries, and for the same reason.
@@ -59,7 +60,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
creatorId: user.id,
displayName: parsed.data.displayName,
dateOfBirth: parsed.data.dateOfBirth
? new Date(`${parsed.data.dateOfBirth}T00:00:00.000Z`)
? dayKeyAsUtcMidnight(parsed.data.dateOfBirth)
: null,
locale: parsed.data.locale,
timezone: parsed.data.timezone,
@@ -28,6 +28,7 @@ import {
resolveSlotForWriteByBand,
} from "@/lib/medications/scheduling/slot-upsert";
import { NextRequest } from "next/server";
import { dateOnlyKey } from "@/lib/tz/date-only";
type RouteParams = { params: Promise<{ id: string; eventId: string }> };
@@ -100,7 +101,7 @@ export const PUT = apiHandler(
});
if (medication?.startsOn) {
const takenDay = dayKeyForUserTz(data.takenAt, user.timezone);
const startsOnDay = medication.startsOn.toISOString().slice(0, 10);
const startsOnDay = dateOnlyKey(medication.startsOn);
if (takenDay < startsOnDay) {
annotate({
action: {
@@ -41,6 +41,7 @@ import {
parseNotificationPrefs,
resolveNotificationPrefs,
} from "@/lib/validations/notification-prefs";
import { dayKeyAsUtcMidnight, dateOnlyKey } from "@/lib/tz/date-only";
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;
+2 -5
View File
@@ -39,6 +39,7 @@ import {
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { isCalendarDateKey } from "@/lib/tz/date-only";
/** Sleep is read row-per-stage, so the window is bounded to one year. */
const SLEEP_NIGHT_MAX_DAYS = 365;
@@ -48,11 +49,7 @@ const querySchema = z.object({
date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD")
.refine((s) => {
const parsed = new Date(`${s}T00:00:00Z`);
if (Number.isNaN(parsed.getTime())) return false;
return s === parsed.toISOString().slice(0, 10);
}, "date must be a real calendar date (YYYY-MM-DD)")
.refine(isCalendarDateKey, "date must be a real calendar date (YYYY-MM-DD)")
.optional(),
});
+1
View File
@@ -33,6 +33,7 @@ export const GET = apiHandler(
const bundle = buildFhirDocumentBundle(resolved.report, {
insuranceNumber: null,
});
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const stamp = new Date().toISOString().slice(0, 10);
return new NextResponse(JSON.stringify(bundle), {
status: 200,
+1
View File
@@ -46,6 +46,7 @@ export const GET = apiHandler(
insuranceNumber: null,
includeCharts: true,
});
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const stamp = new Date().toISOString().slice(0, 10);
return new NextResponse(bytes.slice().buffer, {
status: 200,
+2 -3
View File
@@ -44,6 +44,7 @@ import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { cn } from "@/lib/utils";
import type { CalendarDay, CervicalMucus, OvulationTest } from "./types";
import { PHASE_HUE, OVULATION_HUE } from "./phase-tokens";
import { shiftDateKey } from "@/lib/tz/format";
/** Trailing fallback window (days) when no MENSTRUAL-anchored cycle is active. */
const FALLBACK_WINDOW_DAYS = 35;
@@ -97,9 +98,7 @@ export function BbtChart({
// recent temperatures.
const fromDate = useMemo(() => {
if (cycleStartDate) return cycleStartDate;
const fallback = new Date(ymdToMs(today));
fallback.setUTCDate(fallback.getUTCDate() - FALLBACK_WINDOW_DAYS);
return fallback.toISOString().slice(0, 10);
return shiftDateKey(today, -FALLBACK_WINDOW_DAYS);
}, [cycleStartDate, today]);
const points = useMemo<BbtPoint[]>(() => {
@@ -31,6 +31,7 @@ import {
DOCUMENT_LIST_MAX_LIMIT,
type InboundDocumentDto,
} from "@/lib/validations/inbound-documents";
import { daysBetweenDateKeys } from "@/lib/tz/format";
/**
* How many pages the picker walks. At the route's ceiling of 100 that is a
@@ -38,8 +39,6 @@ import {
*/
const VAULT_PICKER_MAX_PAGES = 10;
const DAY_MS = 24 * 60 * 60 * 1000;
/** The fields the option list reads off a document. */
export type VaultDocument = Pick<
InboundDocumentDto,
@@ -82,10 +81,12 @@ export function isNearAnchor(
anchorIso: string | null,
): boolean {
if (!documentDay || !anchorIso) return false;
const day = Date.parse(`${documentDay.slice(0, 10)}T00:00:00.000Z`);
const anchor = Date.parse(`${anchorIso.slice(0, 10)}T00:00:00.000Z`);
if (Number.isNaN(day) || Number.isNaN(anchor)) return false;
return Math.abs(day - anchor) <= ENCOUNTER_SUGGEST_WINDOW_DAYS * DAY_MS;
const gap = daysBetweenDateKeys(
anchorIso.slice(0, 10),
documentDay.slice(0, 10),
);
if (Number.isNaN(gap)) return false;
return Math.abs(gap) <= ENCOUNTER_SUGGEST_WINDOW_DAYS;
}
/**
@@ -98,6 +98,11 @@ import {
useMeasurementReminderMutations,
type MeasurementReminder,
} from "@/hooks/use-measurement-reminders";
import {
detectBrowserTimezone,
shiftDateKey,
userDayKey,
} from "@/lib/tz/format";
// v1.18.1 (V3) — the active-measurement set, grouped so the dropdown
// doesn't read as a flat 15-item list. Mirrors the Zod allow-list in
@@ -177,8 +182,7 @@ function toDateInputValue(iso: string | null): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const offset = d.getTimezoneOffset();
return new Date(d.getTime() - offset * 60 * 1000).toISOString().slice(0, 10);
return userDayKey(d, detectBrowserTimezone());
}
/**
@@ -839,12 +843,11 @@ export function postponeOffsetTargets(
// in between moves it an hour, which flips the date for anyone acting near
// midnight. Anchoring today's local date at UTC noon and stepping whole
// UTC days keeps every offset on the day its label names.
const anchor = new Date(`${isoDayInTz(nowMs, tz)}T12:00:00.000Z`);
return SNOOZE_CHIP_DAYS.map((days) => {
const target = new Date(anchor);
target.setUTCDate(target.getUTCDate() + days);
return { days, date: target.toISOString().slice(0, 10) };
});
const today = isoDayInTz(nowMs, tz);
return SNOOZE_CHIP_DAYS.map((days) => ({
days,
date: shiftDateKey(today, days),
}));
}
function intervalProgress(
@@ -66,6 +66,7 @@ export function DataPortabilityRow({
intake: includeIntake ? "true" : "false",
medicationId,
}).toString();
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const stamp = new Date().toISOString().slice(0, 10);
await downloadFromUrl(
`/api/export/medications?${query}`,
@@ -30,6 +30,7 @@ import { useQuery } from "@tanstack/react-query";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type { LinkedDayContext } from "@/lib/mood/linked-context";
import { userDayKey } from "@/lib/tz/format";
/**
* The browser's own zone — the right answer for an entry that does not exist
@@ -42,9 +43,7 @@ export function browserTimezone(): string {
/** `YYYY-MM-DD` for a `datetime-local` value, or today when it is empty. */
export function dayOfLocalInput(value: string): string {
if (value.length >= 10) return value.slice(0, 10);
const now = new Date();
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60 * 1000);
return local.toISOString().slice(0, 10);
return userDayKey(new Date(), browserTimezone());
}
/**
@@ -16,6 +16,7 @@ import type {
HeightDraft,
HeightUnitAdapter,
} from "@/lib/profile/height-unit-display";
import { detectBrowserTimezone, userDayKey } from "@/lib/tz/format";
/**
* The four profile inputs of the onboarding baseline step.
@@ -184,7 +185,7 @@ export function BaselineFields({
id={IDS.dateOfBirth}
value={value.dateOfBirth}
onChange={(next) => onChange("dateOfBirth", next)}
max={new Date().toISOString().slice(0, 10)}
max={userDayKey(new Date(), detectBrowserTimezone())}
autoComplete="bday"
aria-invalid={errors.dateOfBirth ? true : undefined}
aria-describedby={
+8 -4
View File
@@ -27,6 +27,7 @@ import {
import { useTranslations } from "@/lib/i18n/context";
import type { AllergyDTO } from "@/lib/records/dto";
import { dateOnlyAtNoonUtc, isCalendarDateKey } from "@/lib/tz/date-only";
const TEXT_MAX_LENGTH = 2000;
/** Select sentinel for the optional "not assessed" severity. */
@@ -41,11 +42,14 @@ interface AllergyFormProps {
footerSlot?: HTMLElement | null;
}
/** ISO date input (YYYY-MM-DD) → start-of-day UTC instant, or undefined. */
/**
* ISO date input (YYYY-MM-DD) → the instant a date-only value is stored at
* (noon UTC), or undefined. UTC midnight read back as the previous day west
* of UTC.
*/
function toInstant(value: string): string | undefined {
if (!value) return undefined;
const d = new Date(`${value}T00:00:00.000Z`);
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
if (!isCalendarDateKey(value)) return undefined;
return dateOnlyAtNoonUtc(value).toISOString();
}
/**
@@ -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
@@ -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 (
<div className="space-y-6">
@@ -246,6 +246,7 @@ function CsvCard({
since: since || undefined,
until: until || undefined,
});
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const stamp = new Date().toISOString().slice(0, 10);
const filename = `${filenamePrefix}-${stamp}.csv`;
await downloadFromUrl(`${endpoint}${query}`, filename);
@@ -329,6 +330,7 @@ function MedicationsCsvCard() {
until: until || undefined,
intake: includeIntake ? "true" : "false",
});
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const stamp = new Date().toISOString().slice(0, 10);
await downloadFromUrl(
`/api/export/medications${query}`,
@@ -447,6 +449,7 @@ function FullBackupCard() {
}
async function downloadOnce() {
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const stamp = new Date().toISOString().slice(0, 10);
if (encrypt) {
const res = await throwIfReproofRequired(
@@ -25,6 +25,7 @@ import { Button } from "@/components/ui/button";
import { useEncounters } from "@/hooks/use-encounters";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { EncounterKind } from "@/generated/prisma/client";
import { detectBrowserTimezone, userDayKey } from "@/lib/tz/format";
/**
* How far ahead a booked visit is treated as the reason for this report.
@@ -36,9 +37,7 @@ export const REPORT_VISIT_HORIZON_DAYS = 14;
/** The local `yyyy-MM-dd` a `DateField` takes, in the reader's own day. */
function localDay(iso: string): string {
const at = new Date(iso);
const offset = at.getTimezoneOffset();
return new Date(at.getTime() - offset * 60_000).toISOString().slice(0, 10);
return userDayKey(new Date(iso), detectBrowserTimezone());
}
export function ReportVisitOffer({
@@ -66,6 +66,13 @@ import { orderLeaves } from "@/lib/report-selection/selection";
import { ReportScopePicker } from "./report-scope-picker";
import { ScopeSummary } from "./scope-summary";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
import {
DEFAULT_TIMEZONE,
detectBrowserTimezone,
shiftDateKey,
validTimezoneOr,
} from "@/lib/tz/format";
type ExportFormat = "pdf" | "fhir" | "package";
@@ -78,6 +85,12 @@ class ReportRequestError extends Error {}
export function HealthRecordExportPanel() {
const { t, locale } = useTranslations();
const { user } = useAuth();
// The report reads its window in the profile zone (the server's
// `reportTz`), so the picked days are cut there too.
const reportTz = validTimezoneOr(
user?.timezone ?? detectBrowserTimezone(),
DEFAULT_TIMEZONE,
);
const queryClient = useQueryClient();
const [format, setFormat] = useState<ExportFormat>(
@@ -155,8 +168,15 @@ export function HealthRecordExportPanel() {
const range =
customRange && startDate && endDate
? {
startDate: new Date(`${startDate}T00:00:00Z`).toISOString(),
endDate: new Date(`${endDate}T23:59:59Z`).toISOString(),
// The picked days are the user's days: from the first instant
// of the first to the last instant of the last, in their zone.
// UTC midnight started the window the previous evening west of
// UTC, and the report read it as one day more.
startDate: startOfLocalDayKey(startDate, reportTz).toISOString(),
endDate: new Date(
startOfLocalDayKey(shiftDateKey(endDate, 1), reportTz).getTime() -
1,
).toISOString(),
}
: { days };
const res = await throwIfReproofRequired(
@@ -190,6 +210,7 @@ export function HealthRecordExportPanel() {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
a.download = `healthlog-health-record-${new Date()
.toISOString()
.slice(0, 10)}.${ext}`;
@@ -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) => {
@@ -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? */
@@ -198,7 +198,11 @@ function DoseRow({
) : null}
</div>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs">
<span>{format.date(record.occurredAt)}</span>
<span>
{/* A date, stored at UTC midnight: render its date part, not
the instant (the previous evening west of UTC). */}
{format.date(`${record.occurredAt.slice(0, 10)}T12:00:00.000Z`)}
</span>
{record.lotNumber ? (
<span data-slot="vaccination-lot">
{t("vaccinations.row.lot", { lot: record.lotNumber })}
+1
View File
@@ -235,6 +235,7 @@ export async function readDailySpend(
* the local meter to the same boundary keeps reasoning trivial.
*/
export function buildDateKey(at: Date = new Date()): string {
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the spend meter follows the provider's UTC billing day (see the doc comment)
return at.toISOString().slice(0, 10);
}
+3 -2
View File
@@ -43,6 +43,7 @@ import { addDays } from "@/lib/cycle/day-math";
import { readSourceDayAggregates } from "@/lib/measurements/day-aggregates";
import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
/** Trailing window the phase contrast walks (days). Mirrors the read route. */
const WINDOW_DAYS = 365;
@@ -191,7 +192,7 @@ export async function buildCycleSnapshotBlock(
deletedAt: null,
type: "WRIST_TEMPERATURE",
measuredAt: {
gte: new Date(Date.parse(`${addDays(today, -90)}T00:00:00Z`)),
gte: startOfLocalDayKey(addDays(today, -90), tz),
},
},
orderBy: { measuredAt: "asc" },
@@ -206,7 +207,7 @@ export async function buildCycleSnapshotBlock(
readSourceDayAggregates({
userId,
types: PHASE_CROSSTAB_METRIC_TYPES,
since: new Date(Date.parse(`${from}T00:00:00Z`)),
since: startOfLocalDayKey(from, tz),
timeZone: tz,
}),
prisma.user.findUnique({
+2 -4
View File
@@ -30,7 +30,7 @@
*/
import type { Locale } from "@/lib/i18n/config";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { userDayKey } from "@/lib/tz/format";
import { userDayKey, shiftDateKey } from "@/lib/tz/format";
import { annotate } from "@/lib/logging/context";
import type {
CoachFollowUp,
@@ -93,9 +93,7 @@ export function followUpChipsEnabled(prefs: CoachPrefs): boolean {
/** `YYYY-MM-DD` minus `days`, as a day key. */
function dayKeyMinus(dayKey: string, days: number): string {
const at = new Date(`${dayKey}T00:00:00Z`);
at.setUTCDate(at.getUTCDate() - days);
return at.toISOString().slice(0, 10);
return shiftDateKey(dayKey, -days);
}
/** True when the record's first reading lies before `dayKey`. */
+10 -14
View File
@@ -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 };
}
}
+10 -7
View File
@@ -33,7 +33,7 @@ import { prisma } from "@/lib/db";
import type { MeasurementType } from "@/generated/prisma/client";
import type { Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
import { shiftDateKey, userDayKey, weekdayOfDateKey } from "@/lib/tz/format";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
import {
readDailySeries,
@@ -66,6 +66,7 @@ import type {
import { aggregationKind } from "./chart-spec";
import { RESULT_TABLE_MAX_ROWS } from "./project";
import { dateOnlyKey } from "@/lib/tz/date-only";
/**
* Sources the table tool leaves to their own tools: glucose needs its
@@ -167,16 +168,14 @@ export function resolveTableRange(args: {
// ── Period keys ─────────────────────────────────────────────────────────
/** `YYYY-MM-DD` of a UTC-midnight bucket timestamp. */
/** `YYYY-MM-DD` of a UTC-midnight bucket timestamp (a day label). */
function dayKeyOfUtc(ms: number): string {
return new Date(ms).toISOString().slice(0, 10);
return dateOnlyKey(new Date(ms));
}
/** Monday (`YYYY-MM-DD`) of the ISO week `dayKey` falls in. */
function weekKeyOf(dayKey: string): string {
const ms = Date.parse(`${dayKey}T00:00:00Z`);
const dow = (new Date(ms).getUTCDay() + 6) % 7;
return dayKeyOfUtc(ms - dow * 86_400_000);
return shiftDateKey(dayKey, -((weekdayOfDateKey(dayKey) + 6) % 7));
}
function monthKeyOf(dayKey: string): string {
@@ -416,8 +415,12 @@ async function readMeasurementDays(args: {
return { series: ordered, counts, suffixes };
}
/** `YYYY-MM-DD` of an instant in UTC. */
/**
* `YYYY-MM-DD` of an instant in UTC. Only for the all-time month read: the
* MONTH rollup tier buckets UTC months, so its range edges are UTC days.
*/
function utcDayKey(date: Date): string {
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the MONTH rollup tier buckets UTC months, so this read keys its edges on UTC days
return date.toISOString().slice(0, 10);
}
+4 -8
View File
@@ -12,7 +12,7 @@
*/
import type { Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
import { shiftDateKey, userDayKey, weekdayOfDateKey } from "@/lib/tz/format";
import type {
CoachResultCell,
CoachResultTable,
@@ -189,17 +189,13 @@ function mondayOfIsoWeek(weekIso: string): string | null {
const year = Number(match[1]);
const week = Number(match[2]);
// Week 1 holds January 4th; its Monday is the ISO year's first Monday.
const jan4 = Date.UTC(year, 0, 4);
const jan4Dow = (new Date(jan4).getUTCDay() + 6) % 7;
const monday = jan4 - jan4Dow * 86_400_000 + (week - 1) * 7 * 86_400_000;
return new Date(monday).toISOString().slice(0, 10);
const jan4 = `${String(year).padStart(4, "0")}-01-04`;
return shiftDateKey(mondayOfDay(jan4), (week - 1) * 7);
}
/** Monday (`YYYY-MM-DD`) of the ISO week a day key falls in. */
function mondayOfDay(dayKey: string): string {
const ms = Date.parse(`${dayKey}T00:00:00Z`);
const dow = (new Date(ms).getUTCDay() + 6) % 7;
return shiftDateKey(new Date(ms).toISOString().slice(0, 10), -dow);
return shiftDateKey(dayKey, -((weekdayOfDateKey(dayKey) + 6) % 7));
}
/**
@@ -59,8 +59,8 @@ export function buildCoreMetricsBlocks(
);
const olderSys = sysRows.filter((row) => row.measuredAt < ctx.recentCutoff);
const olderDia = diaRows.filter((row) => row.measuredAt < ctx.recentCutoff);
const sysBand = bpBandFromRows(sysRows, "BLOOD_PRESSURE_SYS");
const diaBand = bpBandFromRows(diaRows, "BLOOD_PRESSURE_DIA");
const sysBand = bpBandFromRows(sysRows, "BLOOD_PRESSURE_SYS", ctx.userTz);
const diaBand = bpBandFromRows(diaRows, "BLOOD_PRESSURE_DIA", ctx.userTz);
const usualRange =
sysBand || diaBand
? {
+3 -1
View File
@@ -297,10 +297,12 @@ export function buildDailyBpRows(
export function bpBandFromRows(
rows: ReadonlyArray<{ measuredAt: Date; value: number }>,
type: "BLOOD_PRESSURE_SYS" | "BLOOD_PRESSURE_DIA",
tz: string,
): { low: number; high: number } | null {
const byDay = new Map<string, { sum: number; count: number }>();
for (const r of rows) {
const day = r.measuredAt.toISOString().slice(0, 10);
// The user's day, like every other day mean in the snapshot.
const day = tzDayKey(r.measuredAt, tz);
const acc = byDay.get(day) ?? { sum: 0, count: 0 };
acc.sum += r.value;
acc.count += 1;
@@ -468,6 +468,7 @@ function bucketWindow(
* safe for every code path that reaches this helper.
*/
function bucketDayKey(d: Date): string {
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: rollup bucketStart key; non-near-UTC users are routed to the live path (see the doc comment)
return d.toISOString().slice(0, 10);
}
+2 -1
View File
@@ -66,6 +66,7 @@ import { wallClockInTz } from "@/lib/tz/wall-clock";
import { defaultLocale, type Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking";
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
/**
* v1.4.37 W2 — cold-path correlation window. Trim from 30 to 28 days
@@ -413,7 +414,7 @@ async function fetchSeriesChunked(
}
function dateFromDayKey(key: string): Date {
return new Date(`${key}T00:00:00.000Z`);
return dayKeyAsUtcMidnight(key);
}
function isoWeekdayInTz(d: Date, timeZone: string): number {
+1
View File
@@ -183,6 +183,7 @@ export async function loadIntradayPulse(
dateKey: string,
): Promise<IntradayPulseResult> {
const localOf = makeLocalResolver(timezone);
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: anchor of a superset read window; rows are cut to the exact local day afterwards
const anchorMs = new Date(`${dateKey}T00:00:00.000Z`).getTime();
const start = new Date(anchorMs - WINDOW_LEAD_MS);
const end = new Date(anchorMs + WINDOW_TRAIL_MS);
+1
View File
@@ -60,6 +60,7 @@ export const MAX_PROGNOSIS_ROWS = 5000;
/** `YYYY-MM-DD`, this many days before the given instant, in UTC terms. */
export function dayKeyBefore(now: Date, days: number): string {
// eslint-disable-next-line healthlog/no-utc-day-key -- baseline: far edge of the fit window on stored day keys; a day either way does not change the fit
return new Date(now.getTime() - days * 86_400_000).toISOString().slice(0, 10);
}
+5 -5
View File
@@ -60,6 +60,7 @@ import {
type RangeDeltaResult,
type WindowAggregate,
} from "@/lib/analytics/range-shared";
import { dayKeyAsUtcMidnight, dateOnlyKey } from "@/lib/tz/date-only";
// The range constants + result shapes live in the client-safe
// `range-shared` module so the insights client bundle never pulls the
@@ -156,6 +157,7 @@ function splitPerPeriod(
export function dailyTotalsEndKey(now: number, timezone: string): string {
const localYesterday = shiftDateKey(userDayKey(new Date(now), timezone), -1);
const utcYesterday = shiftDateKey(
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the DAY rollups are UTC buckets, so the earlier of the local and the UTC yesterday wins (see the doc comment)
new Date(now).toISOString().slice(0, 10),
-1,
);
@@ -193,13 +195,11 @@ async function readDailyTotals(
windowDays: number,
endKey: string,
): Promise<Array<{ day: string; value: number }>> {
const from = new Date(
`${shiftDateKey(endKey, -(2 * windowDays - 1))}T00:00:00.000Z`,
);
const to = new Date(`${shiftDateKey(endKey, 1)}T00:00:00.000Z`);
const from = dayKeyAsUtcMidnight(shiftDateKey(endKey, -(2 * windowDays - 1)));
const to = dayKeyAsUtcMidnight(shiftDateKey(endKey, 1));
const rows = await readRollupBuckets(userId, type, "DAY", from, to);
return rows.map((r) => ({
day: r.bucketStart.toISOString().slice(0, 10),
day: dateOnlyKey(r.bucketStart),
value: r.mean * r.count,
}));
}
+1
View File
@@ -16,6 +16,7 @@ export const LIPIDS_WINDOW_DAYS = 365;
export const LIPIDS_MIN_PANELS = 1;
function panelKey(row: LipidReading): string {
// eslint-disable-next-line healthlog/no-utc-day-key -- baseline: grouping key only, never shown; the readings of one panel share one takenAt, so any consistent day partition groups them alike
return `${row.at.toISOString().slice(0, 10)}|${row.panel ?? ""}`;
}
+2 -1
View File
@@ -31,6 +31,7 @@ import {
type ReportSelection,
} from "@/lib/report-selection/selection";
import type { ShareContext } from "@/lib/clinician-share/resolve-share-token";
import { dateOnlyKey } from "@/lib/tz/date-only";
/**
* v1.28 — metadata for one document on the share's frozen set. NEVER carries
@@ -207,7 +208,7 @@ async function loadShareDocuments(
title: document.title,
kind: document.kind,
documentDate: document.documentDate
? document.documentDate.toISOString().slice(0, 10)
? dateOnlyKey(document.documentDate)
: null,
byteSize: document.byteSize,
mimeType: document.mimeType,
+4 -4
View File
@@ -27,6 +27,7 @@ import { addDays } from "@/lib/cycle/day-math";
import { BBT_WINDOW } from "@/lib/cycle/types";
import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
/** Day-log lookback: the calendar's default past span or the symptothermal
* window, whichever reaches further back. */
@@ -97,10 +98,9 @@ export async function refreshPredictionCacheForUser(
deletedAt: null,
type: "WRIST_TEMPERATURE",
measuredAt: {
gte: new Date(
Date.parse(
`${addDays(today, -TEMPERATURE_LOOKBACK_DAYS)}T00:00:00Z`,
),
gte: startOfLocalDayKey(
addDays(today, -TEMPERATURE_LOOKBACK_DAYS),
tz,
),
},
},
+2 -1
View File
@@ -30,6 +30,7 @@
import type { MeasurementType } from "@/generated/prisma/client";
import type { ServerTranslator } from "@/lib/i18n/server-translator";
import { MIN_IN_RUN, type StreakResult } from "@/lib/insights/streak-detector";
import { shiftDateKey } from "@/lib/tz/format";
/** Closed set of durable-state kinds. Grows by PR, never with a counter field. */
export const MILESTONE_KINDS = [
@@ -101,7 +102,7 @@ function dayKeyToSerial(key: string): number {
}
function serialToDayKey(serial: number): string {
return new Date(serial * 86_400_000).toISOString().slice(0, 10);
return shiftDateKey("1970-01-01", serial);
}
/** Shift a UTC-ISO day key by a whole number of days. */
+3 -1
View File
@@ -120,6 +120,7 @@ import {
toProfileSex,
type ProfileSex,
} from "@/lib/profile/sex";
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
/** Briefing freshness window — mirrors the 24 h TTL on the advisor cache. */
const BRIEFING_TTL_MS = 24 * 60 * 60 * 1000;
@@ -602,6 +603,7 @@ async function buildNutrientWaterBlock(
prisma: PrismaClient,
userId: string,
): Promise<{ summary: DataSummary; lastSeenAt: string } | null> {
// eslint-disable-next-line healthlog/no-utc-day-key -- baseline: 30-day lower bound on stored water day keys; a day either way at the far edge does not change the tile
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
@@ -617,7 +619,7 @@ async function buildNutrientWaterBlock(
}
const points: DataPoint[] = [...sumByDay.entries()]
.map(([day, amount]) => ({
date: new Date(`${day}T00:00:00.000Z`),
date: dayKeyAsUtcMidnight(day),
value: amount,
}))
.sort((a, b) => a.date.getTime() - b.date.getTime());
+4 -3
View File
@@ -73,6 +73,7 @@ import {
loadSurgicalHistory,
loadLabResults,
} from "./clinical-records";
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
const DENSE_REPORT_RAW_WINDOW_DAYS = 90;
@@ -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)
+2 -5
View File
@@ -52,6 +52,7 @@ import {
type MedicationStatementFactData,
type ObservationFactData,
} from "@/lib/validations/inbound-documents";
import { statedDateKey } from "@/lib/tz/date-only";
const SYSTEM_PROMPT = `You transcribe a photograph, PDF, or scan of a CLINICAL DOCUMENT (a doctor's report or a hospital discharge letter) into structured facts.
@@ -103,11 +104,7 @@ NOTE: the document below was produced by automatic OCR, so it may contain garble
/** A normalised ISO date string (YYYY-MM-DD) or null. */
function normaliseDate(raw: string | null): string | null {
if (!raw) return null;
const trimmed = raw.trim();
if (!trimmed) return null;
const parsed = new Date(trimmed);
if (Number.isNaN(parsed.getTime())) return null;
return parsed.toISOString().slice(0, 10);
return statedDateKey(raw);
}
export class InboundExtractError extends Error {
+2 -2
View File
@@ -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 {
+3 -6
View File
@@ -35,6 +35,7 @@ import type {
InboundDocumentKindValue,
InboundFactType,
} from "@/lib/validations/inbound-documents";
import { dateOnlyKey } from "@/lib/tz/date-only";
/**
* The two storage codecs `InboundDocument.contentEncrypted` may carry,
@@ -226,12 +227,8 @@ export function serialiseDocument(
byteSize: doc.byteSize,
status: doc.status as InboundDocumentStatus,
providerType: doc.providerType,
reportDate: doc.reportDate
? doc.reportDate.toISOString().slice(0, 10)
: null,
documentDate: doc.documentDate
? doc.documentDate.toISOString().slice(0, 10)
: null,
reportDate: doc.reportDate ? dateOnlyKey(doc.reportDate) : null,
documentDate: doc.documentDate ? dateOnlyKey(doc.documentDate) : null,
errorReason: doc.errorReason,
factCount: counts.factCount,
pendingCount: counts.pendingCount,
+4 -8
View File
@@ -28,8 +28,8 @@ import {
type DailyEnvironmentObservation,
} from "@/lib/environment/open-meteo";
import type { EnvironmentLocationSource } from "@/generated/prisma/client";
import { shiftDateKey } from "@/lib/tz/format";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/** Default lookback window (days) for the nightly fetch — absorbs the archive
* settling lag and any days missed across worker reboots. */
export const ENVIRONMENT_LOOKBACK_DAYS = 7;
@@ -63,19 +63,15 @@ interface TravelOverride {
/** UTC YYYY-MM-DD for `date` (used to bound the default lookback window). */
export function utcDayKey(date: Date): string {
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the environment module keys homeSince and the backfill window on UTC days throughout, and the worker compares against the same keys
return date.toISOString().slice(0, 10);
}
/** Inclusive list of YYYY-MM-DD keys from `start` to `end` (date arithmetic). */
export function enumerateDays(start: string, end: string): string[] {
const out: string[] = [];
const [sy, sm, sd] = start.split("-").map(Number);
const [ey, em, ed] = end.split("-").map(Number);
let cur = Date.UTC(sy, sm - 1, sd);
const last = Date.UTC(ey, em - 1, ed);
while (cur <= last) {
out.push(new Date(cur).toISOString().slice(0, 10));
cur += MS_PER_DAY;
for (let cur = start; cur <= end; cur = shiftDateKey(cur, 1)) {
out.push(cur);
}
return out;
}
+3 -4
View File
@@ -32,6 +32,7 @@ import {
type AllergyDTO,
type FamilyHistoryEntryDTO,
} from "@/lib/records/dto";
import { dateOnlyKey } from "@/lib/tz/date-only";
/** Disclosed in the payload manifest AND mirrored in the export UI copy. */
export const DOCUMENTS_MANIFEST_NOTE =
@@ -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,
+1
View File
@@ -386,6 +386,7 @@ export const FITBIT_SLEEP_RANGE_DAYS = 30;
/** `YYYY-MM-DD` (UTC) for a Fitbit date-path segment. */
export function fitbitDate(d: Date): string {
// eslint-disable-next-line healthlog/no-utc-day-key -- baseline: request window edge for the provider API, not a displayed day; rows carry their own dates
return d.toISOString().slice(0, 10);
}
+1
View File
@@ -646,6 +646,7 @@ export function incrementalFilter(
case "date":
return {
field: `${dateStyle === "camel" ? dataType.key : dataType.filter}.date`,
// eslint-disable-next-line healthlog/no-utc-day-key -- baseline: request window edge for the provider API, not a displayed day; rows carry their own dates
bound: start.toISOString().slice(0, 10),
};
case "rollup":
+2 -3
View File
@@ -44,6 +44,7 @@ import {
nowProvenanceTimestamp,
} from "@/lib/insights/derived/coverage";
import type { Derived } from "@/lib/insights/derived/types";
import { daysBetweenDateKeys } from "@/lib/tz/format";
/* ── tunables (documented invariants) ────────────────────────────────── */
@@ -1050,7 +1051,5 @@ function runFlag(
/** Whole-day signed difference `to − from` for two `YYYY-MM-DD` keys. */
export function dayDiff(from: string, to: string): number {
const a = Date.parse(`${from}T00:00:00Z`);
const b = Date.parse(`${to}T00:00:00Z`);
return Math.round((b - a) / (24 * 60 * 60 * 1000));
return daysBetweenDateKeys(from, to);
}
@@ -61,6 +61,7 @@ export function classifyBriefingFailure(args: {
/** UTC YYYY-MM-DD calendar-day key for the marker payload. */
function dateKey(at: Date = new Date()): string {
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: internal once-per-day marker key, written and read by this module only, never shown
return at.toISOString().slice(0, 10);
}
+2 -1
View File
@@ -123,6 +123,7 @@ import {
isFullyCovered,
probeRollupCoverage,
} from "@/lib/rollups/measurement-coverage";
import { dateOnlyKey } from "@/lib/tz/date-only";
/**
* Heavy aggregate row — count/min/max/mean alongside the non-composable
@@ -939,7 +940,7 @@ function buildDailyByType(
// `bucketStart` is the UTC midnight of the bucketed day — the
// same boundary Postgres' `date_trunc('day', ...)` would pick
// when the session timezone is UTC (the container default).
day: b.day.toISOString().slice(0, 10),
day: dateOnlyKey(b.day),
// Match the legacy `ROUND(AVG, 2)::double precision` semantics
// so correlation thresholds tuned against the v1.4.34 output
// don't drift on the third decimal.
@@ -155,6 +155,7 @@ const BRIEFING_REROLL_TEMPERATURE = 0.6;
/** UTC YYYY-MM-DD calendar-day key gating the once-per-day briefing re-roll. */
function buildRerollDateKey(at: Date = new Date()): string {
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: internal once-per-day re-roll gate key, never shown
return at.toISOString().slice(0, 10);
}
@@ -60,6 +60,7 @@ import type {
MeasurementType,
} from "@/generated/prisma/client";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
import { dateOnlyKey } from "@/lib/tz/date-only";
/**
* v1.21.0 (FDREXTEND) — build the user's MEDICATION_COMPLIANCE daily series.
@@ -223,6 +224,7 @@ export async function fetchEnvironmentSeries(
userId: string,
since: Date,
): Promise<NamedSeries[]> {
// eslint-disable-next-line healthlog/no-utc-day-key -- baseline: lower bound on stored environment day keys; a day either way at the far edge does not change the series
const sinceKey = since.toISOString().slice(0, 10);
const rows = await prisma.environmentContext.findMany({
where: { userId, date: { gte: sinceKey } },
@@ -758,7 +760,7 @@ function composeRollupDailyMeans(
// DAY buckets are minted at UTC midnight; the UTC date IS the bucket's
// day key (the same convention graded-series and the derived baselines
// read the tier under).
const day = r.bucketStart.toISOString().slice(0, 10);
const day = dateOnlyKey(r.bucketStart);
const acc = byDay.get(day) ?? { sum: 0, count: 0 };
acc.sum += sum;
acc.count += r.count;
+2 -4
View File
@@ -41,6 +41,7 @@
import { pearson, MIN_PAIRED_N } from "@/lib/insights/correlations";
import { type Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { shiftDateKey } from "@/lib/tz/format";
/**
* Locale-aware string builder — the same signature the server translator
@@ -238,10 +239,7 @@ export interface CorrelationDiscoveryResult {
/** Add `lagDays` to a YYYY-MM-DD day key, returning the shifted key. */
function shiftDay(day: string, lagDays: number): string {
const [y, m, d] = day.split("-").map(Number);
const dt = new Date(Date.UTC(y, m - 1, d));
dt.setUTCDate(dt.getUTCDate() + lagDays);
return dt.toISOString().slice(0, 10);
return shiftDateKey(day, lagDays);
}
/**
@@ -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 },
});
+2 -1
View File
@@ -54,6 +54,7 @@ import {
type Derived,
type DerivedProvenanceSource,
} from "./types";
import { dateOnlyKey } from "@/lib/tz/date-only";
/** k for the median ± k·MAD band — ≈3σ-equivalent for normal data. */
const DEFAULT_MAD_K = 3;
@@ -255,7 +256,7 @@ export async function readDayMeanSeries(
const points = resolved.rows
.filter((row) => isPlausibleMetricValue(type, row.mean))
.map((row) => ({
day: row.bucketStart.toISOString().slice(0, 10),
day: dateOnlyKey(row.bucketStart),
mean: row.mean,
}));
if (points.length > 0) return { points, source: "DAY" };
+4 -9
View File
@@ -52,6 +52,7 @@ import {
} from "./coverage";
import { readDayMeanSeries, type BaselineProfile } from "./baseline";
import type { Derived, DerivedProvenanceSource } from "./types";
import { daysBetweenDateKeys, shiftDateKey } from "@/lib/tz/format";
/** Trailing window the fit reads (days). Matches the baseline engine. */
const DEFAULT_WINDOW_DAYS = 30;
@@ -266,11 +267,7 @@ export async function computeTrajectory(
}
// x = days since the FIRST in-window day; y = the per-day mean.
const dayMs = 24 * 60 * 60 * 1000;
const startMs = new Date(`${points[0].day}T00:00:00Z`).getTime();
const xs = points.map(
(p) => (new Date(`${p.day}T00:00:00Z`).getTime() - startMs) / dayMs,
);
const xs = points.map((p) => daysBetweenDateKeys(points[0].day, p.day));
const ys = points.map((p) => p.mean);
const fit = fitOls(xs, ys);
@@ -286,9 +283,7 @@ export async function computeTrajectory(
// Project from the last observed day-offset forward over the horizon,
// each point carrying the widening prediction band.
const lastX = xs[xs.length - 1];
const lastDayMs = new Date(
`${points[points.length - 1].day}T00:00:00Z`,
).getTime();
const lastDay = points[points.length - 1].day;
const projection: TrajectoryPoint[] = [];
for (let h = 1; h <= horizonDays; h++) {
const x = lastX + h;
@@ -296,7 +291,7 @@ export async function computeTrajectory(
const half = predictionIntervalHalfWidth(fit, x);
projection.push({
dayOffset: h,
date: new Date(lastDayMs + h * dayMs).toISOString().slice(0, 10),
date: shiftDateKey(lastDay, h),
projected,
bandLow: projected - half,
bandHigh: projected + half,
+2 -1
View File
@@ -23,6 +23,7 @@ import type { StrainAnchor } from "@/lib/insights/strain-score";
import { computeReadiness, type ReadinessComponent } from "./readiness";
import { resolveCanonicalRecovery } from "./recovery-resolve";
import { SPARKLINE_MAX_POINTS, type Derived } from "./types";
import { dateOnlyKey } from "@/lib/tz/date-only";
/** A 0–100 wellness score band. Higher is better for recovery; for stress a
* higher score is worse, so the band direction flips (see `WELLNESS_DIR`). */
@@ -207,7 +208,7 @@ export async function computeWellnessScore(
// truth is the cache row, not a re-derivation here.
let anchor: StrainAnchor | null = null;
if (type === "STRAIN_SCORE") {
const day = latest.measuredAt.toISOString().slice(0, 10);
const day = dateOnlyKey(latest.measuredAt);
const cache = await prisma.strainTrimpCache.findUnique({
where: { userId_day: { userId, day } },
select: { anchor: true },
+1
View File
@@ -104,6 +104,7 @@ export async function detectGlp1Plateau(
drug,
doseValue: latestDose.doseValue,
doseUnit: latestDose.doseUnit,
// eslint-disable-next-line healthlog/no-utc-day-key -- baseline: dose-start label in a prompt; this builder has no zone yet
doseSince: latestDose.effectiveFrom.toISOString().slice(0, 10),
daysOnDose,
weightDeltaKg: Math.round(delta * 10) / 10,
+1
View File
@@ -84,6 +84,7 @@ const ABSENT: LabChangesSummary = {
/** UTC calendar-day key (YYYY-MM-DD) for a sample instant. */
function dayKey(d: Date): string {
// eslint-disable-next-line healthlog/no-utc-day-key -- baseline: groups draws by UTC day; this summariser has no zone yet (labReadingDay is the target)
return d.toISOString().slice(0, 10);
}
+3 -4
View File
@@ -1,3 +1,4 @@
import { daysBetweenDateKeys } from "@/lib/tz/format";
/**
* How old a reading may be before "today" stops being true about it.
*
@@ -50,8 +51,6 @@ export function isCurrentForTodayClaim(
* answers `null` rather than a fabricated distance.
*/
export function dayKeyAgeInDays(then: string, today: string): number | null {
const a = Date.parse(`${then}T00:00:00.000Z`);
const b = Date.parse(`${today}T00:00:00.000Z`);
if (!Number.isFinite(a) || !Number.isFinite(b)) return null;
return Math.round((b - a) / 86_400_000);
const days = daysBetweenDateKeys(then, today);
return Number.isFinite(days) ? days : null;
}
+2 -5
View File
@@ -17,7 +17,7 @@ import { welchTTest } from "@/lib/insights/correlations";
import { round } from "@/lib/insights/status-shared";
import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum";
import { MS_PER_DAY } from "@/lib/time-constants";
import { userDayKey } from "@/lib/tz/format";
import { userDayKey, shiftDateKey } from "@/lib/tz/format";
import type {
CrossMetricMeasurement,
MoodAggregateEntry,
@@ -254,10 +254,7 @@ export function metricDayMap(
/** Add `lagDays` to a YYYY-MM-DD day key (UTC-anchored, DST-immune). */
export function shiftDayKey(day: string, lagDays: number): string {
const [y, m, d] = day.split("-").map(Number);
const dt = new Date(Date.UTC(y, m - 1, d));
dt.setUTCDate(dt.getUTCDate() + lagDays);
return dt.toISOString().slice(0, 10);
return shiftDateKey(day, lagDays);
}
/** Convert a raw metric value to the row's display unit. */
+1
View File
@@ -34,6 +34,7 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000;
* `YYYY-MM-DD`. Resolved in ONE place so all three engines agree.
*/
export function scoreDayKey(now: Date): string {
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the derived-score engines score UTC days, resolved here in one place (see the doc comment)
return new Date(now.getTime() - MS_PER_DAY).toISOString().slice(0, 10);
}
+3 -6
View File
@@ -71,6 +71,7 @@ import {
scoreMeasuredAt,
upsertScoreRow,
} from "@/lib/insights/score-row";
import { shiftDateKey } from "@/lib/tz/format";
/** The per-day idempotency-key prefix for a stored Strain score row. */
export const STRAIN_SCORE_EXTERNAL_ID_PREFIX = "strain:";
@@ -395,6 +396,7 @@ export async function computeStrainScore(
},
): Promise<StrainComputeResult> {
const dayKey = strainDayKey(now);
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: UTC day window of the UTC-day score engines (scoreDayKey)
const dayStart = new Date(`${dayKey}T00:00:00.000Z`);
const dayEnd = new Date(dayStart.getTime() + MS_PER_DAY);
@@ -548,12 +550,7 @@ export async function loadStrainAnchorHistory(
now: Date,
): Promise<StrainAnchorHistory> {
const dayKey = strainDayKey(now);
const windowStart = new Date(
new Date(`${dayKey}T00:00:00.000Z`).getTime() -
STRAIN_CHRONIC_WINDOW_DAYS * MS_PER_DAY,
)
.toISOString()
.slice(0, 10);
const windowStart = shiftDateKey(dayKey, -STRAIN_CHRONIC_WINDOW_DAYS);
// The chronic window of cached rows, excluding the scored day itself (its
// TRIMP is added at compute time so the distribution always reflects the
+3 -4
View File
@@ -30,6 +30,7 @@
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { buildBaselineBand } from "@/lib/insights/derived/baseline";
import { daysBetweenDateKeys } from "@/lib/tz/format";
/** One DAY-bucket point: a calendar day key (YYYY-MM-DD) + that day's mean. */
export interface StreakPoint {
@@ -220,8 +221,6 @@ function buildBandFromSeries(
/** Whole-day gap between two YYYY-MM-DD keys (>=1; 1 = adjacent). */
function dayGap(earlier: string, later: string): number {
const a = Date.parse(`${earlier}T00:00:00Z`);
const b = Date.parse(`${later}T00:00:00Z`);
if (Number.isNaN(a) || Number.isNaN(b)) return Number.POSITIVE_INFINITY;
return Math.round((b - a) / (24 * 60 * 60 * 1000));
const gap = daysBetweenDateKeys(earlier, later);
return Number.isNaN(gap) ? Number.POSITIVE_INFINITY : gap;
}
+1
View File
@@ -133,6 +133,7 @@ async function readIntradaySdnn(
now: Date,
): Promise<number[]> {
const dayKey = stressDayKey(now);
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: UTC day window of the UTC-day score engines (scoreDayKey)
const dayStart = new Date(`${dayKey}T00:00:00.000Z`);
const dayEnd = new Date(dayStart.getTime() + MS_PER_DAY);
const rows = await prisma.measurement.findMany({
+1
View File
@@ -293,6 +293,7 @@ export async function runCoachPlanReviewTick(
timeZone: "UTC",
db: prisma,
});
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the day series above is read with timeZone UTC, so the plan start is cut on the same calendar
const startKey = plan.createdAt.toISOString().slice(0, 10);
const before: number[] = [];
const after: number[] = [];
+1
View File
@@ -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({
+2 -1
View File
@@ -211,7 +211,8 @@ export async function processOffhostPurges(
const subject = request.subjectId;
const day =
request.reason === "data_wiped" && live.has(subject)
? request.requestedAt.toISOString().slice(0, 10)
? // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: matches the UTC dates in the off-host object keys
request.requestedAt.toISOString().slice(0, 10)
: null;
const prior = lastDayToDelete.get(subject);
if (prior === undefined) lastDayToDelete.set(subject, day);
@@ -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 } },
+1
View File
@@ -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),
);
@@ -119,6 +119,7 @@ export async function runStepConsolidationRepairForUser(
// in different UTC days, so both instants are tracked).
const touchedByUtcDay = new Map<string, Date>();
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);
};
+2 -5
View File
@@ -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);
}
/**
+3 -3
View File
@@ -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)),
};
}
+2 -1
View File
@@ -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),
};
}
+3 -2
View File
@@ -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,
}
: {}),
+1
View File
@@ -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;
}
@@ -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({
@@ -250,6 +250,7 @@ export async function pruneIntradayCumulativeProfiles(
retentionDays: number = CUMULATIVE_PROFILE_RETENTION_DAYS,
): Promise<number> {
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,
);
@@ -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<string>();
const recomputeJobs: Array<Promise<void>> = [];
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(
+1
View File
@@ -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);
+1
View File
@@ -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);
}
+1
View File
@@ -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);
}
+1
View File
@@ -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,
}));
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff. Show more