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

- New src/date_view.rs: pure read-side functions (row_true_instant,
  row_display_wall, row_display_day, filename_true_instant) applying the
  per-source convention table for unconverted rows and the new-contract
  triple for converted rows (ADR-0001).
- /memories: grouping on the row's true instant rendered in the owner's
  display zone (prefs -> app default), not the client device offset.
  SQL is a coarse interval pre-filter over the full years-back window;
  exact month-day/week/month matching across past years happens in Rust
  (memories::day_in_span, sqlite_w_week).
- Reels: ReelSelector::Memories carries an IANA display_tz instead of a
  fixed minute offset; pre-gen params read REEL_PREGEN_DISPLAY_TZ; the
  tz_offset_minutes ledger column stores the zone offset at render time.
- Insights: prompt-facing dates render as the display wall; the
  proximity timestamp and chat context block use the true instant;
  InsightGenerator reads the owner's display zone from prefs per call.
- GpsPhotoSummary carries the new-contract triple alongside the frozen
  legacy date_taken.

Closes #119
This commit is contained in:
Cameron Cordes committed 2026-09-26 19:28:26 -04:00
1 parent cd375f18db
commit af87765bbb
13 files changed
+1590 -301

No files matched your search

+185 -17
View File
@@ -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<String> = 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<crate::database::models::ImageExif>,
file_path: &str,
display_zone: chrono_tz::Tz,
server_zone: Option<chrono_tz::Tz>,
) -> Option<String> {
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"));
}
}