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

+265 -17
View File
@@ -369,6 +369,12 @@ pub trait ExifDao: Sync + Send {
/// `(library_id, date_taken)` index added in the multi_library
/// migration depends on `library_id` being part of the WHERE clause —
/// callers that have a library context must pass it.
///
/// `date_range` filters on the **true instant** (ADR-0001, ticket
/// #152): converted rows on `date_utc`, unconverted rows (drain pending
/// or the permanent floor) through `date_view::row_true_instant`'s
/// per-source fallback. Every row returned satisfies
/// [`InstantRange::contains`](crate::date_view::InstantRange::contains).
fn query_by_exif(
&mut self,
library_id: Option<i32>,
@@ -376,8 +382,7 @@ pub trait ExifDao: Sync + Send {
camera_model: Option<&str>,
lens_model: Option<&str>,
gps_bounds: Option<(f64, f64, f64, f64)>, // (min_lat, max_lat, min_lon, max_lon)
date_from: Option<i64>,
date_to: Option<i64>,
date_range: Option<crate::date_view::InstantRange>,
) -> Result<Vec<ImageExif>, DbError>;
/// Get distinct camera makes with counts
@@ -1052,8 +1057,7 @@ impl ExifDao for SqliteExifDao {
camera_model_filter: Option<&str>,
lens_model_filter: Option<&str>,
gps_bounds: Option<(f64, f64, f64, f64)>,
date_from: Option<i64>,
date_to: Option<i64>,
date_range: Option<crate::date_view::InstantRange>,
) -> Result<Vec<ImageExif>, DbError> {
trace_db_call("query", "query_by_exif", |_span| {
use schema::image_exif::dsl::*;
@@ -1087,20 +1091,34 @@ impl ExifDao for SqliteExifDao {
.filter(gps_longitude.is_not_null());
}
// Date range
if let Some(from) = date_from {
query = query.filter(date_taken.ge(from));
}
if let Some(to) = date_to {
query = query.filter(date_taken.le(to));
}
if date_from.is_some() || date_to.is_some() {
query = query.filter(date_taken.is_not_null());
// Date range, on the true instant (ADR-0001). SQL is a coarse
// pre-filter: converted rows are tested on `date_utc` exactly;
// unconverted rows on legacy `date_taken` widened by the
// per-source band. Converted rows are deliberately NOT bounded
// by `date_taken` — a re-dated row's legacy value can sit years
// from its instant (filename class outranking EXIF).
if let Some(range) = date_range {
let band = crate::date_view::LEGACY_INSTANT_BAND_SECS;
let lo = range.from.unwrap_or(i64::MIN);
let hi = range.to.unwrap_or(i64::MAX);
query = query.filter(date_taken.is_not_null()).filter(
date_utc.ge(lo).and(date_utc.le(hi)).or(date_utc
.is_null()
.and(date_taken.ge(lo.saturating_sub(band)))
.and(date_taken.le(hi.saturating_add(band)))),
);
}
query
let rows = query
.load::<ImageExif>(connection.deref_mut())
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
.map_err(|e| anyhow::anyhow!("Query error: {}", e))?;
// Exact pass: the band admits unconverted rows up to 15 h
// outside the range; `row_true_instant` decides.
Ok(match date_range {
Some(range) => rows.into_iter().filter(|r| range.contains(r)).collect(),
None => rows,
})
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
@@ -2553,19 +2571,249 @@ mod exif_dao_tests {
// Union: both rows.
let all = dao
.query_by_exif(None, None, None, None, None, None, None)
.query_by_exif(None, None, None, None, None, None)
.unwrap();
assert_eq!(all.len(), 2);
// Scoped to lib 2: only archive row.
let lib2 = dao
.query_by_exif(Some(2), None, None, None, None, None, None)
.query_by_exif(Some(2), None, None, None, None, None)
.unwrap();
assert_eq!(lib2.len(), 1);
assert_eq!(lib2[0].file_path, "archive/a.jpg");
assert_eq!(lib2[0].library_id, 2);
}
/// Row with the full date triple, for the true-instant filter tests.
#[allow(clippy::too_many_arguments)]
fn insert_dated_row(
dao: &mut SqliteExifDao,
rel: &str,
legacy: i64,
source: &str,
date_utc: Option<i64>,
offset: Option<i32>,
tz_source: Option<&str>,
) {
dao.store_exif(InsertImageExif {
library_id: 1,
file_path: rel.to_string(),
camera_make: None,
camera_model: None,
lens_model: None,
width: None,
height: None,
orientation: None,
gps_latitude: None,
gps_longitude: None,
gps_altitude: None,
focal_length: None,
aperture: None,
shutter_speed: None,
iso: None,
date_taken: Some(legacy),
created_time: 0,
last_modified: 0,
content_hash: None,
size_bytes: None,
phash_64: None,
dhash_64: None,
date_taken_source: Some(source.to_string()),
date_utc,
camera_offset_minutes: offset,
date_taken_tz_source: tz_source.map(str::to_string),
})
.expect("insert dated exif row");
}
/// 2024-07-05 in America/New_York, as true instants.
const JUL5_NY_START: i64 = 1_720_152_000;
const JUL5_NY_END: i64 = 1_720_238_399;
fn jul5_ny(from: Option<i64>, to: Option<i64>) -> Option<crate::date_view::InstantRange> {
crate::date_view::InstantRange::new(
from,
to,
chrono_tz::America::New_York,
Some(chrono_tz::America::New_York),
)
}
fn paths(rows: Vec<ImageExif>) -> Vec<String> {
let mut v: Vec<String> = rows.into_iter().map(|r| r.file_path).collect();
v.sort();
v
}
/// #152: converted rows filter on `date_utc`, never on the legacy
/// column — not even as an outer bound, since a re-dated row's legacy
/// value can be years away from its instant.
#[test]
fn query_by_exif_date_range_uses_date_utc_for_converted_rows() {
let mut dao = setup_two_libraries();
// Re-dated: legacy 2014, true instant 2024-07-05 12:00 EDT.
insert_dated_row(
&mut dao,
"redated.jpg",
1_415_409_780,
"filename",
Some(1_720_195_200),
Some(-240),
Some("utc_direct"),
);
// Legacy inside the window, true instant (Jul 4 22:00 EDT) outside.
insert_dated_row(
&mut dao,
"legacy_inside.jpg",
1_720_195_200,
"exif",
Some(1_720_144_800),
Some(-240),
Some("assumed_server"),
);
let rows = dao
.query_by_exif(
None,
None,
None,
None,
None,
jul5_ny(Some(JUL5_NY_START), Some(JUL5_NY_END)),
)
.unwrap();
assert_eq!(paths(rows), vec!["redated.jpg"]);
}
/// Unconverted rows (drain pending / permanent floor) are decided by
/// `row_true_instant`: the SQL band admits them, the exact pass rules.
#[test]
fn query_by_exif_date_range_reads_unconverted_rows_by_true_instant() {
let mut dao = setup_two_libraries();
// exif legacy = wall-as-UTC. Jul 4 22:00 EDT wall: its legacy value
// sits inside the ±15 h band but the instant is the day before.
insert_dated_row(
&mut dao,
"jul4_late.jpg",
1_720_130_400,
"exif",
None,
None,
None,
);
// Jul 5 01:00 EDT wall: legacy (01:00 "UTC") is BEFORE the window's
// 04:00Z start — the legacy-column filter used to drop it.
insert_dated_row(
&mut dao,
"jul5_early.jpg",
1_720_141_200,
"exif",
None,
None,
None,
);
let rows = dao
.query_by_exif(
None,
None,
None,
None,
None,
jul5_ny(Some(JUL5_NY_START), Some(JUL5_NY_END)),
)
.unwrap();
assert_eq!(paths(rows), vec!["jul5_early.jpg"]);
}
/// #152 acceptance: a travel-zone photo is in or out of a range
/// exactly as its owner-day grouping says. Tokyo 23:30 on Jul 5 is
/// 10:30 EDT on Jul 5 — the owner's Jul 5 — while its wall-as-UTC
/// legacy value (Jul 5 23:30 "UTC") would mean something else.
#[test]
fn query_by_exif_date_range_follows_owner_day_for_travel_photo() {
let mut dao = setup_two_libraries();
insert_dated_row(
&mut dao,
"tokyo.jpg",
1_720_222_200,
"exif",
Some(1_720_189_800),
Some(540),
Some("gps"),
);
let in_day = dao
.query_by_exif(
None,
None,
None,
None,
None,
jul5_ny(Some(JUL5_NY_START), Some(JUL5_NY_END)),
)
.unwrap();
assert_eq!(paths(in_day), vec!["tokyo.jpg"]);
// Its legacy value falls in the owner's evening; a 12:00–23:59 EDT
// range must still exclude it (the instant is 10:30).
let afternoon = dao
.query_by_exif(
None,
None,
None,
None,
None,
jul5_ny(Some(1_720_195_200), Some(JUL5_NY_END)),
)
.unwrap();
assert!(afternoon.is_empty());
}
/// Open-ended bounds work, and no range means no date filter at all —
/// rows with no date at all stay eligible (unchanged behavior).
#[test]
fn query_by_exif_date_range_open_bounds_and_absent_range() {
let mut dao = setup_two_libraries();
insert_dated_row(
&mut dao,
"a.jpg",
0,
"fs_time",
Some(1_720_195_200),
Some(-240),
Some("utc_direct"),
);
insert_row(&mut dao, 1, "undated.jpg", None);
let from_only = dao
.query_by_exif(
None,
None,
None,
None,
None,
jul5_ny(Some(JUL5_NY_START), None),
)
.unwrap();
assert_eq!(paths(from_only), vec!["a.jpg"]);
let to_only = dao
.query_by_exif(
None,
None,
None,
None,
None,
jul5_ny(None, Some(JUL5_NY_START)),
)
.unwrap();
assert!(to_only.is_empty());
assert!(jul5_ny(None, None).is_none());
let all = dao
.query_by_exif(None, None, None, None, None, None)
.unwrap();
assert_eq!(paths(all), vec!["a.jpg", "undated.jpg"]);
}
#[test]
fn get_exif_batch_scopes_by_library_id() {
let mut dao = setup_two_libraries();