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:
1 parent
9e780774d9
commit
19fbf43d40
12 files changed
+478
-90
No files matched your search
@@ -96,6 +96,74 @@ pub fn row_true_instant(row: &ImageExif, display_zone: Tz, server_zone: Option<T
|
||||
}
|
||||
}
|
||||
|
||||
/// The true instant a calendar day starts at in `zone`. Midnight can fall
|
||||
/// in a DST gap in a few zones; fall back to 01:00, then to a UTC reading.
|
||||
pub fn day_start_instant(day: NaiveDate, zone: Tz) -> i64 {
|
||||
[0, 1]
|
||||
.iter()
|
||||
.find_map(|h| {
|
||||
zone.from_local_datetime(&day.and_hms_opt(*h, 0, 0).expect("valid hour"))
|
||||
.earliest()
|
||||
})
|
||||
.map(|dt| dt.timestamp())
|
||||
.unwrap_or_else(|| {
|
||||
day.and_hms_opt(0, 0, 0)
|
||||
.expect("midnight")
|
||||
.and_utc()
|
||||
.timestamp()
|
||||
})
|
||||
}
|
||||
|
||||
/// How far an unconverted row's legacy `date_taken` can sit from its true
|
||||
/// instant under the per-source conventions: wall↔instant confusion ≤ 12 h,
|
||||
/// zone range ≤ 2 h, DST margin 1 h. SQL pre-filters on the legacy column
|
||||
/// widen their bounds by this much so [`row_true_instant`] can make the
|
||||
/// exact call in Rust without a row being dropped first.
|
||||
pub const LEGACY_INSTANT_BAND_SECS: i64 = 15 * 3600;
|
||||
|
||||
/// 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
|
||||
/// [`row_display_day`] grouping by construction.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct InstantRange {
|
||||
pub from: Option<i64>,
|
||||
pub to: Option<i64>,
|
||||
pub display_zone: Tz,
|
||||
pub server_zone: Option<Tz>,
|
||||
}
|
||||
|
||||
impl InstantRange {
|
||||
/// `None` when neither bound is set — no date filter at all, so rows
|
||||
/// with no date stay eligible.
|
||||
pub fn new(
|
||||
from: Option<i64>,
|
||||
to: Option<i64>,
|
||||
display_zone: Tz,
|
||||
server_zone: Option<Tz>,
|
||||
) -> Option<Self> {
|
||||
(from.is_some() || to.is_some()).then_some(InstantRange {
|
||||
from,
|
||||
to,
|
||||
display_zone,
|
||||
server_zone,
|
||||
})
|
||||
}
|
||||
|
||||
/// Exact test: the row's true instant lies inside the range. A row with
|
||||
/// no date at all is never inside a date range.
|
||||
pub fn contains(&self, row: &ImageExif) -> bool {
|
||||
match row_true_instant(row, self.display_zone, self.server_zone) {
|
||||
Some(t) => self.from.is_none_or(|f| t >= f) && self.to.is_none_or(|x| t <= x),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The wall clock the owner sees for this row — camera wall when the row
|
||||
/// carries an offset (a Japan photo shows the Japan wall), display-zone
|
||||
/// rendering when it doesn't. Unconverted rows use their per-source
|
||||
|
||||
Reference in new issue
Block a user