feat(dates): foundation for the true-instant date contract (#114)

Populate the new-contract date columns at ingest while keeping the
legacy date_taken column byte-identical (ADR-0001):

- camera_tz module: offset cascade (offset tag -> GPS zone -> server
  zone) as a pure, testable function; bundled offline TZF geometry for
  GPS -> IANA resolution; DST-correct offsets for the capture date;
  display-zone helpers (DEFAULT_DISPLAY_TZ, display_zone_for,
  display_zone_offset_minutes) for the read-side cutover.
- EXIF branch: kamadak-exif now collects OffsetTime* tags paired with
  the winning date tag; resolve_date_taken takes an ExifPrior and
  emits (date_utc, camera_offset_minutes, date_taken_tz_source) per
  branch: exif -> cascade, exiftool/fs_time -> utc_direct, filename
  -> unclassified (pending #116).
- Schema: migration adds image_exif.date_utc /
  camera_offset_minutes / date_taken_tz_source and
  user_ai_prefs.display_tz (all nullable; down.sql drops them).
- DAO + ingest: backfill_date_taken persists the triple alongside the
  legacy pair; watcher, upload, and set_image_gps call sites feed the
  cascade.
- Response surface: ExifMetadata and ExifSummary carry the triple so
  /image/metadata and /photos/exif expose it (null for unconverted
  rows); reel requests mirror display_tz into prefs.

cargo build + clippy clean; 625 lib + 702 bin tests pass (the one
failing doctest in utils.rs is pre-existing at the base commit).
This commit is contained in:
Cameron Cordes committed 2026-09-24 21:58:46 -04:00
1 parent ee773a34ef
commit 2f2107d4fa
20 files changed
+1039 -36

No files matched your search

+99
View File
@@ -292,6 +292,15 @@ pub struct ExifMetadata {
/// `"exif" | "exiftool" | "filename" | "fs_time" | "manual"`. NULL when
/// `date_taken` itself is NULL.
pub date_taken_source: Option<String>,
/// True UTC instant under the new date contract (ADR-0001). NULL while
/// the row is still on the legacy convention (drain pending, or the
/// unclassified filename branch).
pub date_utc: Option<i64>,
/// Camera offset (local − UTC, minutes) that produced `date_utc`.
pub camera_offset_minutes: Option<i32>,
/// Why the offset is what it is: `offset_tag` | `gps` |
/// `assumed_server` | `utc_direct` | `manual`.
pub date_taken_tz_source: Option<String>,
/// When `date_taken_source = "manual"`, the prior `date_taken` snapshot.
/// Used by the UI's revert affordance and to label "manually overridden;
/// originally X" in the details modal.
@@ -383,6 +392,9 @@ impl From<ImageExif> for ExifMetadata {
},
date_taken: exif.date_taken,
date_taken_source: exif.date_taken_source,
date_utc: exif.date_utc,
camera_offset_minutes: exif.camera_offset_minutes,
date_taken_tz_source: exif.date_taken_tz_source,
original_date_taken: exif.original_date_taken,
original_date_taken_source: exif.original_date_taken_source,
}
@@ -428,6 +440,13 @@ pub struct ExifSummary {
pub gps_latitude: Option<f64>,
pub gps_longitude: Option<f64>,
pub date_taken: Option<i64>,
/// True UTC instant under the new date contract (ADR-0001). NULL while
/// the row is still on the legacy convention.
pub date_utc: Option<i64>,
/// Camera offset (local − UTC, minutes) that produced `date_utc`.
pub camera_offset_minutes: Option<i32>,
/// How the offset was determined. See `camera_tz::TzSource`.
pub date_taken_tz_source: Option<String>,
}
#[derive(Debug, Serialize)]
@@ -564,3 +583,83 @@ mod tests {
assert!(previews[1].get("preview_url").is_none());
}
}
#[cfg(test)]
mod date_contract_tests {
use super::*;
fn sample_row() -> crate::database::models::ImageExif {
crate::database::models::ImageExif {
id: 1,
library_id: 1,
file_path: "2024/vacation/IMG_0001.jpg".to_string(),
camera_make: None,
camera_model: Some("NIKON Z 8".to_string()),
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,
// Wall 2024-07-04 12:00:00 stored legacy-style (as-UTC).
date_taken: Some(1_720_094_400),
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(1_720_108_800),
camera_offset_minutes: Some(-240),
date_taken_tz_source: Some("offset_tag".to_string()),
}
}
/// /image/metadata contract: the new-contract date fields ride along on
/// the EXIF metadata payload alongside the legacy `date_taken`.
#[test]
fn exif_metadata_response_includes_date_contract_fields() {
let meta = ExifMetadata::from(sample_row());
assert_eq!(meta.date_taken, Some(1_720_094_400));
assert_eq!(meta.date_utc, Some(1_720_108_800));
assert_eq!(meta.camera_offset_minutes, Some(-240));
assert_eq!(meta.date_taken_tz_source.as_deref(), Some("offset_tag"));
}
/// /photos/exif contract: Apollo's per-photo summary carries the same
/// fields so the viewer flip (spec #113) can read one shape.
#[test]
fn exif_summary_shape_includes_date_contract_fields() {
let summary = ExifSummary {
file_path: "2024/vacation/IMG_0001.jpg".to_string(),
library_id: 1,
library_name: Some("main".to_string()),
camera_model: Some("NIKON Z 8".to_string()),
width: None,
height: None,
gps_latitude: None,
gps_longitude: None,
date_taken: Some(1_720_094_400),
date_utc: Some(1_720_108_800),
camera_offset_minutes: Some(-240),
date_taken_tz_source: Some("offset_tag".to_string()),
};
// Verify the JSON wire names clients (Apollo, FVR) will consume.
let json = serde_json::to_value(&summary).unwrap();
assert_eq!(json["date_utc"], 1_720_108_800);
assert_eq!(json["camera_offset_minutes"], -240);
assert_eq!(json["date_taken_tz_source"], "offset_tag");
}
}