Dates: true_instant on metadata responses for instant matching

The mobile viewer anchored its "messages around this photo" window on
the legacy date_taken (a wall clock stored as UTC for EXIF rows), while
SMS-API dates are true instants: the window was off by the photo's UTC
offset (4-5 h for photos taken at home; ImageApi #113 user story 3).
Verified SMS-API side: importers store backup epochs as UTC, and 40/40
sampled rows equal their source epochs exactly.

`/image/metadata` (+ GPS / date mutation responses) now carries
`true_instant` from `date_view::file_true_instant`: `row_true_instant`,
else the filename-class instant, else the earliest fs time. Server-side
for the same reason as `wall_clock`: the drain's permanent floor rows
have no `date_utc`. `set_capture_dates` fills both fields at all four
response sites.

dev/e2e: the manifest records each fixture's expected instant (the wall
read in its GPS zone, else America/New_York) and `e2e.sh check` asserts
it (97/97).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-09-29 17:55:29 -04:00
1 parent bc41c737e5
commit 9269b5c9fd
5 files changed
+117 -18

No files matched your search

+6 -1
View File
@@ -263,6 +263,10 @@ pub struct MetadataResponse {
/// (`2018-09-29T14:00:00`) — `date_view::file_wall_clock`. Render it
/// verbatim; never zone-convert it (ADR-0001).
pub wall_clock: Option<String>,
/// True instant of capture (unix seconds) — `date_view::file_true_instant`.
/// For instant-to-instant matching (SMS / location windows); never
/// render it, render `wall_clock`.
pub true_instant: Option<i64>,
}
impl From<fs::Metadata> for MetadataResponse {
@@ -281,7 +285,8 @@ impl From<fs::Metadata> for MetadataResponse {
filename_date: None, // Will be set in endpoint handler
library_id: None,
library_name: None,
wall_clock: None, // Set in the endpoint handler
wall_clock: None, // Set in the endpoint handler
true_instant: None, // Set in the endpoint handler
}
}
}
+59
View File
@@ -242,6 +242,26 @@ pub fn file_wall_clock(
wall.map(format_wall_clock)
}
/// True instant for a file that may have no `image_exif` row yet — the
/// [`file_wall_clock`] precedence (row, then filename-class date, then
/// earliest fs time). The anchor for instant-to-instant matching (the
/// "messages around this photo" window), never for display.
pub fn file_true_instant(
row: Option<&ImageExif>,
rel_path: &str,
fs_earliest: Option<i64>,
display_zone: Tz,
server_zone: Option<Tz>,
) -> Option<i64> {
if let Some(instant) = row.and_then(|r| row_true_instant(r, display_zone, server_zone)) {
return Some(instant);
}
match crate::memories::extract_date_from_filename(rel_path) {
Some(fd) => filename_true_instant(&fd, server_zone),
None => fs_earliest,
}
}
/// Render a unix instant as a naive wall in `zone`.
fn render_in_zone_opt(instant: i64, zone: Tz) -> Option<NaiveDateTime> {
zone.timestamp_opt(instant, 0)
@@ -720,4 +740,43 @@ mod tests {
None
);
}
#[test]
fn file_true_instant_uses_the_row_then_filename_then_fs_time() {
// Row: the converted instant, not the wall (Tokyo 14:00 = 05:00Z).
let r = row(
"otd.jpg",
Some(utc_epoch(2018, 9, 29, 14, 0)),
Some("exif"),
Some(utc_epoch(2018, 9, 29, 5, 0)),
Some(540),
);
assert_eq!(
file_true_instant(Some(&r), "otd.jpg", Some(0), ny(), Some(ny())),
Some(utc_epoch(2018, 9, 29, 5, 0))
);
// No row: a wall-pattern filename is read in the server zone
// (10:15 EDT = 14:15Z).
assert_eq!(
file_true_instant(
None,
"Screenshots/Screenshot_20220315-101500.png",
Some(0),
ny(),
Some(ny())
),
Some(utc_epoch(2022, 3, 15, 14, 15))
);
// No row, no filename date: fs time is already an instant.
assert_eq!(
file_true_instant(
None,
"Undated/scan.jpg",
Some(1_425_229_200),
ny(),
Some(ny())
),
Some(1_425_229_200)
);
}
}
+24 -13
View File
@@ -528,12 +528,11 @@ pub async fn get_file_metadata(
.lock()
.ok()
.and_then(|mut dao| dao.get_exif(&path.path).ok().flatten());
response.wall_clock = crate::date_view::file_wall_clock(
set_capture_dates(
&mut response,
row.as_ref(),
&path.path,
earliest_fs_time(&response),
crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao),
crate::camera_tz::server_zone(),
);
response.exif = row.map(Into::into);
@@ -714,14 +713,14 @@ pub async fn set_image_gps(
library_id: None,
library_name: None,
wall_clock: None,
true_instant: None,
},
};
response.wall_clock = crate::date_view::file_wall_clock(
set_capture_dates(
&mut response,
Some(&row),
&normalized_path,
earliest_fs_time(&response),
crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao),
crate::camera_tz::server_zone(),
);
response.exif = Some(row.into());
response.library_id = Some(resolved_library.id);
@@ -881,15 +880,10 @@ fn build_metadata_response_for_date_mutation(
library_id: None,
library_name: None,
wall_clock: None,
true_instant: None,
},
};
response.wall_clock = crate::date_view::file_wall_clock(
Some(&exif),
rel_path,
earliest_fs_time(&response),
display_zone,
crate::camera_tz::server_zone(),
);
set_capture_dates(&mut response, Some(&exif), rel_path, display_zone);
response.exif = Some(exif.into());
response.library_id = Some(library.id);
response.library_name = Some(library.name.clone());
@@ -898,6 +892,23 @@ fn build_metadata_response_for_date_mutation(
response
}
/// Fill the capture-date fields every metadata response carries (ADR-0001):
/// `wall_clock` to display and `true_instant` to match against. Needs the
/// fs times already on the response (the unindexed-file fallback).
fn set_capture_dates(
response: &mut MetadataResponse,
row: Option<&ImageExif>,
rel_path: &str,
display_zone: chrono_tz::Tz,
) {
let server_zone = crate::camera_tz::server_zone();
let fs_earliest = earliest_fs_time(response);
response.wall_clock =
crate::date_view::file_wall_clock(row, rel_path, fs_earliest, display_zone, server_zone);
response.true_instant =
crate::date_view::file_true_instant(row, rel_path, fs_earliest, display_zone, server_zone);
}
/// Earlier of the file's created / modified times — the ingest waterfall's
/// `fs_time` rule — for the wall-clock fallback of unindexed files.
fn earliest_fs_time(response: &MetadataResponse) -> Option<i64> {