From bfc3c1c21a3cbe9c031aac3c4900c2f0dd836f52 Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Fri, 16 Oct 2026 23:10:27 -0400 Subject: [PATCH] feat(dates): calendar-day date filters in the owner's display zone (#152) /photos, /photos/exif and unified search take date_from_day / date_to_day (YYYY-MM-DD): the server turns them into the first day's start and the last day's end in the owner's display zone, the same days /memories groups by. Each replaces its instant counterpart. Clients stop computing midnights in the device's zone, which drifted by the travel offset whenever the phone's zone differed from display_tz. e2e check: every fixture is included by its display-zone day and excluded by the days either side. Co-Authored-By: Claude Opus 5.5 --- dev/e2e/check_wall_clock.py | 21 +++++++++- src/data/mod.rs | 10 ++++- src/date_view.rs | 76 ++++++++++++++++++++++++++++++++++++- src/files.rs | 19 +++++++++- src/unified_search.rs | 12 ++++++ 5 files changed, 133 insertions(+), 5 deletions(-) diff --git a/dev/e2e/check_wall_clock.py b/dev/e2e/check_wall_clock.py index e8eb944..a3e76a6 100755 --- a/dev/e2e/check_wall_clock.py +++ b/dev/e2e/check_wall_clock.py @@ -7,7 +7,10 @@ For every indexed fixture, the manifest's expected wall clock must equal the `/photos/exif`; and `/image/metadata` / `/photos/exif`'s `true_instant` must equal the expected instant. The `/photos/exif` date filter must also treat that instant as a range edge (#152): `[t, t]` includes the file, and a range -ending at `t - 1` or starting at `t + 1` excludes it. One disagreement +ending at `t - 1` or starting at `t + 1` excludes it; and filtering by the +file's calendar day in the owner's display zone (`date_from_day` / +`date_to_day`) includes it, while the day before or after excludes it. One +disagreement anywhere is exactly the class of bug these fields exist to end, so any mismatch fails the run. @@ -19,9 +22,13 @@ import os import subprocess import sys import urllib.parse +from datetime import datetime, timedelta, timezone from pathlib import Path +from zoneinfo import ZoneInfo E2E = Path(__file__).with_name("e2e.sh") +# The owner's display zone in the e2e env (no prefs row → the app default). +DISPLAY_ZONE = ZoneInfo("America/New_York") def api(path: str) -> dict: @@ -44,6 +51,10 @@ def main() -> int: rows = api(f"photos/exif?date_from={lo}&date_to={hi}")["photos"] return any(r["file_path"] == path for r in rows) + def in_exif_days(path: str, first: str, last: str) -> bool: + rows = api(f"photos/exif?date_from_day={first}&date_to_day={last}")["photos"] + return any(r["file_path"] == path for r in rows) + failures = 0 for item in items: path, want = item["path"], item["wall_clock"] @@ -70,6 +81,14 @@ def main() -> int: bad["photos/exif [.., t-1]"] = "included" if in_exif_range(path, t + 1, t + 86_400): bad["photos/exif [t+1, ..]"] = "included" + # Calendar days are the owner's display-zone days (#152). + day = datetime.fromtimestamp(t, timezone.utc).astimezone(DISPLAY_ZONE).date() + iso = lambda d: d.isoformat() + if not in_exif_days(path, iso(day), iso(day)): + bad[f"photos/exif day {day}"] = "excluded" + for other in (day - timedelta(days=1), day + timedelta(days=1)): + if in_exif_days(path, iso(other), iso(other)): + bad[f"photos/exif day {other}"] = "included" if bad: failures += 1 print(f"MISMATCH {path}: expected {want}, got {bad}") diff --git a/src/data/mod.rs b/src/data/mod.rs index 1f79a1c..a9cc04d 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -153,9 +153,13 @@ pub struct FilesRequest { // Date range filtering: inclusive true instants (unix seconds), compared // against each row's true instant (ADR-0001, #152). For calendar days, - // send the day's bounds in the owner's display zone. + // send `date_from_day` / `date_to_day` (`YYYY-MM-DD`) instead: the server + // takes their bounds in the owner's display zone, and each replaces its + // instant counterpart. pub date_from: Option, pub date_to: Option, + pub date_from_day: Option, + pub date_to_day: Option, // Media type filtering pub media_type: Option, @@ -538,6 +542,10 @@ pub struct ExifBatchRequest { pub date_from: Option, /// Upper bound (inclusive). Same semantics as `date_to` on `/photos`. pub date_to: Option, + /// Calendar-day bounds (`YYYY-MM-DD`) in the owner's display zone; each + /// replaces its instant counterpart. Same as on `/photos`. + pub date_from_day: Option, + pub date_to_day: Option, /// Restrict results to a single library by id. Omit (or "" / "all") for /// union mode — the default. Filtered post-query in the handler so the /// existing `query_by_exif` DAO trait stays untouched. diff --git a/src/date_view.rs b/src/date_view.rs index b49f75b..f4b58a4 100644 --- a/src/date_view.rs +++ b/src/date_view.rs @@ -121,13 +121,39 @@ pub fn day_start_instant(day: NaiveDate, zone: Tz) -> i64 { /// exact call in Rust without a row being dropped first. pub const LEGACY_INSTANT_BAND_SECS: i64 = 15 * 3600; +/// Fold calendar-day filter params (`date_from_day` / `date_to_day`, +/// `YYYY-MM-DD`) into the instant bounds: the first day's start and the +/// last day's end in the owner's display zone, the same days `/memories` +/// groups by (#152). A day param replaces its instant counterpart; an +/// unparseable one is ignored. `zone` is only called when a day is given, +/// so requests without them never read prefs. +pub fn apply_day_bounds( + from: &mut Option, + to: &mut Option, + from_day: Option<&str>, + to_day: Option<&str>, + zone: impl FnOnce() -> Tz, +) { + if from_day.is_none() && to_day.is_none() { + return; + } + let zone = zone(); + let parse = |d: &str| NaiveDate::parse_from_str(d.trim(), "%Y-%m-%d").ok(); + if let Some(day) = from_day.and_then(parse) { + *from = Some(day_start_instant(day, zone)); + } + if let Some(next) = to_day.and_then(parse).and_then(|d| d.succ_opt()) { + *to = Some(day_start_instant(next, zone) - 1); + } +} + /// An inclusive true-instant range (`date_from` / `date_to` on the photo /// listing endpoints), plus the zones [`row_true_instant`] needs to read /// unconverted rows. Either bound may be open. /// /// Bounds are instants, never wall clocks: a client filtering by calendar -/// day sends that day's midnights in the owner's display zone (see -/// `nl_query::iso_to_unix`), so the range agrees with +/// day sends `date_from_day` / `date_to_day` and the handler turns them into +/// display-zone day bounds ([`apply_day_bounds`]), so the range agrees with /// [`row_display_day`] grouping by construction. #[derive(Clone, Copy, Debug)] pub struct InstantRange { @@ -698,6 +724,52 @@ mod tests { ); } + #[test] + fn day_bounds_are_the_display_zone_day() { + // July 5 is a different stretch of instants for a NY and a Tokyo + // owner; the device's zone plays no part (#152). + let (mut from, mut to) = (None, None); + apply_day_bounds( + &mut from, + &mut to, + Some("2024-07-05"), + Some("2024-07-05"), + ny, + ); + assert_eq!((from, to), (Some(1_720_152_000), Some(1_720_238_399))); + let (mut from, mut to) = (None, None); + apply_day_bounds( + &mut from, + &mut to, + Some("2024-07-05"), + Some("2024-07-05"), + || Tz::Asia__Tokyo, + ); + assert_eq!((from, to), (Some(1_720_105_200), Some(1_720_191_599))); + // A 23-hour DST day still ends at the next midnight. + let (mut from, mut to) = (None, None); + apply_day_bounds( + &mut from, + &mut to, + Some("2024-03-10"), + Some("2024-03-10"), + ny, + ); + assert_eq!((from, to), (Some(1_710_046_800), Some(1_710_129_599))); + } + + #[test] + fn day_bounds_replace_their_side_only_and_skip_bad_input() { + let (mut from, mut to) = (Some(1), Some(2)); + apply_day_bounds(&mut from, &mut to, None, Some("2024-07-05"), ny); + assert_eq!((from, to), (Some(1), Some(1_720_238_399))); + let (mut from, mut to) = (Some(1), Some(2)); + apply_day_bounds(&mut from, &mut to, Some("July 5th"), None, ny); + assert_eq!((from, to), (Some(1), Some(2))); + // No day params: the zone (a prefs read) is never asked for. + apply_day_bounds(&mut from, &mut to, None, None, || panic!("zone read")); + } + #[test] fn display_day_respects_a_non_new_york_display_zone() { // Owner in Tokyo: 2024-07-05 01:30 UTC = 10:30 JST July 5 (not diff --git a/src/files.rs b/src/files.rs index 3be4815..7d04779 100644 --- a/src/files.rs +++ b/src/files.rs @@ -199,6 +199,14 @@ pub async fn list_photos( tag_dao: Data>, exif_dao: Data>>, ) -> HttpResponse { + let mut req = req.into_inner(); + crate::date_view::apply_day_bounds( + &mut req.date_from, + &mut req.date_to, + req.date_from_day.as_deref(), + req.date_to_day.as_deref(), + || crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao), + ); let search_path = &req.path; let span = tracing::Span::current(); @@ -1176,7 +1184,8 @@ pub async fn get_gps_summary( /// Handler for the batch EXIF endpoint at `GET /photos/exif`. /// /// Returns a single-row projection of `image_exif` for every photo whose -/// `date_taken` falls in `[date_from, date_to]`, across all libraries. +/// true instant falls in `[date_from, date_to]` (or the display-zone +/// `date_from_day` / `date_to_day`), across all libraries. /// Designed to replace the N+1 pattern of `/photos` + per-file /// `/image/metadata` for window-scoped consumers like Apollo's photo-to- /// track matcher: one DB query, one HTTP round-trip, one mutex acquisition. @@ -1194,6 +1203,14 @@ pub async fn list_exif_summary( app_state: Data, ) -> Result { let span = tracing::Span::current(); + let mut req = req.into_inner(); + crate::date_view::apply_day_bounds( + &mut req.date_from, + &mut req.date_to, + req.date_from_day.as_deref(), + req.date_to_day.as_deref(), + || crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao), + ); span.set_attribute( "date_from", diff --git a/src/unified_search.rs b/src/unified_search.rs index fa67800..aab0936 100644 --- a/src/unified_search.rs +++ b/src/unified_search.rs @@ -77,6 +77,10 @@ pub struct UnifiedQuery { pub media_type: Option, pub date_from: Option, pub date_to: Option, + /// Calendar days (`YYYY-MM-DD`) in the owner's display zone; each + /// replaces its instant counterpart (see `date_view::apply_day_bounds`). + pub date_from_day: Option, + pub date_to_day: Option, pub camera_make: Option, pub camera_model: Option, pub gps_lat: Option, @@ -292,6 +296,14 @@ pub async fn unified_search( tag_dao: Data>, query: Query, ) -> HttpResponse { + let mut query = query.into_inner(); + crate::date_view::apply_day_bounds( + &mut query.date_from, + &mut query.date_to, + query.date_from_day.as_deref(), + query.date_to_day.as_deref(), + || crate::date_view::owner_display_zone(&state.user_ai_prefs_dao), + ); let nl = query.q.trim().to_string(); if nl.is_empty() { return bad_request("query parameter `q` is required");