Files
ImageApi/src/date_view.rs
T
Cameron CordesandClaude Opus 5.5 19fbf43d40 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>
2026-10-10 21:52:48 -04:00

851 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Read-side views of the true-instant date contract (ADR-0001).
//!
//! [`crate::date_resolver`] is the ingest-side counterpart: it *writes* the
//! `(date_utc, camera_offset_minutes, date_taken_tz_source)` triple. This
//! module is what *reads* it. For a row — converted or not yet — it answers:
//!
//! 1. **What true instant does the row name?** — [`row_true_instant`].
//! Anchors time-window queries (SMS messages, location history, calendar
//! events) with zero offset arithmetic: the window is centered on the
//! instant, never on a wall clock.
//! 2. **What wall clock does the owner see?** — [`row_display_wall`].
//! The date insight prompts tell the model (camera wall clock when the
//! row carries an offset, display-zone rendering when it doesn't). Must
//! equal the wall the metadata endpoint reports, so prompt and UI agree.
//! 3. **Which owner day does the row land on?** — [`row_display_day`].
//! The grouping rule for memories buckets and reels: a photo taken in
//! Japan at 21:00 JST lands in the *owner's* day, not the camera's.
//!
//! Converted rows read the new triple directly. Unconverted rows (the
//! `backfill_utc_dates` drain has not reached them yet) fall back to the
//! legacy `date_taken` under its per-source convention (ADR-0001):
//!
//! | source | legacy holds |
//! |---|---|
//! | `exif` | camera wall clock, reinterpreted as UTC |
//! | `exiftool` (video) | the container's UTC-rendered naive, rendered in the server zone |
//! | `exiftool` (image) | camera wall clock, rendered in the server zone |
//! | `filename` (wall patterns) | wall clock, reinterpreted as UTC |
//! | `filename` (unix-timestamp) | a true instant |
//! | `fs_time` | a true instant |
//! | `manual` | the display-zone wall the operator picked, reinterpreted as UTC |
//!
//! so mixed rows mid-drain still group into the right day and the insight
//! prompts stay correct row-by-row as the drain converts.
use chrono::{NaiveDate, NaiveDateTime, TimeZone};
use chrono_tz::Tz;
use crate::camera_tz;
use crate::database::models::ImageExif;
/// Best-effort true instant (unix seconds) the row names.
///
/// `date_utc` when the drain has converted the row; otherwise derived from
/// the legacy value under its per-source convention. `server_zone` is the
/// fallback rung the drain would use without offset-tag or GPS evidence
/// (the camera was probably carried in the operator's zone); `display_zone`
/// interprets manual rows.
pub fn row_true_instant(row: &ImageExif, display_zone: Tz, server_zone: Option<Tz>) -> Option<i64> {
let legacy = row.date_taken?;
// Converted rows name their instant directly.
if let Some(utc) = row.date_utc {
return Some(utc);
}
match row.date_taken_source.as_deref() {
// Camera wall reinterpreted as UTC: the wall is the legacy value
// itself; the instant is that wall in the assumed zone.
Some("exif") => wall_in_zone(legacy, server_zone),
// exiftool rendered a naive string in the server zone. Videos hold
// the container's UTC-rendered creation time (a true instant behind
// one server-zone render); photos hold the camera wall.
Some("exiftool") => {
if crate::file_types::is_video_file(std::path::Path::new(&row.file_path)) {
// legacy = server.render(naive_utc) → recover naive_utc and
// read it back as UTC.
let naive = render_in_zone_fallback(legacy, server_zone);
Some(naive.and_utc().timestamp())
} else {
// legacy = server.render(wall); the instant is that wall in
// the server zone, which is `legacy` by construction.
Some(legacy)
}
}
// Filename class: wall patterns name a wall clock, the
// unix-timestamp pattern names a true instant directly.
Some("filename") => match crate::memories::match_filename_date(&row.file_path) {
Some(m) if matches!(m.date, crate::memories::FilenameDate::Wall(_)) => {
wall_in_zone(legacy, server_zone)
}
_ => Some(legacy),
},
Some("fs_time") => Some(legacy),
// The operator picked a display-zone wall; interpret it there.
Some("manual") => camera_tz::manual_date_resolution(Some(display_zone.name()), legacy)
.map(|(instant, _)| instant),
// Unknown / absent source: treat the legacy value as a true instant
// (the most common convention) rather than a wall clock.
_ => Some(legacy),
}
}
/// 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
/// convention (see module docs).
pub fn row_display_wall(
row: &ImageExif,
display_zone: Tz,
server_zone: Option<Tz>,
) -> Option<NaiveDateTime> {
if let Some(utc) = row.date_utc {
// Converted: wall = instant + camera offset (offset = local − UTC).
if let Some(offset_min) = row.camera_offset_minutes {
let wall = chrono::DateTime::from_timestamp(utc + offset_min as i64 * 60, 0)?;
return Some(wall.naive_utc());
}
return render_in_zone_opt(utc, display_zone);
}
let legacy = row.date_taken?;
match row.date_taken_source.as_deref() {
// The wall clock itself, stored as naive-as-UTC.
Some("exif") => Some(legacy_as_naive(legacy)),
Some("exiftool") => {
if crate::file_types::is_video_file(std::path::Path::new(&row.file_path)) {
// No camera wall exists for a container; show the owner's
// zone rendering of the recovered instant.
let naive = render_in_zone_fallback(legacy, server_zone);
let instant = naive.and_utc().timestamp();
render_in_zone_opt(instant, display_zone)
} else {
// The camera wall, recovered from the server-zone render.
Some(render_in_zone_fallback(legacy, server_zone))
}
}
Some("filename") => match crate::memories::match_filename_date(&row.file_path) {
Some(m) if matches!(m.date, crate::memories::FilenameDate::Wall(_)) => {
Some(legacy_as_naive(legacy))
}
_ => render_in_zone_opt(legacy, display_zone),
},
Some("fs_time") => render_in_zone_opt(legacy, display_zone),
Some("manual") => Some(legacy_as_naive(legacy)),
_ => render_in_zone_opt(legacy, display_zone),
}
}
/// The true instant a filename-class date names, for callers that fall
/// back to the filename when the row carries no date at all: wall patterns
/// are read in `server_zone` (the zone the camera was probably in),
/// unix-timestamp patterns are true instants outright.
pub fn filename_true_instant(
fd: &crate::memories::FilenameDate,
server_zone: Option<Tz>,
) -> Option<i64> {
match fd {
crate::memories::FilenameDate::Utc(dt) => Some(dt.timestamp()),
crate::memories::FilenameDate::Wall(wall) => match server_zone {
Some(z) => z
.from_local_datetime(wall)
.single()
.map(|dt| dt.timestamp()),
None => Some(wall.and_utc().timestamp()),
},
}
}
/// The owner's calendar day for this row: the day of the true instant in
/// the owner's display zone. This is the grouping key — a photo taken in
/// Japan at 21:00 JST lands in the owner's day, not the camera's.
pub fn row_display_day(
row: &ImageExif,
display_zone: Tz,
server_zone: Option<Tz>,
) -> Option<NaiveDate> {
let instant = row_true_instant(row, display_zone, server_zone)?;
Some(
display_zone
.timestamp_opt(instant, 0)
.single()?
.date_naive(),
)
}
/// Wire format for a wall clock: naive ISO-8601, no zone designator
/// (`2018-09-29T14:00:00`). Clients render it verbatim — no zone math —
/// so the UI shows exactly the wall [`row_display_wall`] computed and
/// insight prompts use (ADR-0001: consumers never re-anchor).
pub fn format_wall_clock(wall: NaiveDateTime) -> String {
wall.format("%Y-%m-%dT%H:%M:%S").to_string()
}
/// The owner's display zone for a request: prefs `display_tz`, else the
/// app default (never fails — an unreadable prefs row is the default).
pub fn owner_display_zone(
prefs_dao: &std::sync::Mutex<Box<dyn crate::database::UserAiPrefsDao>>,
) -> Tz {
use std::str::FromStr;
let stored = prefs_dao
.lock()
.ok()
.and_then(|mut dao| dao.get_prefs().ok().flatten())
.and_then(|p| p.display_tz);
Tz::from_str(&camera_tz::display_zone_for(stored.as_deref()))
.unwrap_or(chrono_tz::Tz::America__New_York)
}
/// [`row_display_wall`] in wire format.
pub fn row_wall_clock(
row: &ImageExif,
display_zone: Tz,
server_zone: Option<Tz>,
) -> Option<String> {
row_display_wall(row, display_zone, server_zone).map(format_wall_clock)
}
/// Wall clock for a file that may have no `image_exif` row yet (not yet
/// ingested, or excluded): the row's wall when it has one, else the
/// filename-class date (a wall pattern *is* the wall; a unix-timestamp
/// pattern renders in the display zone), else the file's earliest
/// filesystem time rendered in the display zone. The same precedence the
/// ingest waterfall applies, so the value doesn't jump once the row lands.
pub fn file_wall_clock(
row: Option<&ImageExif>,
rel_path: &str,
fs_earliest: Option<i64>,
display_zone: Tz,
server_zone: Option<Tz>,
) -> Option<String> {
if let Some(wall) = row.and_then(|r| row_display_wall(r, display_zone, server_zone)) {
return Some(format_wall_clock(wall));
}
let wall = match crate::memories::extract_date_from_filename(rel_path) {
Some(crate::memories::FilenameDate::Wall(wall)) => Some(wall),
Some(crate::memories::FilenameDate::Utc(dt)) => {
render_in_zone_opt(dt.timestamp(), display_zone)
}
None => fs_earliest.and_then(|t| render_in_zone_opt(t, display_zone)),
};
wall.map(format_wall_clock)
}
/// True instant for a file that may have no `image_exif` row yet — the
/// [`file_wall_clock`] precedence (row, then filename-class date, then
/// earliest fs time). The anchor for instant-to-instant matching (the
/// "messages around this photo" window), never for display.
pub fn file_true_instant(
row: Option<&ImageExif>,
rel_path: &str,
fs_earliest: Option<i64>,
display_zone: Tz,
server_zone: Option<Tz>,
) -> Option<i64> {
if let Some(instant) = row.and_then(|r| row_true_instant(r, display_zone, server_zone)) {
return Some(instant);
}
match crate::memories::extract_date_from_filename(rel_path) {
Some(fd) => filename_true_instant(&fd, server_zone),
None => fs_earliest,
}
}
/// Render a unix instant as a naive wall in `zone`.
fn render_in_zone_opt(instant: i64, zone: Tz) -> Option<NaiveDateTime> {
zone.timestamp_opt(instant, 0)
.single()
.map(|dt| dt.naive_local())
}
/// Render a unix instant as a naive wall in `zone`, falling back to the
/// naive-as-UTC reading when the zone is unknown.
fn render_in_zone_fallback(instant: i64, zone: Option<Tz>) -> NaiveDateTime {
match zone {
Some(z) => z
.timestamp_opt(instant, 0)
.single()
.map(|dt| dt.naive_local())
.unwrap_or_else(|| legacy_as_naive(instant)),
None => legacy_as_naive(instant),
}
}
/// Read a stored wall-as-UTC epoch back as its naive wall.
fn legacy_as_naive(legacy: i64) -> NaiveDateTime {
chrono::DateTime::from_timestamp(legacy, 0)
.expect("valid epoch")
.naive_utc()
}
/// The true instant of a wall clock assumed to have been read in `zone`;
/// naive-as-UTC when the zone is unknown.
fn wall_in_zone(legacy_wall_epoch: i64, zone: Option<Tz>) -> Option<i64> {
let wall = legacy_as_naive(legacy_wall_epoch);
match zone {
Some(z) => z
.from_local_datetime(&wall)
.single()
.map(|dt| dt.timestamp()),
None => Some(legacy_wall_epoch),
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{NaiveDate, TimeZone};
use chrono_tz::Tz;
fn ny() -> Tz {
Tz::America__New_York
}
/// Build a minimal `ImageExif` row for date-field tests.
fn row(
path: &str,
date_taken: Option<i64>,
source: Option<&str>,
date_utc: Option<i64>,
offset: Option<i32>,
) -> ImageExif {
ImageExif {
id: 1,
library_id: 1,
file_path: path.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,
created_time: 0,
last_modified: 0,
content_hash: None,
size_bytes: None,
phash_64: None,
dhash_64: None,
duplicate_of_hash: None,
duplicate_decided_at: None,
date_taken_source: source.map(str::to_string),
original_date_taken: None,
original_date_taken_source: None,
clip_embedding: None,
clip_model_version: None,
date_utc,
camera_offset_minutes: offset,
date_taken_tz_source: None,
original_date_utc: None,
original_camera_offset_minutes: None,
original_date_taken_tz_source: None,
}
}
fn utc_epoch(y: i32, mo: u32, d: u32, h: u32, mi: u32) -> i64 {
NaiveDate::from_ymd_opt(y, mo, d)
.unwrap()
.and_hms_opt(h, mi, 0)
.unwrap()
.and_utc()
.timestamp()
}
/// The AC-3 fixture: three rows naming the same true instant
/// (2024-07-05 01:30 UTC = 2024-07-04 21:30 EDT — the owner's day is
/// July 4), one per source convention. Server zone = display zone = NY.
fn ac3_fixtures() -> (ImageExif, ImageExif, ImageExif, i64) {
let instant = utc_epoch(2024, 7, 5, 1, 30);
let exif = row(
"photos/IMG_1234.jpg",
Some(utc_epoch(2024, 7, 4, 21, 30)), // wall 21:30 as naive-as-UTC
Some("exif"),
None,
None,
);
let fs = row(
"photos/scan.pdf.jpg",
Some(instant),
Some("fs_time"),
None,
None,
);
// Container: exiftool rendered the UTC naive "2024-07-05 01:30" in
// the server zone (EDT) → instant + 4h.
let container = row(
"videos/clip.mp4",
Some(instant + 4 * 3600),
Some("exiftool"),
None,
None,
);
(exif, fs, container, instant)
}
#[test]
fn ac3_same_instant_three_sources_group_into_one_owner_day() {
let (exif, fs, container, instant) = ac3_fixtures();
let d = ny();
for (name, r) in [
("exif", &exif),
("fs_time", &fs),
("exiftool-video", &container),
] {
assert_eq!(
row_true_instant(r, d, Some(d)),
Some(instant),
"{name}: wrong instant"
);
assert_eq!(
row_display_day(r, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-07-04".to_string()),
"{name}: wrong owner day"
);
}
}
#[test]
fn ac3_old_client_offset_grouping_would_have_misplaced_the_container() {
// Regression guard for the double-shift: rendering the container's
// legacy value in the client (NY) offset lands on July 5, a day
// after the owner's.
let (_, _, container, _) = ac3_fixtures();
let legacy = container.date_taken.unwrap();
let old_grouping_day = chrono::FixedOffset::east_opt(-4 * 3600)
.unwrap()
.timestamp_opt(legacy, 0)
.single()
.unwrap()
.date_naive();
assert_eq!(old_grouping_day.to_string(), "2024-07-05");
}
#[test]
fn converted_row_with_offset_shows_camera_wall() {
// Japan (UTC+9) photo: instant 2024-07-05 12:00 UTC, wall 21:00 JST.
let r = row(
"japan/IMG_0001.jpg",
Some(utc_epoch(2024, 7, 5, 21, 0)),
Some("exif"),
Some(utc_epoch(2024, 7, 5, 12, 0)),
Some(9 * 60),
);
let d = ny();
assert_eq!(
row_display_wall(&r, d, Some(d)).map(|w| w.to_string()),
Some("2024-07-05 21:00:00".to_string())
);
// ...but groups into the owner's (NY) day: 12:00 UTC = 08:00 EDT.
assert_eq!(
row_display_day(&r, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-07-05".to_string())
);
assert_eq!(
row_true_instant(&r, d, Some(d)),
Some(utc_epoch(2024, 7, 5, 12, 0))
);
}
#[test]
fn converted_row_without_offset_renders_in_display_zone() {
// fs_time row, no camera offset: wall = display-zone rendering.
// 2024-01-15 04:30 UTC = 23:30 Jan 14 EST (DST-aware).
let r = row(
"fs/IMG_9.jpg",
Some(utc_epoch(2024, 1, 15, 4, 30)),
Some("fs_time"),
Some(utc_epoch(2024, 1, 15, 4, 30)),
None,
);
let d = ny();
assert_eq!(
row_display_wall(&r, d, Some(d)).map(|w| w.to_string()),
Some("2024-01-14 23:30:00".to_string())
);
assert_eq!(
row_display_day(&r, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-01-14".to_string())
);
}
#[test]
fn unconverted_exif_wall_is_the_legacy_naive_and_instant_uses_server_zone() {
// Wall 2024-01-15 21:30 (EST era), legacy = 21:30 naive-as-UTC.
// Instant = 21:30 EST = 2024-01-16 02:30 UTC (DST-aware, not +4h).
let r = row(
"photo.jpg",
Some(utc_epoch(2024, 1, 15, 21, 30)),
Some("exif"),
None,
None,
);
let d = ny();
assert_eq!(
row_display_wall(&r, d, Some(d)).map(|w| w.to_string()),
Some("2024-01-15 21:30:00".to_string())
);
assert_eq!(
row_true_instant(&r, d, Some(d)),
Some(utc_epoch(2024, 1, 16, 2, 30))
);
assert_eq!(
row_display_day(&r, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-01-15".to_string())
);
}
#[test]
fn unconverted_exiftool_photo_wall_is_server_zone_render() {
// Camera wall 2024-07-04 12:00 rendered in server zone EDT →
// legacy = 16:00 UTC. The wall comes back out the other side.
let r = row(
"raw/IMG_5.NEF",
Some(utc_epoch(2024, 7, 4, 16, 0)),
Some("exiftool"),
None,
None,
);
let d = ny();
assert_eq!(
row_display_wall(&r, d, Some(d)).map(|w| w.to_string()),
Some("2024-07-04 12:00:00".to_string())
);
// Instant = that wall in the server zone = the legacy value itself.
assert_eq!(
row_true_instant(&r, d, Some(d)),
Some(utc_epoch(2024, 7, 4, 16, 0))
);
}
#[test]
fn unconverted_manual_row_interprets_wall_in_display_zone() {
// Operator picked 2024-07-04 21:30 in their display zone (NY/EDT):
// legacy = 21:30 naive-as-UTC, instant = 01:30 UTC July 5.
let r = row(
"manual.jpg",
Some(utc_epoch(2024, 7, 4, 21, 30)),
Some("manual"),
None,
None,
);
let d = ny();
assert_eq!(
row_display_wall(&r, d, Some(d)).map(|w| w.to_string()),
Some("2024-07-04 21:30:00".to_string())
);
assert_eq!(
row_true_instant(&r, d, Some(d)),
Some(utc_epoch(2024, 7, 5, 1, 30))
);
assert_eq!(
row_display_day(&r, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-07-04".to_string())
);
}
#[test]
fn unconverted_filename_wall_and_utc_patterns_diverge() {
// Wall pattern (screenshot): legacy = wall as naive-as-UTC.
let wall = row(
"Screenshot_2024-07-04-21-30-00.png",
Some(utc_epoch(2024, 7, 4, 21, 30)),
Some("filename"),
None,
None,
);
// Unix-timestamp pattern: legacy is the true instant itself.
let ts = row(
"1720128600.jpg",
Some(utc_epoch(2024, 7, 5, 1, 30)),
Some("filename"),
None,
None,
);
let d = ny();
assert_eq!(
row_true_instant(&wall, d, Some(d)),
Some(utc_epoch(2024, 7, 5, 1, 30)),
"wall 21:30 EDT → 01:30 UTC"
);
assert_eq!(
row_true_instant(&ts, d, Some(d)),
Some(utc_epoch(2024, 7, 5, 1, 30)),
"timestamp pattern is already the instant"
);
// Both land on the owner's July 4.
for r in [&wall, &ts] {
assert_eq!(
row_display_day(r, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-07-04".to_string())
);
}
}
#[test]
fn display_day_is_dst_correct_across_the_year() {
// Same UTC instant, winter vs summer: the owner's day differs by
// the DST shift. 04:30 UTC = 23:30 previous day EST, 00:30 same
// day EDT.
let winter = row(
"w.jpg",
Some(utc_epoch(2024, 1, 15, 4, 30)),
Some("fs_time"),
Some(utc_epoch(2024, 1, 15, 4, 30)),
None,
);
let summer = row(
"s.jpg",
Some(utc_epoch(2024, 7, 15, 4, 30)),
Some("fs_time"),
Some(utc_epoch(2024, 7, 15, 4, 30)),
None,
);
let d = ny();
assert_eq!(
row_display_day(&winter, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-01-14".to_string())
);
assert_eq!(
row_display_day(&summer, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-07-15".to_string())
);
}
#[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
// the July 4 a NY owner sees).
let (_, fs, _, instant) = ac3_fixtures();
let tokyo = Tz::Asia__Tokyo;
assert_eq!(
row_display_day(&fs, tokyo, Some(ny())).map(|dt| dt.to_string()),
Some("2024-07-05".to_string())
);
assert_eq!(row_true_instant(&fs, tokyo, Some(ny())), Some(instant));
}
#[test]
fn wall_clock_is_the_camera_wall_for_a_travel_zone_photo() {
// 14:00 in Tokyo (+09:00) = 05:00Z. The owner is in New York, but
// the wall clock is the camera's: 14:00, not 01:00.
let r = row(
"otd.jpg",
Some(utc_epoch(2018, 9, 29, 14, 0)),
Some("exif"),
Some(utc_epoch(2018, 9, 29, 5, 0)),
Some(540),
);
assert_eq!(
row_wall_clock(&r, ny(), Some(ny())),
Some("2018-09-29T14:00:00".to_string())
);
}
#[test]
fn wall_clock_without_offset_renders_the_instant_in_the_display_zone() {
// Container date (utc_direct): 12:30Z is 08:30 in New York (EDT).
let r = row(
"summit.mp4",
None,
Some("exiftool"),
Some(utc_epoch(2022, 8, 20, 12, 30)),
None,
);
assert_eq!(
row_wall_clock(&r, ny(), Some(ny())),
Some("2022-08-20T08:30:00".to_string())
);
}
#[test]
fn wall_clock_serves_unconverted_rows_by_their_legacy_convention() {
// The drain's permanent floor never gets date_utc; an `exif` legacy
// value is the wall stored as UTC and must come back unshifted.
let r = row(
"edited.jpg",
Some(utc_epoch(2021, 5, 12, 10, 30)),
Some("exif"),
None,
None,
);
assert_eq!(
row_wall_clock(&r, ny(), Some(ny())),
Some("2021-05-12T10:30:00".to_string())
);
}
#[test]
fn file_wall_clock_prefers_the_row() {
let r = row(
"Screenshots/Screenshot_20220315-101500.png",
Some(utc_epoch(2022, 3, 15, 10, 15)),
Some("filename"),
Some(utc_epoch(2022, 3, 15, 14, 15)),
Some(-240),
);
assert_eq!(
file_wall_clock(Some(&r), &r.file_path, Some(0), ny(), Some(ny())),
Some("2022-03-15T10:15:00".to_string())
);
}
#[test]
fn file_wall_clock_without_a_row_uses_the_filename_wall() {
assert_eq!(
file_wall_clock(
None,
"Screenshots/Screenshot_20220315-101500.png",
Some(0),
ny(),
Some(ny())
),
Some("2022-03-15T10:15:00".to_string())
);
}
#[test]
fn file_wall_clock_without_a_row_or_filename_date_uses_fs_time_in_display_zone() {
// 2015-03-01 17:00Z = 12:00 in New York (EST).
assert_eq!(
file_wall_clock(
None,
"Undated/scan_01.jpg",
Some(utc_epoch(2015, 3, 1, 17, 0)),
ny(),
Some(ny())
),
Some("2015-03-01T12:00:00".to_string())
);
assert_eq!(
file_wall_clock(None, "Undated/scan_01.jpg", None, ny(), Some(ny())),
None
);
}
#[test]
fn file_true_instant_uses_the_row_then_filename_then_fs_time() {
// Row: the converted instant, not the wall (Tokyo 14:00 = 05:00Z).
let r = row(
"otd.jpg",
Some(utc_epoch(2018, 9, 29, 14, 0)),
Some("exif"),
Some(utc_epoch(2018, 9, 29, 5, 0)),
Some(540),
);
assert_eq!(
file_true_instant(Some(&r), "otd.jpg", Some(0), ny(), Some(ny())),
Some(utc_epoch(2018, 9, 29, 5, 0))
);
// No row: a wall-pattern filename is read in the server zone
// (10:15 EDT = 14:15Z).
assert_eq!(
file_true_instant(
None,
"Screenshots/Screenshot_20220315-101500.png",
Some(0),
ny(),
Some(ny())
),
Some(utc_epoch(2022, 3, 15, 14, 15))
);
// No row, no filename date: fs time is already an instant.
assert_eq!(
file_true_instant(
None,
"Undated/scan.jpg",
Some(1_425_229_200),
ny(),
Some(ny())
),
Some(1_425_229_200)
);
}
}