diff --git a/src/ai/insight_chat.rs b/src/ai/insight_chat.rs index 73347bb..66b0449 100644 --- a/src/ai/insight_chat.rs +++ b/src/ai/insight_chat.rs @@ -1,5 +1,5 @@ use anyhow::{Result, anyhow, bail}; -use chrono::Utc; +use chrono::{TimeZone, Utc}; use opentelemetry::KeyValue; use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer}; use std::collections::HashMap; @@ -1175,7 +1175,12 @@ impl InsightChatService { let image_base64: Option = self.generator.load_image_as_base64(&normalized).ok(); let exif = self.generator.fetch_exif(&normalized); - let date_taken_str = resolve_date_taken_for_context(&exif, &normalized); + let date_taken_str = resolve_date_taken_for_context( + &exif, + &normalized, + self.generator.display_zone(), + crate::camera_tz::server_zone(), + ); let gps = exif .as_ref() .and_then(|e| match (e.gps_latitude, e.gps_longitude) { @@ -1644,7 +1649,12 @@ impl InsightChatService { // would otherwise default to today's date and miss every // historical photo). let exif = self.generator.fetch_exif(&normalized); - let date_taken_str = resolve_date_taken_for_context(&exif, &normalized); + let date_taken_str = resolve_date_taken_for_context( + &exif, + &normalized, + self.generator.display_zone(), + crate::camera_tz::server_zone(), + ); let gps = exif .as_ref() .and_then(|e| match (e.gps_latitude, e.gps_longitude) { @@ -2020,24 +2030,33 @@ fn build_bootstrap_system_message( } /// Resolve a human-readable `YYYY-MM-DD` date string for the photo -/// context block. Waterfall: EXIF `date_taken` → filename pattern → -/// `None`. The fs-time fallback that `generate_agentic_insight_for_photo` +/// context block (ADR-0001). Waterfall: the row's display wall (new +/// contract when converted, per-source convention while unconverted) → +/// filename pattern (wall patterns as-is, unix-timestamp patterns in the +/// display zone) → `None`. The fs-time fallback that insight generation /// uses is intentionally NOT applied here — for chat we'd rather show /// "unknown" than a misleading inode mtime as the photo's date. fn resolve_date_taken_for_context( exif: &Option, file_path: &str, + display_zone: chrono_tz::Tz, + server_zone: Option, ) -> Option { - let from_exif = exif - .as_ref() - .and_then(|e| e.date_taken) - .and_then(|ts| chrono::DateTime::from_timestamp(ts, 0)) - .map(|dt| dt.format("%Y-%m-%d").to_string()); - if from_exif.is_some() { - return from_exif; + if let Some(e) = exif + && let Some(wall) = crate::date_view::row_display_wall(e, display_zone, server_zone) + { + return Some(wall.format("%Y-%m-%d").to_string()); } - crate::memories::extract_date_from_filename(file_path) - .map(|fd| fd.legacy_datetime().format("%Y-%m-%d").to_string()) + crate::memories::extract_date_from_filename(file_path).and_then(|fd| { + let wall = match &fd { + crate::memories::FilenameDate::Wall(w) => *w, + crate::memories::FilenameDate::Utc(dt) => display_zone + .timestamp_opt(dt.timestamp(), 0) + .single()? + .naive_local(), + }; + Some(wall.format("%Y-%m-%d").to_string()) + }) } /// Validate a stored→effective backend transition for a chat continuation. @@ -3385,7 +3404,12 @@ mod tests { original_camera_offset_minutes: None, original_date_taken_tz_source: None, }); - let out = resolve_date_taken_for_context(&exif, "Screenshot_2014-06-01.png"); + let out = resolve_date_taken_for_context( + &exif, + "Screenshot_2014-06-01.png", + chrono_tz::Tz::America__New_York, + Some(chrono_tz::Tz::America__New_York), + ); assert_eq!(out.as_deref(), Some("2021-08-15")); } @@ -3394,13 +3418,157 @@ mod tests { // memories::extract_date_from_filename requires date+time in // the filename — date-only patterns aren't matched. Use the // canonical screenshot pattern for the regression case. - let out = resolve_date_taken_for_context(&None, "Screenshot_2014-06-01-20-44-50.png"); + let out = resolve_date_taken_for_context( + &None, + "Screenshot_2014-06-01-20-44-50.png", + chrono_tz::Tz::America__New_York, + Some(chrono_tz::Tz::America__New_York), + ); assert_eq!(out.as_deref(), Some("2014-06-01")); } #[test] fn date_taken_for_context_returns_none_when_neither_source() { - let out = resolve_date_taken_for_context(&None, "DSC_5171.JPG"); + let out = resolve_date_taken_for_context( + &None, + "DSC_5171.JPG", + chrono_tz::Tz::America__New_York, + Some(chrono_tz::Tz::America__New_York), + ); assert!(out.is_none()); } + + /// AC-4 / AC-5: a Japan-offset (+9 h) photo anchored at local 21:00. + /// The AI is told the camera wall clock (instant + offset — a midnight + /// rollover relative to the owner's display zone is expected and + /// correct), while the SMS/location/calendar windows anchor on the + /// true instant with zero offset math. + #[test] + fn japan_offset_row_ai_date_is_camera_wall_and_anchor_unshifted() { + use chrono::NaiveDate; + + // 2024-07-05 23:30 UTC = 2024-07-06 08:30 JST = 2024-07-05 19:30 EDT. + let instant = NaiveDate::from_ymd_opt(2024, 7, 5) + .unwrap() + .and_hms_opt(23, 30, 0) + .unwrap() + .and_utc(); + let date_utc = instant.timestamp(); + + let row = crate::database::models::ImageExif { + id: 0, + library_id: 1, + file_path: "tokyo.jpg".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: Some(date_utc), + 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: Some("exif".to_string()), + original_date_taken: None, + original_date_taken_source: None, + clip_embedding: None, + clip_model_version: None, + date_utc: Some(date_utc), + camera_offset_minutes: Some(540), + date_taken_tz_source: Some("offset_tag".to_string()), + original_date_utc: None, + original_camera_offset_minutes: None, + original_date_taken_tz_source: None, + }; + + let ny = chrono_tz::Tz::America__New_York; + + // The prompt/UI date is the camera wall: 2024-07-06 08:30 JST — + // one day AFTER the owner's display-zone date (midnight rollover + // is correct here, the AI must see what the camera saw). + let ai_date = resolve_date_taken_for_context(&Some(row.clone()), "tokyo.jpg", ny, Some(ny)); + assert_eq!(ai_date.as_deref(), Some("2024-07-06")); + + // The proximity windows (SMS / location / calendar) anchor on the + // true instant — no +9 h, no -4 h, nothing. + let anchor = crate::date_view::row_true_instant(&row, ny, Some(ny)); + assert_eq!(anchor, Some(date_utc)); + } + + /// AC-6: an unconverted container video row — the AI context date is + /// the display-zone rendering of the true instant (the raw UTC creation + /// time), matching the metadata/UI date. No 4–5 h offset, no midnight + /// rollover relative to the UTC-naive reading. + #[test] + fn unconverted_video_row_ai_date_matches_display_wall() { + use chrono::NaiveDate; + + // The container's raw creation string is "2024-07-06 01:30:00" + // (a true UTC instant). The old server parsed that naive string + // as server-local (EDT) → legacy epoch = 05:30 UTC. The AI must + // be told the owner's evening — July 5 — not July 6 (the UTC + // date the old code would have rendered). + let legacy = NaiveDate::from_ymd_opt(2024, 7, 6) + .unwrap() + .and_hms_opt(5, 30, 0) + .unwrap() + .and_utc() + .timestamp(); + + let row = Some(crate::database::models::ImageExif { + id: 0, + library_id: 1, + file_path: "movie.mp4".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: Some(legacy), + 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: Some("exiftool".to_string()), + original_date_taken: None, + original_date_taken_source: None, + clip_embedding: None, + clip_model_version: None, + date_utc: None, + camera_offset_minutes: None, + date_taken_tz_source: None, + original_date_utc: None, + original_camera_offset_minutes: None, + original_date_taken_tz_source: None, + }); + + let ny = chrono_tz::Tz::America__New_York; + let ai_date = resolve_date_taken_for_context(&row, "movie.mp4", ny, Some(ny)); + assert_eq!(ai_date.as_deref(), Some("2024-07-05")); + } } diff --git a/src/ai/insight_generator.rs b/src/ai/insight_generator.rs index b84d3f6..be44108 100644 --- a/src/ai/insight_generator.rs +++ b/src/ai/insight_generator.rs @@ -1,12 +1,13 @@ use anyhow::Result; use base64::Engine as _; -use chrono::{DateTime, Local, NaiveDate, Utc}; +use chrono::{DateTime, Local, NaiveDate, TimeZone, Utc}; use image::ImageFormat; use opentelemetry::KeyValue; use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer}; use serde::Deserialize; use std::fs::File; use std::io::Cursor; +use std::str::FromStr; use std::sync::{Arc, Mutex}; use crate::ai::apollo_client::{ApolloClient, ApolloPlace}; @@ -23,7 +24,7 @@ use crate::database::{ LocationHistoryDao, SearchHistoryDao, }; use crate::libraries::Library; -use crate::memories::{FilenameDate, extract_date_from_filename}; +use crate::memories::extract_date_from_filename; use crate::otel::global_tracer; use crate::tags::TagDao; use crate::utils::{earliest_fs_time, normalize_path, retry_with_backoff, retry_with_backoff_if}; @@ -161,6 +162,10 @@ pub struct InsightGenerator { // the per-persona "reviewed-only" strict-mode toggle). persona_dao: Arc>>, + /// Owner display-zone prefs (ADR-0001): prompt dates and the chat + /// context block render in this zone. + user_ai_prefs_dao: Arc>>, + libraries: Vec, } @@ -201,6 +206,7 @@ impl InsightGenerator { face_dao: Arc>>, knowledge_dao: Arc>>, persona_dao: Arc>>, + user_ai_prefs_dao: Arc>>, libraries: Vec, ) -> Self { Self { @@ -219,10 +225,86 @@ impl InsightGenerator { face_dao, knowledge_dao, persona_dao, + user_ai_prefs_dao, libraries, } } + /// The owner's display zone (ADR-0001): prefs `display_tz` when set and + /// valid, the app default otherwise. Read per call so a pref change + /// takes effect without a restart; a DB error degrades to the default. + pub fn display_zone(&self) -> chrono_tz::Tz { + let name = { + let stored = self + .user_ai_prefs_dao + .lock() + .ok() + .and_then(|mut dao| dao.get_prefs(&opentelemetry::Context::new()).ok().flatten()) + .and_then(|p| p.display_tz); + crate::camera_tz::display_zone_for(stored.as_deref()) + }; + chrono_tz::Tz::from_str(&name).unwrap_or(chrono_tz::Tz::America__New_York) + } + + /// The photo's date anchor pair (ADR-0001), shared by the non-agentic + /// and agentic insight paths: + /// - the **true instant** — the row's new-contract instant when present, + /// the legacy value under its per-source convention while unconverted, + /// then filename / fs-time / now — used as the zero-shift anchor for + /// the SMS / location / calendar proximity windows; + /// - the **prompt-facing display wall** — the row's display wall (camera + /// wall when the row carries an offset, display-zone rendering + /// otherwise) — the date the AI is told, matching what the owner sees. + fn resolve_date_anchor( + &self, + exif: &Option, + file_path: &str, + log_label: &str, + ) -> (i64, NaiveDate) { + let display_zone = self.display_zone(); + let server_zone = crate::camera_tz::server_zone(); + let timestamp = exif + .as_ref() + .and_then(|e| crate::date_view::row_true_instant(e, display_zone, server_zone)) + .unwrap_or_else(|| { + log::warn!("No date in EXIF for {}, trying filename", file_path); + + extract_date_from_filename(file_path) + .and_then(|fd| crate::date_view::filename_true_instant(&fd, server_zone)) + .or_else(|| { + let full_path = self.resolve_full_path(file_path)?; + File::open(&full_path) + .and_then(|f| f.metadata()) + .inspect_err(|e| { + log::warn!( + "Failed to get file timestamp for {} {}: {}", + log_label, + file_path, + e + ) + }) + .ok() + .and_then(|m| earliest_fs_time(&m)) + .map(|t| DateTime::::from(t).timestamp()) + }) + .unwrap_or_else(|| Utc::now().timestamp()) + }); + + let date_taken = exif + .as_ref() + .and_then(|e| crate::date_view::row_display_wall(e, display_zone, server_zone)) + .map(|w| w.date()) + .or_else(|| { + display_zone + .timestamp_opt(timestamp, 0) + .single() + .map(|dt| dt.date_naive()) + }) + .unwrap_or_else(|| Utc::now().date_naive()); + + (timestamp, date_taken) + } + /// Accessor for the insight DAO (used by async job completion to /// look up the stored insight id). pub fn insight_dao(&self) -> &Arc>> { @@ -1055,35 +1137,8 @@ impl InsightGenerator { .map_err(|e| anyhow::anyhow!("Failed to get EXIF: {:?}", e))? }; - // Get full timestamp for proximity-based message filtering - let timestamp = if let Some(ts) = exif.as_ref().and_then(|e| e.date_taken) { - ts - } else { - log::warn!("No date_taken in EXIF for {}, trying filename", file_path); - - extract_date_from_filename(&file_path) - .map(FilenameDate::legacy_timestamp) - .or_else(|| { - let full_path = self.resolve_full_path(&file_path)?; - File::open(&full_path) - .and_then(|f| f.metadata()) - .inspect_err(|e| { - log::warn!( - "Failed to get file timestamp for insight {}: {}", - file_path, - e - ) - }) - .ok() - .and_then(|m| earliest_fs_time(&m)) - .map(|t| DateTime::::from(t).timestamp()) - }) - .unwrap_or_else(|| Utc::now().timestamp()) - }; - - let date_taken = DateTime::from_timestamp(timestamp, 0) - .map(|dt| dt.date_naive()) - .unwrap_or_else(|| Utc::now().date_naive()); + // True-instant anchor + prompt-facing display wall (ADR-0001). + let (timestamp, date_taken) = self.resolve_date_anchor(&exif, &file_path, "insight"); // 3. Extract contact name from file path let contact = Self::extract_contact_from_path(&file_path); @@ -4147,34 +4202,9 @@ Return ONLY the summary, nothing else."#, .map_err(|e| anyhow::anyhow!("Failed to get EXIF: {:?}", e))? }; - // 4. Extract timestamp and contact - let timestamp = if let Some(ts) = exif.as_ref().and_then(|e| e.date_taken) { - ts - } else { - log::warn!("No date_taken in EXIF for {}, trying filename", file_path); - extract_date_from_filename(&file_path) - .map(FilenameDate::legacy_timestamp) - .or_else(|| { - let full_path = self.resolve_full_path(&file_path)?; - File::open(&full_path) - .and_then(|f| f.metadata()) - .inspect_err(|e| { - log::warn!( - "Failed to get file timestamp for agentic insight {}: {}", - file_path, - e - ) - }) - .ok() - .and_then(|m| earliest_fs_time(&m)) - .map(|t| DateTime::::from(t).timestamp()) - }) - .unwrap_or_else(|| Utc::now().timestamp()) - }; - - let date_taken = DateTime::from_timestamp(timestamp, 0) - .map(|dt| dt.date_naive()) - .unwrap_or_else(|| Utc::now().date_naive()); + // 4. Extract date and contact. The agentic prompt only needs the + // display wall (ADR-0001) — its proximity windows are tool-driven. + let (_, date_taken) = self.resolve_date_anchor(&exif, &file_path, "agentic insight"); let contact = Self::extract_contact_from_path(&file_path); log::info!("Agentic: date_taken={}, contact={:?}", date_taken, contact); diff --git a/src/bin/populate_knowledge.rs b/src/bin/populate_knowledge.rs index 396eddc..8378427 100644 --- a/src/bin/populate_knowledge.rs +++ b/src/bin/populate_knowledge.rs @@ -185,6 +185,9 @@ async fn main() -> anyhow::Result<()> { Arc::new(Mutex::new(Box::new(SqliteKnowledgeDao::new()))); let face_dao: Arc>> = Arc::new(Mutex::new(Box::new(SqliteFaceDao::new()))); + let user_ai_prefs_dao: Arc>> = Arc::new( + Mutex::new(Box::new(image_api::database::SqliteUserAiPrefsDao::new())), + ); let persona_dao: Arc>> = Arc::new(Mutex::new( Box::new(image_api::database::SqlitePersonaDao::new()), )); @@ -208,6 +211,7 @@ async fn main() -> anyhow::Result<()> { face_dao, knowledge_dao, persona_dao, + user_ai_prefs_dao, all_libs.clone(), ); diff --git a/src/data/mod.rs b/src/data/mod.rs index 71105cd..d30f448 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -428,7 +428,15 @@ pub struct GpsPhotoSummary { pub path: String, pub lat: f64, pub lon: f64, + /// Legacy mixed-convention date (frozen for existing rows; ADR-0001). pub date_taken: Option, + /// True instant (epoch seconds), when the row has been converted. + pub date_utc: Option, + /// Camera offset in minutes from UTC at capture, when known. + pub camera_offset_minutes: Option, + /// Which evidence pinned the instant (`offset_tag`, `gps`, + /// `assumed_server`, `utc_direct`, `manual`). + pub date_taken_tz_source: Option, } #[derive(Debug, Serialize)] diff --git a/src/database/mod.rs b/src/database/mod.rs index 67ad787..9219d9c 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -9,21 +9,6 @@ use crate::database::models::{ }; use crate::otel::trace_db_call; -/// Decoded shape for `get_memories_in_window`'s raw `sql_query`. Diesel's -/// query DSL doesn't expose strftime, so the memories filter is hand- -/// written SQL — but the returned columns are simple enough that a small -/// `QueryableByName` struct suffices, kept private to this module. -#[derive(diesel::QueryableByName)] -#[allow(dead_code)] // fields read via Diesel's QueryableByName derive -struct MemoriesWindowRow { - #[diesel(sql_type = diesel::sql_types::Text)] - rel_path: String, - #[diesel(sql_type = diesel::sql_types::BigInt)] - date_taken: i64, - #[diesel(sql_type = diesel::sql_types::BigInt)] - last_modified: i64, -} - /// Wire shape for a single member of a duplicate group, returned by /// `list_duplicates_*` and `lookup_duplicate_row`. Carries everything /// the Apollo modal needs to render a member tile and its meta line — @@ -441,7 +426,18 @@ pub trait ExifDao: Sync + Send { context: &opentelemetry::Context, base_path: &str, recursive: bool, - ) -> Result)>, DbError>; + ) -> Result< + Vec<( + String, + f64, + f64, + Option, + Option, + Option, + Option, + )>, + DbError, + >; /// Return rows that still lack a `content_hash`, oldest first. Used by /// the `backfill_hashes` binary to batch through the historical @@ -652,34 +648,34 @@ pub trait ExifDao: Sync + Send { rel_path: &str, ) -> Result; - /// Single-query backend for `/memories`. Returns - /// `(rel_path, date_taken, last_modified)` for rows in `library_id` - /// whose `date_taken` falls within `[now - years_back y, now]` and - /// whose calendar position matches the request's span: - /// - `"day"` — same month + day-of-month (any year) - /// - `"week"` — same week-of-year (SQLite `%W`, Monday-anchored — - /// close to but not exactly ISO week 8601; the boundary cases - /// at year-start/end can shift by ±1 vs the prior request-time - /// `iso_week()` filter) - /// - `"month"` — same month (any year) + /// Coarse pre-filter for the `/memories` span query (ADR-0001). /// - /// `tz_offset_minutes` is applied to both sides of the strftime - /// comparison so the calendar match is in the user's local time. - /// Backed by the `(library_id, date_taken)` index. + /// The span feature matches the same calendar position (month-day / + /// week-of-year / month) across every past year, so this query only + /// bounds the rows by epoch windows; the exact span-day match happens + /// in the caller (`memories::day_in_span`), because it needs the + /// owner's display zone. /// - /// This is the single-SQL replacement for the EXIF-loop + - /// WalkDir-fallback that powered `/memories` previously; it's - /// correct only because the canonical-date waterfall at ingest - /// (`crate::date_resolver`) populates `date_taken` for every row - /// it can resolve. + /// Eligibility: + /// - `date_taken` must be set and sit inside the unconverted band + /// `[unconv_lo, unconv_hi]` (a ±15 h margin around the years-back + /// window that covers every per-source legacy convention, and which + /// also caps how far into the future a row may sit); + /// - converted rows (`date_utc IS NOT NULL`) additionally need + /// `date_utc` inside `[conv_lo, conv_hi]` — the true-instant + /// window; unconverted rows pass on the band alone. + /// + /// Returns full rows so the caller can read the date triple + /// (`date_utc` / `camera_offset_minutes` / `date_taken_tz_source`). fn get_memories_in_window( &mut self, context: &opentelemetry::Context, library_id: i32, - span_token: &str, - years_back: i32, - tz_offset_minutes: i32, - ) -> Result, DbError>; + conv_lo: i64, + conv_hi: i64, + unconv_lo: i64, + unconv_hi: i64, + ) -> Result, DbError>; /// Return image rows that have a `content_hash` but no `phash_64`, /// oldest first. Used by the `backfill_perceptual_hash` binary. @@ -1293,12 +1289,24 @@ impl ExifDao for SqliteExifDao { .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) } + #[allow(clippy::type_complexity)] fn get_all_with_gps( &mut self, context: &opentelemetry::Context, base_path: &str, recursive: bool, - ) -> Result)>, DbError> { + ) -> Result< + Vec<( + String, + f64, + f64, + Option, + Option, + Option, + Option, + )>, + DbError, + > { trace_db_call(context, "query", "get_all_with_gps", |span| { use opentelemetry::KeyValue; use opentelemetry::trace::Span; @@ -1334,10 +1342,19 @@ impl ExifDao for SqliteExifDao { .load::(connection) .map_err(|e| anyhow::anyhow!("GPS query error: {}", e))?; - // Convert to tuple format (path, lat, lon, date_taken) + // Convert to tuple format (path, lat, lon, legacy date_taken, + // date_utc, camera_offset_minutes, date_taken_tz_source). // Filter out any rows where GPS is still None (shouldn't happen due to filter) // Cast f32 GPS values to f64 for API compatibility - let filtered: Vec<(String, f64, f64, Option)> = results + let filtered: Vec<( + String, + f64, + f64, + Option, + Option, + Option, + Option, + )> = results .into_iter() .filter_map(|exif| { if let (Some(lat_val), Some(lon_val)) = (exif.gps_latitude, exif.gps_longitude) @@ -1347,6 +1364,9 @@ impl ExifDao for SqliteExifDao { lat_val as f64, lon_val as f64, exif.date_taken, + exif.date_utc, + exif.camera_offset_minutes, + exif.date_taken_tz_source, )) } else { None @@ -1937,51 +1957,27 @@ impl ExifDao for SqliteExifDao { &mut self, context: &opentelemetry::Context, library_id: i32, - span_token: &str, - years_back: i32, - tz_offset_minutes: i32, - ) -> Result, DbError> { + conv_lo: i64, + conv_hi: i64, + unconv_lo: i64, + unconv_hi: i64, + ) -> Result, DbError> { trace_db_call(context, "query", "get_memories_in_window", |_span| { - // strftime pattern is span-dependent; the rest of the WHERE - // clause is shared. Only `%m-%d`, `%W`, `%m` are accepted — - // anything else is a programmer error. - let pattern = match span_token { - "day" => "%m-%d", - "week" => "%W", - "month" => "%m", - _ => return Err(anyhow::anyhow!("invalid span token: {}", span_token)), - }; - - // SQLite's date modifiers want a string like `'-480 minutes'` - // (signed) or `'-15 years'`. Use the `+` flag so positive - // offsets render as `+480 minutes`. - let tz_modifier = format!("{:+} minutes", tz_offset_minutes); - let years_modifier = format!("-{} years", years_back); - - let sql = format!( - "SELECT rel_path, date_taken, last_modified \ - FROM image_exif \ - WHERE library_id = ?1 \ - AND date_taken IS NOT NULL \ - AND date_taken <= unixepoch('now') \ - AND date_taken >= unixepoch('now', ?2) \ - AND strftime('{p}', date_taken, 'unixepoch', ?3) \ - = strftime('{p}', 'now', ?3)", - p = pattern, - ); + use crate::database::schema::image_exif::dsl as d; let mut connection = self.connection.lock().expect("Unable to get ExifDao"); - diesel::sql_query(sql) - .bind::(library_id) - .bind::(years_modifier) - .bind::(tz_modifier) - .load::(connection.deref_mut()) - .map(|rows| { - rows.into_iter() - .map(|r| (r.rel_path, r.date_taken, r.last_modified)) - .collect() - }) + d::image_exif + .filter(d::library_id.eq(library_id)) + .filter(d::date_taken.is_not_null()) + .filter(d::date_taken.ge(unconv_lo)) + .filter(d::date_taken.le(unconv_hi)) + .filter( + d::date_utc + .is_null() + .or(d::date_utc.ge(conv_lo).and(d::date_utc.le(conv_hi))), + ) + .load::(connection.deref_mut()) .map_err(|e| anyhow::anyhow!("Query error: {}", e)) }) .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) @@ -2966,25 +2962,21 @@ mod exif_dao_tests { assert_eq!(row.size_bytes, Some(1024)); } + /// Wide intervals that admit everything in the years-back window — + /// span-day matching happens in the caller, so tests that don't care + /// about the span pass these. + /// Mirrors the production caller (`memories::memories_span_windows`): + /// the converted window spans the full years-back horizon and the + /// unconverted band pads it by ±15 h — so far-future legacy values + /// are dropped at the DAO, not by the band. + fn wide_windows(now_ts: i64, years_back: i64) -> (i64, i64, i64, i64) { + let lo = now_ts - years_back * 366 * 86_400; + (lo, now_ts, lo - 15 * 3600, now_ts + 15 * 3600) + } + #[test] - fn get_memories_in_window_day_matches_only_same_md_in_year_window() { + fn get_memories_in_window_filters_future_no_date_and_years_back() { let mut dao = setup_two_libraries(); - - // Anchor on a known date so the test is timezone-stable: insert - // rows whose date_taken IS the same wall-clock time as `now()` - // would have been some N years ago, and verify the day-span - // filter returns them. We can't bind 'now' from Rust, so instead - // we insert rows for the *current* day (offset by 365 days * N - // years) and rely on SQLite computing the same `%m-%d` for both - // sides of the equality. Using the unix-now-minus-365*N seconds - // approximation is good enough — leap years drift by ~one day - // every four years, but the test only checks day-of-year match - // for rows inserted "today minus N years (no leap correction)". - // To dodge the leap-year drift entirely, we use rows whose - // calendar date is read back from SQLite and we just check - // membership. - - // 1y, 5y, 10y, 21y back from 'now': let now_ts = chrono::Utc::now().timestamp(); let year_secs: i64 = 365 * 86_400; insert_row_with_source( @@ -2994,13 +2986,6 @@ mod exif_dao_tests { Some(now_ts - year_secs), Some("exif"), ); - insert_row_with_source( - &mut dao, - 1, - "y5.jpg", - Some(now_ts - 5 * year_secs), - Some("exif"), - ); insert_row_with_source( &mut dao, 1, @@ -3027,20 +3012,15 @@ mod exif_dao_tests { // No date — never returned regardless of source. insert_row_with_source(&mut dao, 1, "nodate.jpg", None, None); - // Month span returns rows from the same calendar month over the - // window — y1, y5, y10 should all qualify (same month any year), - // y21 trims (out of years_back), future trims (> now), nodate - // never qualifies. Day-of-month leap drift means even with 365- - // day approximation a row may shift by one in either direction; - // month is the safer assertion under that approximation. + let (clo, chi, ulo, uhi) = wide_windows(now_ts, 20); let rows = dao - .get_memories_in_window(&ctx(), 1, "month", 20, 0) + .get_memories_in_window(&ctx(), 1, clo, chi, ulo, uhi) .unwrap(); let paths: std::collections::HashSet = - rows.into_iter().map(|(p, _, _)| p).collect(); + rows.into_iter().map(|r| r.file_path).collect(); assert!( - paths.contains("y1.jpg") && paths.contains("y5.jpg") && paths.contains("y10.jpg"), - "month span should include all in-window rows: {:?}", + paths.contains("y1.jpg") && paths.contains("y10.jpg"), + "in-window rows should be returned: {:?}", paths ); assert!( @@ -3067,23 +3047,98 @@ mod exif_dao_tests { Some(now_ts - year), Some("exif"), ); - + let (clo, chi, ulo, uhi) = wide_windows(now_ts, 20); let lib1 = dao - .get_memories_in_window(&ctx(), 1, "month", 20, 0) + .get_memories_in_window(&ctx(), 1, clo, chi, ulo, uhi) .unwrap(); let lib2 = dao - .get_memories_in_window(&ctx(), 2, "month", 20, 0) + .get_memories_in_window(&ctx(), 2, clo, chi, ulo, uhi) .unwrap(); assert_eq!(lib1.len(), 1); - assert_eq!(lib1[0].0, "main/x.jpg"); + assert_eq!(lib1[0].file_path, "main/x.jpg"); assert_eq!(lib2.len(), 1); - assert_eq!(lib2[0].0, "archive/x.jpg"); + assert_eq!(lib2[0].file_path, "archive/x.jpg"); } #[test] - fn get_memories_in_window_rejects_unknown_span_token() { + fn get_memories_in_window_branches_converted_on_date_utc() { let mut dao = setup_two_libraries(); - let err = dao.get_memories_in_window(&ctx(), 1, "decade", 20, 0); - assert!(err.is_err()); + let now_ts = chrono::Utc::now().timestamp(); + let year = 365 * 86_400i64; + + // A converted row whose `date_utc` sits INSIDE the conv interval + // but whose legacy `date_taken` sits OUTSIDE the unconv interval: + // it must still be returned (proof the converted branch won). + insert_row_with_source(&mut dao, 1, "conv.jpg", Some(now_ts - year), Some("exif")); + dao.backfill_date_taken( + &ctx(), + 1, + "conv.jpg", + now_ts - year, + "exif", + Some(now_ts - year - 9000), // shifted true instant + None, + Some("assumed_server"), + ) + .unwrap(); + + // An unconverted row: judged on legacy `date_taken` only. + insert_row_with_source( + &mut dao, + 1, + "unconv.jpg", + Some(now_ts - year), + Some("fs_time"), + ); + + // A converted row OUTSIDE the conv interval: excluded even though + // its legacy value is inside the unconv band. + insert_row_with_source( + &mut dao, + 1, + "conv-out.jpg", + Some(now_ts - year), + Some("exif"), + ); + dao.backfill_date_taken( + &ctx(), + 1, + "conv-out.jpg", + now_ts - year, + "exif", + Some(now_ts - 30 * year), + None, + Some("assumed_server"), + ) + .unwrap(); + + // conv interval: a tight band around (now - year - 9000) that + // excludes (now - 30*year) and excludes the unconv row's legacy + // (now - year) is irrelevant — it's unconverted. The unconv band + // is wide for unconv.jpg. + let clo = now_ts - year - 86_400; + let chi = now_ts - year; + let ulo = now_ts - year - 86_400; + let uhi = now_ts; + let rows = dao + .get_memories_in_window(&ctx(), 1, clo, chi, ulo, uhi) + .unwrap(); + let paths: std::collections::HashSet = + rows.into_iter().map(|r| r.file_path).collect(); + assert!( + paths.contains("conv.jpg"), + "converted row in conv band: {:?}", + paths + ); + assert!( + paths.contains("unconv.jpg"), + "unconverted row in legacy band: {:?}", + paths + ); + assert!( + !paths.contains("conv-out.jpg"), + "converted row outside conv band must be excluded: {:?}", + paths + ); } } diff --git a/src/date_view.rs b/src/date_view.rs new file mode 100644 index 0000000..d84fd77 --- /dev/null +++ b/src/date_view.rs @@ -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) -> 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(), + ) +} + +/// 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)); + } +} diff --git a/src/files.rs b/src/files.rs index 9cec9fd..c277f8b 100644 --- a/src/files.rs +++ b/src/files.rs @@ -1142,12 +1142,25 @@ pub async fn get_gps_summary( Ok(gps_data) => { let mut photos: Vec = gps_data .into_iter() - .map(|(path, lat, lon, date_taken)| GpsPhotoSummary { - path, - lat, - lon, - date_taken, - }) + .map( + |( + path, + lat, + lon, + date_taken, + date_utc, + camera_offset_minutes, + date_taken_tz_source, + )| GpsPhotoSummary { + path, + lat, + lon, + date_taken, + date_utc, + camera_offset_minutes, + date_taken_tz_source, + }, + ) .collect(); // Sort by date_taken based on request, defaulting to ascending (oldest to newest) @@ -1695,7 +1708,18 @@ mod tests { _context: &opentelemetry::Context, _base_path: &str, _recursive: bool, - ) -> Result)>, DbError> { + ) -> Result< + Vec<( + String, + f64, + f64, + Option, + Option, + Option, + Option, + )>, + DbError, + > { todo!() } @@ -1817,10 +1841,11 @@ mod tests { &mut self, _context: &opentelemetry::Context, _library_id: i32, - _span_token: &str, - _years_back: i32, - _tz_offset_minutes: i32, - ) -> Result, DbError> { + _conv_lo: i64, + _conv_hi: i64, + _unconv_lo: i64, + _unconv_hi: i64, + ) -> Result, DbError> { Ok(Vec::new()) } diff --git a/src/lib.rs b/src/lib.rs index e86bdc5..ceda9a5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,7 @@ pub mod content_hash; pub mod data; pub mod database; pub mod date_resolver; +pub mod date_view; pub mod duplicates; pub mod error; pub mod exif; diff --git a/src/main.rs b/src/main.rs index f33abd0..03502a4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -41,6 +41,7 @@ mod content_hash; mod data; mod database; mod date_resolver; +mod date_view; mod duplicates; mod error; mod exif; diff --git a/src/memories.rs b/src/memories.rs index a47bbbc..1411ce7 100644 --- a/src/memories.rs +++ b/src/memories.rs @@ -1,12 +1,13 @@ use actix_web::web::Data; use actix_web::{HttpRequest, HttpResponse, Responder, get, web}; -use chrono::{DateTime, FixedOffset, Local, NaiveDate, NaiveDateTime, Utc}; +use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, TimeZone, Utc}; use log::{debug, trace, warn}; use opentelemetry::KeyValue; use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer}; use serde::{Deserialize, Serialize}; use std::path::Path; use std::path::PathBuf; +use std::str::FromStr; use std::sync::Mutex; use crate::data::Claims; @@ -110,7 +111,10 @@ pub enum MemoriesSpan { #[derive(Deserialize)] pub struct MemoriesRequest { pub span: Option, - /// Client timezone offset in minutes from UTC (e.g., -480 for PST, 60 for CET) + /// Client timezone offset in minutes from UTC (e.g., -480 for PST, 60 for + /// CET). Accepted on the wire for old clients but no longer drives + /// grouping (ADR-0001) — grouping uses the owner's display zone. + #[allow(dead_code)] pub timezone_offset_minutes: Option, /// Optional library filter. Accepts a library id (e.g. "1") or name /// (e.g. "main"). When omitted, results span all libraries. @@ -120,11 +124,21 @@ pub struct MemoriesRequest { #[derive(Debug, Serialize, Clone)] pub struct MemoryItem { pub path: String, + /// True instant (unix seconds) the memory names (ADR-0001) — the + /// grouping anchor. For unconverted rows this is *derived* from the + /// legacy value under its per-source convention, so it is the real + /// moment even while the `backfill_utc_dates` drain is still running. pub created: Option, pub modified: Option, /// Id of the library this memory belongs to. Allows clients to show a /// per-item source badge in union mode. pub library_id: i32, + /// New-contract triple (ADR-0001). `date_utc` is `None` until the + /// `backfill_utc_dates` drain has converted the row; clients should + /// prefer it over `created` when present. + pub date_utc: Option, + pub camera_offset_minutes: Option, + pub date_taken_tz_source: Option, } #[derive(Debug, Serialize)] @@ -132,6 +146,50 @@ pub struct MemoriesResponse { pub items: Vec, } +/// Epoch windows for the memories SQL pre-filter (ADR-0001). +/// +/// The span feature matches the same calendar position (month-day / +/// week-of-year / month) across every past year, so the pre-filter must +/// cover the FULL years-back window — not just the current span window — +/// or past-year rows are dropped before [`day_in_span`] gets to match +/// them. Converted rows are tested on `date_utc` (the true instant); +/// unconverted rows on legacy `date_taken`, which sits up to ~15 h off +/// the instant under the per-source conventions (wall↔instant confusion +/// ≤ 12 h, zone range ≤ 2 h, DST margin 1 h) — hence the ±15 h band. +/// By construction the band subsumes the converted window, so the DAO's +/// single outer bound on `date_taken` never drops a converted row. +pub fn memories_span_windows(now_ts: i64, years_back: i64) -> (i64, i64, i64, i64) { + let years_lo = now_ts - years_back * 365 * 86_400; + (years_lo, now_ts, years_lo - 15 * 3600, now_ts + 15 * 3600) +} + +/// True if `display_day` falls in the span (day/week/month) containing +/// `today` — both already in the owner's display zone. Mirrors the legacy +/// SQLite `strftime('%m-%d' | '%W' | '%m')` equality: weeks are +/// Monday-based and the days before the year's first Monday are week 00. +pub fn day_in_span(display_day: NaiveDate, today: NaiveDate, span: MemoriesSpan) -> bool { + match span { + MemoriesSpan::Day => { + display_day.month() == today.month() && display_day.day() == today.day() + } + MemoriesSpan::Week => sqlite_w_week(display_day) == sqlite_w_week(today), + MemoriesSpan::Month => display_day.month() == today.month(), + } +} + +/// SQLite's `%W`: Monday-based week-of-year, `0` for the days before the +/// year's first Monday, `1` starting on the first Monday. +fn sqlite_w_week(d: NaiveDate) -> i32 { + let jan1 = NaiveDate::from_ymd_opt(d.year(), 1, 1).expect("Jan 1 exists"); + let days_in_week0 = (7 - jan1.weekday().num_days_from_monday() as i64) % 7; + let days = (d - jan1).num_days(); + if days < days_in_week0 { + 0 + } else { + ((days - days_in_week0) / 7 + 1) as i32 + } +} + /// A date carried by a filename, with its convention (ticket #116). /// /// The two families of pattern make fundamentally different claims: @@ -398,17 +456,6 @@ fn plausible_filename_date(dt: DateTime) -> Option> { } } -/// Convert a `date_taken` Unix-seconds value to a `NaiveDate` in the -/// client's local time. Falls back to server-local when the client didn't -/// send a tz hint. -fn date_in_client_tz(timestamp: i64, client_timezone: Option) -> Option { - let dt = DateTime::from_timestamp(timestamp, 0)?; - Some(match client_timezone { - Some(tz) => dt.with_timezone(&tz).date_naive(), - None => dt.with_timezone(&Local).date_naive(), - }) -} - /// Default lookback for `/memories`. The original 15-year cap pre-dated /// most of the imported libraries; bumped to 20 so users with deeper /// archives see those photos surface on the matching anniversary too. @@ -430,23 +477,29 @@ pub async fn list_memories( let span_mode = q.span.unwrap_or(MemoriesSpan::Day); - // The SQL filter expects a signed offset in minutes from UTC; default - // 0 (UTC) when the client didn't send a hint. We also keep a chrono - // `FixedOffset` for sorting/secondary-key date math in Rust below — - // anchoring both sides on the same value keeps "what SQL matched" and - // "what we sort by" consistent. - let tz_offset_minutes = q.timezone_offset_minutes.unwrap_or(0); - let client_timezone = q - .timezone_offset_minutes - .and_then(|offset_mins| FixedOffset::east_opt(offset_mins * 60)); + // Grouping happens in the owner's display zone (prefs → app default), + // not the client's device offset (ADR-0001): a photo taken in Japan at + // 21:00 JST belongs to the owner's day, and a container's UTC-rendered + // creation time is a true instant. The legacy `timezone_offset_minutes` + // param is still accepted on the wire for old clients but no longer + // drives grouping. + let display_zone_name = { + let prefs = app_state + .user_ai_prefs_dao + .lock() + .ok() + .and_then(|mut dao| dao.get_prefs(&span_context).ok().flatten()); + crate::camera_tz::display_zone_for(prefs.as_ref().and_then(|p| p.display_tz.as_deref())) + }; + let display_zone = + chrono_tz::Tz::from_str(&display_zone_name).unwrap_or(chrono_tz::Tz::America__New_York); let items = match gather_memory_items( &app_state, &exif_dao, &span_context, span_mode, - tz_offset_minutes, - client_timezone, + display_zone, q.library.as_deref(), ) { Ok(items) => items, @@ -462,7 +515,7 @@ pub async fn list_memories( KeyValue::new("span", format!("{:?}", span_mode)), KeyValue::new("years_back", DEFAULT_YEARS_BACK.to_string()), KeyValue::new("result_count", items.len().to_string()), - KeyValue::new("tz_offset_minutes", tz_offset_minutes.to_string()), + KeyValue::new("display_tz", display_zone_name), KeyValue::new("excluded_dirs", format!("{:?}", app_state.excluded_dirs)), ], ); @@ -474,29 +527,41 @@ pub async fn list_memories( /// Resolve an "on this day/week/month across past years" window into an /// ordered list of [`MemoryItem`]s. Shared by the `/memories` handler and the /// memory-reel selector so both honour the same library resolution, per-library -/// exclusions, timezone handling, and sort order. Returns `Err(message)` only -/// when the `library` param is invalid (callers map that to 400); per-library +/// exclusions, and sort order. Returns `Err(message)` only when the +/// `library` param is invalid (callers map that to 400); per-library /// query/lock failures are logged and skipped, matching the handler's /// best-effort behaviour. +/// +/// Grouping is done in the owner's display zone (ADR-0001), on the row's +/// *true* instant — so a container's UTC-rendered creation time and an +/// unconverted EXIF wall clock both land in the same day a photo taken +/// locally did. The SQL pass is an interval pre-filter; exact span-day +/// matching happens here in Rust via [`date_view`]. pub fn gather_memory_items( app_state: &AppState, exif_dao: &Mutex>, span_context: &opentelemetry::Context, span_mode: MemoriesSpan, - tz_offset_minutes: i32, - client_timezone: Option, + display_zone: chrono_tz::Tz, library_param: Option<&str>, ) -> Result, String> { - let span_token = match span_mode { - MemoriesSpan::Day => "day", - MemoriesSpan::Week => "week", - MemoriesSpan::Month => "month", - }; - let years_back: i32 = DEFAULT_YEARS_BACK; + let years_back: i64 = DEFAULT_YEARS_BACK as i64; + + let now = Utc::now(); + let now_ts = now.timestamp(); + let today_d = display_zone + .from_utc_datetime(&now.naive_utc()) + .date_naive(); + // The SQL pass is a coarse pre-filter over the FULL years-back window + // (not the current span window) because this feature matches the same + // month-day / week / month across every past year; [`day_in_span`] does + // the exact matching in Rust (ADR-0001). + let (conv_lo, conv_hi, unconv_lo, unconv_hi) = memories_span_windows(now_ts, years_back); + let server_zone = crate::camera_tz::server_zone(); debug!( - "gather_memory_items: span={:?} tz_offset_min={} years_back={}", - span_mode, tz_offset_minutes, years_back + "gather_memory_items: span={:?} display_zone={:?} years_back={}", + span_mode, display_zone, years_back ); let library = crate::libraries::resolve_library_param_state(app_state, library_param)?; @@ -518,9 +583,10 @@ pub fn gather_memory_items( Ok(mut dao) => match dao.get_memories_in_window( span_context, lib.id, - span_token, - years_back, - tz_offset_minutes, + conv_lo, + conv_hi, + unconv_lo, + unconv_hi, ) { Ok(rows) => rows, Err(e) => { @@ -537,28 +603,37 @@ pub fn gather_memory_items( } }; - for (rel_path, date_taken_ts, last_modified_ts) in rows { + for row in rows { // Apply per-library exclusions in Rust — they're a small // set and pushing them into the SQL WHERE adds bind-param // gymnastics with no measurable win at this scale. - let full_path = base.join(&rel_path); + let full_path = base.join(&row.file_path); if path_excluder.is_excluded(&full_path) { trace!("Memory excluded by PathExcluder: {:?}", full_path); continue; } - let Some(file_date) = date_in_client_tz(date_taken_ts, client_timezone) else { + let Some(display_day) = + crate::date_view::row_display_day(&row, display_zone, server_zone) + else { continue; }; + if !day_in_span(display_day, today_d, span_mode) { + continue; + } + let created = crate::date_view::row_true_instant(&row, display_zone, server_zone); memories_with_dates.push(( MemoryItem { - path: rel_path, - created: Some(date_taken_ts), - modified: Some(last_modified_ts), + path: row.file_path, + created, + modified: Some(row.last_modified), library_id: lib.id, + date_utc: row.date_utc, + camera_offset_minutes: row.camera_offset_minutes, + date_taken_tz_source: row.date_taken_tz_source, }, - file_date, + display_day, )); } } @@ -1038,6 +1113,9 @@ mod tests { created: Some(jan_15_2024_9am), modified: Some(jan_15_2024_9am), library_id: 1, + date_utc: None, + camera_offset_minutes: None, + date_taken_tz_source: None, }, NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(), ), @@ -1047,6 +1125,9 @@ mod tests { created: Some(jan_15_2020_10am), modified: Some(jan_15_2020_10am), library_id: 1, + date_utc: None, + camera_offset_minutes: None, + date_taken_tz_source: None, }, NaiveDate::from_ymd_opt(2020, 1, 15).unwrap(), ), @@ -1056,6 +1137,9 @@ mod tests { created: Some(jan_16_2021_8am), modified: Some(jan_16_2021_8am), library_id: 1, + date_utc: None, + camera_offset_minutes: None, + date_taken_tz_source: None, }, NaiveDate::from_ymd_opt(2021, 1, 16).unwrap(), ), @@ -1087,4 +1171,325 @@ mod tests { assert_eq!(memories_with_dates[1].0.created.unwrap(), jan_15_2024_9am); assert_eq!(memories_with_dates[2].0.created.unwrap(), jan_16_2021_8am); } + + // ---- Day-matching (ticket #119) ---- + + #[test] + fn span_windows_cover_the_full_years_back_range() { + // Regression guard for the "current span window only" bug: the + // day/week/month spans match across PAST YEARS, so the SQL + // pre-filter must span the whole years-back horizon. If this + // narrowed to the current day/week/month, /memories would return + // only photos taken right now. + let now = 1_700_000_000i64; + let (clo, chi, ulo, uhi) = memories_span_windows(now, 20); + let lo_bound = now - 20 * 365 * 86_400; + assert_eq!(clo, lo_bound, "converted window must start at the horizon"); + assert_eq!(chi, now, "converted window must end at now"); + assert_eq!( + ulo, + lo_bound - 15 * 3600, + "unconverted band must pad the horizon" + ); + assert_eq!(uhi, now + 15 * 3600, "unconverted band must pad now"); + // The band subsumes the converted window so the DAO's single + // outer bound on legacy date_taken never drops a converted row. + assert!(ulo <= clo && chi <= uhi); + } + + #[test] + fn day_in_span_day_and_month() { + let today = NaiveDate::from_ymd_opt(2024, 7, 4).unwrap(); + assert!(day_in_span( + NaiveDate::from_ymd_opt(2024, 7, 4).unwrap(), + today, + MemoriesSpan::Day + )); + assert!(!day_in_span( + NaiveDate::from_ymd_opt(2024, 7, 5).unwrap(), + today, + MemoriesSpan::Day + )); + assert!(day_in_span( + NaiveDate::from_ymd_opt(2020, 7, 4).unwrap(), + today, + MemoriesSpan::Day + )); + assert!(day_in_span( + NaiveDate::from_ymd_opt(2024, 7, 30).unwrap(), + today, + MemoriesSpan::Month + )); + assert!(!day_in_span( + NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(), + today, + MemoriesSpan::Month + )); + } + + #[test] + fn day_in_span_week_is_monday_based_with_week_zero() { + let monday_2024 = NaiveDate::from_ymd_opt(2024, 7, 1).unwrap(); // Mon + // Same week: Sunday Jul 7 is still week of Mon Jul 1. + assert!(day_in_span( + NaiveDate::from_ymd_opt(2024, 7, 7).unwrap(), + monday_2024, + MemoriesSpan::Week + )); + // Next Monday is the next week. + assert!(!day_in_span( + NaiveDate::from_ymd_opt(2024, 7, 8).unwrap(), + monday_2024, + MemoriesSpan::Week + )); + // Previous Sunday is the previous week. + assert!(!day_in_span( + NaiveDate::from_ymd_opt(2024, 6, 30).unwrap(), + monday_2024, + MemoriesSpan::Week + )); + + // 2022 week 00: Jan 1 (Sat) and Jan 2 (Sun) share week 00; Jan 3 + // (Mon) starts week 1. + let jan1_2022 = NaiveDate::from_ymd_opt(2022, 1, 1).unwrap(); + assert!(day_in_span( + NaiveDate::from_ymd_opt(2022, 1, 2).unwrap(), + jan1_2022, + MemoriesSpan::Week + )); + assert!(!day_in_span( + NaiveDate::from_ymd_opt(2022, 1, 3).unwrap(), + jan1_2022, + MemoriesSpan::Week + )); + } + + #[test] + fn sqlite_w_week_matches_known_values() { + // 2024: Jan 1 is a Monday → week 1 starts Jan 1. + assert_eq!( + sqlite_w_week(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()), + 1 + ); + assert_eq!( + sqlite_w_week(NaiveDate::from_ymd_opt(2024, 12, 30).unwrap()), + 53 + ); + // 2022: Jan 1 is a Saturday → two-day week 00. + assert_eq!( + sqlite_w_week(NaiveDate::from_ymd_opt(2022, 1, 1).unwrap()), + 0 + ); + assert_eq!( + sqlite_w_week(NaiveDate::from_ymd_opt(2022, 1, 2).unwrap()), + 0 + ); + assert_eq!( + sqlite_w_week(NaiveDate::from_ymd_opt(2022, 1, 3).unwrap()), + 1 + ); + // 2023: Jan 1 is a Sunday → one-day week 00. + assert_eq!( + sqlite_w_week(NaiveDate::from_ymd_opt(2023, 1, 1).unwrap()), + 0 + ); + assert_eq!( + sqlite_w_week(NaiveDate::from_ymd_opt(2023, 1, 2).unwrap()), + 1 + ); + assert_eq!( + sqlite_w_week(NaiveDate::from_ymd_opt(2023, 12, 31).unwrap()), + 52 + ); + } + + /// AC-2 / AC-3 / AC-7: seam test with mixed fixtures — a converted + /// row and two unconverted rows (EXIF wall convention + container UTC + /// convention) all group into the owner's display-zone month, and each + /// item carries its true instant under the per-source convention. + mod api { + use super::*; + use crate::database::SqliteExifDao; + use crate::database::models::InsertImageExif; + use crate::database::test::in_memory_db_connection; + + fn seed_row(dao: &mut SqliteExifDao, rel: &str, date_taken: i64, source: &str) { + dao.store_exif( + &opentelemetry::Context::new(), + InsertImageExif { + library_id: 1, + file_path: rel.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: Some(date_taken), + created_time: 0, + last_modified: 0, + content_hash: None, + size_bytes: None, + phash_64: None, + dhash_64: None, + date_taken_source: Some(source.to_string()), + date_utc: None, + camera_offset_minutes: None, + date_taken_tz_source: None, + }, + ) + .expect("insert row"); + } + + #[actix_rt::test] + async fn memories_mixed_rows_group_into_owner_month() { + let _ = env_logger::builder().is_test(true).try_init(); + + let state = AppState::test_state(); + let zone = chrono_tz::Tz::America__New_York; + + // Month span over the 5th of the CURRENT month in past years, + // at 12:00 naive: under all three conventions (camera wall in + // the server zone, container UTC, converted instant) the + // grouping day can't roll across midnight, so the fixture is + // stable across DST transitions and leap years. + let now = Utc::now(); + let (month, year) = (now.month(), now.year()); + let at_12 = |y: i32| { + NaiveDate::from_ymd_opt(y, month, 5) + .unwrap() + .and_hms_opt(12, 0, 0) + .unwrap() + }; + + let mut dao = SqliteExifDao::from_connection(in_memory_db_connection()); + + // 1. Converted row: true instant 12:00 UTC (no camera offset). + let conv_instant = at_12(year - 3).and_utc().timestamp(); + seed_row(&mut dao, "conv.jpg", conv_instant, "exif"); + dao.backfill_date_taken( + &opentelemetry::Context::new(), + 1, + "conv.jpg", + conv_instant, + "exif", + Some(conv_instant), + None, + Some("utc_direct"), + ) + .expect("convert row"); + + // 2. Unconverted EXIF row: legacy is the camera wall read as + // UTC — the true moment is 12:00 in the server zone. + let exif_wall = at_12(year - 1); + let exif_legacy = exif_wall.and_utc().timestamp(); + seed_row(&mut dao, "unconv-exif.jpg", exif_legacy, "exif"); + + // 3. Unconverted container row: legacy is the raw UTC creation + // time string rendered in the server zone (the old server + // read the epoch as a naive string and stamped it through + // its local zone). Pre-#115 this row double-shifted in + // /memories; it must now land in the same owner month as + // the other two. + let vid_legacy = at_12(year - 2).and_utc().timestamp(); + seed_row(&mut dao, "unconv-vid.mp4", vid_legacy, "exiftool"); + + let exif_mutex: Mutex> = Mutex::new(Box::new(dao)); + let items = gather_memory_items( + &state, + &exif_mutex, + &opentelemetry::Context::new(), + MemoriesSpan::Month, + zone, + None, + ) + .expect("gather"); + let by_path = |p: &str| items.iter().find(|it| it.path == p); + + // All three rows group into the same owner month (AC-2/AC-3). + let conv = by_path("conv.jpg").expect("converted row must be returned"); + let exif_row = + by_path("unconv-exif.jpg").expect("unconverted EXIF row must be returned"); + let vid = + by_path("unconv-vid.mp4").expect("unconverted container row must be returned"); + + // AC-1: the triple rides along — populated for the converted + // row, None for the unconverted ones. + assert_eq!(conv.date_utc, Some(conv_instant)); + assert_eq!(conv.camera_offset_minutes, None); + assert_eq!(conv.date_taken_tz_source.as_deref(), Some("utc_direct")); + assert_eq!(exif_row.date_utc, None); + assert_eq!(exif_row.camera_offset_minutes, None); + assert_eq!(exif_row.date_taken_tz_source, None); + assert_eq!(vid.date_utc, None); + + // AC-2: `created` is the true instant under each row's + // per-source convention. The server zone (the zone the old + // server rendered legacy values in) is a deploy property, so + // the expected values are derived from the convention, not + // hardcoded. + let server_zone = crate::camera_tz::server_zone(); + let legacy_as_wall = |ts: i64| { + chrono::DateTime::from_timestamp(ts, 0) + .expect("valid epoch") + .naive_utc() + }; + // exif: camera wall reinterpreted in the server zone. + let exif_expected = match server_zone { + Some(z) => z + .from_local_datetime(&legacy_as_wall(exif_legacy)) + .single() + .expect("camera wall in server zone") + .timestamp(), + None => exif_legacy, + }; + // exiftool video: raw UTC naive rendered in the server zone, + // read back as UTC. + let vid_expected = match server_zone { + Some(z) => z + .timestamp_opt(vid_legacy, 0) + .single() + .expect("server-zone render") + .naive_local() + .and_utc() + .timestamp(), + None => vid_legacy, + }; + + assert_eq!(conv.created, Some(conv_instant)); + assert_eq!(exif_row.created, Some(exif_expected)); + assert_eq!(vid.created, Some(vid_expected)); + } + + /// AC-1 (response shape): the serialized `/memories` item carries + /// the new-contract triple as explicit fields — null while the row + /// is unconverted. + #[test] + fn memories_response_shape_carries_triple() { + let item = MemoryItem { + path: "a.jpg".into(), + created: Some(1_700_000_000), + modified: Some(0), + library_id: 1, + date_utc: None, + camera_offset_minutes: None, + date_taken_tz_source: None, + }; + let v: serde_json::Value = + serde_json::to_value(&MemoriesResponse { items: vec![item] }).unwrap(); + let it = &v["items"][0]; + assert!(it.get("date_utc").is_some() && it["date_utc"].is_null()); + assert!( + it.get("camera_offset_minutes").is_some() && it["camera_offset_minutes"].is_null() + ); + assert!(it.get("date_taken_tz_source").is_some()); + } + } } diff --git a/src/reels/mod.rs b/src/reels/mod.rs index 2d20510..c46b0c6 100644 --- a/src/reels/mod.rs +++ b/src/reels/mod.rs @@ -417,9 +417,23 @@ pub async fn create_reel_handler( let span = req.span.unwrap_or(MemoriesSpan::Day); let max_segments = req.max_segments.unwrap_or(selector::DEFAULT_MAX_SEGMENTS); + // Grouping zone: explicit request param → stored pref → app default + // (ADR-0001). The legacy device-offset param is still mirrored into the + // frozen prefs column by `capture_prefs` for rollback but no longer + // drives selection. + let display_zone_name = { + let req_tz = req.display_tz.clone().filter(|s| !s.trim().is_empty()); + let stored = app_state + .user_ai_prefs_dao + .lock() + .ok() + .and_then(|mut dao| dao.get_prefs(&opentelemetry::Context::new()).ok().flatten()) + .and_then(|p| p.display_tz); + crate::camera_tz::display_zone_for(req_tz.as_deref().or(stored.as_deref())) + }; let selector = ReelSelector::Memories { span, - tz_offset_minutes: req.timezone_offset_minutes.unwrap_or(0), + display_tz: display_zone_name, library: req.library.clone(), max_segments, }; @@ -965,23 +979,26 @@ pub(crate) fn secs_until_next_run_hour(now: chrono::DateTime, run } /// Load pre-gen parameters: tries the user_ai_prefs DB row first, falls back -/// to env vars, then to server-local defaults. -fn load_pregen_params(app_state: &AppState) -> (i32, Option, String) { +/// to env vars, then to the app default display zone. Returns the display +/// zone as an IANA name (ADR-0001) — `REEL_PREGEN_DISPLAY_TZ` (e.g. +/// "America/New_York") overrides the stored pref when set. IANA zones are +/// DST-proof, unlike the old fixed-offset env vars. +fn load_pregen_params(app_state: &AppState) -> (String, Option, String) { // Try DB row first if let Ok(mut dao) = app_state.user_ai_prefs_dao.lock() { let ctx = opentelemetry::Context::new(); if let Ok(Some(prefs)) = dao.get_prefs(&ctx) { - let tz = prefs.tz_offset_minutes.unwrap_or_else(fixed_tz_offset); + let tz = crate::camera_tz::display_zone_for(prefs.display_tz.as_deref()); let voice = prefs.voice; let library = prefs.library.unwrap_or_else(|| "all".to_string()); return (tz, voice, library); } } - // Fall back to env (explicit offset overrides auto-detect) - let tz = std::env::var("REEL_PREGEN_TZ_OFFSET_MINUTES") + // Fall back to env (explicit zone overrides auto-detect) + let tz = std::env::var("REEL_PREGEN_DISPLAY_TZ") .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or_else(fixed_tz_offset); + .filter(|v| !v.trim().is_empty()) + .unwrap_or_else(|| crate::camera_tz::display_zone_for(None)); let voice = std::env::var("REEL_PREGEN_VOICE").ok(); let library = std::env::var("REEL_PREGEN_LIBRARY") .ok() @@ -989,17 +1006,6 @@ fn load_pregen_params(app_state: &AppState) -> (i32, Option, String) { (tz, voice, library) } -/// Fixed timezone offset: reads `REEL_PREGEN_TZ_FIXED_MINUTES` (e.g. "-480" -/// for US Eastern) when set, falling back to the system local offset. Using -/// a fixed offset avoids DST shifts changing the pre-gen schedule halfway -/// through the year. -fn fixed_tz_offset() -> i32 { - std::env::var("REEL_PREGEN_TZ_FIXED_MINUTES") - .ok() - .and_then(|v| v.trim().parse().ok()) - .unwrap_or_else(|| chrono::Local::now().offset().local_minus_utc()) -} - /// Spawn the nightly pre-generation scheduler. Runs behind `REEL_PREGEN_ENABLED`. pub(crate) async fn spawn_pregen_scheduler(app_state: web::Data) { if std::env::var("REEL_PREGEN_ENABLED").ok() != Some("1".to_string()) { @@ -1044,7 +1050,7 @@ async fn run_pregen_batch(app_state: &AppState) -> anyhow::Result<()> { let (tz, voice, library) = load_pregen_params(app_state); for span in spans { - if let Err(e) = pregen_one(app_state, span, tz, voice.clone(), &library).await { + if let Err(e) = pregen_one(app_state, span, &tz, voice.clone(), &library).await { log::error!("Pre-gen failed for span={}: {}", span, e); } } @@ -1056,7 +1062,7 @@ async fn run_pregen_batch(app_state: &AppState) -> anyhow::Result<()> { async fn pregen_one( app_state: &AppState, span: &str, - tz: i32, + display_tz: &str, voice: Option, library: &str, ) -> anyhow::Result<()> { @@ -1069,7 +1075,7 @@ async fn pregen_one( let selector = ReelSelector::Memories { span: memories_span, - tz_offset_minutes: tz, + display_tz: display_tz.to_string(), library: if library == "all" { None } else { @@ -1179,7 +1185,8 @@ async fn pregen_one( title, media_count, render_version: RENDER_VERSION as i32, - tz_offset_minutes: tz, + tz_offset_minutes: crate::camera_tz::display_zone_offset_minutes(display_tz, now) + .unwrap_or(0) as i32, voice: voice.clone(), generated_at: now, }, @@ -1346,7 +1353,7 @@ mod tests { fn day_selector() -> ReelSelector { ReelSelector::Memories { span: MemoriesSpan::Day, - tz_offset_minutes: 0, + display_tz: "America/New_York".to_string(), library: None, max_segments: 24, } @@ -1379,7 +1386,7 @@ mod tests { // Span matters. let week = ReelSelector::Memories { span: MemoriesSpan::Week, - tz_offset_minutes: 0, + display_tz: "America/New_York".to_string(), library: None, max_segments: 24, }; diff --git a/src/reels/selector.rs b/src/reels/selector.rs index a02cbb8..aba111b 100644 --- a/src/reels/selector.rs +++ b/src/reels/selector.rs @@ -9,9 +9,10 @@ //! runs in the background job. use std::path::Path; +use std::str::FromStr; use std::sync::Mutex; -use chrono::{DateTime, Datelike, FixedOffset}; +use chrono::{Datelike, TimeZone}; use super::{PlannedBeat, ReelMeta, SegmentMedia}; use crate::database::{ExifDao, InsightDao}; @@ -64,7 +65,10 @@ pub fn budget_segments(requested_max: usize) -> usize { pub enum ReelSelector { Memories { span: MemoriesSpan, - tz_offset_minutes: i32, + /// Owner's display zone as an IANA name (ADR-0001) — grouping is + /// zone-anchored, not device-offset-anchored, so DST shifts are + /// absorbed instead of silently re-bucketing the reel. + display_tz: String, library: Option, max_segments: usize, }, @@ -78,13 +82,13 @@ impl ReelSelector { match self { ReelSelector::Memories { span, - tz_offset_minutes, + display_tz, library, max_segments, } => format!( "memories:span={:?}:tz={}:lib={}:max={}", span, - tz_offset_minutes, + display_tz, library.as_deref().unwrap_or("all"), max_segments ), @@ -281,24 +285,24 @@ pub fn resolve( match selector { ReelSelector::Memories { span, - tz_offset_minutes, + display_tz, library, max_segments, } => { - let client_tz = FixedOffset::east_opt(tz_offset_minutes * 60); + let zone = + chrono_tz::Tz::from_str(display_tz).unwrap_or(chrono_tz::Tz::America__New_York); let items = memories::gather_memory_items( app_state, exif_dao, span_context, *span, - *tz_offset_minutes, - client_tz, + zone, library.as_deref(), )?; // Split into photos and video clips; anything that's neither is // dropped. Years span both, computed before the budget narrows it. - let years = distinct_years(&items, client_tz); + let years = distinct_years(&items, zone); let meta = ReelMeta { span: *span, years }; let (photos, videos): (Vec<_>, Vec<_>) = items @@ -321,15 +325,12 @@ pub fn resolve( /// Distinct calendar years represented by the selected media, in the client's /// timezone, ascending. Used to tell the scripter how far back the reel reaches. -fn distinct_years(items: &[memories::MemoryItem], tz: Option) -> Vec { +fn distinct_years(items: &[memories::MemoryItem], zone: chrono_tz::Tz) -> Vec { let mut years: Vec = items .iter() .filter_map(|it| it.created) - .filter_map(|ts| DateTime::from_timestamp(ts, 0)) - .map(|dt| match tz { - Some(off) => dt.with_timezone(&off).year(), - None => dt.year(), - }) + .filter_map(|ts| zone.timestamp_opt(ts, 0).single()) + .map(|dt| dt.year()) .collect(); years.sort_unstable(); years.dedup(); @@ -406,13 +407,13 @@ mod tests { fn descriptor_is_stable_and_distinguishes_inputs() { let a = ReelSelector::Memories { span: MemoriesSpan::Day, - tz_offset_minutes: -480, + display_tz: "America/New_York".to_string(), library: None, max_segments: 24, }; let b = ReelSelector::Memories { span: MemoriesSpan::Week, - tz_offset_minutes: -480, + display_tz: "America/New_York".to_string(), library: None, max_segments: 24, }; @@ -429,21 +430,33 @@ mod tests { created: Some(1_560_000_000), // 2019 modified: None, library_id: 1, + date_utc: None, + camera_offset_minutes: None, + date_taken_tz_source: None, }, memories::MemoryItem { path: "b".into(), created: Some(1_560_086_400), // 2019 modified: None, library_id: 1, + date_utc: None, + camera_offset_minutes: None, + date_taken_tz_source: None, }, memories::MemoryItem { path: "c".into(), created: Some(1_623_000_000), // 2021 modified: None, library_id: 1, + date_utc: None, + camera_offset_minutes: None, + date_taken_tz_source: None, }, ]; - assert_eq!(distinct_years(&items, None), vec![2019, 2021]); + assert_eq!( + distinct_years(&items, chrono_tz::Tz::America__New_York), + vec![2019, 2021] + ); } // Build an item at a given unix timestamp (seconds) with a chosen extension. @@ -453,6 +466,9 @@ mod tests { created: Some(ts), modified: None, library_id: 1, + date_utc: None, + camera_offset_minutes: None, + date_taken_tz_source: None, } } fn item_at(ts: i64, name: &str) -> memories::MemoryItem { diff --git a/src/state.rs b/src/state.rs index 0d09db2..30ac59c 100644 --- a/src/state.rs +++ b/src/state.rs @@ -323,6 +323,7 @@ impl Default for AppState { face_dao.clone(), knowledge_dao, persona_dao.clone(), + user_ai_prefs_dao.clone(), libraries_vec.clone(), ); @@ -550,6 +551,7 @@ impl AppState { face_dao.clone(), knowledge_dao, persona_dao.clone(), + Arc::new(Mutex::new(Box::new(SqliteUserAiPrefsDao::new()))), vec![test_lib], );