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 <noreply@anthropic.com>
This commit is contained in:
1 parent
a11d20bdcd
commit
bfc3c1c21a
5 files changed
+133
-5
No files matched your search
+9
-1
@@ -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<i64>,
|
||||
pub date_to: Option<i64>,
|
||||
pub date_from_day: Option<String>,
|
||||
pub date_to_day: Option<String>,
|
||||
|
||||
// Media type filtering
|
||||
pub media_type: Option<MediaType>,
|
||||
@@ -538,6 +542,10 @@ pub struct ExifBatchRequest {
|
||||
pub date_from: Option<i64>,
|
||||
/// Upper bound (inclusive). Same semantics as `date_to` on `/photos`.
|
||||
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
|
||||
/// union mode — the default. Filtered post-query in the handler so the
|
||||
/// existing `query_by_exif` DAO trait stays untouched.
|
||||
|
||||
+74
-2
@@ -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<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
|
||||
/// 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
|
||||
|
||||
+18
-1
@@ -199,6 +199,14 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
||||
tag_dao: Data<Mutex<TagD>>,
|
||||
exif_dao: Data<Mutex<Box<dyn ExifDao>>>,
|
||||
) -> 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<AppState>,
|
||||
) -> Result<HttpResponse, actix_web::Error> {
|
||||
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",
|
||||
|
||||
@@ -77,6 +77,10 @@ pub struct UnifiedQuery {
|
||||
pub media_type: Option<MediaType>,
|
||||
pub date_from: 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_model: Option<String>,
|
||||
pub gps_lat: Option<f64>,
|
||||
@@ -292,6 +296,14 @@ pub async fn unified_search<TagD: TagDao>(
|
||||
tag_dao: Data<Mutex<TagD>>,
|
||||
query: Query<UnifiedQuery>,
|
||||
) -> 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");
|
||||
|
||||
Reference in new issue
Block a user