feat(dates): read paths on the true-instant contract — /memories, reels, insights (#119) #125

Merged
cameron merged 2 commits from feature/date-reads-insights into master 2026-09-27 15:07:13 +00:00
Owner

Summary

Read-side switch of every date consumer onto the ADR-0001 new contract (date_utc, camera_offset_minutes, date_taken_tz_source), with per-source fallback conventions for rows the #118 drain hasn't converted yet. No writes, no migrations — the legacy date_taken column stays frozen.

src/
├── date_view.rs             # NEW — pure read views, no DB/IO
│                            #   row_true_instant  → window anchors (SMS/location/calendar)
│                            #   row_display_wall  → prompt/UI date (must match metadata)
│                            #   row_display_day   → grouping key (owner's day, always)
├── memories.rs              # /memories groups in the owner's display zone (prefs),
│                            #   not the client offset; items carry the triple
├── ai/insight_generator.rs  # timestamp = true instant; prompt date = display wall
├── ai/insight_chat.rs       # chat bootstrap date = display wall (same waterfall)
├── reels/                   # pregen + selector take an IANA zone (REEL_PREGEN_DISPLAY_TZ)
└── data/mod.rs              # GpsPhotoSummary carries the triple

Per row, the read path is:

read(row)
  if row.date_utc is set                      # converted by #118
    instant = row.date_utc
    wall    = instant + camera_offset         # camera wall when offset known
  else                                        # per-source convention (ADR-0001)
    (instant, wall) = convention[row.date_taken_source]
  day     = render(instant, owner_display_zone)   # grouping key — always owner's day

Unconverted-row conventions (the mixed-row safety net until the drain finishes):

  • exif / filename-wall / manual: legacy is a wall clock → instant = wall in server (manual: display) zone
  • exiftool video: legacy is the container's UTC naive rendered in the server zone → recovered by one server-zone round-trip, read back as UTC (kills the /memories double-shift)
  • exiftool photo / filename-ts / fs_time: legacy is already the right thing under its own convention

Evidence

Before: an unconverted container row in /memories rendered its legacy epoch in the client offset — a 05:30 UTC video landed on July 5 instead of the owner's July 4 evening (off by one day, exactly the #115 bug on the read path). A Japan +9h photo told the AI "July 5" (the UTC date) while the owner saw July 6.

After: four new tests at the seams the spec calls out:

memories::api::memories_mixed_rows_group_into_owner_month
  converted + unconverted-exif + unconverted-container fixtures →
  all three bucket into the owner's month; `created` = true instant
  under each row's convention; triple populated/None as expected
memories::api::memories_response_shape_carries_triple
  serialized item carries date_utc / camera_offset_minutes /
  date_taken_tz_source (null while unconverted)
insight_chat::japan_offset_row_ai_date_is_camera_wall_and_anchor_unshifted
  23:30 UTC + 9h → AI told 2024-07-06 (camera wall), window anchor
  = date_utc with zero offset math
insight_chat::unconverted_video_row_ai_date_matches_display_wall
  container "2024-07-06 01:30 UTC" → AI told 2024-07-05 (owner's
  evening), not 2024-07-06

Full suite: cargo test → 1,410 passed, 0 failed. cargo fmt --check clean, cargo clippy --all-targets clean.

Spec + standards review (two-axis, #119 branch): spec READY with 4 test-gap notes — all closed above. Standards: no blockers; duplicated-code and naming findings fixed in-branch; the (display_zone, server_zone) data-clump bundle is a P2 judgement call, deferred.

Merge Danger

Door: two-way. Read-only switch; no schema change, no new writes, legacy columns byte-identical. Reverting restores the old render paths with zero data migration.

Blast Radius: display. /memories day buckets, precomputed-reel pregen dates, AI insight/chat prompt dates, and the GPS summary payload change for unconverted rows (that's the point — the double-shift goes away). Clients that still send timezone_offset_minutes to /memories keep working (param accepted, ignored for grouping). Reels pregen rows written under a different display zone before the cutover will be regenerated on their normal staleness cycle.

## Summary Read-side switch of every date consumer onto the ADR-0001 new contract (`date_utc`, `camera_offset_minutes`, `date_taken_tz_source`), with per-source fallback conventions for rows the #118 drain hasn't converted yet. No writes, no migrations — the legacy `date_taken` column stays frozen. ``` src/ ├── date_view.rs # NEW — pure read views, no DB/IO │ # row_true_instant → window anchors (SMS/location/calendar) │ # row_display_wall → prompt/UI date (must match metadata) │ # row_display_day → grouping key (owner's day, always) ├── memories.rs # /memories groups in the owner's display zone (prefs), │ # not the client offset; items carry the triple ├── ai/insight_generator.rs # timestamp = true instant; prompt date = display wall ├── ai/insight_chat.rs # chat bootstrap date = display wall (same waterfall) ├── reels/ # pregen + selector take an IANA zone (REEL_PREGEN_DISPLAY_TZ) └── data/mod.rs # GpsPhotoSummary carries the triple ``` Per row, the read path is: ``` read(row) if row.date_utc is set # converted by #118 instant = row.date_utc wall = instant + camera_offset # camera wall when offset known else # per-source convention (ADR-0001) (instant, wall) = convention[row.date_taken_source] day = render(instant, owner_display_zone) # grouping key — always owner's day ``` Unconverted-row conventions (the mixed-row safety net until the drain finishes): - `exif` / filename-wall / `manual`: legacy is a wall clock → instant = wall in server (manual: display) zone - `exiftool` video: legacy is the container's UTC naive rendered in the server zone → recovered by one server-zone round-trip, read back as UTC (kills the /memories double-shift) - `exiftool` photo / filename-ts / `fs_time`: legacy is already the right thing under its own convention ## Evidence **Before:** an unconverted container row in `/memories` rendered its legacy epoch in the client offset — a 05:30 UTC video landed on July 5 instead of the owner's July 4 evening (off by one day, exactly the #115 bug on the read path). A Japan +9h photo told the AI "July 5" (the UTC date) while the owner saw July 6. **After:** four new tests at the seams the spec calls out: ``` memories::api::memories_mixed_rows_group_into_owner_month converted + unconverted-exif + unconverted-container fixtures → all three bucket into the owner's month; `created` = true instant under each row's convention; triple populated/None as expected memories::api::memories_response_shape_carries_triple serialized item carries date_utc / camera_offset_minutes / date_taken_tz_source (null while unconverted) insight_chat::japan_offset_row_ai_date_is_camera_wall_and_anchor_unshifted 23:30 UTC + 9h → AI told 2024-07-06 (camera wall), window anchor = date_utc with zero offset math insight_chat::unconverted_video_row_ai_date_matches_display_wall container "2024-07-06 01:30 UTC" → AI told 2024-07-05 (owner's evening), not 2024-07-06 ``` Full suite: `cargo test` → 1,410 passed, 0 failed. `cargo fmt --check` clean, `cargo clippy --all-targets` clean. Spec + standards review (two-axis, #119 branch): spec READY with 4 test-gap notes — all closed above. Standards: no blockers; duplicated-code and naming findings fixed in-branch; the `(display_zone, server_zone)` data-clump bundle is a P2 judgement call, deferred. ## Merge Danger **Door:** two-way. Read-only switch; no schema change, no new writes, legacy columns byte-identical. Reverting restores the old render paths with zero data migration. **Blast Radius:** display. `/memories` day buckets, precomputed-reel pregen dates, AI insight/chat prompt dates, and the GPS summary payload change for unconverted rows (that's the point — the double-shift goes away). Clients that still send `timezone_offset_minutes` to `/memories` keep working (param accepted, ignored for grouping). Reels pregen rows written under a different display zone before the cutover will be regenerated on their normal staleness cycle.
cameron added 1 commit 2026-09-27 02:41:11 +00:00
- New src/date_view.rs: pure read-side functions (row_true_instant,
  row_display_wall, row_display_day, filename_true_instant) applying the
  per-source convention table for unconverted rows and the new-contract
  triple for converted rows (ADR-0001).
- /memories: grouping on the row's true instant rendered in the owner's
  display zone (prefs -> app default), not the client device offset.
  SQL is a coarse interval pre-filter over the full years-back window;
  exact month-day/week/month matching across past years happens in Rust
  (memories::day_in_span, sqlite_w_week).
- Reels: ReelSelector::Memories carries an IANA display_tz instead of a
  fixed minute offset; pre-gen params read REEL_PREGEN_DISPLAY_TZ; the
  tz_offset_minutes ledger column stores the zone offset at render time.
- Insights: prompt-facing dates render as the display wall; the
  proximity timestamp and chat context block use the true instant;
  InsightGenerator reads the owner's display zone from prefs per call.
- GpsPhotoSummary carries the new-contract triple alongside the frozen
  legacy date_taken.

Closes #119
cameron added 1 commit 2026-09-27 03:39:02 +00:00
A container whose /etc/localtime is a copy rather than a symlink makes
server_zone() return None; the assumed_server rung then fell back to
Local::now()'s offset, which is only correct for captures from the
current season. Historical winter walls on a DST host skewed date_utc
by an hour. Resolve the process-local zone at the wall instead
(earliest reading for ambiguous walls).

Surfaced by test-server validation of #119.
cameron merged commit 7f348022d4 into master 2026-09-27 15:07:13 +00:00
Sign in to join this conversation.