Merge pull request #1073 from MBombeck/fix/v1397-tz

Compute every user-facing day in the user's zone, and refuse new UTC day keys
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-10-01 11:28:57 +02:00
commit 4e4bc6f8be
128 files changed
+1676 -256

No files matched your search

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