feat(dates): photo date filters on the true instant (#152)

date_from / date_to on /photos, /photos/exif and unified search ran on
the legacy mixed-convention date_taken, so a photo could display inside
a range yet be filtered out of it by up to its UTC offset (or by years,
for rows the filename class re-dated).

The filter lives in the shared ExifDao::query_by_exif, so it's fixed
once there:

- query_by_exif takes Option<date_view::InstantRange> (bounds + display
  and server zones) instead of raw date_from / date_to.
- SQL pre-filter: converted rows on date_utc exactly; unconverted rows
  (drain pending, permanent floor) on date_taken widened by
  LEGACY_INSTANT_BAND_SECS (±15 h, now shared with /memories). Converted
  rows are deliberately not bounded by date_taken: a re-dated row's
  legacy value can sit years from its instant.
- Exact pass in Rust via InstantRange::contains → row_true_instant, so
  every returned row's true instant is inside the range.

Calendar days now mean the owner's days: nl_query::iso_to_unix builds
day bounds in the display zone (it used UTC midnights, which only lined
up with the legacy wall-as-UTC column), and unified search's "today" is
the owner's today rather than UTC's. The midnight-with-DST-gap fallback
moves from memories_anchor into date_view::day_start_instant.

Compatibility: clients already sending real instants (Apollo's
/photos/exif windows) get correct edges with no change. Clients sending
UTC-midnight day bounds see ranges shift by up to the UTC offset until
they send display-zone bounds.

No index on date_utc; the OR clause scans, which is milliseconds at
personal-library scale (same as /memories today).

dev/e2e check gains /photos/exif wall_clock + true_instant assertions and
range-edge checks ([t, t] in, [.., t-1] and [t+1, ..] out). Not run here:
the harness needs the WSL + Windows toolchain.

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:52:48 -04:00
1 parent 9e780774d9
commit 19fbf43d40
12 files changed
+478 -90

No files matched your search

+11 -6
View File
@@ -317,6 +317,14 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
// filter must be scoped to it — otherwise camera/date/GPS hits
// from other libraries would pollute the result set even though
// downstream filesystem walks would never visit those files.
// True-instant date range (ADR-0001, #152). Zones resolved before
// the DAO mutex so the prefs lock isn't nested inside it.
let date_range = crate::date_view::InstantRange::new(
req.date_from,
req.date_to,
crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao),
crate::camera_tz::server_zone(),
);
let mut exif_dao_guard = exif_dao.lock().expect("Unable to get ExifDao");
let exif_results = exif_dao_guard
.query_by_exif(
@@ -325,8 +333,7 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
req.camera_model.as_deref(),
req.lens_model.as_deref(),
gps_bounds,
req.date_from,
req.date_to,
date_range,
)
.unwrap_or_else(|e| {
warn!("EXIF query failed: {:?}", e);
@@ -1228,8 +1235,7 @@ pub async fn list_exif_summary(
None,
None,
None,
req.date_from,
req.date_to,
crate::date_view::InstantRange::new(req.date_from, req.date_to, display_zone, server_zone),
) {
Ok(rows) => {
let photos: Vec<ExifSummary> = rows
@@ -1599,8 +1605,7 @@ mod tests {
_: Option<&str>,
_: Option<&str>,
_: Option<(f64, f64, f64, f64)>,
_: Option<i64>,
_: Option<i64>,
_: Option<crate::date_view::InstantRange>,
) -> Result<Vec<crate::database::models::ImageExif>, DbError> {
Ok(Vec::new())
}