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.
16 KiB
Feature proposal — per-user timezone
Status: documented, not scheduled. Trigger: GitHub issue #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.moodLoggedAtareDateTime(timestamptz). Postgres stores UTC; correct. No write-path changes needed.MoodEntry.dateis aStringformattedYYYY-MM-DD— anchored to Berlin at the time of write. This is the migration risk: the same calendar moment falls into differentdatestrings depending on the chosen anchor. See §7.MedicationSchedule.windowStart/.windowEndareString"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:
- CSV / JSON export reads as UTC. Issue #167. Fix in
export.ts. - 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.
- "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.
- Mood-entry date drift.
MoodEntry.dateis aStringwritten in Berlin. A New York user entering at 21:00 EDT (= 03:00 next-day Berlin) gets the wrongdatestring, which then disagrees with themoodLoggedAttimestamp. - Withings sync day-mapping. Withings ships UTC timestamps;
withings-syncbuckets them viaberlin-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. - Charts X-axis labels. Mobile chart x-axis label formatter
passes
timeZone: "Europe/Berlin"directly. A New York user seesMai 11for a reading they remember entering onMay 1022:30 EDT. - Email / push notification timestamps. Notification body templates render times in the dispatcher, container TZ. Same mis-attribution as #2 once #2 is fixed.
- PDF doctor report.
src/app/api/doctor-report/pdf/route.tsrenders rows withformatInTimeZoneagainst Berlin. A PDF a US user takes to their doctor lists every reading at a Berlin timestamp. - 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.
- OpenAPI / iOS DTO contract.
docs/api/openapi.yamlships timestamps asstring($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
Intlliteral to a hook/util. - Existing data is unaffected by storage (still
timestamptz). - Existing
MoodEntry.datestrings andMedicationSchedule.windowStart/.windowEndare 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
- 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. - 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)
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:
// 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 fromformatInTimeZone(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.dateis 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
dateas derived frommoodLoggedAt, redo the string under the new TZ. Cleaner code, but historical "day" assignments shift, which can change streak counts and break achievements awarded retroactively.
- Decision A — freeze: TZ is captured at the time of write.
Schema gains
- 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
- Is per-user TZ a v1.5 commitment, or does it slip to v1.6+ behind the iOS work?
- 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.
- Decision A (freeze) vs Decision B (re-anchor) split per surface needs a product call, not an engineering call. Pre-fill from §7.
- 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.mdunder "From issue triage". - One-line carry in
.planning/ROADMAP.mdunder 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.