//! 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) -> Option { 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, ) -> Option { 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, ) -> Option { 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, ) -> Option { let instant = row_true_instant(row, display_zone, server_zone)?; Some( display_zone .timestamp_opt(instant, 0) .single()? .date_naive(), ) } /// Wire format for a wall clock: naive ISO-8601, no zone designator /// (`2018-09-29T14:00:00`). Clients render it verbatim — no zone math — /// so the UI shows exactly the wall [`row_display_wall`] computed and /// insight prompts use (ADR-0001: consumers never re-anchor). pub fn format_wall_clock(wall: NaiveDateTime) -> String { wall.format("%Y-%m-%dT%H:%M:%S").to_string() } /// The owner's display zone for a request: prefs `display_tz`, else the /// app default (never fails — an unreadable prefs row is the default). pub fn owner_display_zone( prefs_dao: &std::sync::Mutex>, context: &opentelemetry::Context, ) -> Tz { use std::str::FromStr; let stored = prefs_dao .lock() .ok() .and_then(|mut dao| dao.get_prefs(context).ok().flatten()) .and_then(|p| p.display_tz); Tz::from_str(&camera_tz::display_zone_for(stored.as_deref())) .unwrap_or(chrono_tz::Tz::America__New_York) } /// [`row_display_wall`] in wire format. pub fn row_wall_clock( row: &ImageExif, display_zone: Tz, server_zone: Option, ) -> Option { row_display_wall(row, display_zone, server_zone).map(format_wall_clock) } /// Wall clock for a file that may have no `image_exif` row yet (not yet /// ingested, or excluded): the row's wall when it has one, else the /// filename-class date (a wall pattern *is* the wall; a unix-timestamp /// pattern renders in the display zone), else the file's earliest /// filesystem time rendered in the display zone. The same precedence the /// ingest waterfall applies, so the value doesn't jump once the row lands. pub fn file_wall_clock( row: Option<&ImageExif>, rel_path: &str, fs_earliest: Option, display_zone: Tz, server_zone: Option, ) -> Option { if let Some(wall) = row.and_then(|r| row_display_wall(r, display_zone, server_zone)) { return Some(format_wall_clock(wall)); } let wall = match crate::memories::extract_date_from_filename(rel_path) { Some(crate::memories::FilenameDate::Wall(wall)) => Some(wall), Some(crate::memories::FilenameDate::Utc(dt)) => { render_in_zone_opt(dt.timestamp(), display_zone) } None => fs_earliest.and_then(|t| render_in_zone_opt(t, display_zone)), }; wall.map(format_wall_clock) } /// Render a unix instant as a naive wall in `zone`. fn render_in_zone_opt(instant: i64, zone: Tz) -> Option { 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) -> 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) -> Option { 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, source: Option<&str>, date_utc: Option, offset: Option, ) -> 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)); } #[test] fn wall_clock_is_the_camera_wall_for_a_travel_zone_photo() { // 14:00 in Tokyo (+09:00) = 05:00Z. The owner is in New York, but // the wall clock is the camera's: 14:00, not 01:00. let r = row( "otd.jpg", Some(utc_epoch(2018, 9, 29, 14, 0)), Some("exif"), Some(utc_epoch(2018, 9, 29, 5, 0)), Some(540), ); assert_eq!( row_wall_clock(&r, ny(), Some(ny())), Some("2018-09-29T14:00:00".to_string()) ); } #[test] fn wall_clock_without_offset_renders_the_instant_in_the_display_zone() { // Container date (utc_direct): 12:30Z is 08:30 in New York (EDT). let r = row( "summit.mp4", None, Some("exiftool"), Some(utc_epoch(2022, 8, 20, 12, 30)), None, ); assert_eq!( row_wall_clock(&r, ny(), Some(ny())), Some("2022-08-20T08:30:00".to_string()) ); } #[test] fn wall_clock_serves_unconverted_rows_by_their_legacy_convention() { // The drain's permanent floor never gets date_utc; an `exif` legacy // value is the wall stored as UTC and must come back unshifted. let r = row( "edited.jpg", Some(utc_epoch(2021, 5, 12, 10, 30)), Some("exif"), None, None, ); assert_eq!( row_wall_clock(&r, ny(), Some(ny())), Some("2021-05-12T10:30:00".to_string()) ); } #[test] fn file_wall_clock_prefers_the_row() { let r = row( "Screenshots/Screenshot_20220315-101500.png", Some(utc_epoch(2022, 3, 15, 10, 15)), Some("filename"), Some(utc_epoch(2022, 3, 15, 14, 15)), Some(-240), ); assert_eq!( file_wall_clock(Some(&r), &r.file_path, Some(0), ny(), Some(ny())), Some("2022-03-15T10:15:00".to_string()) ); } #[test] fn file_wall_clock_without_a_row_uses_the_filename_wall() { assert_eq!( file_wall_clock( None, "Screenshots/Screenshot_20220315-101500.png", Some(0), ny(), Some(ny()) ), Some("2022-03-15T10:15:00".to_string()) ); } #[test] fn file_wall_clock_without_a_row_or_filename_date_uses_fs_time_in_display_zone() { // 2015-03-01 17:00Z = 12:00 in New York (EST). assert_eq!( file_wall_clock( None, "Undated/scan_01.jpg", Some(utc_epoch(2015, 3, 1, 17, 0)), ny(), Some(ny()) ), Some("2015-03-01T12:00:00".to_string()) ); assert_eq!( file_wall_clock(None, "Undated/scan_01.jpg", None, ny(), Some(ny())), None ); } }