From 314e9c1aa3aff2710ee8f791170bcdcec7b6f4a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 17:03:19 +0200 Subject: [PATCH] refactor(tz): take the medication and reminder trees off the zone literal The medication and measurement-reminder code now imports DEFAULT_TIMEZONE from the tz module like every other fallback, so the lint rule no longer exempts those paths. --- .../no-default-zone-literal.test.mjs | 19 ++++++++++++------- .../healthlog/no-default-zone-literal.js | 11 ----------- .../[id]/complete/route.ts | 3 +-- .../api/measurement-reminders/[id]/route.ts | 4 +--- .../[id]/satisfy/route.ts | 3 +-- .../measurement-reminders/[id]/skip/route.ts | 3 +-- .../[id]/snooze/route.ts | 3 +-- src/app/api/measurement-reminders/route.ts | 3 +-- src/lib/jobs/measurement-reminder.ts | 7 ++++--- src/lib/measurement-reminders/scheduling.ts | 3 ++- src/lib/medications/default-medication.ts | 3 +-- src/lib/medications/list-read.ts | 3 ++- src/lib/medications/window-status.ts | 6 ++---- 13 files changed, 29 insertions(+), 42 deletions(-) diff --git a/eslint-plugins/healthlog/__tests__/no-default-zone-literal.test.mjs b/eslint-plugins/healthlog/__tests__/no-default-zone-literal.test.mjs index 8213cc9b4..f5f45dabe 100644 --- a/eslint-plugins/healthlog/__tests__/no-default-zone-literal.test.mjs +++ b/eslint-plugins/healthlog/__tests__/no-default-zone-literal.test.mjs @@ -1,7 +1,6 @@ /** * Unit tests for the `healthlog/no-default-zone-literal` rule: the literal is - * banned in application source, allowed in `src/lib/tz/` and in tests, and - * the pending files are exempt until their owner switches them. + * banned in application source and allowed only in `src/lib/tz/` and in tests. */ import { describe, it } from "vitest"; import { RuleTester } from "eslint"; @@ -40,17 +39,23 @@ ruleTester.run("no-default-zone-literal", rule, { code: "// read on Europe/Berlin days before v1.39.3\nconst x = 1;", filename: "/repo/src/lib/insights/graded-series.ts", }, - { - // Pending: owned by another change, switched at merge. - code: 'const DEFAULT_TZ = "Europe/Berlin";', - filename: "/repo/src/lib/medications/window-status.ts", - }, { code: 'const other = "America/New_York";', filename: "/repo/src/lib/insights/graded-series.ts", }, ], invalid: [ + { + // The medication and reminder trees carry no exemption. + code: 'const DEFAULT_TZ = "Europe/Berlin";', + filename: "/repo/src/lib/medications/window-status.ts", + errors: [{ messageId: "zoneLiteral" }], + }, + { + code: 'const tz = timezone || "Europe/Berlin";', + filename: "/repo/src/app/api/measurement-reminders/route.ts", + errors: [{ messageId: "zoneLiteral" }], + }, { code: 'const tz = user.timezone || "Europe/Berlin";', filename: "/repo/src/app/api/insights/cards/route.ts", diff --git a/eslint-plugins/healthlog/no-default-zone-literal.js b/eslint-plugins/healthlog/no-default-zone-literal.js index f3a26edc3..25a53ba8c 100644 --- a/eslint-plugins/healthlog/no-default-zone-literal.js +++ b/eslint-plugins/healthlog/no-default-zone-literal.js @@ -19,8 +19,6 @@ * zone, or a Berlin day computed through an `Intl.DateTimeFormat` built from * a variable. It removes the literal, not every way to hard-code a calendar. * - * `PENDING` lists files another change owns at the moment; they switch to the - * constant when that change lands, and the list shrinks to nothing. */ "use strict"; @@ -29,14 +27,6 @@ const ZONE = "Europe/Berlin"; const EXEMPT_ROOTS = ["src/lib/tz/"]; -// Owned by the medication-scheduling change in flight; switched at merge. -const PENDING = [ - "src/lib/medications/", - "src/lib/jobs/measurement-reminder.ts", - "src/lib/measurement-reminders/", - "src/app/api/measurement-reminders/", -]; - function toPosix(filename) { return filename.replace(/\\/g, "/"); } @@ -54,7 +44,6 @@ 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 (PENDING.some((p) => posix.includes(p))) return false; if (isTestFile(posix)) return false; return true; } diff --git a/src/app/api/measurement-reminders/[id]/complete/route.ts b/src/app/api/measurement-reminders/[id]/complete/route.ts index a993579cd..a87c1d742 100644 --- a/src/app/api/measurement-reminders/[id]/complete/route.ts +++ b/src/app/api/measurement-reminders/[id]/complete/route.ts @@ -30,11 +30,10 @@ import { annotate } from "@/lib/logging/context"; import { satisfyReminder } from "@/lib/measurement-reminders/satisfy"; import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; import { isScreeningReminderType } from "@/lib/validations/measurement-reminders"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; type RouteParams = { params: Promise<{ id: string }> }; -const DEFAULT_TIMEZONE = "Europe/Berlin"; - export const POST = apiHandler( async (request: NextRequest, { params }: RouteParams) => { // v1.37.0 — MANAGE. Marking a reminder satisfied re-anchors a schedule the level diff --git a/src/app/api/measurement-reminders/[id]/route.ts b/src/app/api/measurement-reminders/[id]/route.ts index 78b69439c..39228567a 100644 --- a/src/app/api/measurement-reminders/[id]/route.ts +++ b/src/app/api/measurement-reminders/[id]/route.ts @@ -33,13 +33,11 @@ import { } from "@/lib/measurement-reminders/scheduling"; import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock"; -import { userDayKey } from "@/lib/tz/format"; +import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format"; import { holdsOpenAfterReminder } from "@/lib/measurement-reminders/holds-open"; type RouteParams = { params: Promise<{ id: string }> }; -const DEFAULT_TIMEZONE = "Europe/Berlin"; - async function resolveTimezone(userId: string): Promise { const row = await prisma.user.findUnique({ where: { id: userId }, diff --git a/src/app/api/measurement-reminders/[id]/satisfy/route.ts b/src/app/api/measurement-reminders/[id]/satisfy/route.ts index d5f35d63d..21a003a33 100644 --- a/src/app/api/measurement-reminders/[id]/satisfy/route.ts +++ b/src/app/api/measurement-reminders/[id]/satisfy/route.ts @@ -16,11 +16,10 @@ import { annotate } from "@/lib/logging/context"; import { satisfyReminder } from "@/lib/measurement-reminders/satisfy"; import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; import { isScreeningReminderType } from "@/lib/validations/measurement-reminders"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; type RouteParams = { params: Promise<{ id: string }> }; -const DEFAULT_TIMEZONE = "Europe/Berlin"; - export const POST = apiHandler( async (request: NextRequest, { params }: RouteParams) => { // v1.37.0 — MANAGE. The same primitive from the checklist side re-anchors a schedule the level diff --git a/src/app/api/measurement-reminders/[id]/skip/route.ts b/src/app/api/measurement-reminders/[id]/skip/route.ts index 7405a958c..b413ae71d 100644 --- a/src/app/api/measurement-reminders/[id]/skip/route.ts +++ b/src/app/api/measurement-reminders/[id]/skip/route.ts @@ -22,11 +22,10 @@ import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { skipReminder } from "@/lib/measurement-reminders/satisfy"; import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; type RouteParams = { params: Promise<{ id: string }> }; -const DEFAULT_TIMEZONE = "Europe/Berlin"; - export const POST = apiHandler( async (request: NextRequest, { params }: RouteParams) => { // MANAGE — the same level as satisfy/complete: deciding to let a cycle diff --git a/src/app/api/measurement-reminders/[id]/snooze/route.ts b/src/app/api/measurement-reminders/[id]/snooze/route.ts index bc5f06826..c61146547 100644 --- a/src/app/api/measurement-reminders/[id]/snooze/route.ts +++ b/src/app/api/measurement-reminders/[id]/snooze/route.ts @@ -29,11 +29,10 @@ import { annotate } from "@/lib/logging/context"; import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; import { snoozeMeasurementReminderSchema } from "@/lib/validations/measurement-reminders"; import { zonedWallClockToUtc } from "@/lib/tz/wall-clock"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; type RouteParams = { params: Promise<{ id: string }> }; -const DEFAULT_TIMEZONE = "Europe/Berlin"; - /** Furthest a snooze may reach: five years from now. */ const SNOOZE_CAP_MS = 5 * 365.25 * 24 * 60 * 60 * 1000; diff --git a/src/app/api/measurement-reminders/route.ts b/src/app/api/measurement-reminders/route.ts index 2f55a8ec9..06fe23443 100644 --- a/src/app/api/measurement-reminders/route.ts +++ b/src/app/api/measurement-reminders/route.ts @@ -25,8 +25,7 @@ import { } from "@/lib/measurement-reminders/scheduling"; import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; import { listMeasurementReminders } from "@/lib/measurement-reminders/list-read"; - -const DEFAULT_TIMEZONE = "Europe/Berlin"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; async function resolveTimezone(userId: string): Promise { const row = await prisma.user.findUnique({ diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts index 407eb9489..4e1e89082 100644 --- a/src/lib/jobs/measurement-reminder.ts +++ b/src/lib/jobs/measurement-reminder.ts @@ -59,6 +59,7 @@ import { satisfyReminder } from "@/lib/measurement-reminders/satisfy"; import { evaluateCoachContextReminders } from "@/lib/ai/coach/context-reminders"; import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day"; import { holdsOpenAfterReminder } from "@/lib/measurement-reminders/holds-open"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; /** * v1.18.0 — map a reminder's `measurementType` to the toggleable module @@ -231,7 +232,7 @@ export function evaluateMeasurementReminderDue( if (!reminder.enabled || reminder.nextDueAt === null) { return { fire: false, inHourWindow: false, isDue: false }; } - const tz = timezone || "Europe/Berlin"; + const tz = timezone || DEFAULT_TIMEZONE; const isDue = now.getTime() >= reminder.nextDueAt.getTime(); const parts = wallClockInTz(now, tz); const inHourWindow = parts.hour === reminderSlotHour(reminder, tz); @@ -326,7 +327,7 @@ export async function runMeasurementReminderTick( for (const reminder of reminders) { summary.candidatesScanned += 1; try { - const timezone = reminder.user.timezone || "Europe/Berlin"; + const timezone = reminder.user.timezone || DEFAULT_TIMEZONE; // v1.39.2 — a reminder already sent for its current slot stays in // this scan every tick until it is satisfied. Outside its notify hour @@ -587,7 +588,7 @@ export async function runReminderSatisfyForUser( where: { id: userId }, select: { timezone: true }, }); - const timezone = user?.timezone || "Europe/Berlin"; + const timezone = user?.timezone || DEFAULT_TIMEZONE; const reminders = await prisma.measurementReminder.findMany({ where: { userId, deletedAt: null, enabled: true }, diff --git a/src/lib/measurement-reminders/scheduling.ts b/src/lib/measurement-reminders/scheduling.ts index 11ea9440b..2227d0008 100644 --- a/src/lib/measurement-reminders/scheduling.ts +++ b/src/lib/measurement-reminders/scheduling.ts @@ -30,6 +30,7 @@ import { nextOccurrenceAfter, } from "@/lib/medications/scheduling/recurrence"; import { wallClockInTz } from "@/lib/tz/wall-clock"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; /** * The reminder fields this module reads. A subset of the Prisma row so @@ -133,7 +134,7 @@ export function buildReminderRecurrence( // `anchorDate` / `endsOn` are instants instead (the form sends local // midnight, a Coach course sends `now + days`), so hand the engine the // calendar day each instant falls on in the user's zone. - const tz = timeZone || "Europe/Berlin"; + const tz = timeZone || DEFAULT_TIMEZONE; const startsOn = calendarDayInZone( reminder.anchorDate ?? reminder.createdAt, tz, diff --git a/src/lib/medications/default-medication.ts b/src/lib/medications/default-medication.ts index bb74efc0f..dee3be9b9 100644 --- a/src/lib/medications/default-medication.ts +++ b/src/lib/medications/default-medication.ts @@ -2,6 +2,7 @@ import { reduceCurrentWindowStatus, type ScheduleWindowInput, } from "@/lib/medications/window-status"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; export interface DefaultMedicationOption { id: string; @@ -24,8 +25,6 @@ const DEFAULT_THRESHOLDS: MedicationDueThresholds = { missedMinutes: 240, }; -const DEFAULT_TIMEZONE = "Europe/Berlin"; - /** * Pick the medication that should be pre-selected when an intake form opens. * Current, late, and overdue schedule windows win; otherwise the fallback is diff --git a/src/lib/medications/list-read.ts b/src/lib/medications/list-read.ts index b9a5ca0a3..ef9b787b8 100644 --- a/src/lib/medications/list-read.ts +++ b/src/lib/medications/list-read.ts @@ -35,6 +35,7 @@ import { } from "@/lib/medications/intake-tracking"; import { liveEraStartsByMedication } from "@/lib/medications/scheduling/live-era"; import { floorWhole } from "@/lib/medications/units-per-dose"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; export type MedicationsListResult = Array>; @@ -327,7 +328,7 @@ export async function readMedicationsListCached(user: { id: string; timezone: string | null; }): Promise { - const userTz = user.timezone || "Europe/Berlin"; + const userTz = user.timezone || DEFAULT_TIMEZONE; return cachedSwr( caches.medications as ServerCache, user.id, diff --git a/src/lib/medications/window-status.ts b/src/lib/medications/window-status.ts index d1add2e47..80e04fa94 100644 --- a/src/lib/medications/window-status.ts +++ b/src/lib/medications/window-status.ts @@ -48,6 +48,7 @@ import { wallClockInTz, type WallClockParts, } from "@/lib/tz/wall-clock"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; /** One explicit per-dose on-time window as the API serialises it. */ export interface DoseWindowEntryInput { @@ -123,9 +124,6 @@ export interface NextDueGate { overdue: boolean; } -/** Fallback timezone until every call site threads the user's own. */ -const DEFAULT_TZ = "Europe/Berlin"; - /** * Minute-of-day an observer in the target zone reads off the wall clock. * Every band comparison below is minute-of-day arithmetic, so this is the @@ -516,7 +514,7 @@ export function reduceCurrentWindowStatus< active, lastTakenAt, todayEventCount, - tz = DEFAULT_TZ, + tz = DEFAULT_TIMEZONE, nextDue, } = options; const nowParts = wallClockInTz(now, tz);