From 604009dc4f29aa8cb3d778fe958352f45e586c7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 12 May 2026 22:45:42 +0200 Subject: [PATCH] =?UTF-8?q?docs(planning):=20per-user=20timezone=20proposa?= =?UTF-8?q?l=20=E2=80=94=20long-form=20record=20for=20v1.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #167 (Warsaw user, v1.4.23) reports that the CSV export reads as UTC because `Date.prototype.toISOString()` writes the `Z` suffix and spreadsheet viewers drop the marker. The technical bug is small — 4 lines in `src/lib/export.ts` — but the architectural surface it exposes is large: the whole app is hardcoded to `Europe/Berlin` for display, analytics buckets, reminder cron, Withings sync mapping, charts, PDF, Coach context (10 distinct symptoms total). This commit records the long-form analysis so a future planning pass does not start from zero: - `.planning/feature-user-timezone.md` — full proposal: symptom catalogue across 10 surfaces, design space (A: export-only quick fix, B: per-user `displayTimezone` setting, C: rejected auto-detect), migration strategy with concrete file/line touches, the date-string risk split (`MoodEntry.date` freeze vs `MedicationSchedule.windowStart` re-anchor — they need different contracts), DST edge cases, parametrised test plan (Berlin / UTC / New York / Tokyo / Kiritimati), and open product-call questions for the v1.5 planning pass. - `.planning/v15-backlog.md` — one-line carry under a new "From issue triage" section pointing at the proposal. - `.planning/ROADMAP.md` — reserved-milestones block notes per-user timezone as a v1.5 candidate, with v1.6+ as fallback if v1.5 stays iOS-focused. Scope-clean: the v1.4.25 quick fix (ISO-8601-with-offset CSV) is documented as independent. It closes #167 on its own without touching the architectural scope captured here. --- .planning/ROADMAP.md | 7 +- .planning/feature-user-timezone.md | 345 +++++++++++++++++++++++++++++ .planning/v15-backlog.md | 15 ++ 3 files changed, 365 insertions(+), 2 deletions(-) create mode 100644 .planning/feature-user-timezone.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index d746d64e4..8ef916931 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -35,10 +35,13 @@ Reserved next strategic milestones: - **v1.5** — iOS app + Apple Health integration + per-metric APNs alerts. Strategic plan at - `.planning/phase-W5-v1422-product-lead-review.md`. + `.planning/phase-W5-v1422-product-lead-review.md`. Per-user + timezone (issue #167) is a candidate for this milestone — see + `.planning/feature-user-timezone.md` for the full proposal. - **v1.6+** — Auto-correlation discovery (FDR-controlled), Coach full-page route at `/insights/coach`, conversation-driven goal - setting. + setting. Per-user timezone slips here if v1.5 stays + iOS-focused. --- diff --git a/.planning/feature-user-timezone.md b/.planning/feature-user-timezone.md new file mode 100644 index 000000000..06aa8fcbb --- /dev/null +++ b/.planning/feature-user-timezone.md @@ -0,0 +1,345 @@ +# Feature proposal — per-user timezone + +**Status:** documented, not scheduled. +**Trigger:** GitHub issue +[#167](https://github.com/MBombeck/HealthLog/issues/167) (Warsaw user, +v1.4.23). +**Captured:** 2026-05-12, during the v1.4.24 release retrospective. +**Owner:** unassigned — picks up in v1.5 product-lead planning. + +This document is the long-form record so a future planning pass does +not start from zero. Read together with +`.planning/v15-backlog.md` (which only carries the one-line entry). + +--- + +## 1. Background + +Issue #167 reports that a Warsaw user enters a measurement at 11:05 +local time, then sees `09:05` in the CSV export. He set +`TZ=Europe/Warsaw` + `PGTZ=Europe/Warsaw` on the container; the +discrepancy persisted. + +That is not a deployment bug. The export writes ISO-8601 with the `Z` +suffix (UTC), and his viewer (Excel / LibreOffice) drops the marker, +so the user reads UTC as local time. The CSV is technically correct, +the framing is hostile. See `src/lib/export.ts:53, :103, :104, :129`. + +The interesting part of the issue is what it surfaces: HealthLog is +hardcoded to `Europe/Berlin` end-to-end. The Warsaw reporter is in the +same UTC offset as Berlin year-round (CET/CEST), so he is the easiest +case. The same code path mis-labels every reading for a user in +London, New York, Tokyo or São Paulo — and does it silently. + +--- + +## 2. Current state — where Berlin is wired in + +Hardcoded `"Europe/Berlin"` references at the time of writing +(`grep -rn "Europe/Berlin" src/`): + +| File | Role | +| ----------------------------------------------------- | --------------------------------------------------------- | +| `src/lib/format-locale.ts:18` | `DISPLAY_TIMEZONE` constant — single source for display. | +| `src/lib/analytics/berlin-day.ts:17` | Day-bucket key for analytics (`YYYY-MM-DD` in Berlin). | +| `src/lib/analytics/correlations.ts:109, :117` | Weekly ISO-Monday bucketing for correlations. | +| `src/lib/analytics/bp-in-target.ts:64, :70, :160` | SYS/DIA pairing fallback when timestamps drift ≥ 5 min. | +| `src/lib/charts/bucket-time-series.ts:38` | Daily / weekly / monthly chart buckets. | +| `src/lib/charts/comparison-shift.ts` | `chartData_compare` overlay anchored at Berlin-day-noon. | +| `src/components/charts/health-chart.tsx:195` | X-axis tick formatter. | +| `src/components/medications/medication-card.tsx` | "Last taken" + next-window time labels. | +| `src/components/admin/api-token-overview-section.tsx` | "Created / last used" timestamps in the admin table. | +| `Dockerfile` | `ENV TZ=Europe/Berlin` — container TZ for the Node clock. | +| `CLAUDE.md` | Convention: "Berlin for display, UTC for storage". | + +Reminder schedules: + +| File | Role | +| ---------------------------------------------------------- | ----------------------------------------------------------------------------- | +| `src/lib/jobs/reminder-worker.ts` | pg-boss cron tick interpreted in container TZ. | +| `src/lib/medications/window-resolution.ts` (or equivalent) | `windowStart` / `windowEnd` strings (`"07:00"`–`"09:00"`) are container-time. | +| `ReminderPhaseConfig` rows in `prisma/schema.prisma:387` | Window thresholds per user, schema-naive ("just a string"). | + +Persistence: + +- `Measurement.measuredAt`, `MedicationIntakeEvent.scheduledFor` / + `.takenAt`, `MoodEntry.moodLoggedAt` are `DateTime` (`timestamptz`). + Postgres stores UTC; correct. No write-path changes needed. +- `MoodEntry.date` is a `String` formatted `YYYY-MM-DD` — anchored to + Berlin at the time of write. **This is the migration risk**: the + same calendar moment falls into different `date` strings depending + on the chosen anchor. See §7. +- `MedicationSchedule.windowStart` / `.windowEnd` are `String` + `"HH:MM"` — same problem: a "08:00" window is Berlin local today. + +--- + +## 3. Symptom catalogue — beyond the CSV + +Closing the CSV alone is a 4-files patch. The architectural problem is +broader. Each row below is a separate symptom that an end-user can +hit: + +1. **CSV / JSON export reads as UTC.** Issue #167. Fix in `export.ts`. +2. **Reminders fire at Berlin time, not user time.** A Tokyo user sets + a 08:00 morning reminder; the pg-boss cron tick checks against + container TZ, so the message arrives at 16:00 Tokyo. Hidden today + because the only known users are CET/CEST. +3. **"Today" buckets misalign.** A user logs a 23:30 reading in their + local zone that maps to "tomorrow" UTC and "tomorrow" Berlin → + their day-summary card carries the reading on the wrong calendar + day. Same effect on the streak counter (achievements) and the + "today vs yesterday" comparison. +4. **Mood-entry date drift.** `MoodEntry.date` is a `String` written + in Berlin. A New York user entering at 21:00 EDT (= 03:00 next-day + Berlin) gets the wrong `date` string, which then disagrees with the + `moodLoggedAt` timestamp. +5. **Withings sync day-mapping.** Withings ships UTC timestamps; + `withings-sync` buckets them via `berlin-day.ts`. A reading taken + at 23:50 local in Tokyo (14:50 UTC) lands on yesterday-Berlin + instead of today-Tokyo. The user sees their evening reading on + yesterday's chart. +6. **Charts X-axis labels.** Mobile chart x-axis label formatter + passes `timeZone: "Europe/Berlin"` directly. A New York user sees + `Mai 11` for a reading they remember entering on `May 10` 22:30 EDT. +7. **Email / push notification timestamps.** Notification body + templates render times in the dispatcher, container TZ. Same + mis-attribution as #2 once #2 is fixed. +8. **PDF doctor report.** `src/app/api/doctor-report/pdf/route.ts` + renders rows with `formatInTimeZone` against Berlin. A PDF a US + user takes to their doctor lists every reading at a Berlin + timestamp. +9. **AI Coach + analytics context.** The Coach prompt context says + "yesterday" and "this week" against Berlin-anchored buckets. Same + day-misalignment problem as #3, but tucked inside the prompt where + nobody will see it until a user complains the recommendation is + for the wrong day. +10. **OpenAPI / iOS DTO contract.** `docs/api/openapi.yaml` ships + timestamps as `string($date-time)` without a contract on offset. + iOS will round-trip them as UTC. If the future iOS app renders + its own clock, it will diverge from the web UI until the web UI + learns user TZ too. + +That is ten distinct surfaces, not one. + +--- + +## 4. Design space + +### Option A — leave display TZ alone, fix the export only + +CSV/JSON write ISO-8601 with offset (`2026-05-11T11:05:00+02:00`) +instead of `Z`. Same instant, machine-parseable, human readable. + +- Scope: 4 lines in `src/lib/export.ts` + 2 test updates. +- Closes issue #167. +- Does not fix the 9 other symptoms. +- Sensible if the product stays Europe-only. +- **Cost:** ~2 h. + +### Option B — per-user `displayTimezone` setting, opt-in, default Berlin + +Add a `User.displayTimezone` column (DEFAULT `"Europe/Berlin"`). +Surface a picker in `/profile`. Every display-side helper takes the +user pref instead of `DISPLAY_TIMEZONE`. Reminder cron + Withings +day-mapping + Coach context all become user-TZ-aware. + +- Scope: large. ~30 files touch the constant, ~10 buckets need to + parametrise their bucketing helper, ~5 surfaces switch from `Intl` + literal to a hook/util. +- Existing data is unaffected by storage (still `timestamptz`). +- Existing `MoodEntry.date` strings and `MedicationSchedule.windowStart` + / `.windowEnd` are the migration risk — see §7. +- Reminder worker rewrite: pg-boss cron is timezone-naive; choose + between (a) per-user cron rows, (b) global 1-minute tick with + in-app "is it time in user TZ?", (c) precompute the next absolute + UTC fire time per reminder on every settings change and on each fire. +- **Cost:** 1–2 weeks elapsed, conservative. The honest number when + every symptom from §3 is closed and the test suite is parametrised + per TZ branch. +- **Recommended.** + +### Option C — auto-detect from browser + +`Intl.DateTimeFormat().resolvedOptions().timeZone` from the client, +stored in a cookie or sent with every request. No user-visible +picker. + +- Implicit: the user never opted in, never sees what they are set to, + cannot fix a wrong guess. +- Useless for the iOS app, CLI consumers, API-token clients. +- **Rejected** as a primary mechanism. Reasonable as the _default + value_ for the §B picker on signup. + +--- + +## 5. Recommended path + +1. **v1.4.25 quick fix** (Option A, separate from this proposal): + ship the ISO-8601-with-offset export so issue #167 closes + immediately. Adds an `aria-label` / footer note in the export-UI + that timestamps carry the user's display zone. Documented in the + v1.4.25 changelog as a follow-on; no roadmap impact. +2. **v1.5 (or later) full feature** (Option B): plan as a dedicated + wave. The migration risk in §7 dominates the schedule. + +The two are independent — A can ship next week, B can sit on the +backlog without blocking it. + +--- + +## 6. Migration strategy for Option B + +### 6.1 Schema additions (forward-only, no breakage) + +```prisma +model User { + ... + displayTimezone String @default("Europe/Berlin") + ... +} +``` + +Migration `00XX_user_display_timezone` — additive, DEFAULT covers +every existing row, no backfill needed. + +### 6.2 Constant → user-pref accessor + +Replace every direct `DISPLAY_TIMEZONE` import with a context-aware +helper: + +```ts +// Server side +const tz = await getUserTimezone(); // reads from session.user.displayTimezone + +// Client side +const tz = useUserTimezone(); // hook, reads from auth context +``` + +`format-locale.ts` keeps exporting `DISPLAY_TIMEZONE` as a _fallback_ +for non-user-scoped surfaces (admin tables, audit log viewer, +analytics QA dashboard — every place where "whose timezone?" has no +answer). Worker code (`reminder-worker.ts`, `withings-sync`) reads the +target user's pref before any bucketing. + +### 6.3 Per-surface fixes (order matches §3) + +| # | Surface | Change | +| --- | ---------------------- | ---------------------------------------------------------------------- | +| 1 | CSV/JSON export | `formatInTimeZone(..., userTz, "yyyy-MM-dd'T'HH:mm:ssXXX")` | +| 2 | Reminder worker | recompute next-fire on settings change, in user TZ | +| 3 | "Today" buckets | `berlin-day.ts` → `user-day.ts` taking `tz` arg | +| 4 | `MoodEntry.date` drift | see §7 | +| 5 | Withings sync | bucket via user-tz instead of `BERLIN_TZ` | +| 6 | Chart x-axis | tick formatter takes `userTz` prop, falls back to Berlin | +| 7 | Notification body | dispatcher reads recipient's tz before formatting | +| 8 | PDF doctor report | header reads user's tz; if export is for a target user, use theirs | +| 9 | Coach prompt context | "yesterday" / "this week" computed in user tz | +| 10 | OpenAPI / iOS DTO | document timestamps as `string($date-time)` w/ note: always UTC offset | + +### 6.4 Test strategy + +`berlin-day.test.ts` has the right shape but pins to Berlin. The +parametrised version runs each test under 3–5 representative zones: + +- `Europe/Berlin` — DST boundary cases stay green. +- `UTC` — sanity baseline. +- `America/New_York` — large negative offset, DST. +- `Asia/Tokyo` — large positive offset, no DST. +- `Pacific/Kiritimati` — UTC+14, extreme positive offset (catches + off-by-one-day bugs in date-only strings). + +A test helper `withTimezone(tz, () => { ... })` wraps the existing +suites. Expect ~30 existing tests to be parametrised; total suite size +roughly doubles for the TZ-sensitive units. Acceptable. + +--- + +## 7. The real risk — date-only strings + +The only thing in the schema that is _not_ time-instant data is the +two strings: + +- `MoodEntry.date` (`YYYY-MM-DD`) — written from + `formatInTimeZone(loggedAt, BERLIN_TZ, "yyyy-MM-dd")`. +- `MedicationSchedule.windowStart` / `.windowEnd` (`HH:MM`) — set by + the user via the medication form; they read as "Berlin local time" + today. + +When the user switches their `displayTimezone` from `Europe/Berlin` to +`America/New_York`: + +- **Mood entries:** every historical `MoodEntry.date` is now a Berlin + calendar day, but the UI will render its day-summary card by + New-York-calendar-day. Existing entries near midnight will appear + to "jump" between days. + - **Decision A — freeze:** TZ is captured at the time of write. + Schema gains `MoodEntry.tz TEXT NOT NULL DEFAULT 'Europe/Berlin'`, + UI groups by `(tz, date)`. Historical entries stay where the user + logged them. **Recommended.** + - **Decision B — recompute:** treat `date` as derived from + `moodLoggedAt`, redo the string under the new TZ. Cleaner code, + but historical "day" assignments shift, which can change streak + counts and break achievements awarded retroactively. +- **Medication windows:** "07:00" today means "07:00 in Berlin". A + user moving to Tokyo legitimately wants their morning dose at + Tokyo-07:00, not Berlin-07:00. **Decision A — freeze** is wrong + here, because the user's expectation is that their reminder follows + them. **Decision B — re-anchor** is correct: schedules are stored + as "wall clock in current display TZ" and reinterpreted under the + new TZ. +- The asymmetry is real: historical observations want freeze, future + schedules want re-anchor. The schema needs to express both + contracts. + +### 7.1 DST edge cases + +The existing `berlin-day.test.ts` already covers Berlin DST. The +parametrised version inherits this. **Open question:** what happens +when a reminder window straddles a DST boundary in the user's TZ? +Today this is a Berlin-only concern and pg-boss handles it implicitly +via container TZ. With per-user TZ, the resolver has to make a choice +("fire at the wall-clock time on both sides" vs "fire at the absolute +UTC time of the pre-DST occurrence"). Document and pick a convention +before implementation. + +--- + +## 8. Out-of-scope for this proposal + +- Per-tenant / per-org TZ: HealthLog is single-tenant. +- TZ-by-IP fallback for unauthenticated surfaces: pricing dashboards + and the auth pages stay Berlin. +- Localised number formats (German 1.234,56 vs US 1,234.56): tracked + separately under i18n. +- Calendar locale (Monday-start vs Sunday-start week): same. + +--- + +## 9. Open questions for the v1.5 product-lead pass + +1. Is per-user TZ a v1.5 commitment, or does it slip to v1.6+ behind + the iOS work? +2. If v1.5, does it ship before or after Apple Health import? Apple + Health entries carry their own source-TZ metadata — could simplify + the migration if we lift that as the canonical representation. +3. Decision A (freeze) vs Decision B (re-anchor) split per surface + needs a product call, not an engineering call. Pre-fill from §7. +4. Acceptance criterion for "done": pick 1–2 user complaints we are + willing to ship with, and 1–2 that block release. Probably "Coach + prompt context drift" is acceptable for an MVP; "reminder fires at + wrong wall-clock time" is not. + +--- + +## 10. Pointers for the next agent + +- This file lives at `.planning/feature-user-timezone.md`. +- One-line carry in `.planning/v15-backlog.md` under "From issue + triage". +- One-line carry in `.planning/ROADMAP.md` under v1.5 / v1.6+ reserved + themes. +- Triggering issue: GitHub #167 (Warsaw user, v1.4.23, CSV export + shows UTC). +- The v1.4.25 quick fix (Option A) is independent — file it as its + own backlog row, not blocked on this proposal. diff --git a/.planning/v15-backlog.md b/.planning/v15-backlog.md index f8105b0b6..1865eeb98 100644 --- a/.planning/v15-backlog.md +++ b/.planning/v15-backlog.md @@ -384,6 +384,21 @@ of the strategic content there is the v1.4.20 plan (Insights redesign commits (A4, A5, A6 in v1.4.19). v1.4.20 should require one worktree per parallel agent. +## From issue triage + +- **Per-user timezone (Option B in the proposal).** Issue #167 + surfaced that the entire app is hardcoded to `Europe/Berlin` — UI + display, analytics buckets, reminder cron, Withings sync mapping, + PDF report, Coach context, ten distinct symptoms in total. The + full design space, migration strategy, schema changes, DST and + date-string risks (`MoodEntry.date` freeze vs `MedicationSchedule` + re-anchor — they need different contracts), test parametrisation + plan and product-call questions are written up in + `.planning/feature-user-timezone.md`. Sized L: 1–2 weeks elapsed. + Independent from the v1.4.25 quick fix (ISO-8601-with-offset CSV + export) which closes #167 on its own without touching this scope. + Triggering issue: GitHub #167. + ## Process / meta - **Cross-agent commit-message drift** — reduced materially in