feat: per-event time zones (#31) #82

Merged
makiolaj merged 11 commits from feat/timezone-support into release/v2.16.0 2026-07-19 09:49:13 +00:00
Owner

Implements #31 — per-event time zones (2.16.0 milestone), plus the review fixes and on-device refinements that followed.

What it does

  • A new Time zone field (event form → "more fields") pins an event to a specific zone, so an 8:00 AM New York call stays 8:00 AM New York wherever it's opened, and keeps tracking that zone across DST instead of drifting.
  • The edit form works in the event's own zone and captions the local equivalent ("2:00 PM – 3:00 PM your time"); the detail screen keeps local time primary and shows the original beneath, matching the When card's hierarchy.
  • Full-screen picker (device zone + recents pinned on top) searchable by city, IANA id, or abbreviation ("CEST" gathers all CEST zones).
  • All-day events stay date-anchored (no zone), per the CalendarContract UTC-midnight convention.

Notable correctness work

  • Two latent provider bugs fixed: editing a foreign-zone event's time used to silently re-stamp EVENT_TIMEZONE to the device (drift at the next DST boundary); a zone-only change wasn't in timesChanged so DTSTART wasn't rewritten. Both covered by tests.
  • Abbreviations resolve in the zone's own region. Android's ICU only surfaces short names commonly used in the reader's region, so an en-DE phone showed "GMT-4" for US zones; asking in the display language + the zone's region (via android.icu, injected so domain/ stays pure-JVM) lights up EDT/PDT/BST/AEST/IST/JST. Verified on-device.

Dependency

  • Bumps the floret-kit submodule to v0.2.1 (adds CollapsingScaffold's scrollable opt-out, needed for the ~600-row lazy picker). Tagged and on kit main.

Testing

  • lint test assembleDebug green; new JVM tests for TimeZoneCatalog (region/DST/search) and the write/edit-form paths.
  • On-device verified on a Pixel (en-DE): abbreviations, search, dual-time display, all-day suppression.

Incidental

  • One unrelated commit fixes CalendarRepositorySmokeTest, which didn't compile against the current CalendarRepositoryImpl constructor (blocked the whole androidTest source set; likely still broken on main).

Changelog updated (CHANGELOG.md + fastlane 21600.txt).

🤖 Generated with Claude Code

Implements **#31 — per-event time zones** (2.16.0 milestone), plus the review fixes and on-device refinements that followed. ## What it does - A new **Time zone** field (event form → "more fields") pins an event to a specific zone, so an 8:00 AM New York call stays 8:00 AM New York wherever it's opened, and keeps tracking that zone across DST instead of drifting. - The edit form works in the event's own zone and captions the local equivalent ("2:00 PM – 3:00 PM your time"); the detail screen keeps local time primary and shows the original beneath, matching the When card's hierarchy. - Full-screen picker (device zone + recents pinned on top) searchable by city, IANA id, or **abbreviation** ("CEST" gathers all CEST zones). - All-day events stay date-anchored (no zone), per the CalendarContract UTC-midnight convention. ## Notable correctness work - **Two latent provider bugs fixed:** editing a foreign-zone event's time used to silently re-stamp `EVENT_TIMEZONE` to the device (drift at the next DST boundary); a zone-only change wasn't in `timesChanged` so `DTSTART` wasn't rewritten. Both covered by tests. - **Abbreviations resolve in the zone's own region.** Android's ICU only surfaces short names commonly used in the reader's region, so an `en-DE` phone showed "GMT-4" for US zones; asking in the display language + the zone's region (via `android.icu`, injected so `domain/` stays pure-JVM) lights up EDT/PDT/BST/AEST/IST/JST. Verified on-device. ## Dependency - Bumps the **floret-kit** submodule to **v0.2.1** (adds `CollapsingScaffold`'s `scrollable` opt-out, needed for the ~600-row lazy picker). Tagged and on kit `main`. ## Testing - `lint test assembleDebug` green; new JVM tests for `TimeZoneCatalog` (region/DST/search) and the write/edit-form paths. - On-device verified on a Pixel (en-DE): abbreviations, search, dual-time display, all-day suppression. ## Incidental - One unrelated commit fixes `CalendarRepositorySmokeTest`, which didn't compile against the current `CalendarRepositoryImpl` constructor (blocked the whole androidTest source set; likely still broken on `main`). Changelog updated (`CHANGELOG.md` + fastlane `21600.txt`). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
makiolaj added 11 commits 2026-07-19 09:48:21 +00:00
Every write sampled ZoneId.systemDefault() and stamped it into
EVENT_TIMEZONE, so the column was real but only ever held the device's
zone: an event synced from elsewhere could be read in its zone, never
authored in one.

Give EventForm a nullable `timezone`, where null keeps meaning "the
device zone at save time" — so every existing call site behaves exactly
as before — and a non-null value pins the event to a zone it then tracks
across DST. toWriteTimes resolves the form's zone ahead of the device's;
toEditForm pins only when the stored zone differs from the device's, and
prefills such an event in its own zone so the form shows the wall-clock
the event actually means.

Two provider-contract bugs fall out of this:

- Editing the time of a foreign-zone event rewrote EVENT_TIMEZONE to the
  device's. The instants stayed right, so nothing looked wrong, but the
  event silently stopped tracking its zone and would drift an hour at the
  next DST boundary. Only the timesChanged gate spared title-only edits.
- A zone change with an untouched wall-clock is still a time change (the
  same 09:00 elsewhere is a different instant), so it now trips
  timesChanged and rewrites DTSTART instead of being dropped.

All-day events keep carrying no zone at all: they're date-anchored, and
the UTC midnights they normalise to are an anchor rather than a location.

TimeZoneCatalog is pure JVM so the search ranking and DST-aware offsets
stay plain JUnit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surface the zone as an optional form field, right after the time fields
it qualifies: hidden on ordinary events, revealed automatically when the
event already carries a foreign zone, and withheld entirely while all-day
is on (a date-anchored event has no zone to show).

The picker is a full-screen one per the app's convention, but it can't
reuse OptionPicker: that composes every option eagerly, and ~600 zones
would all compose on open. It drives its own LazyColumn instead, which
needs the kit's new `scrollable = false` — the scaffold's own
verticalScroll would otherwise throw on a nested same-axis scrollable.
The device zone and recently-picked zones pin to the top so the common
case needs no typing; search is accent- and case-insensitive.

Recents persist in DataStore, capped at five, dropping ids the tz
database no longer knows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The time-zone picker needs the scaffold's `scrollable` opt-out, which
landed in 0.2.1. Pins the tag rather than the branch commit the work was
developed against, keeping the tag-pinning convention.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two review fixes.

The zone picker's search box was a raw Material OutlinedTextField — the
only one left in the app, and against the convention DialogControls
states outright ("the family's InlineTextField over a tonal surface, not
Material's outlined field"). Rebuild it on InlineTextField over a tonal
surface, with the clear button inside the surface since the picker's top
bar is the title rather than a search field.

Showing a pinned event only in its own zone answered "what was it set
to?" while dropping "when is it for me?" — the user had to do the offset
arithmetic. Show both whenever they differ:

- the edit form keeps editing the event in its own zone (that's the time
  it was set at) and captions it with the local equivalent;
- the detail screen keeps local times primary and now leads the zone card
  with the original ("8:00 AM – 9:00 AM in New York") instead of naming
  the zone and nothing else.

EventForm.timesIn is pure, so the conversion — including crossing the
date line and each zone's own DST, which don't move together — is a
plain JUnit test rather than something only reviewable on a phone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The When card reads date-large, time-small-beneath. The zone card led
with the original time at titleMedium and dropped the zone label to
bodyMedium, inverting that — which made the foreign time the loudest
thing on the screen and pulled attention off the local time the reader
actually acts on.

Put the label back on top and the original time small beneath it, so both
cards read the same way and the original stays available without
competing. The label already names the zone, so the range drops its "in
New York" tail (and the string with it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
"Central European Time (Europe/Berlin)" is too wide — it made the zone
field wrap and stretch. Lead with the id ("Europe/Berlin") and follow it
with the abbreviation instead: "CEST · GMT+02:00" in the picker and edit
card, "CET · 8:00 AM – 9:00 AM" on the detail card (abbreviation + the
event's own-zone time).

The abbreviation is resolved DST-aware at the same instant as the offset
(CET vs CEST) via "zzz"; zones with no named abbreviation fall back to a
"GMT+05:30" form, and zoneDescriptor drops the separate offset in that
case so it isn't stated twice. The long localized name is kept on the
option for search only — typing "pacific" still works — but no longer
shown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The abbreviation came from java.time's "zzz" formatter, which on Android
has no short specific-zone names and silently degrades every zone to a
"GMT-4" form — so on device New York showed "GMT-04:00" instead of "EDT".
Desktop couldn't catch it: there "zzz" and java.util.TimeZone agree, and
that agreement is the whole trap.

Resolve through java.util.TimeZone.getDisplayName(inDst, SHORT, locale)
instead — ICU-backed, so it returns the real abbreviation on both Android
and the JVM. Zones with no named abbreviation still fall back to a GMT
form, which zoneDescriptor already collapses to a single token.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
java.util gave abbreviations only for zones in the display locale's region:
an en-DE phone saw "CEST" for Berlin but "GMT-4" for New York, and an
en-US phone the reverse — ICU only surfaces the short names commonly used
where the reader is. Verified on-device (en-DE): US zones fell back to the
offset, EU ones resolved.

Ask ICU in the display language but the *zone's* region instead — New York
in en-US, Berlin in en-DE — using android.icu's zone→region map. On-device
that lights up EDT/PDT/CDT, plus BST, AEST, IST, JST that showed only the
offset before. Where a region still has no name (Athens in en-GR) the
device locale sometimes does, so fall back to it, then to the offset.

The region lookup needs android.icu, which domain/ can't import, so it's
injected: timeZoneOptions takes a regionOf lambda (default none, keeping
the module pure and JVM-tested), and the UI passes icuTimeZoneRegion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CalendarRepositoryImpl gained a SettingsPrefs parameter, but this
instrumented smoke test still called the three-arg constructor — so the
whole androidTest source set failed to compile. Pass a SettingsPrefs
built on the same DataStore.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Search matched city, id, and the long localized name but not the
abbreviation, so typing "CEST" found nothing. Match it too: an exact
abbreviation hit ranks just under a city prefix, so typing an abbreviation
gathers every zone that shows it (all the CEST zones at once). It matches
the region-resolved abbreviation — i.e. exactly what the row displays.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(changelog): note per-event time zones (#31)
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 9m50s
7b7b859fee
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
makiolaj merged commit 60ef920b82 into release/v2.16.0 2026-07-19 09:49:13 +00:00
makiolaj deleted branch feat/timezone-support 2026-07-19 09:49:13 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: makiolaj/calendula#82