From 9d2fd1db14968dbcc488c98761c6f6996f6acc01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 16:22:57 +0200 Subject: [PATCH] fix(tz): one default zone, and the user's own calendar where Berlin was hard-coded The status assessments folded their recent days, weeks, months and years on Berlin days whatever the user's zone, the mood and cycle crosstabs keyed their metric days in Berlin, the resting-pulse proxy defaulted to Berlin days, the briefing's opener rotated at Berlin midnight, and every assessment prompt labelled today's date as Berlin's. They now use the user's zone. Rollup months and years are labelled by their UTC start, which is how they are cut; read in a zone west of UTC the label named the month before. "Europe/Berlin" was written out in some eighty places. DEFAULT_TIMEZONE in lib/tz/format is now the one definition: the second copy in mood/date-key, DISPLAY_TIMEZONE, the Berlin-only day key helpers and the per-route copies are gone. An in-repo lint rule rejects the literal outside lib/tz and tests; four areas another change owns are listed as pending. --- .../no-default-zone-literal.test.mjs | 75 +++++++++++++ eslint-plugins/healthlog/index.js | 4 + .../healthlog/no-default-zone-literal.js | 102 ++++++++++++++++++ eslint.config.mjs | 4 + ...ate-order-explicit-formatter-guard.test.ts | 12 --- .../notifications/reminder-check/route.ts | 3 +- .../api/coach/reminder-suggestions/route.ts | 3 +- src/app/api/coach/suggested-actions/route.ts | 2 +- src/app/api/cycle/calendar/route.ts | 3 +- src/app/api/cycle/day-logs/[id]/route.ts | 2 +- src/app/api/cycle/day-logs/bulk/route.ts | 2 +- src/app/api/cycle/day-logs/route.ts | 2 +- src/app/api/cycle/insights/route.ts | 5 +- src/app/api/cycle/period/route.ts | 2 +- .../episodes/[id]/correlation/route.ts | 2 +- .../illness/episodes/[id]/day-logs/route.ts | 2 +- src/app/api/import/route.ts | 3 +- src/app/api/insights/cards/route.ts | 3 +- src/app/api/insights/comprehensive/route.ts | 6 +- src/app/api/insights/correlations/route.ts | 3 +- src/app/api/insights/generate/route.ts | 1 + src/app/api/measurements/route.ts | 5 +- .../api/medications/[id]/compliance/route.ts | 3 +- .../medications/[id]/dose-history/route.ts | 3 +- .../api/medications/[id]/efficacy/route.ts | 3 +- src/app/api/medications/[id]/route.ts | 5 +- src/app/api/medications/compliance/route.ts | 3 +- src/app/api/mood-entries/[id]/route.ts | 3 +- src/app/api/mood-entries/bulk/route.ts | 3 +- src/app/api/mood-entries/route.ts | 3 +- src/app/api/mood/linked-context/route.ts | 3 +- src/app/api/sync/state/route.ts | 3 +- src/app/medications/page-client.tsx | 3 +- .../charts/medication-compliance-chart.tsx | 3 +- .../dashboard/medication-intake-quick-add.tsx | 3 +- .../insights/intraday-pulse-chart.tsx | 4 +- .../__tests__/vorsorge-section.test.tsx | 6 +- .../card-parts/displayed-slot-instant.ts | 3 +- .../medications/dose-history-ledger.tsx | 3 +- .../medications/glp1-medication-card.tsx | 3 +- .../medications/log-intake-dialog.tsx | 3 +- .../medications/medication-card.tsx | 3 +- .../medications/medication-table.tsx | 3 +- src/components/medications/take-all-due.ts | 3 +- .../settings/account-section/index.tsx | 6 +- .../managed-record-settings-section.tsx | 3 +- src/components/settings/timezone-picker.tsx | 4 +- src/lib/ai/coach/cycle-snapshot.ts | 9 +- src/lib/ai/coach/illness-snapshot.ts | 2 +- src/lib/ai/coach/tools/correlations-read.ts | 3 +- src/lib/ai/coach/workout-evidence-builder.ts | 3 +- .../briefing-personalisation.test.ts | 5 + src/lib/ai/prompts/blood-pressure.ts | 4 +- src/lib/ai/prompts/bmi.ts | 4 +- src/lib/ai/prompts/general-status.ts | 4 +- src/lib/ai/prompts/insight-generator.ts | 15 +-- src/lib/ai/prompts/medication-compliance.ts | 4 +- src/lib/ai/prompts/metric-archetypes.ts | 4 +- src/lib/ai/prompts/mood.ts | 4 +- src/lib/ai/prompts/pulse.ts | 4 +- src/lib/ai/prompts/weight.ts | 4 +- .../__tests__/intraday-pulse-io.test.ts | 5 +- .../analytics/__tests__/resting-pulse.test.ts | 31 ++++-- src/lib/analytics/mood-prognosis/load.ts | 2 +- src/lib/analytics/mood-prognosis/read.ts | 3 +- src/lib/analytics/resting-pulse.ts | 16 ++- .../cycle/__tests__/phase-crosstab.test.ts | 39 +++++-- src/lib/cycle/phase-crosstab.ts | 18 +++- src/lib/cycle/prediction-refresh.ts | 3 +- src/lib/daily/daily-briefing-push.ts | 4 +- src/lib/doctor-report-pdf-core.ts | 4 +- src/lib/doctor-report/collect.ts | 4 +- src/lib/encounters/service.ts | 3 +- src/lib/format-locale.ts | 16 +-- .../__tests__/achievements.test.ts | 11 -- .../__tests__/expansion-metrics.test.ts | 4 +- src/lib/gamification/achievements.ts | 26 ----- src/lib/i18n/context.tsx | 6 +- .../__tests__/biomarker-status.test.ts | 4 +- .../insights/__tests__/bucket-series.test.ts | 44 ++++---- .../__tests__/graded-series-rollups.test.ts | 12 ++- .../insights/__tests__/graded-series.test.ts | 38 +++++-- .../__tests__/mood-aggregates.test.ts | 82 ++++++++++++-- src/lib/insights/blood-pressure-status.ts | 26 +++-- src/lib/insights/bmi-status.ts | 1 + src/lib/insights/bucket-series.ts | 2 +- src/lib/insights/comprehensive-generate.ts | 1 + src/lib/insights/general-status.ts | 10 +- src/lib/insights/graded-series.ts | 66 +++++------- .../insights/medication-compliance-status.ts | 2 +- .../insights/metric-correlation-context.ts | 3 +- src/lib/insights/metric-status.ts | 3 +- src/lib/insights/mood-aggregates.ts | 10 +- src/lib/insights/mood-crosstab.ts | 38 ++++--- src/lib/insights/mood-narratives.ts | 4 +- src/lib/insights/mood-patterns.ts | 4 +- src/lib/insights/mood-status.ts | 3 +- .../narrative/period-narrative-generate.ts | 3 +- .../insights/narrative/period-narrative.ts | 3 +- src/lib/insights/pulse-status.ts | 9 +- src/lib/insights/weight-status.ts | 23 ++-- src/lib/jobs/apple-health-import-worker.ts | 19 ++-- src/lib/jobs/cycle-reminder.ts | 5 +- src/lib/jobs/daily-briefing.ts | 3 +- src/lib/jobs/mood-reminder.ts | 3 +- src/lib/jobs/period-narrative-warm.ts | 3 +- .../reminder/medication-reminder-check.ts | 3 +- src/lib/jobs/reminder/registrar-shared.ts | 3 +- src/lib/mcp/tools.ts | 2 +- src/lib/mcp/writes.ts | 3 +- src/lib/measurements/consolidation-base.ts | 2 +- .../measurements/dense-intraday-retention.ts | 3 +- src/lib/mood/__tests__/date-key.test.ts | 3 +- src/lib/mood/create-from-telegram.ts | 3 +- src/lib/mood/date-key.ts | 4 +- src/lib/mood/linked-context.ts | 3 +- src/lib/openapi/routes/mood.ts | 5 +- .../personal-records/pr-detection-worker.ts | 3 +- src/lib/timezone-mirror.ts | 4 +- src/lib/tz/format.ts | 2 +- src/lib/tz/resolver.ts | 50 +-------- src/lib/vaccinations/service.ts | 2 +- .../cycle-crosstab-day-aggregates.test.ts | 4 +- tests/integration/dense-read-bounded.test.ts | 2 +- 124 files changed, 705 insertions(+), 415 deletions(-) create mode 100644 eslint-plugins/healthlog/__tests__/no-default-zone-literal.test.mjs create mode 100644 eslint-plugins/healthlog/no-default-zone-literal.js diff --git a/eslint-plugins/healthlog/__tests__/no-default-zone-literal.test.mjs b/eslint-plugins/healthlog/__tests__/no-default-zone-literal.test.mjs new file mode 100644 index 000000000..8213cc9b4 --- /dev/null +++ b/eslint-plugins/healthlog/__tests__/no-default-zone-literal.test.mjs @@ -0,0 +1,75 @@ +/** + * 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. + */ +import { describe, it } from "vitest"; +import { RuleTester } from "eslint"; +import rule from "../no-default-zone-literal.js"; + +RuleTester.describe = describe; +RuleTester.it = it; +RuleTester.itOnly = it.only; + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2023, + sourceType: "module", + parserOptions: { ecmaFeatures: { jsx: true } }, + }, +}); + +ruleTester.run("no-default-zone-literal", rule, { + valid: [ + { + code: 'import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; const tz = user.timezone || DEFAULT_TIMEZONE;', + filename: "/repo/src/app/api/insights/cards/route.ts", + }, + { + // The one definition. + code: 'export const DEFAULT_TIMEZONE = "Europe/Berlin";', + filename: "/repo/src/lib/tz/format.ts", + }, + { + // Fixtures pick a concrete zone on purpose. + code: 'const TZ = "Europe/Berlin";', + filename: "/repo/src/lib/insights/__tests__/graded-series.test.ts", + }, + { + // Comments are not checked. + 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: [ + { + code: 'const tz = user.timezone || "Europe/Berlin";', + filename: "/repo/src/app/api/insights/cards/route.ts", + errors: [{ messageId: "zoneLiteral" }], + }, + { + code: 'readDayAggregates({ timeZone: "Europe/Berlin" });', + filename: "/repo/src/lib/insights/graded-series.ts", + errors: [{ messageId: "zoneLiteral" }], + }, + { + code: "const label = `Date: ${todayKey} (Europe/Berlin)`;", + filename: "/repo/src/lib/ai/prompts/pulse.ts", + errors: [{ messageId: "zoneLiteral" }], + }, + { + code: '', + filename: "/repo/src/components/settings/timezone-picker.tsx", + errors: [{ messageId: "zoneLiteral" }], + }, + ], +}); diff --git a/eslint-plugins/healthlog/index.js b/eslint-plugins/healthlog/index.js index 8147b1540..961372c8b 100644 --- a/eslint-plugins/healthlog/index.js +++ b/eslint-plugins/healthlog/index.js @@ -10,6 +10,8 @@ * and no off-scale `5` step on a bg-card shell. * - `job-handler-outcome` — pg-boss bindings route through createAndWork, * whose handler returns a JobOutcome. + * - `no-default-zone-literal` — the default zone is DEFAULT_TIMEZONE from + * src/lib/tz/format, never written out. */ "use strict"; @@ -20,6 +22,7 @@ const apiFetchRequired = require("./api-fetch-required.js"); const noRawPaletteColor = require("./no-raw-palette-color.js"); const spacingScale = require("./spacing-scale.js"); const jobHandlerOutcome = require("./job-handler-outcome.js"); +const noDefaultZoneLiteral = require("./no-default-zone-literal.js"); module.exports = { rules: { @@ -34,5 +37,6 @@ module.exports = { // the semantic sweep). See the rule header. "no-dracula-utility": noRawPaletteColor, "job-handler-outcome": jobHandlerOutcome, + "no-default-zone-literal": noDefaultZoneLiteral, }, }; diff --git a/eslint-plugins/healthlog/no-default-zone-literal.js b/eslint-plugins/healthlog/no-default-zone-literal.js new file mode 100644 index 000000000..f3a26edc3 --- /dev/null +++ b/eslint-plugins/healthlog/no-default-zone-literal.js @@ -0,0 +1,102 @@ +/** + * @fileoverview ESLint rule — the default zone is named in one place. + * + * `"Europe/Berlin"` was written out in some eighty places: fallbacks for a + * user with no zone, day keys cut in Berlin whatever the user's zone, a + * graded series folded on Berlin days, a prompt that labelled every date as + * Berlin's. Each copy was a place where a user outside that zone got + * someone else's calendar. `DEFAULT_TIMEZONE` in `src/lib/tz/format.ts` is + * the one definition; everything else imports it, and a surface that means + * the user's own zone asks for it (`resolveUserTimezone`). + * + * The rule flags a string literal or template element containing + * `Europe/Berlin` in `src/`, outside `src/lib/tz/` and outside test files + * (fixtures pick concrete zones on purpose). Comments are not checked. + * + * WHAT THIS RULE DOES NOT CATCH + * + * A zone assembled from parts (`"Europe/" + city`), a different hard-coded + * 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"; + +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, "/"); +} + +function isTestFile(posix) { + return ( + /\.test\.[cm]?[jt]sx?$/.test(posix) || + /\.spec\.[cm]?[jt]sx?$/.test(posix) || + posix.includes("/__tests__/") || + posix.includes("/__mocks__/") + ); +} + +function isEnforced(filename) { + const posix = toPosix(filename); + if (!posix.includes("/src/") && !posix.startsWith("src/")) return false; + if (EXEMPT_ROOTS.some((root) => posix.includes(root))) return false; + if (PENDING.some((p) => posix.includes(p))) return false; + if (isTestFile(posix)) return false; + return true; +} + +/** @type {import("eslint").Rule.RuleModule} */ +const noDefaultZoneLiteralRule = { + meta: { + type: "problem", + docs: { + description: + "Import DEFAULT_TIMEZONE from @/lib/tz/format instead of writing the zone out.", + }, + schema: [], + messages: { + zoneLiteral: + 'Do not write "Europe/Berlin" out. Import DEFAULT_TIMEZONE from "@/lib/tz/format" for a fallback, or use the user\'s own zone (resolveUserTimezone / the profile timezone) for a day boundary.', + }, + }, + create(context) { + const filename = context.filename ?? context.getFilename?.(); + if (!filename || !isEnforced(filename)) { + return {}; + } + return { + Literal(node) { + if (typeof node.value === "string" && node.value.includes(ZONE)) { + context.report({ node, messageId: "zoneLiteral" }); + } + }, + TemplateElement(node) { + const raw = node.value && (node.value.cooked ?? node.value.raw); + if (typeof raw === "string" && raw.includes(ZONE)) { + context.report({ node, messageId: "zoneLiteral" }); + } + }, + JSXText(node) { + if (typeof node.value === "string" && node.value.includes(ZONE)) { + context.report({ node, messageId: "zoneLiteral" }); + } + }, + }; + }, +}; + +module.exports = noDefaultZoneLiteralRule; diff --git a/eslint.config.mjs b/eslint.config.mjs index 36fb7684e..1399d6eee 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -101,6 +101,10 @@ const eslintConfig = defineConfig([ // catches the omission only — a handler that returns `{ ok: true }` // after swallowing still passes, and its header says so. "healthlog/job-handler-outcome": "error", + // The default zone is named once, in src/lib/tz/format.ts. A copy of the + // literal anywhere else is where a user outside that zone got someone + // else's calendar. src/lib/tz/ and test files are exempt; see the rule. + "healthlog/no-default-zone-literal": "error", }, }, // v1.28.17 — every recharts-rendering component funnels through the diff --git a/src/__tests__/date-order-explicit-formatter-guard.test.ts b/src/__tests__/date-order-explicit-formatter-guard.test.ts index faec9324c..cf89630c0 100644 --- a/src/__tests__/date-order-explicit-formatter-guard.test.ts +++ b/src/__tests__/date-order-explicit-formatter-guard.test.ts @@ -259,18 +259,10 @@ const RAW_ALLOWLIST: ReadonlyArray<{ file: string; why: string }> = [ file: "src/components/measurement-reminders/vorsorge-section.tsx", why: "The ISO day the postpone request writes to the API. A wire value, and the wire format is ISO whatever the reader prefers.", }, - { - file: "src/lib/gamification/achievements.ts", - why: "The day key streak arithmetic counts on.", - }, { file: "src/lib/charts/bucket-time-series.ts", why: "Bucket boundary arithmetic. The bucket LABELS are a separate step and go through `makeBucketLabelFormatters`.", }, - { - file: "src/lib/ai/prompts/insight-generator.ts", - why: "An ISO day inside a model prompt. The model reads ISO; the user never sees this string.", - }, { file: "src/lib/jobs/measurement-reminder.ts", why: "The local day in the notification dedup key. Changing it with a display preference would re-send a claimed reminder.", @@ -283,10 +275,6 @@ const RAW_ALLOWLIST: ReadonlyArray<{ file: string; why: string }> = [ file: "src/lib/i18n/relative-time.ts", why: "The en-CA day keys that decide today / yesterday. A comparison between two keys, never printed.", }, - { - file: "src/lib/tz/resolver.ts", - why: "The Berlin day formatter the zone arithmetic itself is built on.", - }, ]; /** diff --git a/src/app/api/admin/notifications/reminder-check/route.ts b/src/app/api/admin/notifications/reminder-check/route.ts index 6cb32ae31..78b6fc9c8 100644 --- a/src/app/api/admin/notifications/reminder-check/route.ts +++ b/src/app/api/admin/notifications/reminder-check/route.ts @@ -9,6 +9,7 @@ import { parseScheduleRecurrence } from "@/lib/medication-schedule"; import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised"; import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/tz/local-day"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; export const dynamic = "force-dynamic"; @@ -116,7 +117,7 @@ export const POST = apiHandler(async (request: NextRequest) => { let notificationsSent = 0; for (const med of medications) { - const userTz = med.user.timezone || "Europe/Berlin"; + const userTz = med.user.timezone || DEFAULT_TIMEZONE; const { start: todayStart, end: todayEnd } = getUserTodayBounds( now, userTz, diff --git a/src/app/api/coach/reminder-suggestions/route.ts b/src/app/api/coach/reminder-suggestions/route.ts index de7a7f1ed..eaafd4844 100644 --- a/src/app/api/coach/reminder-suggestions/route.ts +++ b/src/app/api/coach/reminder-suggestions/route.ts @@ -44,8 +44,7 @@ import { type ReminderScheduleInput, } from "@/lib/measurement-reminders/scheduling"; import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; - -const DEFAULT_TIMEZONE = "Europe/Berlin"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; async function postAction(request: NextRequest): Promise { const { user } = await requireAuth(); diff --git a/src/app/api/coach/suggested-actions/route.ts b/src/app/api/coach/suggested-actions/route.ts index 8302bc33c..aa8e73959 100644 --- a/src/app/api/coach/suggested-actions/route.ts +++ b/src/app/api/coach/suggested-actions/route.ts @@ -47,8 +47,8 @@ import { type ReminderScheduleInput, } from "@/lib/measurement-reminders/scheduling"; import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; -const DEFAULT_TIMEZONE = "Europe/Berlin"; const CHECKUP_NOTIFY_HOUR = 9; async function postAction(request: NextRequest): Promise { diff --git a/src/app/api/cycle/calendar/route.ts b/src/app/api/cycle/calendar/route.ts index 6e68e71f1..7d3b07dda 100644 --- a/src/app/api/cycle/calendar/route.ts +++ b/src/app/api/cycle/calendar/route.ts @@ -39,7 +39,8 @@ import { import { resolveCycleVerdict } from "@/lib/cycle/verdict"; import { addDays, dayDiff } from "@/lib/cycle/day-math"; import { BBT_WINDOW } from "@/lib/cycle/types"; -import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key"; +import { moodDateKey } from "@/lib/mood/date-key"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; diff --git a/src/app/api/cycle/day-logs/[id]/route.ts b/src/app/api/cycle/day-logs/[id]/route.ts index 211e07eea..4c92fe9a2 100644 --- a/src/app/api/cycle/day-logs/[id]/route.ts +++ b/src/app/api/cycle/day-logs/[id]/route.ts @@ -28,7 +28,7 @@ import { ensureCycleForBleedingDay, removeCycleStartedOn, } from "@/lib/cycle/cycle-boundaries"; -import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { encrypt, decrypt } from "@/lib/crypto"; import { getOrCreateCycleProfile } from "@/lib/cycle/profile"; import { replaceSymptomLinks } from "@/lib/cycle/day-log-write"; diff --git a/src/app/api/cycle/day-logs/bulk/route.ts b/src/app/api/cycle/day-logs/bulk/route.ts index 93c56116b..66abea65a 100644 --- a/src/app/api/cycle/day-logs/bulk/route.ts +++ b/src/app/api/cycle/day-logs/bulk/route.ts @@ -40,7 +40,7 @@ import { type UnstableExternalIdShape, } from "@/lib/validations/external-id"; import { upsertCycleDayLog } from "@/lib/cycle/day-log-write"; -import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; const BATCH_RATE_LIMIT_MAX = 60; const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000; diff --git a/src/app/api/cycle/day-logs/route.ts b/src/app/api/cycle/day-logs/route.ts index cacf927fd..158d85c84 100644 --- a/src/app/api/cycle/day-logs/route.ts +++ b/src/app/api/cycle/day-logs/route.ts @@ -40,7 +40,7 @@ import { import { upsertCycleDayLog } from "@/lib/cycle/day-log-write"; import { findOwningCycleId } from "@/lib/cycle/cycle-attribution"; import { toCycleDayLogDTO, dayLogSymptomInclude } from "@/lib/cycle/dto"; -import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; export const POST = apiHandler(withIdempotency<[NextRequest]>(postDayLog)); diff --git a/src/app/api/cycle/insights/route.ts b/src/app/api/cycle/insights/route.ts index 287406817..4fc89c5b3 100644 --- a/src/app/api/cycle/insights/route.ts +++ b/src/app/api/cycle/insights/route.ts @@ -43,7 +43,8 @@ import { type SymptomDay, } from "@/lib/cycle/symptom-phase"; import { addDays } from "@/lib/cycle/day-math"; -import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key"; +import { moodDateKey } from "@/lib/mood/date-key"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; /** Trailing window the phase-contrast walks (days). */ const WINDOW_DAYS = 365; @@ -228,6 +229,7 @@ export const GET = apiHandler(async () => { phaseByDay, measurements, userPriorityJson, + timeZone: tz, }); const headline = selectHeadlinePhaseRow(rows); // The lagged rows are served with their narrated `interpretation` intact, so @@ -239,6 +241,7 @@ export const GET = apiHandler(async () => { phaseByDay, measurements, userPriorityJson, + timeZone: tz, locale, }); diff --git a/src/app/api/cycle/period/route.ts b/src/app/api/cycle/period/route.ts index 4fc37c6a8..80b276b3f 100644 --- a/src/app/api/cycle/period/route.ts +++ b/src/app/api/cycle/period/route.ts @@ -37,7 +37,7 @@ import { toMenstrualCycleDTO, dayLogSymptomInclude, } from "@/lib/cycle/dto"; -import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { openCycleAt, type MovedAnchors } from "@/lib/cycle/cycle-boundaries"; export const POST = apiHandler(withIdempotency<[NextRequest]>(postPeriod)); diff --git a/src/app/api/illness/episodes/[id]/correlation/route.ts b/src/app/api/illness/episodes/[id]/correlation/route.ts index c0f647364..abc456568 100644 --- a/src/app/api/illness/episodes/[id]/correlation/route.ts +++ b/src/app/api/illness/episodes/[id]/correlation/route.ts @@ -22,7 +22,7 @@ import { apiSuccess, apiError } from "@/lib/api-response"; import { requireIllnessEnabled } from "@/lib/illness/gate"; import { computeEpisodeCorrelation } from "@/lib/illness/correlation-read"; import { notifyIllnessRedFlag } from "@/lib/illness/red-flag-notify"; -import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; type RouteParams = { params: Promise<{ id: string }> }; diff --git a/src/app/api/illness/episodes/[id]/day-logs/route.ts b/src/app/api/illness/episodes/[id]/day-logs/route.ts index 2202f338f..56b18f31d 100644 --- a/src/app/api/illness/episodes/[id]/day-logs/route.ts +++ b/src/app/api/illness/episodes/[id]/day-logs/route.ts @@ -34,7 +34,7 @@ import { } from "@/lib/validations/illness"; import { upsertIllnessDayLog } from "@/lib/illness/day-log-write"; import { toIllnessDayLogDTO, dayLogSymptomInclude } from "@/lib/illness/dto"; -import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; type RouteParams = { params: Promise<{ id: string }> }; diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index 88ea23e34..e827cc674 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -37,7 +37,8 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit"; import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; import { isP2002, isP2025 } from "@/lib/prisma-errors"; -import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key"; +import { moodDateKey } from "@/lib/mood/date-key"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { deriveA1 } from "@/lib/mood/level-a"; import { zonedWallClockToUtc } from "@/lib/tz/wall-clock"; diff --git a/src/app/api/insights/cards/route.ts b/src/app/api/insights/cards/route.ts index b37ae9652..499171ebe 100644 --- a/src/app/api/insights/cards/route.ts +++ b/src/app/api/insights/cards/route.ts @@ -34,6 +34,7 @@ import { } from "@/lib/analytics/compliance"; import type { MeasurementType } from "@/generated/prisma/client"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; type Severity = "alert" | "caution" | "info" | "good"; @@ -216,7 +217,7 @@ export const GET = apiHandler(async () => { arr.push(e); eventsByMed.set(e.medicationId, arr); } - const tz = user.timezone || "Europe/Berlin"; + const tz = user.timezone || DEFAULT_TIMEZONE; for (const med of activeMeds) { const events = eventsByMed.get(med.id) ?? []; const mapped = events.map((e) => ({ diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index 46d6b9be7..cc55cd882 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -41,7 +41,7 @@ import { ensureUserMoodRollupsFresh, readMoodDayRollups, } from "@/lib/rollups/mood-rollups"; -import { userDayKey } from "@/lib/tz/format"; +import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; export const dynamic = "force-dynamic"; @@ -125,7 +125,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) { // pass in this route (resting-pulse proxy, medication-continuity, BP // pairing). Was previously re-derived ad hoc (or hardcoded UTC) at each // call site. - const userTz = user.timezone || "Europe/Berlin"; + const userTz = user.timezone || DEFAULT_TIMEZONE; // Fetch user profile (height + DOB drive BMI + BP targets). const dbUser = await prisma.user.findUnique({ @@ -445,7 +445,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) { const medicationContext = buildComplianceMedicationContext( med, lastNonSkippedTakenAt(mapped), - user.timezone || "Europe/Berlin", + user.timezone || DEFAULT_TIMEZONE, ); const c7 = calculateCompliance(mapped, med.schedules, 7, med.createdAt, { medicationContext, diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index eb4e4358e..f93c5262f 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -41,6 +41,7 @@ import { } from "@/lib/insights/correlation-patterns"; import { assembleDiscoveryMatrix } from "@/lib/insights/discovery-matrix"; import { fetchLabDraws } from "@/lib/insights/correlation-channel-series"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; export const dynamic = "force-dynamic"; @@ -115,7 +116,7 @@ async function buildCorrelationsResponse( where: { id: userId }, select: { timezone: true }, }); - const tz = profile?.timezone ?? "Europe/Berlin"; + const tz = profile?.timezone ?? DEFAULT_TIMEZONE; const now = new Date(); const since = new Date(now.getTime() - WINDOW_DAYS * MS_PER_DAY); diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index b5706dd8b..266d77959 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -692,6 +692,7 @@ export const POST = apiHandler((request: NextRequest) => userId, dbUser?.displayName ?? null, locale, + await resolveUserTimezone(userId), ); // v1.12.7 (B5) — inject the curated SOURCES block for the metric sections diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index ccc8742d6..ffcaafece 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -76,6 +76,7 @@ import type { MeasurementSource, GlucoseContext, } from "@/generated/prisma/client"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireRecordAuth("read", "measurements"); @@ -192,7 +193,7 @@ export const GET = apiHandler(async (request: NextRequest) => { const tz = user.timezone && user.timezone.length > 0 ? user.timezone - : "Europe/Berlin"; + : DEFAULT_TIMEZONE; // Resolve `[dayStart, dayEnd)` for the requested calendar day in // the user's local zone. v1.4.37 W10 — `localDayWindow` honours // DST: the previous shape (`canonicalDailyTimestamp ± 12 h`) @@ -242,7 +243,7 @@ export const GET = apiHandler(async (request: NextRequest) => { const tz = user.timezone && user.timezone.length > 0 ? user.timezone - : "Europe/Berlin"; + : DEFAULT_TIMEZONE; // v1.29.6 — DESC + take so a long history returns the MOST RECENT // window of raw samples for bucketing. The previous `orderBy: asc` // + `take: limit` sliced the OLDEST page of rows before the diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index c53eeba65..4831388da 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -9,6 +9,7 @@ import { } from "@/lib/medications/compliance-payload"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { checkRateLimit } from "@/lib/rate-limit"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; type RouteParams = { params: Promise<{ id: string }> }; @@ -43,7 +44,7 @@ export const GET = apiHandler( return apiError("Too many compliance requests. Please retry later.", 429); } - const userTz = user.timezone || "Europe/Berlin"; + const userTz = user.timezone || DEFAULT_TIMEZONE; // Read-through the per-user compliance cache (15 min fresh TTL + // SWR window). The ownership guard and the medication read live diff --git a/src/app/api/medications/[id]/dose-history/route.ts b/src/app/api/medications/[id]/dose-history/route.ts index f494ed66e..0159323a1 100644 --- a/src/app/api/medications/[id]/dose-history/route.ts +++ b/src/app/api/medications/[id]/dose-history/route.ts @@ -41,6 +41,7 @@ import { } from "@/lib/medications/scheduling/worker-helpers"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { dueSchedules } from "@/lib/medications/intake-tracking"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; type RouteParams = { params: Promise<{ id: string }> }; @@ -120,7 +121,7 @@ export const GET = apiHandler( } const now = new Date(); - const userTz = user.timezone || "Europe/Berlin"; + const userTz = user.timezone || DEFAULT_TIMEZONE; const medication = await prisma.medication.findUnique({ where: { id }, diff --git a/src/app/api/medications/[id]/efficacy/route.ts b/src/app/api/medications/[id]/efficacy/route.ts index 5bc75939d..53c54fedd 100644 --- a/src/app/api/medications/[id]/efficacy/route.ts +++ b/src/app/api/medications/[id]/efficacy/route.ts @@ -2,6 +2,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess, apiError } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { buildMedicationEfficacy } from "@/lib/medications/efficacy/build-efficacy"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; type RouteParams = { params: Promise<{ id: string }> }; @@ -31,7 +32,7 @@ export const GET = apiHandler( return apiError("Too many efficacy requests. Please retry later.", 429); } - const userTz = user.timezone || "Europe/Berlin"; + const userTz = user.timezone || DEFAULT_TIMEZONE; const dto = await buildMedicationEfficacy(user.id, id, userTz); if (!dto) { return apiError("Medication not found", 404); diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 2d281fbff..1afbbbb26 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -46,6 +46,7 @@ import { isRecordOnly, scheduleWireFields, } from "@/lib/medications/intake-tracking"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; type RouteParams = { params: Promise<{ id: string }> }; @@ -175,7 +176,7 @@ export const GET = apiHandler( }, schedules: liveSchedules, now, - userTz: user.timezone || "Europe/Berlin", + userTz: user.timezone || DEFAULT_TIMEZONE, lastIntakeAt: lastIntake?.takenAt ?? null, resolvedSlots: resolvedRows.map(toResolvedSlotMark), eraStart: latestRevision?.validUntil ?? null, @@ -603,7 +604,7 @@ export const PUT = apiHandler( // `syncVersion` so delta-sync clients drop them. The projector and // the reminder worker re-mint the anchors for the new times on their // next pass — the route does not pre-create them. - const userTz = user.timezone || "Europe/Berlin"; + const userTz = user.timezone || DEFAULT_TIMEZONE; const { start: todayStart } = getUserTodayBounds(new Date(), userTz); const tombstoneWhere = { userId: user.id, diff --git a/src/app/api/medications/compliance/route.ts b/src/app/api/medications/compliance/route.ts index 06836c731..eee5e5f97 100644 --- a/src/app/api/medications/compliance/route.ts +++ b/src/app/api/medications/compliance/route.ts @@ -12,6 +12,7 @@ import { import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { checkRateLimit } from "@/lib/rate-limit"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; /** * Batched card-compliance read: one round trip for every medication the @@ -43,7 +44,7 @@ export const GET = apiHandler(async () => { return apiError("Too many compliance requests. Please retry later.", 429); } - const userTz = user.timezone || "Europe/Berlin"; + const userTz = user.timezone || DEFAULT_TIMEZONE; // Same ordering as the medications list so the page's card order and // this payload walk the same sequence. diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index f6535df55..94cb5f59e 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -14,7 +14,8 @@ import { NextRequest } from "next/server"; import { Prisma } from "@/generated/prisma/client"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { moodDateKey } from "@/lib/mood/date-key"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { deriveA1, shapeLevelA } from "@/lib/mood/level-a"; import { contextForWire, persistMoodContext } from "@/lib/mood/context"; import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher"; diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index 29bb815d7..3a504fa71 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -51,7 +51,8 @@ import { UNSTABLE_EXTERNAL_ID_REASON, type UnstableExternalIdShape, } from "@/lib/validations/external-id"; -import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { moodDateKey } from "@/lib/mood/date-key"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { levelAForWrite } from "@/lib/mood/level-a"; import { persistMoodContext } from "@/lib/mood/context"; import { encryptNote } from "@/lib/crypto/note-cipher"; diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 96fb20d0a..86b14901a 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -24,7 +24,8 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { withIdempotency } from "@/lib/idempotency"; import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher"; -import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { moodDateKey } from "@/lib/mood/date-key"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { levelAForWrite, shapeLevelA } from "@/lib/mood/level-a"; import { contextForWire, persistMoodContext } from "@/lib/mood/context"; import { listMoodEntriesPage } from "@/lib/mood/list-read"; diff --git a/src/app/api/mood/linked-context/route.ts b/src/app/api/mood/linked-context/route.ts index c66e0030c..8756d58d0 100644 --- a/src/app/api/mood/linked-context/route.ts +++ b/src/app/api/mood/linked-context/route.ts @@ -38,8 +38,7 @@ import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { resolveLinkedDayContext } from "@/lib/mood/linked-context"; -import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; -import { isValidTimezone } from "@/lib/tz/format"; +import { DEFAULT_TIMEZONE, isValidTimezone } from "@/lib/tz/format"; export const dynamic = "force-dynamic"; diff --git a/src/app/api/sync/state/route.ts b/src/app/api/sync/state/route.ts index b45e269a1..4d4083640 100644 --- a/src/app/api/sync/state/route.ts +++ b/src/app/api/sync/state/route.ts @@ -42,6 +42,7 @@ import { NATIVE_REFRESH_TOKEN_DAYS, TOMBSTONE_RETENTION_DAYS, } from "@/lib/auth/native-client"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; export const GET = apiHandler(async (_request: NextRequest) => { const { user } = await requireAuth(); @@ -119,7 +120,7 @@ export const GET = apiHandler(async (_request: NextRequest) => { return apiSuccess({ userId: user.id, - timezone: currentUser?.timezone ?? "Europe/Berlin", + timezone: currentUser?.timezone ?? DEFAULT_TIMEZONE, lastSyncedAt: lastSyncedAt?.toISOString() ?? null, serverNow: serverNow.toISOString(), measurements: { diff --git a/src/app/medications/page-client.tsx b/src/app/medications/page-client.tsx index 54e39bf6d..e37e45adf 100644 --- a/src/app/medications/page-client.tsx +++ b/src/app/medications/page-client.tsx @@ -48,6 +48,7 @@ import { apiGet } from "@/lib/api/api-fetch"; import { useMedicationComplianceSummaryAll } from "@/lib/queries/use-medication-compliance-summary"; import { useMedicationListLayout } from "@/lib/queries/use-medication-list-layout"; import { applyMedicationOrder } from "@/lib/medications/medication-order"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; interface Schedule { id: string; @@ -397,7 +398,7 @@ export default function MedicationsPageClient() { // medications past their band tail stay takeable from the batch. Page // order is preserved so the confirm dialog lists medications as rendered. const dueMeds = deriveDueMedications(activeMeds, { - tz: user?.timezone || "Europe/Berlin", + tz: user?.timezone || DEFAULT_TIMEZONE, thresholds: thresholds ?? undefined, doseStatusById: new Map( (complianceRows ?? []).map((row) => [ diff --git a/src/components/charts/medication-compliance-chart.tsx b/src/components/charts/medication-compliance-chart.tsx index 866759999..fa5aefa0e 100644 --- a/src/components/charts/medication-compliance-chart.tsx +++ b/src/components/charts/medication-compliance-chart.tsx @@ -69,6 +69,7 @@ import { useViewportWidth } from "@/hooks/use-viewport-width"; import { chooseTickInterval } from "@/lib/charts/x-axis-density"; import { apiGet } from "@/lib/api/api-fetch"; import { shouldFireDataReady } from "@/lib/charts/data-ready-latch"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; interface DailyCompliancePoint { /** Berlin calendar day, "YYYY-MM-DD". */ @@ -208,7 +209,7 @@ interface MedicationComplianceChartProps { export function MedicationComplianceChart({ title, - userTimezone = "Europe/Berlin", + userTimezone = DEFAULT_TIMEZONE, compareBaseline, onDataReady, }: MedicationComplianceChartProps) { diff --git a/src/components/dashboard/medication-intake-quick-add.tsx b/src/components/dashboard/medication-intake-quick-add.tsx index 1476bfb78..52788fd3a 100644 --- a/src/components/dashboard/medication-intake-quick-add.tsx +++ b/src/components/dashboard/medication-intake-quick-add.tsx @@ -31,6 +31,7 @@ import { intakeToastOptions, runUndoIntake, } from "@/components/medications/use-medication-intake"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; /** * v1.4.37 W7b — dashboard "Hinzufügen" → "Medikamenteneinnahme" quick-add. @@ -115,7 +116,7 @@ export function MedicationIntakeQuickAdd({ // source the cards use); Berlin stays the last-resort fallback so // logged-out mounts behave unchanged. const { user } = useAuth(); - const userTz = user?.timezone || "Europe/Berlin"; + const userTz = user?.timezone || DEFAULT_TIMEZONE; // The record this dose lands in, or null in the caller's own. const recordName = useActiveRecordName(); diff --git a/src/components/insights/intraday-pulse-chart.tsx b/src/components/insights/intraday-pulse-chart.tsx index 965014e94..09e1ae79a 100644 --- a/src/components/insights/intraday-pulse-chart.tsx +++ b/src/components/insights/intraday-pulse-chart.tsx @@ -19,7 +19,7 @@ import { useAuth } from "@/hooks/use-auth"; import { apiGet } from "@/lib/api/api-fetch"; import { queryKeys } from "@/lib/query-keys"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; -import { userDayKey, shiftDateKey } from "@/lib/tz/format"; +import { userDayKey, shiftDateKey, DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { TileHeader } from "@/components/insights/tile-header"; import { Button } from "@/components/ui/button"; import { QueryErrorCard } from "@/components/ui/query-error-card"; @@ -94,7 +94,7 @@ export function IntradayPulseChart({ const { isAuthenticated } = useAuth(); const { t } = useTranslations(); const fmt = useFormatters(); - const tz = userTimezone ?? "Europe/Berlin"; + const tz = userTimezone ?? DEFAULT_TIMEZONE; const todayKey = userDayKey(new Date(), tz); // `null` tracks "today" live (so a session left open across midnight keeps // following the current day); a non-null value pins the viewed day once diff --git a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx index 7bba97edb..da56bb22f 100644 --- a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx +++ b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx @@ -34,7 +34,7 @@ vi.mock("@/hooks/use-measurement-reminders", () => ({ })); import { VorsorgeSection, postponeOffsetTargets } from "../vorsorge-section"; -import { DISPLAY_TIMEZONE } from "@/lib/format-locale"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; /** * Noon on the day `offsetDays` from today, counted on the DISPLAY zone's @@ -53,7 +53,7 @@ import { DISPLAY_TIMEZONE } from "@/lib/format-locale"; function noonOnDisplayDay(offsetDays: number): Date { // `en-CA` formats as YYYY-MM-DD, which is the shape we want to add days to. const today = new Intl.DateTimeFormat("en-CA", { - timeZone: DISPLAY_TIMEZONE, + timeZone: DEFAULT_TIMEZONE, year: "numeric", month: "2-digit", day: "2-digit", @@ -219,7 +219,7 @@ describe(" loading + empty", () => { // the wall-clock gap to now exceeds the 24h a rolling delta would key on. // Anchor the due instant to noon on the DISPLAY zone's today, not the // process clock's. `vitest.config.mts` pins the suite to TZ=UTC while the - // component floors in the profile zone (`DISPLAY_TIMEZONE`, Europe/Berlin), + // component floors in the profile zone (`DEFAULT_TIMEZONE`, Europe/Berlin), // and those two disagree about the date for the two hours before UTC // midnight. Anchoring on `setHours` made this case fail every night in // that window, which is the same confusion of clocks the assertion exists diff --git a/src/components/medications/card-parts/displayed-slot-instant.ts b/src/components/medications/card-parts/displayed-slot-instant.ts index 62eedae0d..a8d633239 100644 --- a/src/components/medications/card-parts/displayed-slot-instant.ts +++ b/src/components/medications/card-parts/displayed-slot-instant.ts @@ -1,3 +1,4 @@ +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; /** * v1.12.3 — resolve the canonical slot instant of the dose a medication * card is currently surfacing, so the card's "Genommen" / "Skip" buttons @@ -138,7 +139,7 @@ export function resolveDisplayedSlotInstant( currentWindowStatus, nextDueAt, now, - timeZone = "Europe/Berlin", + timeZone = DEFAULT_TIMEZONE, } = input; // The card surfaces the current/overdue dose band — record that band's diff --git a/src/components/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx index e80592a7f..642e4bb7a 100644 --- a/src/components/medications/dose-history-ledger.tsx +++ b/src/components/medications/dose-history-ledger.tsx @@ -111,6 +111,7 @@ import { type LedgerRow, } from "@/components/medications/dose-history-ledger-compute"; import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -182,7 +183,7 @@ export function DoseHistoryLedger({ // marked — the most-encountered of the set. const recordName = useActiveRecordName(); const queryClient = useQueryClient(); - const timeZone = user?.timezone || "Europe/Berlin"; + const timeZone = user?.timezone || DEFAULT_TIMEZONE; const [addOpen, setAddOpen] = useState(false); const [editingEvent, setEditingEvent] = useState<{ diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index b0f22972e..1895daa0a 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -37,6 +37,7 @@ import { } from "@/components/medications/detail/supply-runway"; import { useAuth } from "@/hooks/use-auth"; import { useMedicationComplianceSummary } from "@/lib/queries/use-medication-compliance-summary"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; /** * v1.4.25 W4d — GLP-1 medication card variant. @@ -202,7 +203,7 @@ export function Glp1MedicationCard({ // v1.16.9 — the card reasons in the PROFILE timezone; Berlin stays the // last-resort fallback for logged-out mounts and legacy fixtures. const { user } = useAuth(); - const userTz = user?.timezone || "Europe/Berlin"; + const userTz = user?.timezone || DEFAULT_TIMEZONE; const fmt = useFormatters(); // Issue #490 — the zone `fmt` renders dates in (mirror → Berlin). The // weekday name paired with `fmt.dateShort` must be derived in this SAME diff --git a/src/components/medications/log-intake-dialog.tsx b/src/components/medications/log-intake-dialog.tsx index 111282526..411ef022f 100644 --- a/src/components/medications/log-intake-dialog.tsx +++ b/src/components/medications/log-intake-dialog.tsx @@ -44,6 +44,7 @@ import { runLogIntake, runUndoIntake, } from "@/components/medications/use-medication-intake"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; interface LogIntakeSchedule extends ScheduleWindowInput { label: string | null; @@ -120,7 +121,7 @@ export function LogIntakeDialog({ const recordName = useActiveRecordName(); const queryClient = useQueryClient(); const { user } = useAuth(); - const userTz = user?.timezone || "Europe/Berlin"; + const userTz = user?.timezone || DEFAULT_TIMEZONE; const formId = useId(); const [medicationOverride, setMedicationOverride] = useState( diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index b59b2ac0b..a4d1623c3 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -34,6 +34,7 @@ import { LogInjectionSiteDialog } from "@/components/medications/log-injection-s import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs"; import { useAuth } from "@/hooks/use-auth"; import type { InjectionSiteKey } from "@/lib/medications/injection-sites"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; interface Schedule { id: string; @@ -149,7 +150,7 @@ export function MedicationCard({ // session carries); Berlin stays the last-resort fallback so logged-out // mounts and legacy fixtures behave unchanged. const { user } = useAuth(); - const userTz = user?.timezone || "Europe/Berlin"; + const userTz = user?.timezone || DEFAULT_TIMEZONE; const fmt = useFormatters(); const weekdayLabel = useWeekdayLabel(); // v1.8.5 — post-dose injection-site prompt state. Holds the intake diff --git a/src/components/medications/medication-table.tsx b/src/components/medications/medication-table.tsx index d5089f611..e52c9c92a 100644 --- a/src/components/medications/medication-table.tsx +++ b/src/components/medications/medication-table.tsx @@ -43,6 +43,7 @@ import { } from "@/lib/queries/use-medication-compliance-summary"; import { useAuth } from "@/hooks/use-auth"; import { cn } from "@/lib/utils"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; /** * v1.16.10 — the compact table view for /medications (issue #316 item 3). @@ -194,7 +195,7 @@ export function MedicationTable({ }: MedicationTableProps) { const { t, locale } = useTranslations(); const { user } = useAuth(); - const userTz = user?.timezone || "Europe/Berlin"; + const userTz = user?.timezone || DEFAULT_TIMEZONE; const [sort, setSort] = useState(initialSort); const [, forceUpdate] = useReducer((x: number) => x + 1, 0); diff --git a/src/components/medications/take-all-due.ts b/src/components/medications/take-all-due.ts index aaab08810..d2ff9ac7a 100644 --- a/src/components/medications/take-all-due.ts +++ b/src/components/medications/take-all-due.ts @@ -57,6 +57,7 @@ import { } from "@/lib/medications/window-status"; import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant"; import type { DoseStatus } from "@/lib/analytics/compliance"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; type Translator = ( key: string, @@ -142,7 +143,7 @@ export function deriveDueMedications( } = {}, ): DueMedication[] { const now = options.now ?? new Date(); - const tz = options.tz ?? "Europe/Berlin"; + const tz = options.tz ?? DEFAULT_TIMEZONE; const { lateMinutes, missedMinutes } = options.thresholds ?? DEFAULT_THRESHOLDS; diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx index e07b9c7fa..41eb35e93 100644 --- a/src/components/settings/account-section/index.tsx +++ b/src/components/settings/account-section/index.tsx @@ -33,7 +33,7 @@ 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 } from "@/lib/tz/format"; +import { detectBrowserTimezone, DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { apiFetchRaw } from "@/lib/api/api-fetch"; import { describeRejectedProfileField, @@ -74,7 +74,7 @@ export function AccountSection() { const [height, setHeight] = useState(EMPTY_HEIGHT_DRAFT); const [dateOfBirth, setDateOfBirth] = useState(""); const [gender, setGender] = useState(""); - const [timezone, setTimezone] = useState("Europe/Berlin"); + const [timezone, setTimezone] = useState(DEFAULT_TIMEZONE); // v1.7.0 — optional patient-identity fields for the health-record export. const [fullName, setFullName] = useState(""); const [insurerName, setInsurerName] = useState(""); @@ -84,7 +84,7 @@ export function AccountSection() { height: EMPTY_HEIGHT_DRAFT, dateOfBirth: "", gender: "", - timezone: "Europe/Berlin", + timezone: DEFAULT_TIMEZONE, fullName: "", insurerName: "", insuranceNumber: "", diff --git a/src/components/settings/managed-record-settings-section.tsx b/src/components/settings/managed-record-settings-section.tsx index 72639967c..fe98c4f5a 100644 --- a/src/components/settings/managed-record-settings-section.tsx +++ b/src/components/settings/managed-record-settings-section.tsx @@ -39,6 +39,7 @@ import { Switch } from "@/components/ui/switch"; import { SettingsCardActions } from "./_card-actions"; import { SettingsCardHeader } from "./_card-header"; import { SettingsCard } from "./settings-card"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; interface ManagedRecordSettingsResponse { recordId: string; @@ -414,7 +415,7 @@ function ProfileSettingsForm({