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 17:54:46 -04:00
1 parent e0c6c696f2
commit e2f33684c8
8 files changed
+257 -93

No files matched your search

+12
View File
@@ -467,6 +467,18 @@ are SMS-derived and unaffected. Progress: Prometheus
`date_backfill_remaining` per library on `GET /libraries` (live count of
`date_utc IS NULL`).
**Displaying a capture time: `wall_clock`.** Every payload a client renders a
capture time from (`/image/metadata` + the date/GPS mutation responses,
`/memories` items, `/photos/gps-summary`) carries `wall_clock`, a naive
ISO-8601 string (`2018-09-29T14:00:00`) computed by
`date_view::row_display_wall` — the camera's wall clock (instant + camera
offset; the owner's display zone when there's no offset), with the per-source
fallback for rows the drain can't convert (its permanent floor).
`file_wall_clock` covers files with no row yet (filename date, then fs time).
Clients render it verbatim and do no zone math (ADR-0001; FileViewer-React #33,
Apollo #37). Insight prompts use the same function, so prompt and UI agree.
`dev/e2e/e2e.sh check` verifies it for every fixture on all three endpoints.
`/memories` is a single SQL query against this column
(`get_memories_in_window` in `src/database/mod.rs`), using
`strftime('%m-%d' | '%W' | '%m', date_taken, 'unixepoch', tz)` for
+8
View File
@@ -259,6 +259,10 @@ pub struct MetadataResponse {
pub filename_date: Option<i64>, // Date extracted from filename
pub library_id: Option<i32>,
pub library_name: Option<String>,
/// The capture wall clock to display, naive ISO-8601
/// (`2018-09-29T14:00:00`) — `date_view::file_wall_clock`. Render it
/// verbatim; never zone-convert it (ADR-0001).
pub wall_clock: Option<String>,
}
impl From<fs::Metadata> for MetadataResponse {
@@ -277,6 +281,7 @@ impl From<fs::Metadata> for MetadataResponse {
filename_date: None, // Will be set in endpoint handler
library_id: None,
library_name: None,
wall_clock: None, // Set in the endpoint handler
}
}
}
@@ -437,6 +442,9 @@ pub struct GpsPhotoSummary {
/// Which evidence pinned the instant (`offset_tag`, `gps`,
/// `assumed_server`, `utc_direct`, `manual`).
pub date_taken_tz_source: Option<String>,
/// Capture wall clock to display (naive ISO-8601; see
/// `MetadataResponse::wall_clock`).
pub wall_clock: Option<String>,
}
#[derive(Debug, Serialize)]
+10 -57
View File
@@ -389,25 +389,15 @@ pub trait ExifDao: Sync + Send {
/// Get all file paths from EXIF database
fn get_all_file_paths(&mut self) -> Result<Vec<String>, DbError>;
/// Get all photos with GPS coordinates
/// Returns Vec<(file_path, latitude, longitude, date_taken)>
#[allow(clippy::type_complexity)]
/// Get all rows with GPS coordinates (both latitude and longitude set)
/// under `base_path`. Full rows, so callers can apply the date contract
/// (`date_view`) — which needs the date source and path, not just the
/// new-contract triple.
fn get_all_with_gps(
&mut self,
base_path: &str,
recursive: bool,
) -> Result<
Vec<(
String,
f64,
f64,
Option<i64>,
Option<i64>,
Option<i32>,
Option<String>,
)>,
DbError,
>;
) -> Result<Vec<ImageExif>, DbError>;
/// Return rows that still lack a `content_hash`, oldest first. Used by
/// the `backfill_hashes` binary to batch through the historical
@@ -1168,23 +1158,11 @@ impl ExifDao for SqliteExifDao {
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
#[allow(clippy::type_complexity)]
fn get_all_with_gps(
&mut self,
base_path: &str,
recursive: bool,
) -> Result<
Vec<(
String,
f64,
f64,
Option<i64>,
Option<i64>,
Option<i32>,
Option<String>,
)>,
DbError,
> {
) -> Result<Vec<ImageExif>, DbError> {
trace_db_call("query", "get_all_with_gps", |span| {
use schema::image_exif::dsl::*;
@@ -1216,36 +1194,11 @@ impl ExifDao for SqliteExifDao {
.load::<ImageExif>(connection)
.map_err(|e| anyhow::anyhow!("GPS query error: {}", e))?;
// Convert to tuple format (path, lat, lon, legacy date_taken,
// date_utc, camera_offset_minutes, date_taken_tz_source).
// Filter out any rows where GPS is still None (shouldn't happen due to filter)
// Cast f32 GPS values to f64 for API compatibility
let filtered: Vec<(
String,
f64,
f64,
Option<i64>,
Option<i64>,
Option<i32>,
Option<String>,
)> = results
// The filter already requires both coordinates; keep the guard so
// callers can rely on lat/lon being present.
let filtered: Vec<ImageExif> = results
.into_iter()
.filter_map(|exif| {
if let (Some(lat_val), Some(lon_val)) = (exif.gps_latitude, exif.gps_longitude)
{
Some((
exif.file_path,
lat_val as f64,
lon_val as f64,
exif.date_taken,
exif.date_utc,
exif.camera_offset_minutes,
exif.date_taken_tz_source,
))
} else {
None
}
})
.filter(|exif| exif.gps_latitude.is_some() && exif.gps_longitude.is_some())
.collect();
span.set_attribute("result_count", filtered.len() as i64);
+156
View File
@@ -184,6 +184,64 @@ 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>>,
) -> 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)
}
/// 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 +622,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
);
}
}
+17 -31
View File
@@ -1100,27 +1100,24 @@ pub async fn get_gps_summary(
let mut exif_dao_guard = exif_dao.lock().expect("Unable to get ExifDao");
match exif_dao_guard.get_all_with_gps(&requested_path, recursive) {
Ok(gps_data) => {
let display_zone = crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao);
let server_zone = crate::camera_tz::server_zone();
let mut photos: Vec<GpsPhotoSummary> = gps_data
.into_iter()
.map(
|(
path,
lat,
lon,
date_taken,
date_utc,
camera_offset_minutes,
date_taken_tz_source,
)| GpsPhotoSummary {
path,
lat,
lon,
date_taken,
date_utc,
camera_offset_minutes,
date_taken_tz_source,
},
)
.filter_map(|row| {
let wall_clock =
crate::date_view::row_wall_clock(&row, display_zone, server_zone);
Some(GpsPhotoSummary {
lat: row.gps_latitude? as f64,
lon: row.gps_longitude? as f64,
path: row.file_path,
date_taken: row.date_taken,
date_utc: row.date_utc,
camera_offset_minutes: row.camera_offset_minutes,
date_taken_tz_source: row.date_taken_tz_source,
wall_clock,
})
})
.collect();
// Sort by date_taken based on request, defaulting to ascending (oldest to newest)
@@ -1632,18 +1629,7 @@ mod tests {
&mut self,
_base_path: &str,
_recursive: bool,
) -> Result<
Vec<(
String,
f64,
f64,
Option<i64>,
Option<i64>,
Option<i32>,
Option<String>,
)>,
DbError,
> {
) -> Result<Vec<crate::database::models::ImageExif>, DbError> {
todo!()
}
+40 -5
View File
@@ -524,11 +524,18 @@ pub async fn get_file_metadata(
.map(memories::FilenameDate::legacy_timestamp);
// Query EXIF data if available
if let Ok(mut dao) = exif_dao.lock()
&& let Ok(Some(exif)) = dao.get_exif(&path.path)
{
response.exif = Some(exif.into());
}
let row = exif_dao
.lock()
.ok()
.and_then(|mut dao| dao.get_exif(&path.path).ok().flatten());
response.wall_clock = crate::date_view::file_wall_clock(
row.as_ref(),
&path.path,
earliest_fs_time(&response),
crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao),
crate::camera_tz::server_zone(),
);
response.exif = row.map(Into::into);
span.add_event(
"Metadata fetched",
@@ -706,8 +713,16 @@ pub async fn set_image_gps(
filename_date: None,
library_id: None,
library_name: None,
wall_clock: None,
},
};
response.wall_clock = crate::date_view::file_wall_clock(
Some(&row),
&normalized_path,
earliest_fs_time(&response),
crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao),
crate::camera_tz::server_zone(),
);
response.exif = Some(row.into());
response.library_id = Some(resolved_library.id);
response.library_name = Some(resolved_library.name.clone());
@@ -848,6 +863,7 @@ fn build_metadata_response_for_date_mutation(
library: &libraries::Library,
rel_path: &str,
exif: ImageExif,
display_zone: chrono_tz::Tz,
) -> MetadataResponse {
let full_path = is_valid_full_path(&library.root_path, &rel_path.to_string(), false);
let fs_meta = full_path
@@ -864,8 +880,16 @@ fn build_metadata_response_for_date_mutation(
filename_date: None,
library_id: None,
library_name: None,
wall_clock: None,
},
};
response.wall_clock = crate::date_view::file_wall_clock(
Some(&exif),
rel_path,
earliest_fs_time(&response),
display_zone,
crate::camera_tz::server_zone(),
);
response.exif = Some(exif.into());
response.library_id = Some(library.id);
response.library_name = Some(library.name.clone());
@@ -874,6 +898,15 @@ fn build_metadata_response_for_date_mutation(
response
}
/// Earlier of the file's created / modified times — the ingest waterfall's
/// `fs_time` rule — for the wall-clock fallback of unindexed files.
fn earliest_fs_time(response: &MetadataResponse) -> Option<i64> {
match (response.created, response.modified) {
(Some(c), Some(m)) => Some(c.min(m)),
(c, m) => c.or(m),
}
}
#[post("/image/exif/date")]
#[tracing::instrument(name = "set_image_date", skip_all)]
pub async fn set_image_date(
@@ -924,6 +957,7 @@ pub async fn set_image_date(
library,
&normalized_path,
row,
crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao),
))
}
Err(e) => {
@@ -972,6 +1006,7 @@ pub async fn clear_image_date(
library,
&normalized_path,
row,
crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao),
))
}
Err(e) => {
+10
View File
@@ -164,6 +164,10 @@ pub struct MemoryItem {
pub date_utc: Option<i64>,
pub camera_offset_minutes: Option<i32>,
pub date_taken_tz_source: Option<String>,
/// Capture wall clock to display, naive ISO-8601 (`row_display_wall`):
/// the camera's clock, which can differ from the owner's day that
/// `created` groups under. Render verbatim (ADR-0001).
pub wall_clock: Option<String>,
}
#[derive(Debug, Serialize)]
@@ -637,6 +641,7 @@ pub fn gather_memory_items(
}
let created = crate::date_view::row_true_instant(&row, display_zone, server_zone);
let wall_clock = crate::date_view::row_wall_clock(&row, display_zone, server_zone);
memories_with_dates.push((
MemoryItem {
path: row.file_path,
@@ -646,6 +651,7 @@ pub fn gather_memory_items(
date_utc: row.date_utc,
camera_offset_minutes: row.camera_offset_minutes,
date_taken_tz_source: row.date_taken_tz_source,
wall_clock,
},
display_day,
));
@@ -1130,6 +1136,7 @@ mod tests {
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
wall_clock: None,
},
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
),
@@ -1142,6 +1149,7 @@ mod tests {
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
wall_clock: None,
},
NaiveDate::from_ymd_opt(2020, 1, 15).unwrap(),
),
@@ -1154,6 +1162,7 @@ mod tests {
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
wall_clock: None,
},
NaiveDate::from_ymd_opt(2021, 1, 16).unwrap(),
),
@@ -1484,6 +1493,7 @@ mod tests {
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
wall_clock: None,
};
let v: serde_json::Value =
serde_json::to_value(&MemoriesResponse { items: vec![item] }).unwrap();
+4
View File
@@ -430,6 +430,7 @@ mod tests {
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
wall_clock: None,
},
memories::MemoryItem {
path: "b".into(),
@@ -439,6 +440,7 @@ mod tests {
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
wall_clock: None,
},
memories::MemoryItem {
path: "c".into(),
@@ -448,6 +450,7 @@ mod tests {
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
wall_clock: None,
},
];
assert_eq!(
@@ -466,6 +469,7 @@ mod tests {
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
wall_clock: None,
}
}
fn item_at(ts: i64, name: &str) -> memories::MemoryItem {