feat(dates): true_instant + wall_clock on /photos/exif (#113)

/photos/exif is Apollo's photo-to-track matching feed, but unlike
/image/metadata and /photos/gps-summary it only carried the raw
new-contract triple. date_utc is NULL for unconverted rows and, by
design, forever for the drain's edited/exported floor (#120), so a
client reading date_utc alone would have to re-implement the
per-source legacy conventions to cover those rows: the re-anchoring
ADR-0001 says consumers delete, not migrate.

ExifSummary now carries:
- true_instant: date_view::row_true_instant (date_utc when converted,
  else the per-source fallback), for instant-to-instant matching.
- wall_clock: date_view::row_wall_clock, rendered verbatim.

The row → wire mapping moves to ExifSummary::from_row so the tests
cover the real projection, including an unconverted row falling back
to the same instant and wall as its converted twin. Additive: legacy
fields are unchanged, so current clients see the same values.

The date_from/date_to filter still runs on the legacy column; that's
#152, which now explicitly covers /photos/exif too.

Unblocks Apollo #37.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-10-10 21:15:41 -04:00
1 parent 991622af2a
commit f1a7fb4ecc
2 files changed
+84 -31

No files matched your search

+76 -15
View File
@@ -479,6 +479,47 @@ pub struct ExifSummary {
pub camera_offset_minutes: Option<i32>,
/// How the offset was determined. See `camera_tz::TzSource`.
pub date_taken_tz_source: Option<String>,
/// True instant of capture (unix seconds) — `date_view::row_true_instant`:
/// `date_utc` when converted, else the legacy value under its per-source
/// convention, so unconverted and permanent-floor rows still match
/// instant-to-instant. For matching (location track, SMS windows); never
/// render it, render `wall_clock`.
pub true_instant: Option<i64>,
/// Capture wall clock to display (naive ISO-8601; see
/// `MetadataResponse::wall_clock`).
pub wall_clock: Option<String>,
}
impl ExifSummary {
/// Project an `image_exif` row onto the `/photos/exif` wire shape.
/// `true_instant` / `wall_clock` go through the same `date_view` reads
/// as `/image/metadata` and `/photos/gps-summary`, so all three agree.
pub fn from_row(
r: ImageExif,
library_name: Option<String>,
display_zone: chrono_tz::Tz,
server_zone: Option<chrono_tz::Tz>,
) -> Self {
ExifSummary {
true_instant: crate::date_view::row_true_instant(&r, display_zone, server_zone),
wall_clock: crate::date_view::row_wall_clock(&r, display_zone, server_zone),
library_name,
file_path: r.file_path,
library_id: r.library_id,
camera_model: r.camera_model,
width: r.width,
height: r.height,
// image_exif stores GPS as f32 to keep row size small; widen
// for the JSON shape so clients don't need to know about the
// on-disk precision.
gps_latitude: r.gps_latitude.map(f64::from),
gps_longitude: r.gps_longitude.map(f64::from),
date_taken: r.date_taken,
date_utc: r.date_utc,
camera_offset_minutes: r.camera_offset_minutes,
date_taken_tz_source: r.date_taken_tz_source,
}
}
}
#[derive(Debug, Serialize)]
@@ -674,27 +715,47 @@ mod date_contract_tests {
}
/// /photos/exif contract: Apollo's per-photo summary carries the same
/// fields so the viewer flip (spec #113) can read one shape.
/// fields so the viewer flip (spec #113) can read one shape, plus the
/// server-computed `true_instant` / `wall_clock` it matches and renders.
#[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()),
};
let summary = ExifSummary::from_row(
sample_row(),
Some("main".to_string()),
chrono_tz::America::New_York,
Some(chrono_tz::America::New_York),
);
// Verify the JSON wire names clients (Apollo, FVR) will consume.
let json = serde_json::to_value(&summary).unwrap();
assert_eq!(json["date_taken"], 1_720_094_400);
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");
assert_eq!(json["true_instant"], 1_720_108_800);
assert_eq!(json["wall_clock"], "2024-07-04T12:00:00");
assert_eq!(json["library_name"], "main");
}
/// A row the drain hasn't converted (or never will — the edited/exported
/// floor) has no `date_utc`, but `/photos/exif` must still hand clients
/// a true instant and wall clock via the per-source fallback, so they
/// never re-derive the legacy convention themselves (ADR-0001).
#[test]
fn exif_summary_unconverted_row_falls_back_to_legacy_convention() {
let mut row = sample_row();
row.date_utc = None;
row.camera_offset_minutes = None;
row.date_taken_tz_source = None;
let summary = ExifSummary::from_row(
row,
None,
chrono_tz::America::New_York,
Some(chrono_tz::America::New_York),
);
assert_eq!(summary.date_utc, None);
// exif legacy = 12:00 wall as-UTC; read in the server zone (EDT)
// that's 16:00 UTC — the same instant the converted row names.
assert_eq!(summary.true_instant, Some(1_720_108_800));
assert_eq!(summary.wall_clock.as_deref(), Some("2024-07-04T12:00:00"));
}
}