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

+23
View File
@@ -136,6 +136,17 @@ pub struct InsertImageExif {
/// drain re-resolves rows whose source is `"fs_time"` once exiftool
/// has had a chance to run.
pub date_taken_source: Option<String>,
/// True UTC instant (unix seconds) under the new date contract
/// (ADR-0001). NULL while the row is still on the legacy convention —
/// either unclassified (filename branch, ticket #116) or pending the
/// backfill drain (#118).
pub date_utc: Option<i64>,
/// Camera offset (local − UTC, minutes) that produced `date_utc`.
/// NULL for direct-true-UTC branches and unclassified rows.
pub camera_offset_minutes: Option<i32>,
/// Why the offset is what it is: `offset_tag` | `gps` |
/// `assumed_server` | `utc_direct` | `manual`. See `camera_tz::TzSource`.
pub date_taken_tz_source: Option<String>,
}
// Field order matches the post-migration column order in `image_exif`.
@@ -190,6 +201,13 @@ pub struct ImageExif {
/// swap of `APOLLO_CLIP_MODEL` re-eligibilizes rows whose stored
/// version differs so the drain rebuilds them.
pub clip_model_version: Option<String>,
/// True UTC instant (unix seconds) 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(Insertable, Clone)]
@@ -549,6 +567,10 @@ pub struct UserAiPrefs {
pub tz_offset_minutes: Option<i32>,
pub library: Option<String>,
pub updated_at: i64,
/// User's display time zone as an IANA name (DST-proof). NULL = app
/// default (`camera_tz::DEFAULT_DISPLAY_TZ`). The legacy
/// `tz_offset_minutes` column stays frozen for rollback.
pub display_tz: Option<String>,
}
#[derive(Insertable, Debug, Clone, serde::Deserialize, serde::Serialize)]
@@ -558,4 +580,5 @@ pub struct UpsertUserAiPrefs {
pub tz_offset_minutes: Option<i32>,
pub library: Option<String>,
pub updated_at: i64,
pub display_tz: Option<String>,
}