feat(dates): calendar-day date filters in the owner's display zone (#152) #178

Merged
cameron merged 2 commits from fix/calendar-day-date-filters into master 2026-10-17 03:25:05 +00:00
6 changed files with 137 additions and 6 deletions

No files matched your search

+4 -1
View File
@@ -523,7 +523,10 @@ GET /photos?path=...&recursive=true&sort=DateTakenDesc&camera_make=Canon&gps_lat
// date_from / date_to are inclusive TRUE INSTANTS, matched against each row's // date_from / date_to are inclusive TRUE INSTANTS, matched against each row's
// true instant (date_utc, else date_view::row_true_instant's fallback) in // true instant (date_utc, else date_view::row_true_instant's fallback) in
// ExifDao::query_by_exif — shared by /photos, /photos/exif and unified search. // ExifDao::query_by_exif — shared by /photos, /photos/exif and unified search.
// A calendar-day range = that day's bounds in the owner's display zone. // For calendar days send date_from_day / date_to_day (YYYY-MM-DD) instead: the
// handler turns them into the days' bounds in the owner's display zone
// (date_view::apply_day_bounds), each replacing its instant counterpart, so
// clients never compute midnights in the device's zone.
// Natural-language search (LLM translates q into filters + a CLIP term) // Natural-language search (LLM translates q into filters + a CLIP term)
GET /photos/search/unified?q=...&model=...&interpret=true GET /photos/search/unified?q=...&model=...&interpret=true
+20 -1
View File
@@ -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 `/photos/exif`; and `/image/metadata` / `/photos/exif`'s `true_instant` must
equal the expected instant. The `/photos/exif` date filter must also treat 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 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 anywhere is exactly the class of bug these fields exist to end, so any
mismatch fails the run. mismatch fails the run.
@@ -19,9 +22,13 @@ import os
import subprocess import subprocess
import sys import sys
import urllib.parse import urllib.parse
from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo
E2E = Path(__file__).with_name("e2e.sh") 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: def api(path: str) -> dict:
@@ -44,6 +51,10 @@ def main() -> int:
rows = api(f"photos/exif?date_from={lo}&date_to={hi}")["photos"] rows = api(f"photos/exif?date_from={lo}&date_to={hi}")["photos"]
return any(r["file_path"] == path for r in rows) 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 failures = 0
for item in items: for item in items:
path, want = item["path"], item["wall_clock"] path, want = item["path"], item["wall_clock"]
@@ -70,6 +81,14 @@ def main() -> int:
bad["photos/exif [.., t-1]"] = "included" bad["photos/exif [.., t-1]"] = "included"
if in_exif_range(path, t + 1, t + 86_400): if in_exif_range(path, t + 1, t + 86_400):
bad["photos/exif [t+1, ..]"] = "included" 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: if bad:
failures += 1 failures += 1
print(f"MISMATCH {path}: expected {want}, got {bad}") print(f"MISMATCH {path}: expected {want}, got {bad}")
+9 -1
View File
@@ -153,9 +153,13 @@ pub struct FilesRequest {
// Date range filtering: inclusive true instants (unix seconds), compared // Date range filtering: inclusive true instants (unix seconds), compared
// against each row's true instant (ADR-0001, #152). For calendar days, // 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<i64>, pub date_from: Option<i64>,
pub date_to: Option<i64>, pub date_to: Option<i64>,
pub date_from_day: Option<String>,
pub date_to_day: Option<String>,
// Media type filtering // Media type filtering
pub media_type: Option<MediaType>, pub media_type: Option<MediaType>,
@@ -538,6 +542,10 @@ pub struct ExifBatchRequest {
pub date_from: Option<i64>, pub date_from: Option<i64>,
/// Upper bound (inclusive). Same semantics as `date_to` on `/photos`. /// Upper bound (inclusive). Same semantics as `date_to` on `/photos`.
pub date_to: Option<i64>, pub date_to: Option<i64>,
/// 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<String>,
pub date_to_day: Option<String>,
/// Restrict results to a single library by id. Omit (or "" / "all") for /// Restrict results to a single library by id. Omit (or "" / "all") for
/// union mode — the default. Filtered post-query in the handler so the /// union mode — the default. Filtered post-query in the handler so the
/// existing `query_by_exif` DAO trait stays untouched. /// existing `query_by_exif` DAO trait stays untouched.
+74 -2
View File
@@ -121,13 +121,39 @@ pub fn day_start_instant(day: NaiveDate, zone: Tz) -> i64 {
/// exact call in Rust without a row being dropped first. /// exact call in Rust without a row being dropped first.
pub const LEGACY_INSTANT_BAND_SECS: i64 = 15 * 3600; 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<i64>,
to: &mut Option<i64>,
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 /// An inclusive true-instant range (`date_from` / `date_to` on the photo
/// listing endpoints), plus the zones [`row_true_instant`] needs to read /// listing endpoints), plus the zones [`row_true_instant`] needs to read
/// unconverted rows. Either bound may be open. /// unconverted rows. Either bound may be open.
/// ///
/// Bounds are instants, never wall clocks: a client filtering by calendar /// Bounds are instants, never wall clocks: a client filtering by calendar
/// day sends that day's midnights in the owner's display zone (see /// day sends `date_from_day` / `date_to_day` and the handler turns them into
/// `nl_query::iso_to_unix`), so the range agrees with /// display-zone day bounds ([`apply_day_bounds`]), so the range agrees with
/// [`row_display_day`] grouping by construction. /// [`row_display_day`] grouping by construction.
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub struct InstantRange { 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] #[test]
fn display_day_respects_a_non_new_york_display_zone() { 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 // Owner in Tokyo: 2024-07-05 01:30 UTC = 10:30 JST July 5 (not
+18 -1
View File
@@ -199,6 +199,14 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
tag_dao: Data<Mutex<TagD>>, tag_dao: Data<Mutex<TagD>>,
exif_dao: Data<Mutex<Box<dyn ExifDao>>>, exif_dao: Data<Mutex<Box<dyn ExifDao>>>,
) -> HttpResponse { ) -> 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 search_path = &req.path;
let span = tracing::Span::current(); 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`. /// Handler for the batch EXIF endpoint at `GET /photos/exif`.
/// ///
/// Returns a single-row projection of `image_exif` for every photo whose /// 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 /// Designed to replace the N+1 pattern of `/photos` + per-file
/// `/image/metadata` for window-scoped consumers like Apollo's photo-to- /// `/image/metadata` for window-scoped consumers like Apollo's photo-to-
/// track matcher: one DB query, one HTTP round-trip, one mutex acquisition. /// 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<AppState>, app_state: Data<AppState>,
) -> Result<HttpResponse, actix_web::Error> { ) -> Result<HttpResponse, actix_web::Error> {
let span = tracing::Span::current(); 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( span.set_attribute(
"date_from", "date_from",
+12
View File
@@ -77,6 +77,10 @@ pub struct UnifiedQuery {
pub media_type: Option<MediaType>, pub media_type: Option<MediaType>,
pub date_from: Option<i64>, pub date_from: Option<i64>,
pub date_to: Option<i64>, pub date_to: Option<i64>,
/// 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<String>,
pub date_to_day: Option<String>,
pub camera_make: Option<String>, pub camera_make: Option<String>,
pub camera_model: Option<String>, pub camera_model: Option<String>,
pub gps_lat: Option<f64>, pub gps_lat: Option<f64>,
@@ -292,6 +296,14 @@ pub async fn unified_search<TagD: TagDao>(
tag_dao: Data<Mutex<TagD>>, tag_dao: Data<Mutex<TagD>>,
query: Query<UnifiedQuery>, query: Query<UnifiedQuery>,
) -> HttpResponse { ) -> 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(); let nl = query.q.trim().to_string();
if nl.is_empty() { if nl.is_empty() {
return bad_request("query parameter `q` is required"); return bad_request("query parameter `q` is required");