mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
feat(lint): refuse UTC day keys outside the time zone module
`instant.toISOString().slice(0, 10)` and parsing a key back as UTC midnight are the idioms behind every day-boundary fix this year: the evening west of UTC and the small hours east of it land on the neighbouring day. A new rule, healthlog/no-utc-day-key, refuses them in src/ outside src/lib/tz and tests, and refuses a disable directive for itself that is not next-line or gives no reason. The time zone module now names every legitimate form: userDayKey for a person's day, dateOnlyKey / dateOnlyAtNoonUtc / dayKeyAsUtcMidnight for date-only values and day labels, daysBetweenDateKeys / weekdayOfDateKey / isCalendarDateKey / statedDateKey for key arithmetic and parsing. Of the 133 sites the rule found, 84 are gone. Most moved onto a named helper with no change in behaviour; these changed behaviour: - cycle calendar, insights, Coach snapshot and prediction refresh start their lookbacks at the first instant of the user's day - a model-transcribed date like "June 10, 2026" no longer lands a day early in a process east of UTC (documents and lab scans) - a custom report range covers exactly the picked days in the profile zone instead of starting the evening before west of UTC - date-of-birth pickers cap at the user's today, not the UTC one - an allergy onset date is stored at noon UTC like other date-only values - the report's cycle summary, the Coach's usual BP range and the intake projector's rollup recompute use the user's day - vaccination dates render as dates instead of the previous evening 49 remain, each with its reason on the line above: 39 UTC by design (15 file name stamps, rollup and object keys, the UTC-day score engines) and 10 marked baseline. A guard test requires a classified reason on every directive and holds the baseline to a ceiling that only goes down.
This commit is contained in:
1 parent
5820078008
commit
7c5ff89def
105 files changed
+807
-200
No files matched your search
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Unit tests for the `healthlog/no-utc-day-key` rule: the UTC-day idioms are
|
||||
* refused in application source, allowed in `src/lib/tz/` and in tests, and a
|
||||
* disable directive for the rule must be next-line-only and carry a reason.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Linter, RuleTester } from "eslint";
|
||||
import rule from "../no-utc-day-key.js";
|
||||
|
||||
RuleTester.describe = describe;
|
||||
RuleTester.it = it;
|
||||
RuleTester.itOnly = it.only;
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
ecmaVersion: 2023,
|
||||
sourceType: "module",
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const APP = "/repo/src/lib/insights/some-reader.ts";
|
||||
|
||||
ruleTester.run("no-utc-day-key", rule, {
|
||||
valid: [
|
||||
{ code: "const day = userDayKey(at, tz);", filename: APP },
|
||||
{ code: "const day = dateOnlyKey(row.documentDate);", filename: APP },
|
||||
// A month key is not a day key.
|
||||
{ code: "const month = at.toISOString().slice(0, 7);", filename: APP },
|
||||
// A full instant is fine.
|
||||
{ code: "const iso = at.toISOString();", filename: APP },
|
||||
// Noon UTC is the date-only storage anchor.
|
||||
{ code: "const d = new Date(`${key}T12:00:00.000Z`);", filename: APP },
|
||||
// A fixed instant literal is not day parsing.
|
||||
{ code: 'const d = new Date("2026-06-10T08:00:00.000Z");', filename: APP },
|
||||
// The time zone module owns the idiom.
|
||||
{
|
||||
code: "export const k = (d) => d.toISOString().slice(0, 10);",
|
||||
filename: "/repo/src/lib/tz/date-only.ts",
|
||||
},
|
||||
// Fixtures pick concrete days on purpose.
|
||||
{
|
||||
code: 'const d = new Date("2026-06-10"); const k = d.toISOString().slice(0, 10);',
|
||||
filename: "/repo/src/lib/insights/__tests__/reader.test.ts",
|
||||
},
|
||||
// Outside src/ entirely.
|
||||
{
|
||||
code: "const k = d.toISOString().slice(0, 10);",
|
||||
filename: "/repo/scripts/backfill.ts",
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: "const day = at.toISOString().slice(0, 10);",
|
||||
filename: APP,
|
||||
errors: [{ messageId: "utcSlice" }],
|
||||
},
|
||||
{
|
||||
code: "const day = at.toISOString().substring(0, 10);",
|
||||
filename: APP,
|
||||
errors: [{ messageId: "utcSlice" }],
|
||||
},
|
||||
{
|
||||
code: 'const day = at.toISOString().split("T")[0];',
|
||||
filename: APP,
|
||||
errors: [{ messageId: "utcSlice" }],
|
||||
},
|
||||
{
|
||||
code: "const day = new Date(now - 86400000)\n .toISOString()\n .slice(0, 10);",
|
||||
filename: APP,
|
||||
errors: [{ messageId: "utcSlice" }],
|
||||
},
|
||||
{
|
||||
code: 'const d = new Date("2026-06-10");',
|
||||
filename: APP,
|
||||
errors: [{ messageId: "utcMidnightParse" }],
|
||||
},
|
||||
{
|
||||
code: "const d = new Date(`${key}T00:00:00.000Z`);",
|
||||
filename: APP,
|
||||
errors: [{ messageId: "utcMidnightParse" }],
|
||||
},
|
||||
{
|
||||
code: "const ms = Date.parse(`${key}T00:00:00Z`);",
|
||||
filename: APP,
|
||||
errors: [{ messageId: "utcMidnightParse" }],
|
||||
},
|
||||
{
|
||||
code: "<input max={new Date().toISOString().slice(0, 10)} />;",
|
||||
filename: "/repo/src/components/settings/form.tsx",
|
||||
errors: [{ messageId: "utcSlice" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* Disable directives name the rule under the plugin's own prefix, which the
|
||||
* RuleTester does not use, so they run through a Linter with the plugin
|
||||
* registered as `healthlog` — the way the flat config loads it.
|
||||
*/
|
||||
describe("no-utc-day-key disable directives", () => {
|
||||
const linter = new Linter({ configType: "flat" });
|
||||
const config = [
|
||||
{
|
||||
files: ["**/*.ts"],
|
||||
plugins: { healthlog: { rules: { "no-utc-day-key": rule } } },
|
||||
rules: { "healthlog/no-utc-day-key": "error" },
|
||||
},
|
||||
];
|
||||
const lint = (code) =>
|
||||
linter
|
||||
.verify(code, config, { filename: "src/lib/insights/reader.ts" })
|
||||
.map((m) => m.messageId ?? m.message);
|
||||
|
||||
it("accepts a next-line directive that gives a reason", () => {
|
||||
expect(
|
||||
lint(
|
||||
"// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp\nconst s = new Date().toISOString().slice(0, 10);",
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a directive without a reason", () => {
|
||||
expect(
|
||||
lint(
|
||||
"// eslint-disable-next-line healthlog/no-utc-day-key\nconst s = d.toISOString().slice(0, 10);",
|
||||
),
|
||||
).toEqual(["missingReason"]);
|
||||
});
|
||||
|
||||
it("refuses a reason too short to say anything", () => {
|
||||
expect(
|
||||
lint(
|
||||
"// eslint-disable-next-line healthlog/no-utc-day-key -- ok\nconst s = d.toISOString().slice(0, 10);",
|
||||
),
|
||||
).toEqual(["missingReason"]);
|
||||
});
|
||||
|
||||
it("refuses a same-line or whole-file disable, which would hide the next site too", () => {
|
||||
// The site itself is suppressed by the directive, so the only report is
|
||||
// the directive's own, placed at the top of the file where the directive
|
||||
// does not reach.
|
||||
expect(
|
||||
lint(
|
||||
"const a = 1;\nconst s = d.toISOString().slice(0, 10); // eslint-disable-line healthlog/no-utc-day-key -- UTC by design: file name stamp",
|
||||
),
|
||||
).toEqual(["nextLineOnly"]);
|
||||
expect(
|
||||
lint(
|
||||
"const a = 1;\n/* eslint-disable healthlog/no-utc-day-key -- UTC by design: file name stamp */\nconst s = d.toISOString().slice(0, 10);",
|
||||
),
|
||||
).toEqual(["nextLineOnly"]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -108,6 +108,13 @@ const eslintConfig = defineConfig([
|
||||
// literal anywhere else is where a user outside that zone got someone
|
||||
// else's calendar. src/lib/tz/ and test files are exempt; see the rule.
|
||||
"healthlog/no-default-zone-literal": "error",
|
||||
// A day is cut through src/lib/tz (userDayKey, dateOnlyKey,
|
||||
// dateOnlyAtNoonUtc, dayKeyAsUtcMidnight), never by slicing an ISO string or by
|
||||
// parsing a key as UTC midnight: each copy put an entry on the
|
||||
// neighbouring day for someone off UTC. A site where UTC is meant
|
||||
// carries `eslint-disable-next-line ... -- <reason>`; the rule refuses
|
||||
// a directive without one. See the rule header.
|
||||
"healthlog/no-utc-day-key": "error",
|
||||
},
|
||||
},
|
||||
// v1.28.17 — every recharts-rendering component funnels through the
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* The allowlist of `healthlog/no-utc-day-key`.
|
||||
*
|
||||
* The lint rule refuses the UTC-day idioms in application source; a site
|
||||
* where the UTC day is right keeps it with an
|
||||
* `eslint-disable-next-line healthlog/no-utc-day-key -- <reason>` directive.
|
||||
* This guard reads every such directive and holds three lines the rule alone
|
||||
* cannot:
|
||||
*
|
||||
* - every directive is the next-line form (a same-line or block disable on
|
||||
* the first line of a file hides the rule's own refusal);
|
||||
* - every reason says which kind of exception it is: `UTC by design:` (the
|
||||
* UTC day is the correct answer) or `baseline:` (a known site that should
|
||||
* move to the user's day and has not yet);
|
||||
* - the `baseline:` set can only shrink. A new UTC-day site is fixed or
|
||||
* argued as UTC by design, never parked.
|
||||
*
|
||||
* Mutation, done once by hand: a bare directive on a new line in
|
||||
* `src/lib/insights/score-row.ts` fails the reason check; an eleventh
|
||||
* `baseline:` directive fails the ceiling.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(__dirname, "..");
|
||||
const RULE = "healthlog/no-utc-day-key";
|
||||
/** Known sites still on the UTC day that should not be. Lower it, never raise it. */
|
||||
const BASELINE_CEILING = 10;
|
||||
|
||||
interface Directive {
|
||||
file: string;
|
||||
line: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function directives(): Directive[] {
|
||||
const out: Directive[] = [];
|
||||
for (const file of walkSourceFiles(SRC, { floor: 2000 })) {
|
||||
// The rule does not run on tests, so no directive there means anything
|
||||
// (this file names the directive in its own documentation).
|
||||
if (/(^|\/)__tests__\/|\.test\.tsx?$/.test(file)) continue;
|
||||
const lines = readFileSync(join(SRC, file), "utf8").split("\n");
|
||||
lines.forEach((text, i) => {
|
||||
if (/eslint-disable/.test(text) && text.includes(RULE)) {
|
||||
out.push({ file, line: i + 1, text: text.trim() });
|
||||
}
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("no-utc-day-key allowlist", () => {
|
||||
const found = directives();
|
||||
|
||||
it("finds the allowlist it guards", () => {
|
||||
// A walk that finds nothing would agree with every assertion below.
|
||||
expect(found.length).toBeGreaterThanOrEqual(40);
|
||||
});
|
||||
|
||||
it("uses only next-line directives", () => {
|
||||
const offenders = found.filter(
|
||||
(d) => !d.text.includes(`eslint-disable-next-line ${RULE}`),
|
||||
);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("gives every directive a classified reason", () => {
|
||||
const offenders = found.filter(
|
||||
(d) => !/ -- (UTC by design|baseline): \S.{8,}/.test(d.text),
|
||||
);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("never grows the baseline", () => {
|
||||
const baseline = found.filter((d) => d.text.includes(" -- baseline: "));
|
||||
expect(baseline.length).toBeLessThanOrEqual(BASELINE_CEILING);
|
||||
});
|
||||
});
|
||||
@@ -132,6 +132,7 @@ export const GET = apiHandler(
|
||||
return apiError("Backup payload failed schema validation", 500);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
|
||||
const isoDate = backup.createdAt.toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
const filename = `healthlog-backup-${backup.userId}-${isoDate}.json`;
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
import { addDays } from "@/lib/cycle/day-math";
|
||||
import { moodDateKey } from "@/lib/mood/date-key";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { startOfLocalDayKey } from "@/lib/tz/local-day";
|
||||
|
||||
/** Trailing window the phase-contrast walks (days). */
|
||||
const WINDOW_DAYS = 365;
|
||||
@@ -124,7 +125,7 @@ export const GET = apiHandler(async () => {
|
||||
deletedAt: null,
|
||||
type: "WRIST_TEMPERATURE",
|
||||
measuredAt: {
|
||||
gte: new Date(Date.parse(`${addDays(today, -90)}T00:00:00Z`)),
|
||||
gte: startOfLocalDayKey(addDays(today, -90), tz),
|
||||
},
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
@@ -140,7 +141,7 @@ export const GET = apiHandler(async () => {
|
||||
deletedAt: null,
|
||||
type: { in: PHASE_CROSSTAB_METRIC_TYPES },
|
||||
measuredAt: {
|
||||
gte: new Date(Date.parse(`${from}T00:00:00Z`)),
|
||||
gte: startOfLocalDayKey(from, tz),
|
||||
},
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
@@ -166,7 +167,7 @@ export const GET = apiHandler(async () => {
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
moodLoggedAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) },
|
||||
moodLoggedAt: { gte: startOfLocalDayKey(from, tz) },
|
||||
},
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
select: { score: true, moodLoggedAt: true },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -17,6 +17,7 @@ import { toManagedProfileView } from "@/lib/managed-profiles/lifecycle";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { createManagedProfileSchema } from "@/lib/validations/managed-profiles";
|
||||
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
|
||||
|
||||
/**
|
||||
* The same ceiling its guardian sibling carries, and for the same reason.
|
||||
@@ -59,7 +60,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
creatorId: user.id,
|
||||
displayName: parsed.data.displayName,
|
||||
dateOfBirth: parsed.data.dateOfBirth
|
||||
? new Date(`${parsed.data.dateOfBirth}T00:00:00.000Z`)
|
||||
? dayKeyAsUtcMidnight(parsed.data.dateOfBirth)
|
||||
: null,
|
||||
locale: parsed.data.locale,
|
||||
timezone: parsed.data.timezone,
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
resolveSlotForWriteByBand,
|
||||
} from "@/lib/medications/scheduling/slot-upsert";
|
||||
import { NextRequest } from "next/server";
|
||||
import { dateOnlyKey } from "@/lib/tz/date-only";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string; eventId: string }> };
|
||||
|
||||
@@ -100,7 +101,7 @@ export const PUT = apiHandler(
|
||||
});
|
||||
if (medication?.startsOn) {
|
||||
const takenDay = dayKeyForUserTz(data.takenAt, user.timezone);
|
||||
const startsOnDay = medication.startsOn.toISOString().slice(0, 10);
|
||||
const startsOnDay = dateOnlyKey(medication.startsOn);
|
||||
if (takenDay < startsOnDay) {
|
||||
annotate({
|
||||
action: {
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
parseNotificationPrefs,
|
||||
resolveNotificationPrefs,
|
||||
} from "@/lib/validations/notification-prefs";
|
||||
import { dayKeyAsUtcMidnight, dateOnlyKey } from "@/lib/tz/date-only";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -93,7 +94,9 @@ export const GET = apiHandler(
|
||||
settings = {
|
||||
displayName: record.displayName,
|
||||
heightCm: record.heightCm,
|
||||
dateOfBirth: record.dateOfBirth?.toISOString().slice(0, 10) ?? null,
|
||||
dateOfBirth: record.dateOfBirth
|
||||
? dateOnlyKey(record.dateOfBirth)
|
||||
: null,
|
||||
gender: record.gender,
|
||||
locale: record.locale,
|
||||
timezone: record.timezone,
|
||||
@@ -195,7 +198,7 @@ export const PATCH = apiHandler(
|
||||
...(patch.dateOfBirth !== undefined
|
||||
? {
|
||||
dateOfBirth: patch.dateOfBirth
|
||||
? new Date(`${patch.dateOfBirth}T00:00:00.000Z`)
|
||||
? dayKeyAsUtcMidnight(patch.dateOfBirth)
|
||||
: null,
|
||||
}
|
||||
: {}),
|
||||
@@ -235,7 +238,9 @@ export const PATCH = apiHandler(
|
||||
}
|
||||
settings = {
|
||||
...record,
|
||||
dateOfBirth: record.dateOfBirth?.toISOString().slice(0, 10) ?? null,
|
||||
dateOfBirth: record.dateOfBirth
|
||||
? dateOnlyKey(record.dateOfBirth)
|
||||
: null,
|
||||
};
|
||||
changed = Object.keys(patch);
|
||||
break;
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import type { AllergyDTO } from "@/lib/records/dto";
|
||||
import { dateOnlyAtNoonUtc, isCalendarDateKey } from "@/lib/tz/date-only";
|
||||
|
||||
const TEXT_MAX_LENGTH = 2000;
|
||||
/** Select sentinel for the optional "not assessed" severity. */
|
||||
@@ -41,11 +42,14 @@ interface AllergyFormProps {
|
||||
footerSlot?: HTMLElement | null;
|
||||
}
|
||||
|
||||
/** ISO date input (YYYY-MM-DD) → start-of-day UTC instant, or undefined. */
|
||||
/**
|
||||
* ISO date input (YYYY-MM-DD) → the instant a date-only value is stored at
|
||||
* (noon UTC), or undefined. UTC midnight read back as the previous day west
|
||||
* of UTC.
|
||||
*/
|
||||
function toInstant(value: string): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const d = new Date(`${value}T00:00:00.000Z`);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
|
||||
if (!isCalendarDateKey(value)) return undefined;
|
||||
return dateOnlyAtNoonUtc(value).toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,7 +33,11 @@ import { TimeFormatSelect } from "@/components/settings/time-format-select";
|
||||
import { DateFormatSelect } from "@/components/settings/date-format-select";
|
||||
import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select";
|
||||
import { GlucoseUnitSelect } from "@/components/settings/glucose-unit-select";
|
||||
import { detectBrowserTimezone, DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import {
|
||||
detectBrowserTimezone,
|
||||
DEFAULT_TIMEZONE,
|
||||
userDayKey,
|
||||
} from "@/lib/tz/format";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
recentProofErrorMessage,
|
||||
@@ -53,6 +57,7 @@ import {
|
||||
} from "./account-section-utils";
|
||||
import { AvatarSection } from "./avatar-section";
|
||||
import { SetupCard } from "./setup-card";
|
||||
import { dateOnlyKey } from "@/lib/tz/date-only";
|
||||
|
||||
export { resolveInitialTimezone } from "./account-section-utils";
|
||||
|
||||
@@ -145,7 +150,7 @@ export function AccountSection() {
|
||||
email: user.email ?? "",
|
||||
height: heightAdapter.toDraft(user.heightCm),
|
||||
dateOfBirth: user.dateOfBirth
|
||||
? new Date(user.dateOfBirth).toISOString().slice(0, 10)
|
||||
? dateOnlyKey(new Date(user.dateOfBirth))
|
||||
: "",
|
||||
gender: user.gender ?? "",
|
||||
timezone: resolveInitialTimezone(user.timezone, detectBrowserTimezone()),
|
||||
@@ -528,7 +533,7 @@ export function AccountSection() {
|
||||
setDateOfBirth(next);
|
||||
clearFieldError("dateOfBirth");
|
||||
}}
|
||||
max={new Date().toISOString().slice(0, 10)}
|
||||
max={userDayKey(new Date(), detectBrowserTimezone())}
|
||||
aria-invalid={fieldErrors.dateOfBirth ? true : undefined}
|
||||
aria-describedby={
|
||||
fieldErrors.dateOfBirth ? "dob-error" : undefined
|
||||
|
||||
@@ -227,7 +227,10 @@ export function EnvironmentSection() {
|
||||
// user can still type an earlier start, but pre-home days resolve to SKIP on
|
||||
// the server — to fill the deep past they add an explicit location period.
|
||||
const effectiveBackfillStart =
|
||||
backfillStart || (home?.since ? userDayKey(new Date(home.since), tz) : "");
|
||||
// `since` stays on the server's day for the home (a UTC day key in the
|
||||
// environment module), so the default start matches what the worker
|
||||
// compares against.
|
||||
backfillStart || home?.since?.slice(0, 10) || "";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -246,6 +246,7 @@ function CsvCard({
|
||||
since: since || undefined,
|
||||
until: until || undefined,
|
||||
});
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
const filename = `${filenamePrefix}-${stamp}.csv`;
|
||||
await downloadFromUrl(`${endpoint}${query}`, filename);
|
||||
@@ -329,6 +330,7 @@ function MedicationsCsvCard() {
|
||||
until: until || undefined,
|
||||
intake: includeIntake ? "true" : "false",
|
||||
});
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
await downloadFromUrl(
|
||||
`/api/export/medications${query}`,
|
||||
@@ -447,6 +449,7 @@ function FullBackupCard() {
|
||||
}
|
||||
|
||||
async function downloadOnce() {
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
if (encrypt) {
|
||||
const res = await throwIfReproofRequired(
|
||||
|
||||
@@ -25,6 +25,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { useEncounters } from "@/hooks/use-encounters";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
import { detectBrowserTimezone, userDayKey } from "@/lib/tz/format";
|
||||
|
||||
/**
|
||||
* How far ahead a booked visit is treated as the reason for this report.
|
||||
@@ -36,9 +37,7 @@ export const REPORT_VISIT_HORIZON_DAYS = 14;
|
||||
|
||||
/** The local `yyyy-MM-dd` a `DateField` takes, in the reader's own day. */
|
||||
function localDay(iso: string): string {
|
||||
const at = new Date(iso);
|
||||
const offset = at.getTimezoneOffset();
|
||||
return new Date(at.getTime() - offset * 60_000).toISOString().slice(0, 10);
|
||||
return userDayKey(new Date(iso), detectBrowserTimezone());
|
||||
}
|
||||
|
||||
export function ReportVisitOffer({
|
||||
|
||||
@@ -66,6 +66,13 @@ import { orderLeaves } from "@/lib/report-selection/selection";
|
||||
|
||||
import { ReportScopePicker } from "./report-scope-picker";
|
||||
import { ScopeSummary } from "./scope-summary";
|
||||
import { startOfLocalDayKey } from "@/lib/tz/local-day";
|
||||
import {
|
||||
DEFAULT_TIMEZONE,
|
||||
detectBrowserTimezone,
|
||||
shiftDateKey,
|
||||
validTimezoneOr,
|
||||
} from "@/lib/tz/format";
|
||||
|
||||
type ExportFormat = "pdf" | "fhir" | "package";
|
||||
|
||||
@@ -78,6 +85,12 @@ class ReportRequestError extends Error {}
|
||||
export function HealthRecordExportPanel() {
|
||||
const { t, locale } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
// The report reads its window in the profile zone (the server's
|
||||
// `reportTz`), so the picked days are cut there too.
|
||||
const reportTz = validTimezoneOr(
|
||||
user?.timezone ?? detectBrowserTimezone(),
|
||||
DEFAULT_TIMEZONE,
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [format, setFormat] = useState<ExportFormat>(
|
||||
@@ -155,8 +168,15 @@ export function HealthRecordExportPanel() {
|
||||
const range =
|
||||
customRange && startDate && endDate
|
||||
? {
|
||||
startDate: new Date(`${startDate}T00:00:00Z`).toISOString(),
|
||||
endDate: new Date(`${endDate}T23:59:59Z`).toISOString(),
|
||||
// The picked days are the user's days: from the first instant
|
||||
// of the first to the last instant of the last, in their zone.
|
||||
// UTC midnight started the window the previous evening west of
|
||||
// UTC, and the report read it as one day more.
|
||||
startDate: startOfLocalDayKey(startDate, reportTz).toISOString(),
|
||||
endDate: new Date(
|
||||
startOfLocalDayKey(shiftDateKey(endDate, 1), reportTz).getTime() -
|
||||
1,
|
||||
).toISOString(),
|
||||
}
|
||||
: { days };
|
||||
const res = await throwIfReproofRequired(
|
||||
@@ -190,6 +210,7 @@ export function HealthRecordExportPanel() {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
|
||||
a.download = `healthlog-health-record-${new Date()
|
||||
.toISOString()
|
||||
.slice(0, 10)}.${ext}`;
|
||||
|
||||
@@ -60,7 +60,8 @@ export function VaccinationDocumentSuggestion({
|
||||
const name = candidate.antigenSlug
|
||||
? t(`vaccinations.catalog.${candidate.antigenSlug}`)
|
||||
: (candidate.vaccineName ?? t("vaccinations.suggestion.unnamed"));
|
||||
return `${name} · ${format.date(candidate.occurredAt)}`;
|
||||
// A date stored at UTC midnight: render the date, not the instant.
|
||||
return `${name} · ${format.date(`${candidate.occurredAt.slice(0, 10)}T12:00:00.000Z`)}`;
|
||||
};
|
||||
|
||||
const onPick = (candidate: VaccinationSuggestion) => {
|
||||
|
||||
@@ -35,6 +35,7 @@ import type { Practitioner } from "@/hooks/use-practitioners";
|
||||
import type { Vaccination, VaccinationWriteBody } from "./use-vaccinations";
|
||||
import { CatalogPicker } from "./catalog-picker";
|
||||
import { VaccinationDocumentPicker } from "./vaccination-document-picker";
|
||||
import { dayKeyAsUtcMidnight, isCalendarDateKey } from "@/lib/tz/date-only";
|
||||
|
||||
/** The seven anatomical sites, mirroring the Prisma `VaccinationSite` enum. */
|
||||
export const VACCINATION_SITES = [
|
||||
@@ -116,8 +117,10 @@ export function draftFromVaccination(row: Vaccination): VaccinationDraft {
|
||||
/** The day, at UTC midnight — an Impfpass carries dates, never times. */
|
||||
export function draftInstant(day: string): string | null {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return null;
|
||||
const parsed = new Date(`${day}T00:00:00.000Z`);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
||||
if (!isCalendarDateKey(day)) return null;
|
||||
// Stored at UTC midnight by the column's own convention; every reader
|
||||
// takes the date part.
|
||||
return dayKeyAsUtcMidnight(day).toISOString();
|
||||
}
|
||||
|
||||
/** Does the draft carry at least one identity arm? */
|
||||
|
||||
@@ -198,7 +198,11 @@ function DoseRow({
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs">
|
||||
<span>{format.date(record.occurredAt)}</span>
|
||||
<span>
|
||||
{/* A date, stored at UTC midnight: render its date part, not
|
||||
the instant (the previous evening west of UTC). */}
|
||||
{format.date(`${record.occurredAt.slice(0, 10)}T12:00:00.000Z`)}
|
||||
</span>
|
||||
{record.lotNumber ? (
|
||||
<span data-slot="vaccination-lot">
|
||||
{t("vaccinations.row.lot", { lot: record.lotNumber })}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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`. */
|
||||
|
||||
@@ -25,7 +25,13 @@ import { matchGlp1SideEffectTags } from "@/lib/medications/glp1-side-effect-tag-
|
||||
import type { Glp1SideEffectTag } from "@/lib/medications/glp1-side-effect-tags";
|
||||
import { isRecordOnly } from "@/lib/medications/intake-tracking";
|
||||
import { floorWhole } from "@/lib/medications/units-per-dose";
|
||||
import { DEFAULT_TIMEZONE, shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
import {
|
||||
DEFAULT_TIMEZONE,
|
||||
daysBetweenDateKeys,
|
||||
shiftDateKey,
|
||||
userDayKey,
|
||||
weekdayOfDateKey,
|
||||
} from "@/lib/tz/format";
|
||||
|
||||
/**
|
||||
* Recommended generic name for the canonical GLP-1 drug brand. The Coach
|
||||
@@ -160,11 +166,6 @@ function weeksBetween(from: Date, to: Date): number {
|
||||
return Math.max(0, Math.round(ms / (7 * 24 * 60 * 60 * 1000)));
|
||||
}
|
||||
|
||||
/** Midnight UTC of a calendar-day key: a label for day arithmetic only. */
|
||||
function keyEpochDays(key: string): number {
|
||||
return Math.round(Date.parse(`${key}T00:00:00.000Z`) / 86_400_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Predict next injection from cadence + last intake.
|
||||
*
|
||||
@@ -191,17 +192,12 @@ function predictNextInjection(
|
||||
if (schedule.cadence === "weekly" && schedule.daysOfWeek.length > 0) {
|
||||
const targetDow = schedule.daysOfWeek[0];
|
||||
// Project forward from the anchor (lastInjection if known, otherwise
|
||||
// today) until we land on the target weekday. The weekday of a
|
||||
// calendar date does not depend on any zone, so the key's UTC label
|
||||
// answers it.
|
||||
// today) until we land on the target weekday.
|
||||
const anchorKey = lastInjection ? lastInjection.date : todayKey;
|
||||
for (let i = 1; i <= 14; i += 1) {
|
||||
const key = shiftDateKey(anchorKey, i);
|
||||
if (new Date(`${key}T00:00:00.000Z`).getUTCDay() === targetDow) {
|
||||
const daysAway = Math.max(
|
||||
0,
|
||||
keyEpochDays(key) - keyEpochDays(todayKey),
|
||||
);
|
||||
if (weekdayOfDateKey(key) === targetDow) {
|
||||
const daysAway = Math.max(0, daysBetweenDateKeys(todayKey, key));
|
||||
return { date: key, daysAway };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -297,10 +297,12 @@ export function buildDailyBpRows(
|
||||
export function bpBandFromRows(
|
||||
rows: ReadonlyArray<{ measuredAt: Date; value: number }>,
|
||||
type: "BLOOD_PRESSURE_SYS" | "BLOOD_PRESSURE_DIA",
|
||||
tz: string,
|
||||
): { low: number; high: number } | null {
|
||||
const byDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const r of rows) {
|
||||
const day = r.measuredAt.toISOString().slice(0, 10);
|
||||
// The user's day, like every other day mean in the snapshot.
|
||||
const day = tzDayKey(r.measuredAt, tz);
|
||||
const acc = byDay.get(day) ?? { sum: 0, count: 0 };
|
||||
acc.sum += r.value;
|
||||
acc.count += 1;
|
||||
|
||||
@@ -468,6 +468,7 @@ function bucketWindow(
|
||||
* safe for every code path that reaches this helper.
|
||||
*/
|
||||
function bucketDayKey(d: Date): string {
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: rollup bucketStart key; non-near-UTC users are routed to the live path (see the doc comment)
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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 ?? ""}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
loadSurgicalHistory,
|
||||
loadLabResults,
|
||||
} from "./clinical-records";
|
||||
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
|
||||
|
||||
const DENSE_REPORT_RAW_WINDOW_DAYS = 90;
|
||||
|
||||
@@ -166,8 +167,8 @@ export async function collectDoctorReportData(
|
||||
// they compare against the window's first and last day in the report zone,
|
||||
// not its instants: west of UTC the window starts hours after UTC midnight,
|
||||
// and a course that ended on the first day read as over before it began.
|
||||
const firstDay = new Date(`${userDayKey(start, reportTz)}T00:00:00.000Z`);
|
||||
const lastDay = new Date(`${userDayKey(end, reportTz)}T00:00:00.000Z`);
|
||||
const firstDay = dayKeyAsUtcMidnight(userDayKey(start, reportTz));
|
||||
const lastDay = dayKeyAsUtcMidnight(userDayKey(end, reportTz));
|
||||
|
||||
const aggregateDenseTypes = days > DENSE_REPORT_RAW_WINDOW_DAYS;
|
||||
const densePulse =
|
||||
@@ -579,7 +580,7 @@ export async function collectDoctorReportData(
|
||||
emergency,
|
||||
] = await Promise.all([
|
||||
gate.admits("CYCLE")
|
||||
? buildCycleExportSummary(userId, end.toISOString().slice(0, 10))
|
||||
? buildCycleExportSummary(userId, userDayKey(end, reportTz))
|
||||
: Promise.resolve(null),
|
||||
gate.admits("LAB_RESULTS")
|
||||
? loadLabResults(userId, start, end)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -58,7 +58,7 @@ import type {
|
||||
DocumentSourceSystemValue,
|
||||
InboundDocumentKindValue,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only";
|
||||
import { dateOnlyAtNoonUtc, dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
@@ -84,7 +84,7 @@ export function personalUploadBucket(userId: string): string {
|
||||
* a stored date goes through `dateOnlyAtNoonUtc`.
|
||||
*/
|
||||
export function isoDateToUtc(value: string): Date {
|
||||
return new Date(`${value}T00:00:00.000Z`);
|
||||
return dayKeyAsUtcMidnight(value);
|
||||
}
|
||||
|
||||
export interface IngestInput {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
import { pearson, MIN_PAIRED_N } from "@/lib/insights/correlations";
|
||||
import { type Locale } from "@/lib/i18n/config";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
|
||||
/**
|
||||
* Locale-aware string builder — the same signature the server translator
|
||||
@@ -238,10 +239,7 @@ export interface CorrelationDiscoveryResult {
|
||||
|
||||
/** Add `lagDays` to a YYYY-MM-DD day key, returning the shifted key. */
|
||||
function shiftDay(day: string, lagDays: number): string {
|
||||
const [y, m, d] = day.split("-").map(Number);
|
||||
const dt = new Date(Date.UTC(y, m - 1, d));
|
||||
dt.setUTCDate(dt.getUTCDate() + lagDays);
|
||||
return dt.toISOString().slice(0, 10);
|
||||
return shiftDateKey(day, lagDays);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
type MedTargetClass,
|
||||
} from "@/lib/medications/med-target-map";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
import { dbDate } from "@/lib/tz/date-only";
|
||||
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
const MAD_TO_SIGMA = 1.4826;
|
||||
@@ -174,7 +174,10 @@ export async function buildAdherenceStoryline(
|
||||
// v1.39.1 (#1033) — the adherence figure below counts tracked
|
||||
// medications only, so a record-only one cannot be its subject.
|
||||
...TRACKED_INTAKE_WHERE,
|
||||
OR: [{ endsOn: null }, { endsOn: { gte: dbDate(todayKey) } }],
|
||||
OR: [
|
||||
{ endsOn: null },
|
||||
{ endsOn: { gte: dayKeyAsUtcMidnight(todayKey) } },
|
||||
],
|
||||
},
|
||||
select: { name: true, treatmentClass: true },
|
||||
});
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { buildBaselineBand } from "@/lib/insights/derived/baseline";
|
||||
import { daysBetweenDateKeys } from "@/lib/tz/format";
|
||||
|
||||
/** One DAY-bucket point: a calendar day key (YYYY-MM-DD) + that day's mean. */
|
||||
export interface StreakPoint {
|
||||
@@ -220,8 +221,6 @@ function buildBandFromSeries(
|
||||
|
||||
/** Whole-day gap between two YYYY-MM-DD keys (>=1; 1 = adjacent). */
|
||||
function dayGap(earlier: string, later: string): number {
|
||||
const a = Date.parse(`${earlier}T00:00:00Z`);
|
||||
const b = Date.parse(`${later}T00:00:00Z`);
|
||||
if (Number.isNaN(a) || Number.isNaN(b)) return Number.POSITIVE_INFINITY;
|
||||
return Math.round((b - a) / (24 * 60 * 60 * 1000));
|
||||
const gap = daysBetweenDateKeys(earlier, later);
|
||||
return Number.isNaN(gap) ? Number.POSITIVE_INFINITY : gap;
|
||||
}
|
||||
@@ -133,6 +133,7 @@ async function readIntradaySdnn(
|
||||
now: Date,
|
||||
): Promise<number[]> {
|
||||
const dayKey = stressDayKey(now);
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: UTC day window of the UTC-day score engines (scoreDayKey)
|
||||
const dayStart = new Date(`${dayKey}T00:00:00.000Z`);
|
||||
const dayEnd = new Date(dayStart.getTime() + MS_PER_DAY);
|
||||
const rows = await prisma.measurement.findMany({
|
||||
|
||||
@@ -293,6 +293,7 @@ export async function runCoachPlanReviewTick(
|
||||
timeZone: "UTC",
|
||||
db: prisma,
|
||||
});
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the day series above is read with timeZone UTC, so the plan start is cut on the same calendar
|
||||
const startKey = plan.createdAt.toISOString().slice(0, 10);
|
||||
const before: number[] = [];
|
||||
const after: number[] = [];
|
||||
|
||||
@@ -897,6 +897,7 @@ export async function runOffhostBackup(
|
||||
}
|
||||
const s3 = s3Override ?? (await getS3Client(cfg));
|
||||
const runStartedAt = options.runStartedAt;
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: off-host object key date, the name the restore drill and the purge look up
|
||||
const dateKey = (runStartedAt ?? now).toISOString().slice(0, 10);
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
|
||||
@@ -211,7 +211,8 @@ export async function processOffhostPurges(
|
||||
const subject = request.subjectId;
|
||||
const day =
|
||||
request.reason === "data_wiped" && live.has(subject)
|
||||
? request.requestedAt.toISOString().slice(0, 10)
|
||||
? // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: matches the UTC dates in the off-host object keys
|
||||
request.requestedAt.toISOString().slice(0, 10)
|
||||
: null;
|
||||
const prior = lastDayToDelete.get(subject);
|
||||
if (prior === undefined) lastDayToDelete.set(subject, day);
|
||||
|
||||
@@ -104,6 +104,7 @@ export async function handleMoodReminderCleanup(
|
||||
try {
|
||||
const cutoff = new Date();
|
||||
cutoff.setUTCDate(cutoff.getUTCDate() - MOOD_REMINDER_RETENTION_DAYS);
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: retention cutoff; a day either way does not matter
|
||||
const cutoffIso = cutoff.toISOString().slice(0, 10);
|
||||
const deleted = await p.moodReminderDispatch.deleteMany({
|
||||
where: { date: { lt: cutoffIso } },
|
||||
|
||||
@@ -167,6 +167,7 @@ export async function runRestoreDrill(
|
||||
}
|
||||
|
||||
const ageDays = Math.floor(
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: age of a UTC-dated off-host object key
|
||||
(now.getTime() - Date.parse(`${dateKey}T00:00:00Z`)) /
|
||||
(24 * 60 * 60 * 1000),
|
||||
);
|
||||
|
||||
@@ -119,6 +119,7 @@ export async function runStepConsolidationRepairForUser(
|
||||
// in different UTC days, so both instants are tracked).
|
||||
const touchedByUtcDay = new Map<string, Date>();
|
||||
const markTouched = (instant: Date) => {
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the rollup tier keys DAY buckets by UTC day (see the comment above)
|
||||
touchedByUtcDay.set(instant.toISOString().slice(0, 10), instant);
|
||||
};
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
labReadingDaySearchRange,
|
||||
} from "@/lib/labs/reading-day";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { statedDateKey } from "@/lib/tz/date-only";
|
||||
|
||||
const SYSTEM_PROMPT = `You transcribe a photograph or PDF of a laboratory test report into structured data.
|
||||
|
||||
@@ -95,12 +96,8 @@ Respond ONLY with a JSON object of this exact shape:
|
||||
/** A normalised ISO date string (YYYY-MM-DD) or null. */
|
||||
function normaliseDate(raw: string | null): string | null {
|
||||
if (!raw) return null;
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
const parsed = new Date(trimmed);
|
||||
if (Number.isNaN(parsed.getTime())) return null;
|
||||
// Keep only the calendar day — the lab reports a date, not an instant.
|
||||
return parsed.toISOString().slice(0, 10);
|
||||
return statedDateKey(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* put a morning draw east of UTC on the previous day, so a scan of the same
|
||||
* report wrote the reading a second time.
|
||||
*/
|
||||
import { dateOnlyKey, dbDate } from "@/lib/tz/date-only";
|
||||
import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
|
||||
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
@@ -31,7 +31,7 @@ export function labReadingDaySearchRange(day: string): {
|
||||
lt: Date;
|
||||
} {
|
||||
return {
|
||||
gte: dbDate(shiftDateKey(day, -1)),
|
||||
lt: dbDate(shiftDateKey(day, 2)),
|
||||
gte: dayKeyAsUtcMidnight(shiftDateKey(day, -1)),
|
||||
lt: dayKeyAsUtcMidnight(shiftDateKey(day, 2)),
|
||||
};
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { encryptFactData, encryptFactProvenance } from "@/lib/documents/store";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import type { ObservationFactData } from "@/lib/validations/inbound-documents";
|
||||
import { dateOnlyKey } from "@/lib/tz/date-only";
|
||||
|
||||
/** One inserted lab row to cross-link back to its source vault document. */
|
||||
export interface InsertedLabForLink {
|
||||
@@ -54,7 +55,7 @@ function observationFromLab(lab: InsertedLabForLink): ObservationFactData {
|
||||
unit: lab.unit,
|
||||
referenceLow: lab.referenceLow,
|
||||
referenceHigh: lab.referenceHigh,
|
||||
effectiveDate: lab.takenAt.toISOString().slice(0, 10),
|
||||
effectiveDate: dateOnlyKey(lab.takenAt),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { dayKeyAsUtcMidnight, dateOnlyKey } from "@/lib/tz/date-only";
|
||||
|
||||
type Transaction = Prisma.TransactionClient;
|
||||
|
||||
@@ -266,7 +267,7 @@ export function toManagedProfileView(row: {
|
||||
return {
|
||||
id: row.id,
|
||||
displayName: row.displayName,
|
||||
dateOfBirth: row.dateOfBirth?.toISOString().slice(0, 10) ?? null,
|
||||
dateOfBirth: row.dateOfBirth ? dateOnlyKey(row.dateOfBirth) : null,
|
||||
gender: row.gender,
|
||||
locale: row.locale,
|
||||
timezone: row.timezone,
|
||||
@@ -373,7 +374,7 @@ export async function updateManagedProfile(input: {
|
||||
...(patch.dateOfBirth !== undefined
|
||||
? {
|
||||
dateOfBirth: patch.dateOfBirth
|
||||
? new Date(`${patch.dateOfBirth}T00:00:00.000Z`)
|
||||
? dayKeyAsUtcMidnight(patch.dateOfBirth)
|
||||
: null,
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -453,6 +453,7 @@ function clip(text: string, max: number): { text: string; truncated: boolean } {
|
||||
}
|
||||
|
||||
function dateOnly(value: Date | null): string | null {
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- baseline: text label mixing date-only columns and instants, read without a zone
|
||||
return value ? value.toISOString().slice(0, 10) : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -708,6 +708,7 @@ export async function runDenseIntradayRetention(
|
||||
// The fold's canonical timestamp is local-noon of the day; reuse
|
||||
// its UTC date for the resting row's `stats:` key so a re-run
|
||||
// upserts the same row instead of minting a sibling.
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: stable stats: externalId key; changing it would mint a sibling row on re-run
|
||||
canonicalTimestamp.toISOString().slice(0, 10),
|
||||
);
|
||||
await pc.measurement.upsert({
|
||||
|
||||
@@ -250,6 +250,7 @@ export async function pruneIntradayCumulativeProfiles(
|
||||
retentionDays: number = CUMULATIVE_PROFILE_RETENTION_DAYS,
|
||||
): Promise<number> {
|
||||
const cutoffKey = shiftDateKey(
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: 400-day retention cutoff; no zone moves it by more than a day (see the doc comment)
|
||||
now.toISOString().slice(0, 10),
|
||||
-retentionDays,
|
||||
);
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
LIVE_ERA_REVISION_ARGS,
|
||||
liveEraStart,
|
||||
} from "@/lib/medications/scheduling/live-era";
|
||||
import { dayKeyForScheduledFor } from "@/lib/rollups/medication-compliance-rollups";
|
||||
|
||||
export interface ProjectTodayIntakesResult {
|
||||
projected: number;
|
||||
@@ -238,7 +239,9 @@ export async function projectTodayIntakesAndRecompute(input: {
|
||||
const seenDayKeys = new Set<string>();
|
||||
const recomputeJobs: Array<Promise<void>> = [];
|
||||
for (const m of missing) {
|
||||
const key = `${m.medicationId}|${m.scheduledFor.toISOString().slice(0, 10)}`;
|
||||
// The compliance rollup's own day for the slot: two slots on different
|
||||
// local days that share a UTC date must each get their recompute.
|
||||
const key = `${m.medicationId}|${dayKeyForScheduledFor(m.scheduledFor, userTz)}`;
|
||||
if (seenDayKeys.has(key)) continue;
|
||||
seenDayKeys.add(key);
|
||||
recomputeJobs.push(
|
||||
|
||||
@@ -236,6 +236,7 @@ function localDayWindow(day: string): { from: Date; to: Date } {
|
||||
// helper agrees belongs to this day. Cheaper and far safer than arithmetic
|
||||
// on `new Date(y, m, d)`, which slips by an hour twice a year and has
|
||||
// already been solved once in this codebase.
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: anchor of a superset window; rows are kept only when their own local day matches
|
||||
const naive = new Date(`${day}T00:00:00.000Z`);
|
||||
const from = new Date(naive.getTime() - 26 * 60 * 60 * 1000);
|
||||
const to = new Date(naive.getTime() + 50 * 60 * 60 * 1000);
|
||||
|
||||
@@ -16,5 +16,6 @@
|
||||
*/
|
||||
export function maxAcceptableNutrientDay(now: Date): string {
|
||||
const limit = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000);
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: upper sanity bound with two days of slack for every real zone (see the doc comment)
|
||||
return limit.toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -181,6 +181,7 @@ export async function recordOuraSyncFailure(
|
||||
}
|
||||
|
||||
function ymd(d: Date): string {
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- baseline: request window edge for the provider API, not a displayed day; rows carry their own dates
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
|
||||
@@ -140,6 +140,7 @@ function toUpsert(
|
||||
// A timed row keys on its instant's UTC date, as it always has.
|
||||
externalId:
|
||||
m.externalId ??
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: timed rows keep their historical UTC-date externalId so re-syncs upsert (see the comment above)
|
||||
`${resourcePrefix}:${m.day ?? m.measuredAt.toISOString().slice(0, 10)}:${m.fieldTag}`,
|
||||
sleepStage: m.sleepStage ?? null,
|
||||
}));
|
||||
|
||||
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user