- New src/date_view.rs: pure read-side functions (row_true_instant, row_display_wall, row_display_day, filename_true_instant) applying the per-source convention table for unconverted rows and the new-contract triple for converted rows (ADR-0001). - /memories: grouping on the row's true instant rendered in the owner's display zone (prefs -> app default), not the client device offset. SQL is a coarse interval pre-filter over the full years-back window; exact month-day/week/month matching across past years happens in Rust (memories::day_in_span, sqlite_w_week). - Reels: ReelSelector::Memories carries an IANA display_tz instead of a fixed minute offset; pre-gen params read REEL_PREGEN_DISPLAY_TZ; the tz_offset_minutes ledger column stores the zone offset at render time. - Insights: prompt-facing dates render as the display wall; the proximity timestamp and chat context block use the true instant; InsightGenerator reads the owner's display zone from prefs per call. - GpsPhotoSummary carries the new-contract triple alongside the frozen legacy date_taken. Closes #119
568 lines
20 KiB
Rust
568 lines
20 KiB
Rust
//! 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 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(),
|
||
)
|
||
}
|
||
|
||
/// 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));
|
||
}
|
||
}
|