mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
docs(planning): per-user timezone proposal — long-form record for v1.5
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.
This commit is contained in:
1 parent
ca03c6099f
commit
604009dc4f
3 files changed
+365
-2
No files matched your search
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user