- dedicated git repo at /home/avi/Projects/Lumen (main)
- TypeScript strict (target ES2022, bundler, exactOptionalPropertyTypes, noUncheckedIndexedAccess)
- ESLint 9 + typescript-eslint strictTypeChecked + eslint-plugin-boundaries for B-1..B-7, no-restricted-globals/syntax for B-1/B-7
- Prettier 3.5
- Structure per IMPLEMENTATION-CONTRACT.md §4 (src/platform/idb|cache|sw, storage, data, sync/{transport,verifier}, domain/{emergency,schedule,map,festival,readiness,clock,favorites}, ui/{components,views,router,render}, app, emergency-baseline, assets, public, content, pipeline, tests, scripts)
- CI: .github/workflows/ci.yml (typecheck + lint + format + test)
- Boundary tests: tests/unit/boundaries.test.ts (4 tests) + scripts/check-boundaries.ts
- No feature code, no PWA/IDB/sync/mesh/accounts per contract Stage 1
189 lines
10 KiB
Markdown
189 lines
10 KiB
Markdown
# SPIKE-08 — Time Model (UTC + Festival IANA Zone + Clock Correction)
|
||
|
||
- **Phase:** Architecture Validation
|
||
- **Date:** 2026-08-30
|
||
- **Decision under test:** ADR-009 (UTC epoch storage, festival IANA zone
|
||
rendering, precomputed `dayKey`, ClockService with server-offset + monotonic
|
||
drift + sanity window), and the readiness independence rule from SPIKE-05.
|
||
- **Method:** disposable experiment `experiments/exp1-time-model.mjs` executed on
|
||
this Linux dev box using Node v22 full-ICU `Intl` (ECMA-402 — the same spec
|
||
browsers implement), plus spec/policy analysis for the parts no Linux box can
|
||
observe. Results labelled CONFIRMED / INFERRED / UNVERIFIED per
|
||
`experiments/README.md`.
|
||
- **Environment limitation (declared up front):** No iOS hardware or iOS
|
||
Simulator is reachable from this Linux box. V8 + ICU behaviour was **observed**;
|
||
JavaScriptCore (iOS) parity is **INFERRED** and queued for device testing.
|
||
System-clock manipulation, real HTTP `Date` headers, and low-end-device timing
|
||
are also out of scope here.
|
||
|
||
## 1. Question
|
||
|
||
Can Lumen compute correct local wall-clock display, festival day boundaries, and
|
||
"Now / Up Next" **entirely offline**, remain correct across DST and unusual
|
||
zones, and degrade honestly when the device clock is wrong — with no network
|
||
call on any critical path?
|
||
|
||
## 2. Architecture under test (recap)
|
||
|
||
| Piece | Design |
|
||
|---|---|
|
||
| Storage | Every event instant is UTC epoch ms (`startUtc`, `endUtc`). |
|
||
| Zone | Manifest carries festival IANA zone (e.g., `America/Chicago`) and festival window `startUtc`/`endUtc`. |
|
||
| Rendering | `Intl.DateTimeFormat` with `timeZone = festival zone` (default); user toggle to device zone. |
|
||
| Day boundaries | `dayKey` precomputed at publish time as the calendar date in the festival zone (no client-side day math). |
|
||
| Clock correction | `ClockService.now()` = `deviceClock + skew` when a persisted server offset exists; otherwise `deviceClock`. `skew` captured from any sync HTTP `Date` header; persisted with `{skew, capturedAtDevice, capturedAtMono, source}`. |
|
||
| Drift detection | `performance.now()` monotonic anchor detects mid-session device-clock jumps. |
|
||
| Sanity window | festival window ± 45 days; outside ⇒ "check your clock" warning. |
|
||
| Classification | `Now: startUtc ≤ now < endUtc`; `Up Next: startUtc > now` (next-N sorted). Overlaps shown as multiple "now". |
|
||
|
||
Readiness (SPIKE-05) must not depend on the clock: READY is time-independent;
|
||
a wrong clock produces a warning, never a readiness demotion.
|
||
|
||
## 3. Findings, item by item
|
||
|
||
### 3.1 UTC storage + festival-zone rendering (T1) — CONFIRMED (observed)
|
||
|
||
- `EXP-1 T1a` — `2026-07-15T18:00:00Z` renders `07/15/2026, 13:00:00` in
|
||
`America/Chicago` (CDT, UTC-5). PASS.
|
||
- `EXP-1 T1b` — rendering is keyed to the explicit `timeZone` option, not the
|
||
host zone. Explicit-zone `Intl` formatting is required by ECMA-402 and is what
|
||
the architecture relies on. PASS.
|
||
- **Conclusion:** UTC epoch storage + explicit-zone `Intl` rendering is correct
|
||
and offline-capable (tz database ships with the OS/browser). No network use.
|
||
|
||
### 3.2 Precomputed `dayKey` and midnight correctness (T2) — CONFIRMED
|
||
|
||
- `T2a` — `2026-07-16T04:59Z` (= 07-15 23:59 CDT) ⇒ `2026-07-15`. PASS.
|
||
- `T2b` — `2026-07-16T05:00Z` (= 07-16 00:00 CDT) ⇒ `2026-07-16`. PASS.
|
||
- `T2c` — `2026-07-16T00:00Z` (= 07-15 19:00 CDT) ⇒ `2026-07-15`, proving that UTC
|
||
midnight is **not** the festival day boundary. PASS.
|
||
- **Conclusion:** client-side date math is unnecessary; publishing `dayKey`
|
||
eliminates an entire class of off-by-one and TZ bugs. Clients group/filter by
|
||
the opaque key. Pipeline gate `T3` in SPIKE-04 validates `dayKey` against the
|
||
festival zone at publish time.
|
||
|
||
### 3.3 DST transitions (T3) — CONFIRMED on V8/ICU; INFERRED on JSC
|
||
|
||
- Spring forward 2026-03-08 America/Chicago: `01:59 CST` → `03:00 CDT`
|
||
(`T3a`/`T3b`). PASS.
|
||
- Fall back 2026-11-01: `01:59 CDT` → `01:00 CST` (`T3c`/`T3d`). PASS.
|
||
- Epoch arithmetic is unaffected by transitions (`T3e`: 60 000 ms). PASS.
|
||
- **INFERRED:** JavaScriptCore on iOS implements the same ECMA-402 + IANA
|
||
database contract. Behaviour is expected identical but is **UNVERIFIED** on
|
||
physical iOS at the DST edges. SPIKE-07-level device check covers it.
|
||
|
||
### 3.4 Unusual zones (T4) — CONFIRMED spec-level
|
||
|
||
- No-DST zone `America/Phoenix` (UTC-7 fixed) — `T4a` PASS.
|
||
- Fixed `+05:45` `Asia/Kathmandu` — `T4b` PASS.
|
||
- 30-minute DST `Australia/Lord_Howe` (+10:30 std / +11:00 DST) both in January
|
||
(DST) and July (std) — `T4c`/`T4d` PASS.
|
||
- **Significance:** festivals in unusual zones or with 30-minute DST are not
|
||
special-cased; `Intl` handles them.
|
||
|
||
### 3.5 Server-skew correction (T5) — CONFIRMED arithmetic; INFERRED header source
|
||
|
||
- Skew arithmetic `skew = serverNow − deviceNow`, `corrected = deviceNow + skew`
|
||
(`T5a`/`T5b`) PASS.
|
||
- Mid-session drift model: 1 h of monotonic time elapsed, observed device jump
|
||
+5 min ⇒ |drift| > 2 min threshold detected (`T5c`) PASS.
|
||
- **INFERRED:** HTTP `Date` headers from the static CDN are NTP-synced and
|
||
accurate enough to serve as the server-time source (discovery B-10). No
|
||
dedicated time endpoint is needed; any sync response suffices. Guarded by the
|
||
sanity window (3.6).
|
||
- Cost: one persisted record; `ClockService.now()` is a pure computation on
|
||
demand; no timers, no background work (C-19).
|
||
|
||
### 3.6 Sanity window (T6) — CONFIRMED logic; REFINEMENT REQUIRED (F-3)
|
||
|
||
- Correctly flags wildly wrong clocks (2023 → outside; festival-day inside)
|
||
(`T6b`/`T6c`) PASS.
|
||
- `T6a` is the intentional subtlety: a **July** `now` against a **September**
|
||
festival is outside the ±45-day window. If the app showed "check your clock"
|
||
to every user who prepared in July, the warning would be noise.
|
||
- **F-3 (change, normative):** the sanity warning is **suppressed until the
|
||
festival window is near or live**. Recommended rule (added to ADR-009):
|
||
warn on `outsideWindow` only if `now ≥ festivalStart − WINDOW` **or** a
|
||
server skew has previously been captured (meaning we have evidence the device
|
||
is truly skewed). Before that, dataset timestamps ("Data as of …") communicate
|
||
staleness without accusing the clock. This keeps early preparation quiet while
|
||
preserving the in-festival safety value.
|
||
|
||
### 3.7 Now / Up Next classification (T7) — CONFIRMED
|
||
|
||
- Overlapping running events both appear in `now` (`T7a`) PASS.
|
||
- Future events correctly move to `up-next` in start-time order (`T7b`/`T7c`/
|
||
`T7d`) PASS; empty `up-next` when nothing is upcoming is handled.
|
||
- **Conclusion:** epoch-ms comparison is sufficient; no IANA or wall-clock
|
||
involved in the predicate. Rendering then formats the instants per 3.1.
|
||
|
||
### 3.8 Wrong-clock scenarios (cross-cutting)
|
||
|
||
| Scenario | What happens | Why acceptable |
|
||
|---|---|---|
|
||
| Device fast/slow, user has been online once (skew captured) | `now()` corrected by persisted skew; sanity check uses corrected time | Correct "Now" without network |
|
||
| Device fast/slow, never online | Uncorrected `now`; sanity warn if near festival; absolute times remain readable | Accepted residual (discovery OF-7, RK-9, W-2); no network fix exists offline |
|
||
| Clock moved mid-session | Monotonic anchor flags drift > 2 min; re-derive base | Warn + keep skew if present |
|
||
| Clock far outside sanity window | Warning surfaced near festival; READY unaffected (SPIKE-05 time independence) | User can still read schedule; network sync fixes it opportunistically |
|
||
|
||
### 3.9 What this spike did NOT prove
|
||
|
||
- **JSC parity on iOS:** `Intl` edge behaviour on iOS 16.4 low-bound and latest
|
||
requires physical iPhone testing (see §5).
|
||
- **HTTP `Date` freshness in the field:** header skew capture depends on the
|
||
CDN's `Date` accuracy and on the app actually performing a sync. Queued for
|
||
staging-environment observation.
|
||
- **User comprehension of the clock warning:** wording/tone needs a UX pass,
|
||
not a logic spike.
|
||
|
||
## 4. Refinements carried to ADR-009
|
||
|
||
- **F-1 (confirmed):** UTC storage + IANA-zone rendering + precomputed `dayKey`
|
||
stands as specified.
|
||
- **F-2 (confirmed):** ClockService as `device + persisted skew` with monotonic
|
||
drift guard — cheap, offline-safe, no background work.
|
||
- **F-3 (change):** sanity-window warning suppressed until
|
||
`now ≥ festivalStart − WINDOW` or a skew has been captured. Prevents noisy
|
||
false warnings during early preparation. Added to ADR-009.
|
||
- **F-4 (clarification):** `Intl.DateTimeFormat` with explicit `timeZone` is the
|
||
**only** time-rendering path; no `Date.toLocaleString()` without options, no
|
||
manual offset arithmetic, no wall-clock string storage.
|
||
|
||
## 5. Must test on physical devices before production
|
||
|
||
On iOS (iPhone, iOS 16.4 low bound + latest) and low-end Android (2021
|
||
mid-range):
|
||
|
||
1. Render the July event at `2026-07-15T18:00Z` in `America/Chicago` → expect
|
||
`01:00 PM CDT` (spot-check JSC parity with EXP-1 T1a).
|
||
2. Day grouping: events at `T2a`/`T2b` instants appear on the correct `dayKey`
|
||
days in the UI.
|
||
3. DST edge: the `T3a`–`T3d` wall-clock sequence around spring-forward and
|
||
fall-back for the festival zone; duration of a span crossing the transition
|
||
equals epoch difference (T3e).
|
||
4. Clock correction: set device clock +30 min, open online (sync captures skew),
|
||
go airplane-mode → "Now" reflects corrected time; mid-session clock bump of
|
||
+10 min triggers the drift warning.
|
||
5. Sanity window: device set to a month before the festival → no warning; device
|
||
set to +60 days past end → warning shown (F-3 rule).
|
||
6. Never-online wrong clock: airplane mode + device clock wrong by 2 hours →
|
||
warning shown near festival; schedule remains readable with absolute times.
|
||
|
||
## 6. Verdict
|
||
|
||
**ACCEPT WITH CHANGE (F-3).**
|
||
|
||
The time model is correct offline, handles DST and unusual zones, and degrades
|
||
honestly for wrong clocks at trivial cost. `EXP-1` passes 24/24. Change F-3
|
||
(sanity-window suppression during early preparation) is the only amendment to
|
||
ADR-009; no direction change. Condition: physical-device parity checks in §5
|
||
must pass before the festival zone + DST edges are declared production-ready.
|
||
|
||
## 7. Cross-checks
|
||
|
||
- Consistent with SPIKE-04: UTC storage, IANA zone in manifest, `dayKey`
|
||
validated at publish time.
|
||
- Consistent with SPIKE-05: readiness predicate is time-independent (C5 does not
|
||
consult clocks); a wrong clock is a warning, never a demotion.
|
||
- Consistent with SPIKE-07 bootstrap: skew is captured opportunistically during
|
||
any sync; no dedicated "time sync" step exists.
|