Dates: serve the display wall clock (wall_clock) to clients

Every payload a client renders a capture time from now carries
`wall_clock`, a naive ISO-8601 string computed by the existing
`date_view::row_display_wall`: the camera's wall clock (true instant +
camera offset; the owner's display zone without an offset), with the
per-source fallback for unconverted rows. Clients render it verbatim and
do no zone math, which is the FileViewer-React #33 / Apollo #37 flip with
the rule kept server-side: the drain's permanent `date_utc IS NULL` floor
(#120: edited/exported files) would otherwise force every client to
carry the per-source legacy conventions forever, and insight prompts
already use the same function, so prompt and UI agree by construction.

- `/image/metadata` (+ the GPS / date / date-clear mutation responses):
  `file_wall_clock` — the row's wall, else the filename-class date, else
  the earliest fs time in the display zone (unindexed files).
- `/memories` items and `/photos/gps-summary` photos: `row_wall_clock`.
- `get_all_with_gps` returns full rows instead of a 7-tuple that dropped
  `date_taken_source`, which unconverted rows need.
- `date_view::owner_display_zone` resolves prefs -> app default per request.

Legacy fields are untouched, so current clients see byte-identical
responses (additive; safe to deploy ahead of the client flip, per #120).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-09-29 13:50:23 -04:00
1 parent f00a309a24
commit 7231383fa6
8 files changed
+259 -93

No files matched your search

+157
View File
@@ -184,6 +184,65 @@ pub fn row_display_day(
)
}
/// 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>>,
context: &opentelemetry::Context,
) -> Tz {
use std::str::FromStr;
let stored = prefs_dao
.lock()
.ok()
.and_then(|mut dao| dao.get_prefs(context).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)
}
/// 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)
@@ -564,4 +623,102 @@ mod tests {
);
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
);
}
}