Switch read paths to the true-instant date contract (ticket #119)

- 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
This commit is contained in:
Cameron Cordes committed 2026-09-26 19:28:26 -04:00
1 parent cd375f18db
commit af87765bbb
13 files changed
+1590 -301

No files matched your search

+567
View File
@@ -0,0 +1,567 @@
//! Read-side views of the true-instant date contract (ADR-0001).
//!
//! [`crate::date_resolver`] is the ingest-side counterpart: it *writes* the
//! `(date_utc, camera_offset_minutes, date_taken_tz_source)` triple. This
//! module is what *reads* it. For a row — converted or not yet — it answers:
//!
//! 1. **What true instant does the row name?** — [`row_true_instant`].
//! Anchors time-window queries (SMS messages, location history, calendar
//! events) with zero offset arithmetic: the window is centered on the
//! instant, never on a wall clock.
//! 2. **What wall clock does the owner see?** — [`row_display_wall`].
//! The date insight prompts tell the model (camera wall clock when the
//! row carries an offset, display-zone rendering when it doesn't). Must
//! equal the wall the metadata endpoint reports, so prompt and UI agree.
//! 3. **Which owner day does the row land on?** — [`row_display_day`].
//! The grouping rule for memories buckets and reels: a photo taken in
//! Japan at 21:00 JST lands in the *owner's* day, not the camera's.
//!
//! Converted rows read the new triple directly. Unconverted rows (the
//! `backfill_utc_dates` drain has not reached them yet) fall back to the
//! legacy `date_taken` under its per-source convention (ADR-0001):
//!
//! | source | legacy holds |
//! |---|---|
//! | `exif` | camera wall clock, reinterpreted as UTC |
//! | `exiftool` (video) | the container's UTC-rendered naive, rendered in the server zone |
//! | `exiftool` (image) | camera wall clock, rendered in the server zone |
//! | `filename` (wall patterns) | wall clock, reinterpreted as UTC |
//! | `filename` (unix-timestamp) | a true instant |
//! | `fs_time` | a true instant |
//! | `manual` | the display-zone wall the operator picked, reinterpreted as UTC |
//!
//! so mixed rows mid-drain still group into the right day and the insight
//! prompts stay correct row-by-row as the drain converts.
use chrono::{NaiveDate, NaiveDateTime, TimeZone};
use chrono_tz::Tz;
use crate::camera_tz;
use crate::database::models::ImageExif;
/// Best-effort true instant (unix seconds) the row names.
///
/// `date_utc` when the drain has converted the row; otherwise derived from
/// the legacy value under its per-source convention. `server_zone` is the
/// fallback rung the drain would use without offset-tag or GPS evidence
/// (the camera was probably carried in the operator's zone); `display_zone`
/// interprets manual rows.
pub fn row_true_instant(row: &ImageExif, display_zone: Tz, server_zone: Option<Tz>) -> Option<i64> {
let legacy = row.date_taken?;
// Converted rows name their instant directly.
if let Some(utc) = row.date_utc {
return Some(utc);
}
match row.date_taken_source.as_deref() {
// Camera wall reinterpreted as UTC: the wall is the legacy value
// itself; the instant is that wall in the assumed zone.
Some("exif") => wall_in_zone(legacy, server_zone),
// exiftool rendered a naive string in the server zone. Videos hold
// the container's UTC-rendered creation time (a true instant behind
// one server-zone render); photos hold the camera wall.
Some("exiftool") => {
if crate::file_types::is_video_file(std::path::Path::new(&row.file_path)) {
// legacy = server.render(naive_utc) → recover naive_utc and
// read it back as UTC.
let naive = render_in_zone_fallback(legacy, server_zone);
Some(naive.and_utc().timestamp())
} else {
// legacy = server.render(wall); the instant is that wall in
// the server zone, which is `legacy` by construction.
Some(legacy)
}
}
// Filename class: wall patterns name a wall clock, the
// unix-timestamp pattern names a true instant directly.
Some("filename") => match crate::memories::match_filename_date(&row.file_path) {
Some(m) if matches!(m.date, crate::memories::FilenameDate::Wall(_)) => {
wall_in_zone(legacy, server_zone)
}
_ => Some(legacy),
},
Some("fs_time") => Some(legacy),
// The operator picked a display-zone wall; interpret it there.
Some("manual") => camera_tz::manual_date_resolution(Some(display_zone.name()), legacy)
.map(|(instant, _)| instant),
// Unknown / absent source: treat the legacy value as a true instant
// (the most common convention) rather than a wall clock.
_ => Some(legacy),
}
}
/// The wall clock the owner sees for this row — camera wall when the row
/// carries an offset (a Japan photo shows the Japan wall), display-zone
/// rendering when it doesn't. Unconverted rows use their per-source
/// convention (see module docs).
pub fn row_display_wall(
row: &ImageExif,
display_zone: Tz,
server_zone: Option<Tz>,
) -> Option<NaiveDateTime> {
if let Some(utc) = row.date_utc {
// Converted: wall = instant + camera offset (offset = local − UTC).
if let Some(offset_min) = row.camera_offset_minutes {
let wall = chrono::DateTime::from_timestamp(utc + offset_min as i64 * 60, 0)?;
return Some(wall.naive_utc());
}
return render_in_zone_opt(utc, display_zone);
}
let legacy = row.date_taken?;
match row.date_taken_source.as_deref() {
// The wall clock itself, stored as naive-as-UTC.
Some("exif") => Some(legacy_as_naive(legacy)),
Some("exiftool") => {
if crate::file_types::is_video_file(std::path::Path::new(&row.file_path)) {
// No camera wall exists for a container; show the owner's
// zone rendering of the recovered instant.
let naive = render_in_zone_fallback(legacy, server_zone);
let instant = naive.and_utc().timestamp();
render_in_zone_opt(instant, display_zone)
} else {
// The camera wall, recovered from the server-zone render.
Some(render_in_zone_fallback(legacy, server_zone))
}
}
Some("filename") => match crate::memories::match_filename_date(&row.file_path) {
Some(m) if matches!(m.date, crate::memories::FilenameDate::Wall(_)) => {
Some(legacy_as_naive(legacy))
}
_ => render_in_zone_opt(legacy, display_zone),
},
Some("fs_time") => render_in_zone_opt(legacy, display_zone),
Some("manual") => Some(legacy_as_naive(legacy)),
_ => render_in_zone_opt(legacy, display_zone),
}
}
/// The true instant a filename-class date names, for callers that fall
/// back to the filename when the row carries no date at all: wall patterns
/// are read in `server_zone` (the zone the camera was probably in),
/// unix-timestamp patterns are true instants outright.
pub fn filename_true_instant(
fd: &crate::memories::FilenameDate,
server_zone: Option<Tz>,
) -> Option<i64> {
match fd {
crate::memories::FilenameDate::Utc(dt) => Some(dt.timestamp()),
crate::memories::FilenameDate::Wall(wall) => match server_zone {
Some(z) => z
.from_local_datetime(wall)
.single()
.map(|dt| dt.timestamp()),
None => Some(wall.and_utc().timestamp()),
},
}
}
/// The owner's calendar day for this row: the day of the true instant in
/// the owner's display zone. This is the grouping key — a photo taken in
/// Japan at 21:00 JST lands in the owner's day, not the camera's.
pub fn row_display_day(
row: &ImageExif,
display_zone: Tz,
server_zone: Option<Tz>,
) -> Option<NaiveDate> {
let instant = row_true_instant(row, display_zone, server_zone)?;
Some(
display_zone
.timestamp_opt(instant, 0)
.single()?
.date_naive(),
)
}
/// Render a unix instant as a naive wall in `zone`.
fn render_in_zone_opt(instant: i64, zone: Tz) -> Option<NaiveDateTime> {
zone.timestamp_opt(instant, 0)
.single()
.map(|dt| dt.naive_local())
}
/// Render a unix instant as a naive wall in `zone`, falling back to the
/// naive-as-UTC reading when the zone is unknown.
fn render_in_zone_fallback(instant: i64, zone: Option<Tz>) -> NaiveDateTime {
match zone {
Some(z) => z
.timestamp_opt(instant, 0)
.single()
.map(|dt| dt.naive_local())
.unwrap_or_else(|| legacy_as_naive(instant)),
None => legacy_as_naive(instant),
}
}
/// Read a stored wall-as-UTC epoch back as its naive wall.
fn legacy_as_naive(legacy: i64) -> NaiveDateTime {
chrono::DateTime::from_timestamp(legacy, 0)
.expect("valid epoch")
.naive_utc()
}
/// The true instant of a wall clock assumed to have been read in `zone`;
/// naive-as-UTC when the zone is unknown.
fn wall_in_zone(legacy_wall_epoch: i64, zone: Option<Tz>) -> Option<i64> {
let wall = legacy_as_naive(legacy_wall_epoch);
match zone {
Some(z) => z
.from_local_datetime(&wall)
.single()
.map(|dt| dt.timestamp()),
None => Some(legacy_wall_epoch),
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{NaiveDate, TimeZone};
use chrono_tz::Tz;
fn ny() -> Tz {
Tz::America__New_York
}
/// Build a minimal `ImageExif` row for date-field tests.
fn row(
path: &str,
date_taken: Option<i64>,
source: Option<&str>,
date_utc: Option<i64>,
offset: Option<i32>,
) -> ImageExif {
ImageExif {
id: 1,
library_id: 1,
file_path: path.to_string(),
camera_make: None,
camera_model: None,
lens_model: None,
width: None,
height: None,
orientation: None,
gps_latitude: None,
gps_longitude: None,
gps_altitude: None,
focal_length: None,
aperture: None,
shutter_speed: None,
iso: None,
date_taken,
created_time: 0,
last_modified: 0,
content_hash: None,
size_bytes: None,
phash_64: None,
dhash_64: None,
duplicate_of_hash: None,
duplicate_decided_at: None,
date_taken_source: source.map(str::to_string),
original_date_taken: None,
original_date_taken_source: None,
clip_embedding: None,
clip_model_version: None,
date_utc,
camera_offset_minutes: offset,
date_taken_tz_source: None,
original_date_utc: None,
original_camera_offset_minutes: None,
original_date_taken_tz_source: None,
}
}
fn utc_epoch(y: i32, mo: u32, d: u32, h: u32, mi: u32) -> i64 {
NaiveDate::from_ymd_opt(y, mo, d)
.unwrap()
.and_hms_opt(h, mi, 0)
.unwrap()
.and_utc()
.timestamp()
}
/// The AC-3 fixture: three rows naming the same true instant
/// (2024-07-05 01:30 UTC = 2024-07-04 21:30 EDT — the owner's day is
/// July 4), one per source convention. Server zone = display zone = NY.
fn ac3_fixtures() -> (ImageExif, ImageExif, ImageExif, i64) {
let instant = utc_epoch(2024, 7, 5, 1, 30);
let exif = row(
"photos/IMG_1234.jpg",
Some(utc_epoch(2024, 7, 4, 21, 30)), // wall 21:30 as naive-as-UTC
Some("exif"),
None,
None,
);
let fs = row(
"photos/scan.pdf.jpg",
Some(instant),
Some("fs_time"),
None,
None,
);
// Container: exiftool rendered the UTC naive "2024-07-05 01:30" in
// the server zone (EDT) → instant + 4h.
let container = row(
"videos/clip.mp4",
Some(instant + 4 * 3600),
Some("exiftool"),
None,
None,
);
(exif, fs, container, instant)
}
#[test]
fn ac3_same_instant_three_sources_group_into_one_owner_day() {
let (exif, fs, container, instant) = ac3_fixtures();
let d = ny();
for (name, r) in [
("exif", &exif),
("fs_time", &fs),
("exiftool-video", &container),
] {
assert_eq!(
row_true_instant(r, d, Some(d)),
Some(instant),
"{name}: wrong instant"
);
assert_eq!(
row_display_day(r, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-07-04".to_string()),
"{name}: wrong owner day"
);
}
}
#[test]
fn ac3_old_client_offset_grouping_would_have_misplaced_the_container() {
// Regression guard for the double-shift: rendering the container's
// legacy value in the client (NY) offset lands on July 5, a day
// after the owner's.
let (_, _, container, _) = ac3_fixtures();
let legacy = container.date_taken.unwrap();
let old_grouping_day = chrono::FixedOffset::east_opt(-4 * 3600)
.unwrap()
.timestamp_opt(legacy, 0)
.single()
.unwrap()
.date_naive();
assert_eq!(old_grouping_day.to_string(), "2024-07-05");
}
#[test]
fn converted_row_with_offset_shows_camera_wall() {
// Japan (UTC+9) photo: instant 2024-07-05 12:00 UTC, wall 21:00 JST.
let r = row(
"japan/IMG_0001.jpg",
Some(utc_epoch(2024, 7, 5, 21, 0)),
Some("exif"),
Some(utc_epoch(2024, 7, 5, 12, 0)),
Some(9 * 60),
);
let d = ny();
assert_eq!(
row_display_wall(&r, d, Some(d)).map(|w| w.to_string()),
Some("2024-07-05 21:00:00".to_string())
);
// ...but groups into the owner's (NY) day: 12:00 UTC = 08:00 EDT.
assert_eq!(
row_display_day(&r, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-07-05".to_string())
);
assert_eq!(
row_true_instant(&r, d, Some(d)),
Some(utc_epoch(2024, 7, 5, 12, 0))
);
}
#[test]
fn converted_row_without_offset_renders_in_display_zone() {
// fs_time row, no camera offset: wall = display-zone rendering.
// 2024-01-15 04:30 UTC = 23:30 Jan 14 EST (DST-aware).
let r = row(
"fs/IMG_9.jpg",
Some(utc_epoch(2024, 1, 15, 4, 30)),
Some("fs_time"),
Some(utc_epoch(2024, 1, 15, 4, 30)),
None,
);
let d = ny();
assert_eq!(
row_display_wall(&r, d, Some(d)).map(|w| w.to_string()),
Some("2024-01-14 23:30:00".to_string())
);
assert_eq!(
row_display_day(&r, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-01-14".to_string())
);
}
#[test]
fn unconverted_exif_wall_is_the_legacy_naive_and_instant_uses_server_zone() {
// Wall 2024-01-15 21:30 (EST era), legacy = 21:30 naive-as-UTC.
// Instant = 21:30 EST = 2024-01-16 02:30 UTC (DST-aware, not +4h).
let r = row(
"photo.jpg",
Some(utc_epoch(2024, 1, 15, 21, 30)),
Some("exif"),
None,
None,
);
let d = ny();
assert_eq!(
row_display_wall(&r, d, Some(d)).map(|w| w.to_string()),
Some("2024-01-15 21:30:00".to_string())
);
assert_eq!(
row_true_instant(&r, d, Some(d)),
Some(utc_epoch(2024, 1, 16, 2, 30))
);
assert_eq!(
row_display_day(&r, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-01-15".to_string())
);
}
#[test]
fn unconverted_exiftool_photo_wall_is_server_zone_render() {
// Camera wall 2024-07-04 12:00 rendered in server zone EDT →
// legacy = 16:00 UTC. The wall comes back out the other side.
let r = row(
"raw/IMG_5.NEF",
Some(utc_epoch(2024, 7, 4, 16, 0)),
Some("exiftool"),
None,
None,
);
let d = ny();
assert_eq!(
row_display_wall(&r, d, Some(d)).map(|w| w.to_string()),
Some("2024-07-04 12:00:00".to_string())
);
// Instant = that wall in the server zone = the legacy value itself.
assert_eq!(
row_true_instant(&r, d, Some(d)),
Some(utc_epoch(2024, 7, 4, 16, 0))
);
}
#[test]
fn unconverted_manual_row_interprets_wall_in_display_zone() {
// Operator picked 2024-07-04 21:30 in their display zone (NY/EDT):
// legacy = 21:30 naive-as-UTC, instant = 01:30 UTC July 5.
let r = row(
"manual.jpg",
Some(utc_epoch(2024, 7, 4, 21, 30)),
Some("manual"),
None,
None,
);
let d = ny();
assert_eq!(
row_display_wall(&r, d, Some(d)).map(|w| w.to_string()),
Some("2024-07-04 21:30:00".to_string())
);
assert_eq!(
row_true_instant(&r, d, Some(d)),
Some(utc_epoch(2024, 7, 5, 1, 30))
);
assert_eq!(
row_display_day(&r, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-07-04".to_string())
);
}
#[test]
fn unconverted_filename_wall_and_utc_patterns_diverge() {
// Wall pattern (screenshot): legacy = wall as naive-as-UTC.
let wall = row(
"Screenshot_2024-07-04-21-30-00.png",
Some(utc_epoch(2024, 7, 4, 21, 30)),
Some("filename"),
None,
None,
);
// Unix-timestamp pattern: legacy is the true instant itself.
let ts = row(
"1720128600.jpg",
Some(utc_epoch(2024, 7, 5, 1, 30)),
Some("filename"),
None,
None,
);
let d = ny();
assert_eq!(
row_true_instant(&wall, d, Some(d)),
Some(utc_epoch(2024, 7, 5, 1, 30)),
"wall 21:30 EDT → 01:30 UTC"
);
assert_eq!(
row_true_instant(&ts, d, Some(d)),
Some(utc_epoch(2024, 7, 5, 1, 30)),
"timestamp pattern is already the instant"
);
// Both land on the owner's July 4.
for r in [&wall, &ts] {
assert_eq!(
row_display_day(r, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-07-04".to_string())
);
}
}
#[test]
fn display_day_is_dst_correct_across_the_year() {
// Same UTC instant, winter vs summer: the owner's day differs by
// the DST shift. 04:30 UTC = 23:30 previous day EST, 00:30 same
// day EDT.
let winter = row(
"w.jpg",
Some(utc_epoch(2024, 1, 15, 4, 30)),
Some("fs_time"),
Some(utc_epoch(2024, 1, 15, 4, 30)),
None,
);
let summer = row(
"s.jpg",
Some(utc_epoch(2024, 7, 15, 4, 30)),
Some("fs_time"),
Some(utc_epoch(2024, 7, 15, 4, 30)),
None,
);
let d = ny();
assert_eq!(
row_display_day(&winter, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-01-14".to_string())
);
assert_eq!(
row_display_day(&summer, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-07-15".to_string())
);
}
#[test]
fn display_day_respects_a_non_new_york_display_zone() {
// Owner in Tokyo: 2024-07-05 01:30 UTC = 10:30 JST July 5 (not
// the July 4 a NY owner sees).
let (_, fs, _, instant) = ac3_fixtures();
let tokyo = Tz::Asia__Tokyo;
assert_eq!(
row_display_day(&fs, tokyo, Some(ny())).map(|dt| dt.to_string()),
Some("2024-07-05".to_string())
);
assert_eq!(row_true_instant(&fs, tokyo, Some(ny())), Some(instant));
}
}