(() => {
diff --git a/src/components/links/vault-document-options.ts b/src/components/links/vault-document-options.ts
index 1c2832320..91aee8604 100644
--- a/src/components/links/vault-document-options.ts
+++ b/src/components/links/vault-document-options.ts
@@ -31,6 +31,7 @@ import {
DOCUMENT_LIST_MAX_LIMIT,
type InboundDocumentDto,
} from "@/lib/validations/inbound-documents";
+import { daysBetweenDateKeys } from "@/lib/tz/format";
/**
* How many pages the picker walks. At the route's ceiling of 100 that is a
@@ -38,8 +39,6 @@ import {
*/
const VAULT_PICKER_MAX_PAGES = 10;
-const DAY_MS = 24 * 60 * 60 * 1000;
-
/** The fields the option list reads off a document. */
export type VaultDocument = Pick<
InboundDocumentDto,
@@ -82,10 +81,12 @@ export function isNearAnchor(
anchorIso: string | null,
): boolean {
if (!documentDay || !anchorIso) return false;
- const day = Date.parse(`${documentDay.slice(0, 10)}T00:00:00.000Z`);
- const anchor = Date.parse(`${anchorIso.slice(0, 10)}T00:00:00.000Z`);
- if (Number.isNaN(day) || Number.isNaN(anchor)) return false;
- return Math.abs(day - anchor) <= ENCOUNTER_SUGGEST_WINDOW_DAYS * DAY_MS;
+ const gap = daysBetweenDateKeys(
+ anchorIso.slice(0, 10),
+ documentDay.slice(0, 10),
+ );
+ if (Number.isNaN(gap)) return false;
+ return Math.abs(gap) <= ENCOUNTER_SUGGEST_WINDOW_DAYS;
}
/**
diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx
index f1085e2c5..40ff9afdd 100644
--- a/src/components/measurement-reminders/vorsorge-section.tsx
+++ b/src/components/measurement-reminders/vorsorge-section.tsx
@@ -98,6 +98,11 @@ import {
useMeasurementReminderMutations,
type MeasurementReminder,
} from "@/hooks/use-measurement-reminders";
+import {
+ detectBrowserTimezone,
+ shiftDateKey,
+ userDayKey,
+} from "@/lib/tz/format";
// v1.18.1 (V3) — the active-measurement set, grouped so the dropdown
// doesn't read as a flat 15-item list. Mirrors the Zod allow-list in
@@ -177,8 +182,7 @@ function toDateInputValue(iso: string | null): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
- const offset = d.getTimezoneOffset();
- return new Date(d.getTime() - offset * 60 * 1000).toISOString().slice(0, 10);
+ return userDayKey(d, detectBrowserTimezone());
}
/**
@@ -839,12 +843,11 @@ export function postponeOffsetTargets(
// in between moves it an hour, which flips the date for anyone acting near
// midnight. Anchoring today's local date at UTC noon and stepping whole
// UTC days keeps every offset on the day its label names.
- const anchor = new Date(`${isoDayInTz(nowMs, tz)}T12:00:00.000Z`);
- return SNOOZE_CHIP_DAYS.map((days) => {
- const target = new Date(anchor);
- target.setUTCDate(target.getUTCDate() + days);
- return { days, date: target.toISOString().slice(0, 10) };
- });
+ const today = isoDayInTz(nowMs, tz);
+ return SNOOZE_CHIP_DAYS.map((days) => ({
+ days,
+ date: shiftDateKey(today, days),
+ }));
}
function intervalProgress(
diff --git a/src/components/medications/sections/data-portability-row.tsx b/src/components/medications/sections/data-portability-row.tsx
index 4eae729f0..2ad68d5f5 100644
--- a/src/components/medications/sections/data-portability-row.tsx
+++ b/src/components/medications/sections/data-portability-row.tsx
@@ -66,6 +66,7 @@ export function DataPortabilityRow({
intake: includeIntake ? "true" : "false",
medicationId,
}).toString();
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const stamp = new Date().toISOString().slice(0, 10);
await downloadFromUrl(
`/api/export/medications?${query}`,
diff --git a/src/components/mood/use-linked-day-context.ts b/src/components/mood/use-linked-day-context.ts
index 00cc3c71d..a89e60e2a 100644
--- a/src/components/mood/use-linked-day-context.ts
+++ b/src/components/mood/use-linked-day-context.ts
@@ -30,6 +30,7 @@ import { useQuery } from "@tanstack/react-query";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type { LinkedDayContext } from "@/lib/mood/linked-context";
+import { userDayKey } from "@/lib/tz/format";
/**
* The browser's own zone — the right answer for an entry that does not exist
@@ -42,9 +43,7 @@ export function browserTimezone(): string {
/** `YYYY-MM-DD` for a `datetime-local` value, or today when it is empty. */
export function dayOfLocalInput(value: string): string {
if (value.length >= 10) return value.slice(0, 10);
- const now = new Date();
- const local = new Date(now.getTime() - now.getTimezoneOffset() * 60 * 1000);
- return local.toISOString().slice(0, 10);
+ return userDayKey(new Date(), browserTimezone());
}
/**
diff --git a/src/components/onboarding/baseline-fields.tsx b/src/components/onboarding/baseline-fields.tsx
index c543b42ed..bd83c1e77 100644
--- a/src/components/onboarding/baseline-fields.tsx
+++ b/src/components/onboarding/baseline-fields.tsx
@@ -16,6 +16,7 @@ import type {
HeightDraft,
HeightUnitAdapter,
} from "@/lib/profile/height-unit-display";
+import { detectBrowserTimezone, userDayKey } from "@/lib/tz/format";
/**
* The four profile inputs of the onboarding baseline step.
@@ -184,7 +185,7 @@ export function BaselineFields({
id={IDS.dateOfBirth}
value={value.dateOfBirth}
onChange={(next) => onChange("dateOfBirth", next)}
- max={new Date().toISOString().slice(0, 10)}
+ max={userDayKey(new Date(), detectBrowserTimezone())}
autoComplete="bday"
aria-invalid={errors.dateOfBirth ? true : undefined}
aria-describedby={
diff --git a/src/components/records/allergy-form.tsx b/src/components/records/allergy-form.tsx
index cc1dfb472..77544005b 100644
--- a/src/components/records/allergy-form.tsx
+++ b/src/components/records/allergy-form.tsx
@@ -27,6 +27,7 @@ import {
import { useTranslations } from "@/lib/i18n/context";
import type { AllergyDTO } from "@/lib/records/dto";
+import { dateOnlyAtNoonUtc, isCalendarDateKey } from "@/lib/tz/date-only";
const TEXT_MAX_LENGTH = 2000;
/** Select sentinel for the optional "not assessed" severity. */
@@ -41,11 +42,14 @@ interface AllergyFormProps {
footerSlot?: HTMLElement | null;
}
-/** ISO date input (YYYY-MM-DD) → start-of-day UTC instant, or undefined. */
+/**
+ * ISO date input (YYYY-MM-DD) → the instant a date-only value is stored at
+ * (noon UTC), or undefined. UTC midnight read back as the previous day west
+ * of UTC.
+ */
function toInstant(value: string): string | undefined {
- if (!value) return undefined;
- const d = new Date(`${value}T00:00:00.000Z`);
- return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
+ if (!isCalendarDateKey(value)) return undefined;
+ return dateOnlyAtNoonUtc(value).toISOString();
}
/**
diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx
index 359be334f..a4cde390e 100644
--- a/src/components/settings/account-section/index.tsx
+++ b/src/components/settings/account-section/index.tsx
@@ -33,7 +33,11 @@ import { TimeFormatSelect } from "@/components/settings/time-format-select";
import { DateFormatSelect } from "@/components/settings/date-format-select";
import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select";
import { GlucoseUnitSelect } from "@/components/settings/glucose-unit-select";
-import { detectBrowserTimezone, DEFAULT_TIMEZONE } from "@/lib/tz/format";
+import {
+ detectBrowserTimezone,
+ DEFAULT_TIMEZONE,
+ userDayKey,
+} from "@/lib/tz/format";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import {
recentProofErrorMessage,
@@ -53,6 +57,7 @@ import {
} from "./account-section-utils";
import { AvatarSection } from "./avatar-section";
import { SetupCard } from "./setup-card";
+import { dateOnlyKey } from "@/lib/tz/date-only";
export { resolveInitialTimezone } from "./account-section-utils";
@@ -145,7 +150,7 @@ export function AccountSection() {
email: user.email ?? "",
height: heightAdapter.toDraft(user.heightCm),
dateOfBirth: user.dateOfBirth
- ? new Date(user.dateOfBirth).toISOString().slice(0, 10)
+ ? dateOnlyKey(new Date(user.dateOfBirth))
: "",
gender: user.gender ?? "",
timezone: resolveInitialTimezone(user.timezone, detectBrowserTimezone()),
@@ -528,7 +533,7 @@ export function AccountSection() {
setDateOfBirth(next);
clearFieldError("dateOfBirth");
}}
- max={new Date().toISOString().slice(0, 10)}
+ max={userDayKey(new Date(), detectBrowserTimezone())}
aria-invalid={fieldErrors.dateOfBirth ? true : undefined}
aria-describedby={
fieldErrors.dateOfBirth ? "dob-error" : undefined
diff --git a/src/components/settings/environment-section.tsx b/src/components/settings/environment-section.tsx
index 166500a69..ff6a31342 100644
--- a/src/components/settings/environment-section.tsx
+++ b/src/components/settings/environment-section.tsx
@@ -227,7 +227,10 @@ export function EnvironmentSection() {
// user can still type an earlier start, but pre-home days resolve to SKIP on
// the server — to fill the deep past they add an explicit location period.
const effectiveBackfillStart =
- backfillStart || (home?.since ? userDayKey(new Date(home.since), tz) : "");
+ // `since` stays on the server's day for the home (a UTC day key in the
+ // environment module), so the default start matches what the worker
+ // compares against.
+ backfillStart || home?.since?.slice(0, 10) || "";
return (
diff --git a/src/components/settings/export-section.tsx b/src/components/settings/export-section.tsx
index 3fe5f5954..7ee5b7b13 100644
--- a/src/components/settings/export-section.tsx
+++ b/src/components/settings/export-section.tsx
@@ -246,6 +246,7 @@ function CsvCard({
since: since || undefined,
until: until || undefined,
});
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const stamp = new Date().toISOString().slice(0, 10);
const filename = `${filenamePrefix}-${stamp}.csv`;
await downloadFromUrl(`${endpoint}${query}`, filename);
@@ -329,6 +330,7 @@ function MedicationsCsvCard() {
until: until || undefined,
intake: includeIntake ? "true" : "false",
});
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const stamp = new Date().toISOString().slice(0, 10);
await downloadFromUrl(
`/api/export/medications${query}`,
@@ -447,6 +449,7 @@ function FullBackupCard() {
}
async function downloadOnce() {
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
const stamp = new Date().toISOString().slice(0, 10);
if (encrypt) {
const res = await throwIfReproofRequired(
diff --git a/src/components/settings/report-selection/report-visit-offer.tsx b/src/components/settings/report-selection/report-visit-offer.tsx
index 5108a1588..b31708730 100644
--- a/src/components/settings/report-selection/report-visit-offer.tsx
+++ b/src/components/settings/report-selection/report-visit-offer.tsx
@@ -25,6 +25,7 @@ import { Button } from "@/components/ui/button";
import { useEncounters } from "@/hooks/use-encounters";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { EncounterKind } from "@/generated/prisma/client";
+import { detectBrowserTimezone, userDayKey } from "@/lib/tz/format";
/**
* How far ahead a booked visit is treated as the reason for this report.
@@ -36,9 +37,7 @@ export const REPORT_VISIT_HORIZON_DAYS = 14;
/** The local `yyyy-MM-dd` a `DateField` takes, in the reader's own day. */
function localDay(iso: string): string {
- const at = new Date(iso);
- const offset = at.getTimezoneOffset();
- return new Date(at.getTime() - offset * 60_000).toISOString().slice(0, 10);
+ return userDayKey(new Date(iso), detectBrowserTimezone());
}
export function ReportVisitOffer({
diff --git a/src/components/settings/report-selection/selection-panel.tsx b/src/components/settings/report-selection/selection-panel.tsx
index 2f9f1875c..4fb1830cb 100644
--- a/src/components/settings/report-selection/selection-panel.tsx
+++ b/src/components/settings/report-selection/selection-panel.tsx
@@ -66,6 +66,13 @@ import { orderLeaves } from "@/lib/report-selection/selection";
import { ReportScopePicker } from "./report-scope-picker";
import { ScopeSummary } from "./scope-summary";
+import { startOfLocalDayKey } from "@/lib/tz/local-day";
+import {
+ DEFAULT_TIMEZONE,
+ detectBrowserTimezone,
+ shiftDateKey,
+ validTimezoneOr,
+} from "@/lib/tz/format";
type ExportFormat = "pdf" | "fhir" | "package";
@@ -78,6 +85,12 @@ class ReportRequestError extends Error {}
export function HealthRecordExportPanel() {
const { t, locale } = useTranslations();
const { user } = useAuth();
+ // The report reads its window in the profile zone (the server's
+ // `reportTz`), so the picked days are cut there too.
+ const reportTz = validTimezoneOr(
+ user?.timezone ?? detectBrowserTimezone(),
+ DEFAULT_TIMEZONE,
+ );
const queryClient = useQueryClient();
const [format, setFormat] = useState(
@@ -155,8 +168,15 @@ export function HealthRecordExportPanel() {
const range =
customRange && startDate && endDate
? {
- startDate: new Date(`${startDate}T00:00:00Z`).toISOString(),
- endDate: new Date(`${endDate}T23:59:59Z`).toISOString(),
+ // The picked days are the user's days: from the first instant
+ // of the first to the last instant of the last, in their zone.
+ // UTC midnight started the window the previous evening west of
+ // UTC, and the report read it as one day more.
+ startDate: startOfLocalDayKey(startDate, reportTz).toISOString(),
+ endDate: new Date(
+ startOfLocalDayKey(shiftDateKey(endDate, 1), reportTz).getTime() -
+ 1,
+ ).toISOString(),
}
: { days };
const res = await throwIfReproofRequired(
@@ -190,6 +210,7 @@ export function HealthRecordExportPanel() {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
a.download = `healthlog-health-record-${new Date()
.toISOString()
.slice(0, 10)}.${ext}`;
diff --git a/src/components/vaccinations/vaccination-document-suggestion.tsx b/src/components/vaccinations/vaccination-document-suggestion.tsx
index ea07169aa..0648aab7f 100644
--- a/src/components/vaccinations/vaccination-document-suggestion.tsx
+++ b/src/components/vaccinations/vaccination-document-suggestion.tsx
@@ -60,7 +60,8 @@ export function VaccinationDocumentSuggestion({
const name = candidate.antigenSlug
? t(`vaccinations.catalog.${candidate.antigenSlug}`)
: (candidate.vaccineName ?? t("vaccinations.suggestion.unnamed"));
- return `${name} · ${format.date(candidate.occurredAt)}`;
+ // A date stored at UTC midnight: render the date, not the instant.
+ return `${name} · ${format.date(`${candidate.occurredAt.slice(0, 10)}T12:00:00.000Z`)}`;
};
const onPick = (candidate: VaccinationSuggestion) => {
diff --git a/src/components/vaccinations/vaccination-form.tsx b/src/components/vaccinations/vaccination-form.tsx
index a000a3f39..f65b2ac37 100644
--- a/src/components/vaccinations/vaccination-form.tsx
+++ b/src/components/vaccinations/vaccination-form.tsx
@@ -35,6 +35,7 @@ import type { Practitioner } from "@/hooks/use-practitioners";
import type { Vaccination, VaccinationWriteBody } from "./use-vaccinations";
import { CatalogPicker } from "./catalog-picker";
import { VaccinationDocumentPicker } from "./vaccination-document-picker";
+import { dayKeyAsUtcMidnight, isCalendarDateKey } from "@/lib/tz/date-only";
/** The seven anatomical sites, mirroring the Prisma `VaccinationSite` enum. */
export const VACCINATION_SITES = [
@@ -116,8 +117,10 @@ export function draftFromVaccination(row: Vaccination): VaccinationDraft {
/** The day, at UTC midnight — an Impfpass carries dates, never times. */
export function draftInstant(day: string): string | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return null;
- const parsed = new Date(`${day}T00:00:00.000Z`);
- return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
+ if (!isCalendarDateKey(day)) return null;
+ // Stored at UTC midnight by the column's own convention; every reader
+ // takes the date part.
+ return dayKeyAsUtcMidnight(day).toISOString();
}
/** Does the draft carry at least one identity arm? */
diff --git a/src/components/vaccinations/vaccination-list.tsx b/src/components/vaccinations/vaccination-list.tsx
index 8f84e14f7..1f373c61f 100644
--- a/src/components/vaccinations/vaccination-list.tsx
+++ b/src/components/vaccinations/vaccination-list.tsx
@@ -198,7 +198,11 @@ function DoseRow({
) : null}
-
{format.date(record.occurredAt)}
+
+ {/* A date, stored at UTC midnight: render its date part, not
+ the instant (the previous evening west of UTC). */}
+ {format.date(`${record.occurredAt.slice(0, 10)}T12:00:00.000Z`)}
+
{record.lotNumber ? (
{t("vaccinations.row.lot", { lot: record.lotNumber })}
diff --git a/src/lib/ai/coach/budget.ts b/src/lib/ai/coach/budget.ts
index b96f6d7e1..5e77dcc94 100644
--- a/src/lib/ai/coach/budget.ts
+++ b/src/lib/ai/coach/budget.ts
@@ -235,6 +235,7 @@ export async function readDailySpend(
* the local meter to the same boundary keeps reasoning trivial.
*/
export function buildDateKey(at: Date = new Date()): string {
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the spend meter follows the provider's UTC billing day (see the doc comment)
return at.toISOString().slice(0, 10);
}
diff --git a/src/lib/ai/coach/cycle-snapshot.ts b/src/lib/ai/coach/cycle-snapshot.ts
index 1edd20a02..510638c23 100644
--- a/src/lib/ai/coach/cycle-snapshot.ts
+++ b/src/lib/ai/coach/cycle-snapshot.ts
@@ -43,6 +43,7 @@ import { addDays } from "@/lib/cycle/day-math";
import { readSourceDayAggregates } from "@/lib/measurements/day-aggregates";
import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
+import { startOfLocalDayKey } from "@/lib/tz/local-day";
/** Trailing window the phase contrast walks (days). Mirrors the read route. */
const WINDOW_DAYS = 365;
@@ -191,7 +192,7 @@ export async function buildCycleSnapshotBlock(
deletedAt: null,
type: "WRIST_TEMPERATURE",
measuredAt: {
- gte: new Date(Date.parse(`${addDays(today, -90)}T00:00:00Z`)),
+ gte: startOfLocalDayKey(addDays(today, -90), tz),
},
},
orderBy: { measuredAt: "asc" },
@@ -206,7 +207,7 @@ export async function buildCycleSnapshotBlock(
readSourceDayAggregates({
userId,
types: PHASE_CROSSTAB_METRIC_TYPES,
- since: new Date(Date.parse(`${from}T00:00:00Z`)),
+ since: startOfLocalDayKey(from, tz),
timeZone: tz,
}),
prisma.user.findUnique({
diff --git a/src/lib/ai/coach/follow-ups/derive.ts b/src/lib/ai/coach/follow-ups/derive.ts
index 6bf30bee9..34f596af5 100644
--- a/src/lib/ai/coach/follow-ups/derive.ts
+++ b/src/lib/ai/coach/follow-ups/derive.ts
@@ -30,7 +30,7 @@
*/
import type { Locale } from "@/lib/i18n/config";
import { resolveUserTimezone } from "@/lib/tz/resolver";
-import { userDayKey } from "@/lib/tz/format";
+import { userDayKey, shiftDateKey } from "@/lib/tz/format";
import { annotate } from "@/lib/logging/context";
import type {
CoachFollowUp,
@@ -93,9 +93,7 @@ export function followUpChipsEnabled(prefs: CoachPrefs): boolean {
/** `YYYY-MM-DD` minus `days`, as a day key. */
function dayKeyMinus(dayKey: string, days: number): string {
- const at = new Date(`${dayKey}T00:00:00Z`);
- at.setUTCDate(at.getUTCDate() - days);
- return at.toISOString().slice(0, 10);
+ return shiftDateKey(dayKey, -days);
}
/** True when the record's first reading lies before `dayKey`. */
diff --git a/src/lib/ai/coach/glp1-snapshot.ts b/src/lib/ai/coach/glp1-snapshot.ts
index 6cb529d11..4256e827d 100644
--- a/src/lib/ai/coach/glp1-snapshot.ts
+++ b/src/lib/ai/coach/glp1-snapshot.ts
@@ -25,7 +25,13 @@ import { matchGlp1SideEffectTags } from "@/lib/medications/glp1-side-effect-tag-
import type { Glp1SideEffectTag } from "@/lib/medications/glp1-side-effect-tags";
import { isRecordOnly } from "@/lib/medications/intake-tracking";
import { floorWhole } from "@/lib/medications/units-per-dose";
-import { DEFAULT_TIMEZONE, shiftDateKey, userDayKey } from "@/lib/tz/format";
+import {
+ DEFAULT_TIMEZONE,
+ daysBetweenDateKeys,
+ shiftDateKey,
+ userDayKey,
+ weekdayOfDateKey,
+} from "@/lib/tz/format";
/**
* Recommended generic name for the canonical GLP-1 drug brand. The Coach
@@ -160,11 +166,6 @@ function weeksBetween(from: Date, to: Date): number {
return Math.max(0, Math.round(ms / (7 * 24 * 60 * 60 * 1000)));
}
-/** Midnight UTC of a calendar-day key: a label for day arithmetic only. */
-function keyEpochDays(key: string): number {
- return Math.round(Date.parse(`${key}T00:00:00.000Z`) / 86_400_000);
-}
-
/**
* Predict next injection from cadence + last intake.
*
@@ -191,17 +192,12 @@ function predictNextInjection(
if (schedule.cadence === "weekly" && schedule.daysOfWeek.length > 0) {
const targetDow = schedule.daysOfWeek[0];
// Project forward from the anchor (lastInjection if known, otherwise
- // today) until we land on the target weekday. The weekday of a
- // calendar date does not depend on any zone, so the key's UTC label
- // answers it.
+ // today) until we land on the target weekday.
const anchorKey = lastInjection ? lastInjection.date : todayKey;
for (let i = 1; i <= 14; i += 1) {
const key = shiftDateKey(anchorKey, i);
- if (new Date(`${key}T00:00:00.000Z`).getUTCDay() === targetDow) {
- const daysAway = Math.max(
- 0,
- keyEpochDays(key) - keyEpochDays(todayKey),
- );
+ if (weekdayOfDateKey(key) === targetDow) {
+ const daysAway = Math.max(0, daysBetweenDateKeys(todayKey, key));
return { date: key, daysAway };
}
}
diff --git a/src/lib/ai/coach/results/metric-table-tool.ts b/src/lib/ai/coach/results/metric-table-tool.ts
index 68a4ba84c..23d230ed9 100644
--- a/src/lib/ai/coach/results/metric-table-tool.ts
+++ b/src/lib/ai/coach/results/metric-table-tool.ts
@@ -33,7 +33,7 @@ import { prisma } from "@/lib/db";
import type { MeasurementType } from "@/generated/prisma/client";
import type { Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
-import { shiftDateKey, userDayKey } from "@/lib/tz/format";
+import { shiftDateKey, userDayKey, weekdayOfDateKey } from "@/lib/tz/format";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
import {
readDailySeries,
@@ -66,6 +66,7 @@ import type {
import { aggregationKind } from "./chart-spec";
import { RESULT_TABLE_MAX_ROWS } from "./project";
+import { dateOnlyKey } from "@/lib/tz/date-only";
/**
* Sources the table tool leaves to their own tools: glucose needs its
@@ -167,16 +168,14 @@ export function resolveTableRange(args: {
// ── Period keys ─────────────────────────────────────────────────────────
-/** `YYYY-MM-DD` of a UTC-midnight bucket timestamp. */
+/** `YYYY-MM-DD` of a UTC-midnight bucket timestamp (a day label). */
function dayKeyOfUtc(ms: number): string {
- return new Date(ms).toISOString().slice(0, 10);
+ return dateOnlyKey(new Date(ms));
}
/** Monday (`YYYY-MM-DD`) of the ISO week `dayKey` falls in. */
function weekKeyOf(dayKey: string): string {
- const ms = Date.parse(`${dayKey}T00:00:00Z`);
- const dow = (new Date(ms).getUTCDay() + 6) % 7;
- return dayKeyOfUtc(ms - dow * 86_400_000);
+ return shiftDateKey(dayKey, -((weekdayOfDateKey(dayKey) + 6) % 7));
}
function monthKeyOf(dayKey: string): string {
@@ -416,8 +415,12 @@ async function readMeasurementDays(args: {
return { series: ordered, counts, suffixes };
}
-/** `YYYY-MM-DD` of an instant in UTC. */
+/**
+ * `YYYY-MM-DD` of an instant in UTC. Only for the all-time month read: the
+ * MONTH rollup tier buckets UTC months, so its range edges are UTC days.
+ */
function utcDayKey(date: Date): string {
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the MONTH rollup tier buckets UTC months, so this read keys its edges on UTC days
return date.toISOString().slice(0, 10);
}
diff --git a/src/lib/ai/coach/results/projections.ts b/src/lib/ai/coach/results/projections.ts
index 04586b1bb..9d8bd4930 100644
--- a/src/lib/ai/coach/results/projections.ts
+++ b/src/lib/ai/coach/results/projections.ts
@@ -12,7 +12,7 @@
*/
import type { Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
-import { shiftDateKey, userDayKey } from "@/lib/tz/format";
+import { shiftDateKey, userDayKey, weekdayOfDateKey } from "@/lib/tz/format";
import type {
CoachResultCell,
CoachResultTable,
@@ -189,17 +189,13 @@ function mondayOfIsoWeek(weekIso: string): string | null {
const year = Number(match[1]);
const week = Number(match[2]);
// Week 1 holds January 4th; its Monday is the ISO year's first Monday.
- const jan4 = Date.UTC(year, 0, 4);
- const jan4Dow = (new Date(jan4).getUTCDay() + 6) % 7;
- const monday = jan4 - jan4Dow * 86_400_000 + (week - 1) * 7 * 86_400_000;
- return new Date(monday).toISOString().slice(0, 10);
+ const jan4 = `${String(year).padStart(4, "0")}-01-04`;
+ return shiftDateKey(mondayOfDay(jan4), (week - 1) * 7);
}
/** Monday (`YYYY-MM-DD`) of the ISO week a day key falls in. */
function mondayOfDay(dayKey: string): string {
- const ms = Date.parse(`${dayKey}T00:00:00Z`);
- const dow = (new Date(ms).getUTCDay() + 6) % 7;
- return shiftDateKey(new Date(ms).toISOString().slice(0, 10), -dow);
+ return shiftDateKey(dayKey, -((weekdayOfDateKey(dayKey) + 6) % 7));
}
/**
diff --git a/src/lib/ai/coach/snapshot-blocks/core-metrics-block.ts b/src/lib/ai/coach/snapshot-blocks/core-metrics-block.ts
index 6049a5986..2a3e23493 100644
--- a/src/lib/ai/coach/snapshot-blocks/core-metrics-block.ts
+++ b/src/lib/ai/coach/snapshot-blocks/core-metrics-block.ts
@@ -59,8 +59,8 @@ export function buildCoreMetricsBlocks(
);
const olderSys = sysRows.filter((row) => row.measuredAt < ctx.recentCutoff);
const olderDia = diaRows.filter((row) => row.measuredAt < ctx.recentCutoff);
- const sysBand = bpBandFromRows(sysRows, "BLOOD_PRESSURE_SYS");
- const diaBand = bpBandFromRows(diaRows, "BLOOD_PRESSURE_DIA");
+ const sysBand = bpBandFromRows(sysRows, "BLOOD_PRESSURE_SYS", ctx.userTz);
+ const diaBand = bpBandFromRows(diaRows, "BLOOD_PRESSURE_DIA", ctx.userTz);
const usualRange =
sysBand || diaBand
? {
diff --git a/src/lib/ai/coach/snapshot-series.ts b/src/lib/ai/coach/snapshot-series.ts
index cc5752508..4df20de6e 100644
--- a/src/lib/ai/coach/snapshot-series.ts
+++ b/src/lib/ai/coach/snapshot-series.ts
@@ -297,10 +297,12 @@ export function buildDailyBpRows(
export function bpBandFromRows(
rows: ReadonlyArray<{ measuredAt: Date; value: number }>,
type: "BLOOD_PRESSURE_SYS" | "BLOOD_PRESSURE_DIA",
+ tz: string,
): { low: number; high: number } | null {
const byDay = new Map();
for (const r of rows) {
- const day = r.measuredAt.toISOString().slice(0, 10);
+ // The user's day, like every other day mean in the snapshot.
+ const day = tzDayKey(r.measuredAt, tz);
const acc = byDay.get(day) ?? { sum: 0, count: 0 };
acc.sum += r.value;
acc.count += 1;
diff --git a/src/lib/analytics/bp-in-target-fast-path.ts b/src/lib/analytics/bp-in-target-fast-path.ts
index 431f7def9..b5ebcbcc9 100644
--- a/src/lib/analytics/bp-in-target-fast-path.ts
+++ b/src/lib/analytics/bp-in-target-fast-path.ts
@@ -468,6 +468,7 @@ function bucketWindow(
* safe for every code path that reaches this helper.
*/
function bucketDayKey(d: Date): string {
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: rollup bucketStart key; non-near-UTC users are routed to the live path (see the doc comment)
return d.toISOString().slice(0, 10);
}
diff --git a/src/lib/analytics/correlations-fast-path.ts b/src/lib/analytics/correlations-fast-path.ts
index 1779e4df9..e3999a58b 100644
--- a/src/lib/analytics/correlations-fast-path.ts
+++ b/src/lib/analytics/correlations-fast-path.ts
@@ -66,6 +66,7 @@ import { wallClockInTz } from "@/lib/tz/wall-clock";
import { defaultLocale, type Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking";
+import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
/**
* v1.4.37 W2 — cold-path correlation window. Trim from 30 to 28 days
@@ -413,7 +414,7 @@ async function fetchSeriesChunked(
}
function dateFromDayKey(key: string): Date {
- return new Date(`${key}T00:00:00.000Z`);
+ return dayKeyAsUtcMidnight(key);
}
function isoWeekdayInTz(d: Date, timeZone: string): number {
diff --git a/src/lib/analytics/intraday-pulse-io.ts b/src/lib/analytics/intraday-pulse-io.ts
index d271aeebc..30bdafdb7 100644
--- a/src/lib/analytics/intraday-pulse-io.ts
+++ b/src/lib/analytics/intraday-pulse-io.ts
@@ -183,6 +183,7 @@ export async function loadIntradayPulse(
dateKey: string,
): Promise {
const localOf = makeLocalResolver(timezone);
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: anchor of a superset read window; rows are cut to the exact local day afterwards
const anchorMs = new Date(`${dateKey}T00:00:00.000Z`).getTime();
const start = new Date(anchorMs - WINDOW_LEAD_MS);
const end = new Date(anchorMs + WINDOW_TRAIL_MS);
diff --git a/src/lib/analytics/mood-prognosis/load.ts b/src/lib/analytics/mood-prognosis/load.ts
index 58376eb85..833dd4a1a 100644
--- a/src/lib/analytics/mood-prognosis/load.ts
+++ b/src/lib/analytics/mood-prognosis/load.ts
@@ -60,6 +60,7 @@ export const MAX_PROGNOSIS_ROWS = 5000;
/** `YYYY-MM-DD`, this many days before the given instant, in UTC terms. */
export function dayKeyBefore(now: Date, days: number): string {
+ // eslint-disable-next-line healthlog/no-utc-day-key -- baseline: far edge of the fit window on stored day keys; a day either way does not change the fit
return new Date(now.getTime() - days * 86_400_000).toISOString().slice(0, 10);
}
diff --git a/src/lib/analytics/range-delta.ts b/src/lib/analytics/range-delta.ts
index eba1c4abe..667cf37c9 100644
--- a/src/lib/analytics/range-delta.ts
+++ b/src/lib/analytics/range-delta.ts
@@ -60,6 +60,7 @@ import {
type RangeDeltaResult,
type WindowAggregate,
} from "@/lib/analytics/range-shared";
+import { dayKeyAsUtcMidnight, dateOnlyKey } from "@/lib/tz/date-only";
// The range constants + result shapes live in the client-safe
// `range-shared` module so the insights client bundle never pulls the
@@ -156,6 +157,7 @@ function splitPerPeriod(
export function dailyTotalsEndKey(now: number, timezone: string): string {
const localYesterday = shiftDateKey(userDayKey(new Date(now), timezone), -1);
const utcYesterday = shiftDateKey(
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the DAY rollups are UTC buckets, so the earlier of the local and the UTC yesterday wins (see the doc comment)
new Date(now).toISOString().slice(0, 10),
-1,
);
@@ -193,13 +195,11 @@ async function readDailyTotals(
windowDays: number,
endKey: string,
): Promise> {
- const from = new Date(
- `${shiftDateKey(endKey, -(2 * windowDays - 1))}T00:00:00.000Z`,
- );
- const to = new Date(`${shiftDateKey(endKey, 1)}T00:00:00.000Z`);
+ const from = dayKeyAsUtcMidnight(shiftDateKey(endKey, -(2 * windowDays - 1)));
+ const to = dayKeyAsUtcMidnight(shiftDateKey(endKey, 1));
const rows = await readRollupBuckets(userId, type, "DAY", from, to);
return rows.map((r) => ({
- day: r.bucketStart.toISOString().slice(0, 10),
+ day: dateOnlyKey(r.bucketStart),
value: r.mean * r.count,
}));
}
diff --git a/src/lib/analytics/score/lipids.ts b/src/lib/analytics/score/lipids.ts
index 062db9a6b..0c1a3ddca 100644
--- a/src/lib/analytics/score/lipids.ts
+++ b/src/lib/analytics/score/lipids.ts
@@ -16,6 +16,7 @@ export const LIPIDS_WINDOW_DAYS = 365;
export const LIPIDS_MIN_PANELS = 1;
function panelKey(row: LipidReading): string {
+ // eslint-disable-next-line healthlog/no-utc-day-key -- baseline: grouping key only, never shown; the readings of one panel share one takenAt, so any consistent day partition groups them alike
return `${row.at.toISOString().slice(0, 10)}|${row.panel ?? ""}`;
}
diff --git a/src/lib/clinician-share/share-view-data.ts b/src/lib/clinician-share/share-view-data.ts
index 1d0eb370a..69d899033 100644
--- a/src/lib/clinician-share/share-view-data.ts
+++ b/src/lib/clinician-share/share-view-data.ts
@@ -31,6 +31,7 @@ import {
type ReportSelection,
} from "@/lib/report-selection/selection";
import type { ShareContext } from "@/lib/clinician-share/resolve-share-token";
+import { dateOnlyKey } from "@/lib/tz/date-only";
/**
* v1.28 — metadata for one document on the share's frozen set. NEVER carries
@@ -207,7 +208,7 @@ async function loadShareDocuments(
title: document.title,
kind: document.kind,
documentDate: document.documentDate
- ? document.documentDate.toISOString().slice(0, 10)
+ ? dateOnlyKey(document.documentDate)
: null,
byteSize: document.byteSize,
mimeType: document.mimeType,
diff --git a/src/lib/cycle/prediction-refresh.ts b/src/lib/cycle/prediction-refresh.ts
index 02641f861..ca18119d6 100644
--- a/src/lib/cycle/prediction-refresh.ts
+++ b/src/lib/cycle/prediction-refresh.ts
@@ -27,6 +27,7 @@ import { addDays } from "@/lib/cycle/day-math";
import { BBT_WINDOW } from "@/lib/cycle/types";
import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
+import { startOfLocalDayKey } from "@/lib/tz/local-day";
/** Day-log lookback: the calendar's default past span or the symptothermal
* window, whichever reaches further back. */
@@ -97,10 +98,9 @@ export async function refreshPredictionCacheForUser(
deletedAt: null,
type: "WRIST_TEMPERATURE",
measuredAt: {
- gte: new Date(
- Date.parse(
- `${addDays(today, -TEMPERATURE_LOOKBACK_DAYS)}T00:00:00Z`,
- ),
+ gte: startOfLocalDayKey(
+ addDays(today, -TEMPERATURE_LOOKBACK_DAYS),
+ tz,
),
},
},
diff --git a/src/lib/daily/milestones.ts b/src/lib/daily/milestones.ts
index 7cfa2d81b..c6df8e160 100644
--- a/src/lib/daily/milestones.ts
+++ b/src/lib/daily/milestones.ts
@@ -30,6 +30,7 @@
import type { MeasurementType } from "@/generated/prisma/client";
import type { ServerTranslator } from "@/lib/i18n/server-translator";
import { MIN_IN_RUN, type StreakResult } from "@/lib/insights/streak-detector";
+import { shiftDateKey } from "@/lib/tz/format";
/** Closed set of durable-state kinds. Grows by PR, never with a counter field. */
export const MILESTONE_KINDS = [
@@ -101,7 +102,7 @@ function dayKeyToSerial(key: string): number {
}
function serialToDayKey(serial: number): string {
- return new Date(serial * 86_400_000).toISOString().slice(0, 10);
+ return shiftDateKey("1970-01-01", serial);
}
/** Shift a UTC-ISO day key by a whole number of days. */
diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts
index 6d0f074a7..9aa25a588 100644
--- a/src/lib/dashboard/snapshot.ts
+++ b/src/lib/dashboard/snapshot.ts
@@ -120,6 +120,7 @@ import {
toProfileSex,
type ProfileSex,
} from "@/lib/profile/sex";
+import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
/** Briefing freshness window — mirrors the 24 h TTL on the advisor cache. */
const BRIEFING_TTL_MS = 24 * 60 * 60 * 1000;
@@ -602,6 +603,7 @@ async function buildNutrientWaterBlock(
prisma: PrismaClient,
userId: string,
): Promise<{ summary: DataSummary; lastSeenAt: string } | null> {
+ // eslint-disable-next-line healthlog/no-utc-day-key -- baseline: 30-day lower bound on stored water day keys; a day either way at the far edge does not change the tile
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
@@ -617,7 +619,7 @@ async function buildNutrientWaterBlock(
}
const points: DataPoint[] = [...sumByDay.entries()]
.map(([day, amount]) => ({
- date: new Date(`${day}T00:00:00.000Z`),
+ date: dayKeyAsUtcMidnight(day),
value: amount,
}))
.sort((a, b) => a.date.getTime() - b.date.getTime());
diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts
index fe2154b0a..53385ae25 100644
--- a/src/lib/doctor-report/collect.ts
+++ b/src/lib/doctor-report/collect.ts
@@ -73,6 +73,7 @@ import {
loadSurgicalHistory,
loadLabResults,
} from "./clinical-records";
+import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
const DENSE_REPORT_RAW_WINDOW_DAYS = 90;
@@ -166,8 +167,8 @@ export async function collectDoctorReportData(
// they compare against the window's first and last day in the report zone,
// not its instants: west of UTC the window starts hours after UTC midnight,
// and a course that ended on the first day read as over before it began.
- const firstDay = new Date(`${userDayKey(start, reportTz)}T00:00:00.000Z`);
- const lastDay = new Date(`${userDayKey(end, reportTz)}T00:00:00.000Z`);
+ const firstDay = dayKeyAsUtcMidnight(userDayKey(start, reportTz));
+ const lastDay = dayKeyAsUtcMidnight(userDayKey(end, reportTz));
const aggregateDenseTypes = days > DENSE_REPORT_RAW_WINDOW_DAYS;
const densePulse =
@@ -579,7 +580,7 @@ export async function collectDoctorReportData(
emergency,
] = await Promise.all([
gate.admits("CYCLE")
- ? buildCycleExportSummary(userId, end.toISOString().slice(0, 10))
+ ? buildCycleExportSummary(userId, userDayKey(end, reportTz))
: Promise.resolve(null),
gate.admits("LAB_RESULTS")
? loadLabResults(userId, start, end)
diff --git a/src/lib/documents/extract.ts b/src/lib/documents/extract.ts
index 3d02c342c..04477338f 100644
--- a/src/lib/documents/extract.ts
+++ b/src/lib/documents/extract.ts
@@ -52,6 +52,7 @@ import {
type MedicationStatementFactData,
type ObservationFactData,
} from "@/lib/validations/inbound-documents";
+import { statedDateKey } from "@/lib/tz/date-only";
const SYSTEM_PROMPT = `You transcribe a photograph, PDF, or scan of a CLINICAL DOCUMENT (a doctor's report or a hospital discharge letter) into structured facts.
@@ -103,11 +104,7 @@ NOTE: the document below was produced by automatic OCR, so it may contain garble
/** A normalised ISO date string (YYYY-MM-DD) or null. */
function normaliseDate(raw: string | null): string | null {
if (!raw) return null;
- const trimmed = raw.trim();
- if (!trimmed) return null;
- const parsed = new Date(trimmed);
- if (Number.isNaN(parsed.getTime())) return null;
- return parsed.toISOString().slice(0, 10);
+ return statedDateKey(raw);
}
export class InboundExtractError extends Error {
diff --git a/src/lib/documents/ingest.ts b/src/lib/documents/ingest.ts
index ece28bae6..9af617538 100644
--- a/src/lib/documents/ingest.ts
+++ b/src/lib/documents/ingest.ts
@@ -58,7 +58,7 @@ import type {
DocumentSourceSystemValue,
InboundDocumentKindValue,
} from "@/lib/validations/inbound-documents";
-import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only";
+import { dateOnlyAtNoonUtc, dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
import { userDayKey } from "@/lib/tz/format";
import { resolveUserTimezone } from "@/lib/tz/resolver";
@@ -84,7 +84,7 @@ export function personalUploadBucket(userId: string): string {
* a stored date goes through `dateOnlyAtNoonUtc`.
*/
export function isoDateToUtc(value: string): Date {
- return new Date(`${value}T00:00:00.000Z`);
+ return dayKeyAsUtcMidnight(value);
}
export interface IngestInput {
diff --git a/src/lib/documents/store.ts b/src/lib/documents/store.ts
index 268504ed7..41849833b 100644
--- a/src/lib/documents/store.ts
+++ b/src/lib/documents/store.ts
@@ -35,6 +35,7 @@ import type {
InboundDocumentKindValue,
InboundFactType,
} from "@/lib/validations/inbound-documents";
+import { dateOnlyKey } from "@/lib/tz/date-only";
/**
* The two storage codecs `InboundDocument.contentEncrypted` may carry,
@@ -226,12 +227,8 @@ export function serialiseDocument(
byteSize: doc.byteSize,
status: doc.status as InboundDocumentStatus,
providerType: doc.providerType,
- reportDate: doc.reportDate
- ? doc.reportDate.toISOString().slice(0, 10)
- : null,
- documentDate: doc.documentDate
- ? doc.documentDate.toISOString().slice(0, 10)
- : null,
+ reportDate: doc.reportDate ? dateOnlyKey(doc.reportDate) : null,
+ documentDate: doc.documentDate ? dateOnlyKey(doc.documentDate) : null,
errorReason: doc.errorReason,
factCount: counts.factCount,
pendingCount: counts.pendingCount,
diff --git a/src/lib/environment/service.ts b/src/lib/environment/service.ts
index 3c2fb43ed..a38f95d00 100644
--- a/src/lib/environment/service.ts
+++ b/src/lib/environment/service.ts
@@ -28,8 +28,8 @@ import {
type DailyEnvironmentObservation,
} from "@/lib/environment/open-meteo";
import type { EnvironmentLocationSource } from "@/generated/prisma/client";
+import { shiftDateKey } from "@/lib/tz/format";
-const MS_PER_DAY = 24 * 60 * 60 * 1000;
/** Default lookback window (days) for the nightly fetch — absorbs the archive
* settling lag and any days missed across worker reboots. */
export const ENVIRONMENT_LOOKBACK_DAYS = 7;
@@ -63,19 +63,15 @@ interface TravelOverride {
/** UTC YYYY-MM-DD for `date` (used to bound the default lookback window). */
export function utcDayKey(date: Date): string {
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the environment module keys homeSince and the backfill window on UTC days throughout, and the worker compares against the same keys
return date.toISOString().slice(0, 10);
}
/** Inclusive list of YYYY-MM-DD keys from `start` to `end` (date arithmetic). */
export function enumerateDays(start: string, end: string): string[] {
const out: string[] = [];
- const [sy, sm, sd] = start.split("-").map(Number);
- const [ey, em, ed] = end.split("-").map(Number);
- let cur = Date.UTC(sy, sm - 1, sd);
- const last = Date.UTC(ey, em - 1, ed);
- while (cur <= last) {
- out.push(new Date(cur).toISOString().slice(0, 10));
- cur += MS_PER_DAY;
+ for (let cur = start; cur <= end; cur = shiftDateKey(cur, 1)) {
+ out.push(cur);
}
return out;
}
diff --git a/src/lib/export/records-backup.ts b/src/lib/export/records-backup.ts
index 2bb4cd278..75fdfb71d 100644
--- a/src/lib/export/records-backup.ts
+++ b/src/lib/export/records-backup.ts
@@ -32,6 +32,7 @@ import {
type AllergyDTO,
type FamilyHistoryEntryDTO,
} from "@/lib/records/dto";
+import { dateOnlyKey } from "@/lib/tz/date-only";
/** Disclosed in the payload manifest AND mirrored in the export UI copy. */
export const DOCUMENTS_MANIFEST_NOTE =
@@ -543,10 +544,8 @@ export async function buildRecordsBackupSection(
mimeType: d.mimeType,
byteSize: d.byteSize,
status: d.status,
- reportDate: d.reportDate ? d.reportDate.toISOString().slice(0, 10) : null,
- documentDate: d.documentDate
- ? d.documentDate.toISOString().slice(0, 10)
- : null,
+ reportDate: d.reportDate ? dateOnlyKey(d.reportDate) : null,
+ documentDate: d.documentDate ? dateOnlyKey(d.documentDate) : null,
sourceSystem: d.sourceSystem,
sourceId: d.sourceId,
sourceInstance: d.sourceInstance,
diff --git a/src/lib/fitbit/client.ts b/src/lib/fitbit/client.ts
index 3d39eea8c..b7be1b503 100644
--- a/src/lib/fitbit/client.ts
+++ b/src/lib/fitbit/client.ts
@@ -386,6 +386,7 @@ export const FITBIT_SLEEP_RANGE_DAYS = 30;
/** `YYYY-MM-DD` (UTC) for a Fitbit date-path segment. */
export function fitbitDate(d: Date): string {
+ // eslint-disable-next-line healthlog/no-utc-day-key -- baseline: request window edge for the provider API, not a displayed day; rows carry their own dates
return d.toISOString().slice(0, 10);
}
diff --git a/src/lib/google-health/client.ts b/src/lib/google-health/client.ts
index f9cc7ea90..923968b2a 100644
--- a/src/lib/google-health/client.ts
+++ b/src/lib/google-health/client.ts
@@ -646,6 +646,7 @@ export function incrementalFilter(
case "date":
return {
field: `${dateStyle === "camel" ? dataType.key : dataType.filter}.date`,
+ // eslint-disable-next-line healthlog/no-utc-day-key -- baseline: request window edge for the provider API, not a displayed day; rows carry their own dates
bound: start.toISOString().slice(0, 10),
};
case "rollup":
diff --git a/src/lib/illness/correlation.ts b/src/lib/illness/correlation.ts
index 7dd52abcc..95f47e60b 100644
--- a/src/lib/illness/correlation.ts
+++ b/src/lib/illness/correlation.ts
@@ -44,6 +44,7 @@ import {
nowProvenanceTimestamp,
} from "@/lib/insights/derived/coverage";
import type { Derived } from "@/lib/insights/derived/types";
+import { daysBetweenDateKeys } from "@/lib/tz/format";
/* ── tunables (documented invariants) ────────────────────────────────── */
@@ -1050,7 +1051,5 @@ function runFlag(
/** Whole-day signed difference `to − from` for two `YYYY-MM-DD` keys. */
export function dayDiff(from: string, to: string): number {
- const a = Date.parse(`${from}T00:00:00Z`);
- const b = Date.parse(`${to}T00:00:00Z`);
- return Math.round((b - a) / (24 * 60 * 60 * 1000));
+ return daysBetweenDateKeys(from, to);
}
diff --git a/src/lib/insights/briefing-failure-marker.ts b/src/lib/insights/briefing-failure-marker.ts
index 71abc304a..025a3abe4 100644
--- a/src/lib/insights/briefing-failure-marker.ts
+++ b/src/lib/insights/briefing-failure-marker.ts
@@ -61,6 +61,7 @@ export function classifyBriefingFailure(args: {
/** UTC YYYY-MM-DD calendar-day key for the marker payload. */
function dateKey(at: Date = new Date()): string {
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: internal once-per-day marker key, written and read by this module only, never shown
return at.toISOString().slice(0, 10);
}
diff --git a/src/lib/insights/comprehensive-aggregator.ts b/src/lib/insights/comprehensive-aggregator.ts
index 9f10da2e0..548f89268 100644
--- a/src/lib/insights/comprehensive-aggregator.ts
+++ b/src/lib/insights/comprehensive-aggregator.ts
@@ -123,6 +123,7 @@ import {
isFullyCovered,
probeRollupCoverage,
} from "@/lib/rollups/measurement-coverage";
+import { dateOnlyKey } from "@/lib/tz/date-only";
/**
* Heavy aggregate row — count/min/max/mean alongside the non-composable
@@ -939,7 +940,7 @@ function buildDailyByType(
// `bucketStart` is the UTC midnight of the bucketed day — the
// same boundary Postgres' `date_trunc('day', ...)` would pick
// when the session timezone is UTC (the container default).
- day: b.day.toISOString().slice(0, 10),
+ day: dateOnlyKey(b.day),
// Match the legacy `ROUND(AVG, 2)::double precision` semantics
// so correlation thresholds tuned against the v1.4.34 output
// don't drift on the third decimal.
diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts
index 0009faab5..79d81f52d 100644
--- a/src/lib/insights/comprehensive-generate.ts
+++ b/src/lib/insights/comprehensive-generate.ts
@@ -155,6 +155,7 @@ const BRIEFING_REROLL_TEMPERATURE = 0.6;
/** UTC YYYY-MM-DD calendar-day key gating the once-per-day briefing re-roll. */
function buildRerollDateKey(at: Date = new Date()): string {
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: internal once-per-day re-roll gate key, never shown
return at.toISOString().slice(0, 10);
}
diff --git a/src/lib/insights/correlation-channel-series.ts b/src/lib/insights/correlation-channel-series.ts
index 9efd60145..aaed68cfa 100644
--- a/src/lib/insights/correlation-channel-series.ts
+++ b/src/lib/insights/correlation-channel-series.ts
@@ -60,6 +60,7 @@ import type {
MeasurementType,
} from "@/generated/prisma/client";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
+import { dateOnlyKey } from "@/lib/tz/date-only";
/**
* v1.21.0 (FDREXTEND) — build the user's MEDICATION_COMPLIANCE daily series.
@@ -223,6 +224,7 @@ export async function fetchEnvironmentSeries(
userId: string,
since: Date,
): Promise {
+ // eslint-disable-next-line healthlog/no-utc-day-key -- baseline: lower bound on stored environment day keys; a day either way at the far edge does not change the series
const sinceKey = since.toISOString().slice(0, 10);
const rows = await prisma.environmentContext.findMany({
where: { userId, date: { gte: sinceKey } },
@@ -758,7 +760,7 @@ function composeRollupDailyMeans(
// DAY buckets are minted at UTC midnight; the UTC date IS the bucket's
// day key (the same convention graded-series and the derived baselines
// read the tier under).
- const day = r.bucketStart.toISOString().slice(0, 10);
+ const day = dateOnlyKey(r.bucketStart);
const acc = byDay.get(day) ?? { sum: 0, count: 0 };
acc.sum += sum;
acc.count += r.count;
diff --git a/src/lib/insights/correlation-discovery.ts b/src/lib/insights/correlation-discovery.ts
index 49852dd93..0fd87e959 100644
--- a/src/lib/insights/correlation-discovery.ts
+++ b/src/lib/insights/correlation-discovery.ts
@@ -41,6 +41,7 @@
import { pearson, MIN_PAIRED_N } from "@/lib/insights/correlations";
import { type Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
+import { shiftDateKey } from "@/lib/tz/format";
/**
* Locale-aware string builder — the same signature the server translator
@@ -238,10 +239,7 @@ export interface CorrelationDiscoveryResult {
/** Add `lagDays` to a YYYY-MM-DD day key, returning the shifted key. */
function shiftDay(day: string, lagDays: number): string {
- const [y, m, d] = day.split("-").map(Number);
- const dt = new Date(Date.UTC(y, m - 1, d));
- dt.setUTCDate(dt.getUTCDate() + lagDays);
- return dt.toISOString().slice(0, 10);
+ return shiftDateKey(day, lagDays);
}
/**
diff --git a/src/lib/insights/derived/adherence-storyline.ts b/src/lib/insights/derived/adherence-storyline.ts
index fd06e1a8b..27a5c3962 100644
--- a/src/lib/insights/derived/adherence-storyline.ts
+++ b/src/lib/insights/derived/adherence-storyline.ts
@@ -36,7 +36,7 @@ import {
type MedTargetClass,
} from "@/lib/medications/med-target-map";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
-import { dbDate } from "@/lib/tz/date-only";
+import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const MAD_TO_SIGMA = 1.4826;
@@ -174,7 +174,10 @@ export async function buildAdherenceStoryline(
// v1.39.1 (#1033) — the adherence figure below counts tracked
// medications only, so a record-only one cannot be its subject.
...TRACKED_INTAKE_WHERE,
- OR: [{ endsOn: null }, { endsOn: { gte: dbDate(todayKey) } }],
+ OR: [
+ { endsOn: null },
+ { endsOn: { gte: dayKeyAsUtcMidnight(todayKey) } },
+ ],
},
select: { name: true, treatmentClass: true },
});
diff --git a/src/lib/insights/derived/baseline.ts b/src/lib/insights/derived/baseline.ts
index 821ab52d6..c267e44c4 100644
--- a/src/lib/insights/derived/baseline.ts
+++ b/src/lib/insights/derived/baseline.ts
@@ -54,6 +54,7 @@ import {
type Derived,
type DerivedProvenanceSource,
} from "./types";
+import { dateOnlyKey } from "@/lib/tz/date-only";
/** k for the median ± k·MAD band — ≈3σ-equivalent for normal data. */
const DEFAULT_MAD_K = 3;
@@ -255,7 +256,7 @@ export async function readDayMeanSeries(
const points = resolved.rows
.filter((row) => isPlausibleMetricValue(type, row.mean))
.map((row) => ({
- day: row.bucketStart.toISOString().slice(0, 10),
+ day: dateOnlyKey(row.bucketStart),
mean: row.mean,
}));
if (points.length > 0) return { points, source: "DAY" };
diff --git a/src/lib/insights/derived/trajectory.ts b/src/lib/insights/derived/trajectory.ts
index 244a06e63..5707ca590 100644
--- a/src/lib/insights/derived/trajectory.ts
+++ b/src/lib/insights/derived/trajectory.ts
@@ -52,6 +52,7 @@ import {
} from "./coverage";
import { readDayMeanSeries, type BaselineProfile } from "./baseline";
import type { Derived, DerivedProvenanceSource } from "./types";
+import { daysBetweenDateKeys, shiftDateKey } from "@/lib/tz/format";
/** Trailing window the fit reads (days). Matches the baseline engine. */
const DEFAULT_WINDOW_DAYS = 30;
@@ -266,11 +267,7 @@ export async function computeTrajectory(
}
// x = days since the FIRST in-window day; y = the per-day mean.
- const dayMs = 24 * 60 * 60 * 1000;
- const startMs = new Date(`${points[0].day}T00:00:00Z`).getTime();
- const xs = points.map(
- (p) => (new Date(`${p.day}T00:00:00Z`).getTime() - startMs) / dayMs,
- );
+ const xs = points.map((p) => daysBetweenDateKeys(points[0].day, p.day));
const ys = points.map((p) => p.mean);
const fit = fitOls(xs, ys);
@@ -286,9 +283,7 @@ export async function computeTrajectory(
// Project from the last observed day-offset forward over the horizon,
// each point carrying the widening prediction band.
const lastX = xs[xs.length - 1];
- const lastDayMs = new Date(
- `${points[points.length - 1].day}T00:00:00Z`,
- ).getTime();
+ const lastDay = points[points.length - 1].day;
const projection: TrajectoryPoint[] = [];
for (let h = 1; h <= horizonDays; h++) {
const x = lastX + h;
@@ -296,7 +291,7 @@ export async function computeTrajectory(
const half = predictionIntervalHalfWidth(fit, x);
projection.push({
dayOffset: h,
- date: new Date(lastDayMs + h * dayMs).toISOString().slice(0, 10),
+ date: shiftDateKey(lastDay, h),
projected,
bandLow: projected - half,
bandHigh: projected + half,
diff --git a/src/lib/insights/derived/wellness-scores.ts b/src/lib/insights/derived/wellness-scores.ts
index fb486bbef..4e0830d4b 100644
--- a/src/lib/insights/derived/wellness-scores.ts
+++ b/src/lib/insights/derived/wellness-scores.ts
@@ -23,6 +23,7 @@ import type { StrainAnchor } from "@/lib/insights/strain-score";
import { computeReadiness, type ReadinessComponent } from "./readiness";
import { resolveCanonicalRecovery } from "./recovery-resolve";
import { SPARKLINE_MAX_POINTS, type Derived } from "./types";
+import { dateOnlyKey } from "@/lib/tz/date-only";
/** A 0–100 wellness score band. Higher is better for recovery; for stress a
* higher score is worse, so the band direction flips (see `WELLNESS_DIR`). */
@@ -207,7 +208,7 @@ export async function computeWellnessScore(
// truth is the cache row, not a re-derivation here.
let anchor: StrainAnchor | null = null;
if (type === "STRAIN_SCORE") {
- const day = latest.measuredAt.toISOString().slice(0, 10);
+ const day = dateOnlyKey(latest.measuredAt);
const cache = await prisma.strainTrimpCache.findUnique({
where: { userId_day: { userId, day } },
select: { anchor: true },
diff --git a/src/lib/insights/glp1-plateau.ts b/src/lib/insights/glp1-plateau.ts
index 3f4cb2768..3bdc36ea3 100644
--- a/src/lib/insights/glp1-plateau.ts
+++ b/src/lib/insights/glp1-plateau.ts
@@ -104,6 +104,7 @@ export async function detectGlp1Plateau(
drug,
doseValue: latestDose.doseValue,
doseUnit: latestDose.doseUnit,
+ // eslint-disable-next-line healthlog/no-utc-day-key -- baseline: dose-start label in a prompt; this builder has no zone yet
doseSince: latestDose.effectiveFrom.toISOString().slice(0, 10),
daysOnDose,
weightDeltaKg: Math.round(delta * 10) / 10,
diff --git a/src/lib/insights/labs-changes.ts b/src/lib/insights/labs-changes.ts
index a16ea22cc..c3afb2cfe 100644
--- a/src/lib/insights/labs-changes.ts
+++ b/src/lib/insights/labs-changes.ts
@@ -84,6 +84,7 @@ const ABSENT: LabChangesSummary = {
/** UTC calendar-day key (YYYY-MM-DD) for a sample instant. */
function dayKey(d: Date): string {
+ // eslint-disable-next-line healthlog/no-utc-day-key -- baseline: groups draws by UTC day; this summariser has no zone yet (labReadingDay is the target)
return d.toISOString().slice(0, 10);
}
diff --git a/src/lib/insights/measurement-freshness.ts b/src/lib/insights/measurement-freshness.ts
index 54068b1ba..2fcf162fa 100644
--- a/src/lib/insights/measurement-freshness.ts
+++ b/src/lib/insights/measurement-freshness.ts
@@ -1,3 +1,4 @@
+import { daysBetweenDateKeys } from "@/lib/tz/format";
/**
* How old a reading may be before "today" stops being true about it.
*
@@ -50,8 +51,6 @@ export function isCurrentForTodayClaim(
* answers `null` rather than a fabricated distance.
*/
export function dayKeyAgeInDays(then: string, today: string): number | null {
- const a = Date.parse(`${then}T00:00:00.000Z`);
- const b = Date.parse(`${today}T00:00:00.000Z`);
- if (!Number.isFinite(a) || !Number.isFinite(b)) return null;
- return Math.round((b - a) / 86_400_000);
+ const days = daysBetweenDateKeys(then, today);
+ return Number.isFinite(days) ? days : null;
}
diff --git a/src/lib/insights/mood-crosstab.ts b/src/lib/insights/mood-crosstab.ts
index 8e42fc9ff..2e59b2ce5 100644
--- a/src/lib/insights/mood-crosstab.ts
+++ b/src/lib/insights/mood-crosstab.ts
@@ -17,7 +17,7 @@ import { welchTTest } from "@/lib/insights/correlations";
import { round } from "@/lib/insights/status-shared";
import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum";
import { MS_PER_DAY } from "@/lib/time-constants";
-import { userDayKey } from "@/lib/tz/format";
+import { userDayKey, shiftDateKey } from "@/lib/tz/format";
import type {
CrossMetricMeasurement,
MoodAggregateEntry,
@@ -254,10 +254,7 @@ export function metricDayMap(
/** Add `lagDays` to a YYYY-MM-DD day key (UTC-anchored, DST-immune). */
export function shiftDayKey(day: string, lagDays: number): string {
- const [y, m, d] = day.split("-").map(Number);
- const dt = new Date(Date.UTC(y, m - 1, d));
- dt.setUTCDate(dt.getUTCDate() + lagDays);
- return dt.toISOString().slice(0, 10);
+ return shiftDateKey(day, lagDays);
}
/** Convert a raw metric value to the row's display unit. */
diff --git a/src/lib/insights/score-row.ts b/src/lib/insights/score-row.ts
index 98d63211a..c82b07487 100644
--- a/src/lib/insights/score-row.ts
+++ b/src/lib/insights/score-row.ts
@@ -34,6 +34,7 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000;
* `YYYY-MM-DD`. Resolved in ONE place so all three engines agree.
*/
export function scoreDayKey(now: Date): string {
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the derived-score engines score UTC days, resolved here in one place (see the doc comment)
return new Date(now.getTime() - MS_PER_DAY).toISOString().slice(0, 10);
}
diff --git a/src/lib/insights/strain-score.ts b/src/lib/insights/strain-score.ts
index 00c2bc400..3cfee08f0 100644
--- a/src/lib/insights/strain-score.ts
+++ b/src/lib/insights/strain-score.ts
@@ -71,6 +71,7 @@ import {
scoreMeasuredAt,
upsertScoreRow,
} from "@/lib/insights/score-row";
+import { shiftDateKey } from "@/lib/tz/format";
/** The per-day idempotency-key prefix for a stored Strain score row. */
export const STRAIN_SCORE_EXTERNAL_ID_PREFIX = "strain:";
@@ -395,6 +396,7 @@ export async function computeStrainScore(
},
): Promise {
const dayKey = strainDayKey(now);
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: UTC day window of the UTC-day score engines (scoreDayKey)
const dayStart = new Date(`${dayKey}T00:00:00.000Z`);
const dayEnd = new Date(dayStart.getTime() + MS_PER_DAY);
@@ -548,12 +550,7 @@ export async function loadStrainAnchorHistory(
now: Date,
): Promise {
const dayKey = strainDayKey(now);
- const windowStart = new Date(
- new Date(`${dayKey}T00:00:00.000Z`).getTime() -
- STRAIN_CHRONIC_WINDOW_DAYS * MS_PER_DAY,
- )
- .toISOString()
- .slice(0, 10);
+ const windowStart = shiftDateKey(dayKey, -STRAIN_CHRONIC_WINDOW_DAYS);
// The chronic window of cached rows, excluding the scored day itself (its
// TRIMP is added at compute time so the distribution always reflects the
diff --git a/src/lib/insights/streak-detector.ts b/src/lib/insights/streak-detector.ts
index bb21c1ffb..0b0db4ac3 100644
--- a/src/lib/insights/streak-detector.ts
+++ b/src/lib/insights/streak-detector.ts
@@ -30,6 +30,7 @@
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { buildBaselineBand } from "@/lib/insights/derived/baseline";
+import { daysBetweenDateKeys } from "@/lib/tz/format";
/** One DAY-bucket point: a calendar day key (YYYY-MM-DD) + that day's mean. */
export interface StreakPoint {
@@ -220,8 +221,6 @@ function buildBandFromSeries(
/** Whole-day gap between two YYYY-MM-DD keys (>=1; 1 = adjacent). */
function dayGap(earlier: string, later: string): number {
- const a = Date.parse(`${earlier}T00:00:00Z`);
- const b = Date.parse(`${later}T00:00:00Z`);
- if (Number.isNaN(a) || Number.isNaN(b)) return Number.POSITIVE_INFINITY;
- return Math.round((b - a) / (24 * 60 * 60 * 1000));
+ const gap = daysBetweenDateKeys(earlier, later);
+ return Number.isNaN(gap) ? Number.POSITIVE_INFINITY : gap;
}
diff --git a/src/lib/insights/stress-score.ts b/src/lib/insights/stress-score.ts
index 887eed35f..04d572859 100644
--- a/src/lib/insights/stress-score.ts
+++ b/src/lib/insights/stress-score.ts
@@ -133,6 +133,7 @@ async function readIntradaySdnn(
now: Date,
): Promise {
const dayKey = stressDayKey(now);
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: UTC day window of the UTC-day score engines (scoreDayKey)
const dayStart = new Date(`${dayKey}T00:00:00.000Z`);
const dayEnd = new Date(dayStart.getTime() + MS_PER_DAY);
const rows = await prisma.measurement.findMany({
diff --git a/src/lib/jobs/coach-plan-review.ts b/src/lib/jobs/coach-plan-review.ts
index acf500618..9ea17eff0 100644
--- a/src/lib/jobs/coach-plan-review.ts
+++ b/src/lib/jobs/coach-plan-review.ts
@@ -293,6 +293,7 @@ export async function runCoachPlanReviewTick(
timeZone: "UTC",
db: prisma,
});
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the day series above is read with timeZone UTC, so the plan start is cut on the same calendar
const startKey = plan.createdAt.toISOString().slice(0, 10);
const before: number[] = [];
const after: number[] = [];
diff --git a/src/lib/jobs/offhost-backup.ts b/src/lib/jobs/offhost-backup.ts
index a2f2e34fa..aad173ceb 100644
--- a/src/lib/jobs/offhost-backup.ts
+++ b/src/lib/jobs/offhost-backup.ts
@@ -897,6 +897,7 @@ export async function runOffhostBackup(
}
const s3 = s3Override ?? (await getS3Client(cfg));
const runStartedAt = options.runStartedAt;
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: off-host object key date, the name the restore drill and the purge look up
const dateKey = (runStartedAt ?? now).toISOString().slice(0, 10);
const users = await prisma.user.findMany({
diff --git a/src/lib/jobs/offhost-purge.ts b/src/lib/jobs/offhost-purge.ts
index a42fcb78f..c7089b98a 100644
--- a/src/lib/jobs/offhost-purge.ts
+++ b/src/lib/jobs/offhost-purge.ts
@@ -211,7 +211,8 @@ export async function processOffhostPurges(
const subject = request.subjectId;
const day =
request.reason === "data_wiped" && live.has(subject)
- ? request.requestedAt.toISOString().slice(0, 10)
+ ? // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: matches the UTC dates in the off-host object keys
+ request.requestedAt.toISOString().slice(0, 10)
: null;
const prior = lastDayToDelete.get(subject);
if (prior === undefined) lastDayToDelete.set(subject, day);
diff --git a/src/lib/jobs/reminder/cleanup-handlers.ts b/src/lib/jobs/reminder/cleanup-handlers.ts
index 5795aae78..14c86bb90 100644
--- a/src/lib/jobs/reminder/cleanup-handlers.ts
+++ b/src/lib/jobs/reminder/cleanup-handlers.ts
@@ -104,6 +104,7 @@ export async function handleMoodReminderCleanup(
try {
const cutoff = new Date();
cutoff.setUTCDate(cutoff.getUTCDate() - MOOD_REMINDER_RETENTION_DAYS);
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: retention cutoff; a day either way does not matter
const cutoffIso = cutoff.toISOString().slice(0, 10);
const deleted = await p.moodReminderDispatch.deleteMany({
where: { date: { lt: cutoffIso } },
diff --git a/src/lib/jobs/restore-drill.ts b/src/lib/jobs/restore-drill.ts
index 4571af414..efd0baf6e 100644
--- a/src/lib/jobs/restore-drill.ts
+++ b/src/lib/jobs/restore-drill.ts
@@ -167,6 +167,7 @@ export async function runRestoreDrill(
}
const ageDays = Math.floor(
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: age of a UTC-dated off-host object key
(now.getTime() - Date.parse(`${dateKey}T00:00:00Z`)) /
(24 * 60 * 60 * 1000),
);
diff --git a/src/lib/jobs/step-consolidation-repair.ts b/src/lib/jobs/step-consolidation-repair.ts
index a0b1db8d2..09c8a3bc3 100644
--- a/src/lib/jobs/step-consolidation-repair.ts
+++ b/src/lib/jobs/step-consolidation-repair.ts
@@ -119,6 +119,7 @@ export async function runStepConsolidationRepairForUser(
// in different UTC days, so both instants are tracked).
const touchedByUtcDay = new Map();
const markTouched = (instant: Date) => {
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the rollup tier keys DAY buckets by UTC day (see the comment above)
touchedByUtcDay.set(instant.toISOString().slice(0, 10), instant);
};
diff --git a/src/lib/labs/ocr-extract.ts b/src/lib/labs/ocr-extract.ts
index bd1872833..af2926a09 100644
--- a/src/lib/labs/ocr-extract.ts
+++ b/src/lib/labs/ocr-extract.ts
@@ -41,6 +41,7 @@ import {
labReadingDaySearchRange,
} from "@/lib/labs/reading-day";
import { resolveUserTimezone } from "@/lib/tz/resolver";
+import { statedDateKey } from "@/lib/tz/date-only";
const SYSTEM_PROMPT = `You transcribe a photograph or PDF of a laboratory test report into structured data.
@@ -95,12 +96,8 @@ Respond ONLY with a JSON object of this exact shape:
/** A normalised ISO date string (YYYY-MM-DD) or null. */
function normaliseDate(raw: string | null): string | null {
if (!raw) return null;
- const trimmed = raw.trim();
- if (!trimmed) return null;
- const parsed = new Date(trimmed);
- if (Number.isNaN(parsed.getTime())) return null;
// Keep only the calendar day — the lab reports a date, not an instant.
- return parsed.toISOString().slice(0, 10);
+ return statedDateKey(raw);
}
/**
diff --git a/src/lib/labs/reading-day.ts b/src/lib/labs/reading-day.ts
index 8c3318c4d..47937c191 100644
--- a/src/lib/labs/reading-day.ts
+++ b/src/lib/labs/reading-day.ts
@@ -9,7 +9,7 @@
* put a morning draw east of UTC on the previous day, so a scan of the same
* report wrote the reading a second time.
*/
-import { dateOnlyKey, dbDate } from "@/lib/tz/date-only";
+import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
const DAY_MS = 86_400_000;
@@ -31,7 +31,7 @@ export function labReadingDaySearchRange(day: string): {
lt: Date;
} {
return {
- gte: dbDate(shiftDateKey(day, -1)),
- lt: dbDate(shiftDateKey(day, 2)),
+ gte: dayKeyAsUtcMidnight(shiftDateKey(day, -1)),
+ lt: dayKeyAsUtcMidnight(shiftDateKey(day, 2)),
};
}
diff --git a/src/lib/labs/vault-link.ts b/src/lib/labs/vault-link.ts
index 4d4a42d5d..936efd781 100644
--- a/src/lib/labs/vault-link.ts
+++ b/src/lib/labs/vault-link.ts
@@ -28,6 +28,7 @@ import { encryptFactData, encryptFactProvenance } from "@/lib/documents/store";
import { annotate } from "@/lib/logging/context";
import { isModuleEnabled } from "@/lib/modules/gate";
import type { ObservationFactData } from "@/lib/validations/inbound-documents";
+import { dateOnlyKey } from "@/lib/tz/date-only";
/** One inserted lab row to cross-link back to its source vault document. */
export interface InsertedLabForLink {
@@ -54,7 +55,7 @@ function observationFromLab(lab: InsertedLabForLink): ObservationFactData {
unit: lab.unit,
referenceLow: lab.referenceLow,
referenceHigh: lab.referenceHigh,
- effectiveDate: lab.takenAt.toISOString().slice(0, 10),
+ effectiveDate: dateOnlyKey(lab.takenAt),
};
}
diff --git a/src/lib/managed-profiles/lifecycle.ts b/src/lib/managed-profiles/lifecycle.ts
index 1160825e5..5ab65a432 100644
--- a/src/lib/managed-profiles/lifecycle.ts
+++ b/src/lib/managed-profiles/lifecycle.ts
@@ -5,6 +5,7 @@ import {
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import type { Prisma } from "@/generated/prisma/client";
+import { dayKeyAsUtcMidnight, dateOnlyKey } from "@/lib/tz/date-only";
type Transaction = Prisma.TransactionClient;
@@ -266,7 +267,7 @@ export function toManagedProfileView(row: {
return {
id: row.id,
displayName: row.displayName,
- dateOfBirth: row.dateOfBirth?.toISOString().slice(0, 10) ?? null,
+ dateOfBirth: row.dateOfBirth ? dateOnlyKey(row.dateOfBirth) : null,
gender: row.gender,
locale: row.locale,
timezone: row.timezone,
@@ -373,7 +374,7 @@ export async function updateManagedProfile(input: {
...(patch.dateOfBirth !== undefined
? {
dateOfBirth: patch.dateOfBirth
- ? new Date(`${patch.dateOfBirth}T00:00:00.000Z`)
+ ? dayKeyAsUtcMidnight(patch.dateOfBirth)
: null,
}
: {}),
diff --git a/src/lib/mcp/record-search.ts b/src/lib/mcp/record-search.ts
index 3084f5b12..bb1b69ffc 100644
--- a/src/lib/mcp/record-search.ts
+++ b/src/lib/mcp/record-search.ts
@@ -453,6 +453,7 @@ function clip(text: string, max: number): { text: string; truncated: boolean } {
}
function dateOnly(value: Date | null): string | null {
+ // eslint-disable-next-line healthlog/no-utc-day-key -- baseline: text label mixing date-only columns and instants, read without a zone
return value ? value.toISOString().slice(0, 10) : null;
}
diff --git a/src/lib/measurements/dense-intraday-retention.ts b/src/lib/measurements/dense-intraday-retention.ts
index 032b173b8..e5e939613 100644
--- a/src/lib/measurements/dense-intraday-retention.ts
+++ b/src/lib/measurements/dense-intraday-retention.ts
@@ -708,6 +708,7 @@ export async function runDenseIntradayRetention(
// The fold's canonical timestamp is local-noon of the day; reuse
// its UTC date for the resting row's `stats:` key so a re-run
// upserts the same row instead of minting a sibling.
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: stable stats: externalId key; changing it would mint a sibling row on re-run
canonicalTimestamp.toISOString().slice(0, 10),
);
await pc.measurement.upsert({
diff --git a/src/lib/measurements/intraday-cumulative-profile.ts b/src/lib/measurements/intraday-cumulative-profile.ts
index 6d527017a..f05e95cd0 100644
--- a/src/lib/measurements/intraday-cumulative-profile.ts
+++ b/src/lib/measurements/intraday-cumulative-profile.ts
@@ -250,6 +250,7 @@ export async function pruneIntradayCumulativeProfiles(
retentionDays: number = CUMULATIVE_PROFILE_RETENTION_DAYS,
): Promise {
const cutoffKey = shiftDateKey(
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: 400-day retention cutoff; no zone moves it by more than a day (see the doc comment)
now.toISOString().slice(0, 10),
-retentionDays,
);
diff --git a/src/lib/medications/scheduling/project-today-intakes.ts b/src/lib/medications/scheduling/project-today-intakes.ts
index 940162cc9..1ab61da58 100644
--- a/src/lib/medications/scheduling/project-today-intakes.ts
+++ b/src/lib/medications/scheduling/project-today-intakes.ts
@@ -42,6 +42,7 @@ import {
LIVE_ERA_REVISION_ARGS,
liveEraStart,
} from "@/lib/medications/scheduling/live-era";
+import { dayKeyForScheduledFor } from "@/lib/rollups/medication-compliance-rollups";
export interface ProjectTodayIntakesResult {
projected: number;
@@ -238,7 +239,9 @@ export async function projectTodayIntakesAndRecompute(input: {
const seenDayKeys = new Set();
const recomputeJobs: Array> = [];
for (const m of missing) {
- const key = `${m.medicationId}|${m.scheduledFor.toISOString().slice(0, 10)}`;
+ // The compliance rollup's own day for the slot: two slots on different
+ // local days that share a UTC date must each get their recompute.
+ const key = `${m.medicationId}|${dayKeyForScheduledFor(m.scheduledFor, userTz)}`;
if (seenDayKeys.has(key)) continue;
seenDayKeys.add(key);
recomputeJobs.push(
diff --git a/src/lib/mood/linked-context.ts b/src/lib/mood/linked-context.ts
index a39910892..ce632e10b 100644
--- a/src/lib/mood/linked-context.ts
+++ b/src/lib/mood/linked-context.ts
@@ -236,6 +236,7 @@ function localDayWindow(day: string): { from: Date; to: Date } {
// helper agrees belongs to this day. Cheaper and far safer than arithmetic
// on `new Date(y, m, d)`, which slips by an hour twice a year and has
// already been solved once in this codebase.
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: anchor of a superset window; rows are kept only when their own local day matches
const naive = new Date(`${day}T00:00:00.000Z`);
const from = new Date(naive.getTime() - 26 * 60 * 60 * 1000);
const to = new Date(naive.getTime() + 50 * 60 * 60 * 1000);
diff --git a/src/lib/nutrients/day-bounds.ts b/src/lib/nutrients/day-bounds.ts
index ee440c155..64b6c816e 100644
--- a/src/lib/nutrients/day-bounds.ts
+++ b/src/lib/nutrients/day-bounds.ts
@@ -16,5 +16,6 @@
*/
export function maxAcceptableNutrientDay(now: Date): string {
const limit = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000);
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: upper sanity bound with two days of slack for every real zone (see the doc comment)
return limit.toISOString().slice(0, 10);
}
diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts
index cc087bbc2..194aaa626 100644
--- a/src/lib/oura/sync.ts
+++ b/src/lib/oura/sync.ts
@@ -181,6 +181,7 @@ export async function recordOuraSyncFailure(
}
function ymd(d: Date): string {
+ // eslint-disable-next-line healthlog/no-utc-day-key -- baseline: request window edge for the provider API, not a displayed day; rows carry their own dates
return d.toISOString().slice(0, 10);
}
diff --git a/src/lib/polar/sync.ts b/src/lib/polar/sync.ts
index db71ff320..23db25dbf 100644
--- a/src/lib/polar/sync.ts
+++ b/src/lib/polar/sync.ts
@@ -140,6 +140,7 @@ function toUpsert(
// A timed row keys on its instant's UTC date, as it always has.
externalId:
m.externalId ??
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: timed rows keep their historical UTC-date externalId so re-syncs upsert (see the comment above)
`${resourcePrefix}:${m.day ?? m.measuredAt.toISOString().slice(0, 10)}:${m.fieldTag}`,
sleepStage: m.sleepStage ?? null,
}));
diff --git a/src/lib/rollups/mood-rollups.ts b/src/lib/rollups/mood-rollups.ts
index 9f9986bd0..a1ebd21e9 100644
--- a/src/lib/rollups/mood-rollups.ts
+++ b/src/lib/rollups/mood-rollups.ts
@@ -55,6 +55,7 @@ import type {
PrismaClient,
RollupGranularity,
} from "@/generated/prisma/client";
+import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
/** Granularities that recompute asynchronously via pg-boss. */
const ASYNC_GRANULARITIES: RollupGranularity[] = ["WEEK", "MONTH", "YEAR"];
@@ -250,7 +251,7 @@ export async function recomputeMoodBucketsForEntry(
// on `entry.date`) and puts a late-evening / after-midnight local
// mood on the calendar day the user logged it in their own timezone.
// `bucket_start` encodes the label as its UTC midnight instant.
- const bucketStart = new Date(`${dateLabel}T00:00:00.000Z`);
+ const bucketStart = dayKeyAsUtcMidnight(dateLabel);
await client.$executeRaw`
WITH aggregate AS (
SELECT
@@ -718,7 +719,7 @@ function bucketSpan(
): { from: Date; to: Date } {
// Anchor at the label's UTC midnight; its UTC calendar parts ARE the
// label's parts, so the existing UTC date math below is exact.
- const anchor = new Date(`${dateLabel}T00:00:00.000Z`);
+ const anchor = dayKeyAsUtcMidnight(dateLabel);
switch (granularity) {
case "DAY": {
return {
diff --git a/src/lib/tz/date-only.ts b/src/lib/tz/date-only.ts
index 92526a625..589fee3c5 100644
--- a/src/lib/tz/date-only.ts
+++ b/src/lib/tz/date-only.ts
@@ -21,6 +21,38 @@ export function isDateOnlyKey(value: string): boolean {
return DATE_ONLY_RE.test(value);
}
+/**
+ * True for a `YYYY-MM-DD` string that names a real calendar date.
+ * `2026-02-30` passes the shape check but overflows to 2 March when parsed,
+ * so a reader would return a different day than the one asked for.
+ */
+export function isCalendarDateKey(value: string): boolean {
+ if (!DATE_ONLY_RE.test(value)) return false;
+ const parsed = new Date(`${value}T00:00:00.000Z`);
+ return (
+ !Number.isNaN(parsed.getTime()) && parsed.toISOString().startsWith(value)
+ );
+}
+
+/**
+ * The calendar date a free-form date string states, or `null`. An ISO string
+ * (`2026-06-10`, `2026-06-10T08:00:00+09:00`) states the date it starts with.
+ * Anything else (`June 10, 2026`) is parsed the way `Date` parses it, as local
+ * time in the process zone, so the date is read back in that same zone;
+ * reading it as UTC put it on the previous day for a process east of UTC.
+ */
+export function statedDateKey(raw: string): string | null {
+ const trimmed = raw.trim();
+ const isoPrefix = /^(\d{4}-\d{2}-\d{2})(?:$|T|\s)/u.exec(trimmed);
+ if (isoPrefix) {
+ return isCalendarDateKey(isoPrefix[1]) ? isoPrefix[1] : null;
+ }
+ const parsed = new Date(trimmed);
+ if (Number.isNaN(parsed.getTime())) return null;
+ const pad = (n: number) => String(n).padStart(2, "0");
+ return `${String(parsed.getFullYear()).padStart(4, "0")}-${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())}`;
+}
+
/** The instant a date-only value is stored as: 12:00 UTC on that date. */
export function dateOnlyAtNoonUtc(key: string): Date {
return new Date(`${key}T12:00:00.000Z`);
@@ -36,11 +68,15 @@ export function dateOnlyKey(value: Date): string {
}
/**
- * The value a `@db.Date` column holds for calendar date `key` (Prisma reads
- * and writes those columns as UTC midnight). Use it to compare such a column
- * with a day: `endsOn >= dbDate(todayKey)` keeps a course through its last
- * day, where comparing against the instant `now` dropped that day.
+ * Calendar date `key` as UTC midnight. A LABEL, never the instant the day
+ * began for anyone: it is how a `@db.Date` column holds a date (Prisma reads
+ * and writes those as UTC midnight), how the day rollups key `bucketStart`,
+ * and how a day-keyed series places a point on a time axis. Use it to compare
+ * such a column with a day (`endsOn >= dayKeyAsUtcMidnight(todayKey)` keeps a
+ * course through its last day, where comparing with the instant `now` dropped
+ * it) or to build such a label. The first instant of a person's day is
+ * `startOfLocalDayKey(key, tz)`.
*/
-export function dbDate(key: string): Date {
+export function dayKeyAsUtcMidnight(key: string): Date {
return new Date(`${key}T00:00:00.000Z`);
}
diff --git a/src/lib/tz/format.ts b/src/lib/tz/format.ts
index 8f026c41d..5e6bde119 100644
--- a/src/lib/tz/format.ts
+++ b/src/lib/tz/format.ts
@@ -233,6 +233,27 @@ export function shiftDateKey(dateKey: string, deltaDays: number): string {
return dt.toISOString().slice(0, 10);
}
+/**
+ * Whole calendar days from `fromKey` to `toKey` (negative when `toKey` is
+ * earlier). Pure key arithmetic like {@link shiftDateKey}: both keys are
+ * already resolved calendar dates, so no zone takes part. `NaN` when either
+ * key is malformed, so a caller decides what a bad key means.
+ */
+export function daysBetweenDateKeys(fromKey: string, toKey: string): number {
+ const a = Date.parse(`${fromKey}T00:00:00.000Z`);
+ const b = Date.parse(`${toKey}T00:00:00.000Z`);
+ return Math.round((b - a) / 86_400_000);
+}
+
+/**
+ * Weekday of a calendar date key, 0 = Sunday … 6 = Saturday (the
+ * `getDay()` / schedule `daysOfWeek` convention). A calendar date has the
+ * same weekday in every zone, so this needs none.
+ */
+export function weekdayOfDateKey(dateKey: string): number {
+ return new Date(`${dateKey}T00:00:00.000Z`).getUTCDay();
+}
+
/**
* Wall-clock hour (0–23) an observer in `tz` reads off the clock at
* `date`. Used for the time-of-day greeting so a traveller whose device
diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts
index 0fe13f33d..8f8483c47 100644
--- a/src/lib/validations/measurement.ts
+++ b/src/lib/validations/measurement.ts
@@ -1,6 +1,7 @@
import { z } from "zod/v4";
import { EXTERNAL_SOURCE } from "@/lib/measurements/external-source";
import { validateEntryInstant } from "./entry-instant";
+import { isCalendarDateKey } from "@/lib/tz/date-only";
export const measurementTypeEnum = z.enum([
"WEIGHT",
@@ -869,11 +870,10 @@ export const listMeasurementsSchema = z
// the admin drain route, so a malformed CLI invocation has the
// same blast radius. Reject the impossible shapes at the
// validator instead.
- .refine((s) => {
- const parsed = new Date(`${s}T00:00:00Z`);
- if (Number.isNaN(parsed.getTime())) return false;
- return s === parsed.toISOString().slice(0, 10);
- }, "dayKey must be a real calendar date (YYYY-MM-DD)")
+ .refine(
+ isCalendarDateKey,
+ "dayKey must be a real calendar date (YYYY-MM-DD)",
+ )
.optional(),
})
.refine(
diff --git a/src/lib/withings/sync-sleep.ts b/src/lib/withings/sync-sleep.ts
index d95cf4302..db58a44b9 100644
--- a/src/lib/withings/sync-sleep.ts
+++ b/src/lib/withings/sync-sleep.ts
@@ -191,6 +191,7 @@ export interface WithingsSleepSegment {
* fetch derives from the same `start` / `now` Dates.
*/
function ymdUtc(d: Date): string {
+ // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: day-grained summary window matching the unix-second segment window (see the doc comment)
return d.toISOString().slice(0, 10);
}