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

+8
View File
@@ -99,6 +99,7 @@ impl UserAiPrefsDao for SqliteUserAiPrefsDao {
dsl::voice.eq(&prefs.voice),
dsl::tz_offset_minutes.eq(&prefs.tz_offset_minutes),
dsl::library.eq(&prefs.library),
dsl::display_tz.eq(&prefs.display_tz),
dsl::updated_at.eq(&prefs.updated_at),
))
.execute(connection.deref_mut())
@@ -145,6 +146,7 @@ mod tests {
tz_offset_minutes: Some(-480),
library: Some("1".to_string()),
updated_at: now,
display_tz: Some("America/New_York".to_string()),
};
dao.upsert_prefs(&ctx(), &prefs).unwrap();
@@ -153,6 +155,7 @@ mod tests {
assert_eq!(row.voice, Some("grandma".to_string()));
assert_eq!(row.tz_offset_minutes, Some(-480));
assert_eq!(row.library, Some("1".to_string()));
assert_eq!(row.display_tz, Some("America/New_York".to_string()));
assert_eq!(row.updated_at, now);
}
@@ -167,6 +170,7 @@ mod tests {
tz_offset_minutes: Some(-480),
library: Some("1".to_string()),
updated_at: now1,
display_tz: Some("America/New_York".to_string()),
};
dao.upsert_prefs(&ctx(), &prefs1).unwrap();
@@ -175,6 +179,7 @@ mod tests {
tz_offset_minutes: Some(-300),
library: None,
updated_at: now2,
display_tz: None,
};
dao.upsert_prefs(&ctx(), &prefs2).unwrap();
@@ -182,6 +187,7 @@ mod tests {
assert_eq!(row.voice, Some("dad".to_string()));
assert_eq!(row.tz_offset_minutes, Some(-300));
assert!(row.library.is_none());
assert!(row.display_tz.is_none());
assert_eq!(row.updated_at, now2);
}
@@ -195,6 +201,7 @@ mod tests {
tz_offset_minutes: Some(-480),
library: None,
updated_at: now,
display_tz: Some("Europe/Berlin".to_string()),
};
dao.upsert_prefs(&ctx(), &prefs).unwrap();
@@ -202,5 +209,6 @@ mod tests {
assert_eq!(row.tz_offset_minutes, Some(-480));
assert!(row.voice.is_none());
assert!(row.library.is_none());
assert_eq!(row.display_tz, Some("Europe/Berlin".to_string()));
}
}