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

+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_backfill_remaining` per library on `GET /libraries` (live count of
`date_utc IS NULL`). `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 `/memories` is a single SQL query against this column
(`get_memories_in_window` in `src/database/mod.rs`), using (`get_memories_in_window` in `src/database/mod.rs`), using
`strftime('%m-%d' | '%W' | '%m', date_taken, 'unixepoch', tz)` for `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 filename_date: Option<i64>, // Date extracted from filename
pub library_id: Option<i32>, pub library_id: Option<i32>,
pub library_name: Option<String>, 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 { 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 filename_date: None, // Will be set in endpoint handler
library_id: None, library_id: None,
library_name: 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`, /// Which evidence pinned the instant (`offset_tag`, `gps`,
/// `assumed_server`, `utc_direct`, `manual`). /// `assumed_server`, `utc_direct`, `manual`).
pub date_taken_tz_source: Option<String>, 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)] #[derive(Debug, Serialize)]
+10 -57
View File
@@ -418,26 +418,16 @@ pub trait ExifDao: Sync + Send {
context: &opentelemetry::Context, context: &opentelemetry::Context,
) -> Result<Vec<String>, DbError>; ) -> Result<Vec<String>, DbError>;
/// Get all photos with GPS coordinates /// Get all rows with GPS coordinates (both latitude and longitude set)
/// Returns Vec<(file_path, latitude, longitude, date_taken)> /// under `base_path`. Full rows, so callers can apply the date contract
#[allow(clippy::type_complexity)] /// (`date_view`) — which needs the date source and path, not just the
/// new-contract triple.
fn get_all_with_gps( fn get_all_with_gps(
&mut self, &mut self,
context: &opentelemetry::Context, context: &opentelemetry::Context,
base_path: &str, base_path: &str,
recursive: bool, recursive: bool,
) -> Result< ) -> Result<Vec<ImageExif>, DbError>;
Vec<(
String,
f64,
f64,
Option<i64>,
Option<i64>,
Option<i32>,
Option<String>,
)>,
DbError,
>;
/// Return rows that still lack a `content_hash`, oldest first. Used by /// Return rows that still lack a `content_hash`, oldest first. Used by
/// the `backfill_hashes` binary to batch through the historical /// the `backfill_hashes` binary to batch through the historical
@@ -1289,24 +1279,12 @@ impl ExifDao for SqliteExifDao {
.map_err(|e| DbError::log(DbErrorKind::QueryError, e)) .map_err(|e| DbError::log(DbErrorKind::QueryError, e))
} }
#[allow(clippy::type_complexity)]
fn get_all_with_gps( fn get_all_with_gps(
&mut self, &mut self,
context: &opentelemetry::Context, context: &opentelemetry::Context,
base_path: &str, base_path: &str,
recursive: bool, recursive: bool,
) -> Result< ) -> Result<Vec<ImageExif>, DbError> {
Vec<(
String,
f64,
f64,
Option<i64>,
Option<i64>,
Option<i32>,
Option<String>,
)>,
DbError,
> {
trace_db_call(context, "query", "get_all_with_gps", |span| { trace_db_call(context, "query", "get_all_with_gps", |span| {
use opentelemetry::KeyValue; use opentelemetry::KeyValue;
use opentelemetry::trace::Span; use opentelemetry::trace::Span;
@@ -1342,36 +1320,11 @@ impl ExifDao for SqliteExifDao {
.load::<ImageExif>(connection) .load::<ImageExif>(connection)
.map_err(|e| anyhow::anyhow!("GPS query error: {}", e))?; .map_err(|e| anyhow::anyhow!("GPS query error: {}", e))?;
// Convert to tuple format (path, lat, lon, legacy date_taken, // The filter already requires both coordinates; keep the guard so
// date_utc, camera_offset_minutes, date_taken_tz_source). // callers can rely on lat/lon being present.
// Filter out any rows where GPS is still None (shouldn't happen due to filter) let filtered: Vec<ImageExif> = results
// Cast f32 GPS values to f64 for API compatibility
let filtered: Vec<(
String,
f64,
f64,
Option<i64>,
Option<i64>,
Option<i32>,
Option<String>,
)> = results
.into_iter() .into_iter()
.filter_map(|exif| { .filter(|exif| exif.gps_latitude.is_some() && exif.gps_longitude.is_some())
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
}
})
.collect(); .collect();
span.set_attribute(KeyValue::new("result_count", filtered.len() as i64)); span.set_attribute(KeyValue::new("result_count", filtered.len() as i64));
+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`. /// Render a unix instant as a naive wall in `zone`.
fn render_in_zone_opt(instant: i64, zone: Tz) -> Option<NaiveDateTime> { fn render_in_zone_opt(instant: i64, zone: Tz) -> Option<NaiveDateTime> {
zone.timestamp_opt(instant, 0) zone.timestamp_opt(instant, 0)
@@ -564,4 +623,102 @@ mod tests {
); );
assert_eq!(row_true_instant(&fs, tokyo, Some(ny())), Some(instant)); 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
);
}
} }
+18 -31
View File
@@ -1140,27 +1140,25 @@ pub async fn get_gps_summary(
let mut exif_dao_guard = exif_dao.lock().expect("Unable to get ExifDao"); let mut exif_dao_guard = exif_dao.lock().expect("Unable to get ExifDao");
match exif_dao_guard.get_all_with_gps(&cx, &requested_path, recursive) { match exif_dao_guard.get_all_with_gps(&cx, &requested_path, recursive) {
Ok(gps_data) => { Ok(gps_data) => {
let display_zone =
crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao, &cx);
let server_zone = crate::camera_tz::server_zone();
let mut photos: Vec<GpsPhotoSummary> = gps_data let mut photos: Vec<GpsPhotoSummary> = gps_data
.into_iter() .into_iter()
.map( .filter_map(|row| {
|( let wall_clock =
path, crate::date_view::row_wall_clock(&row, display_zone, server_zone);
lat, Some(GpsPhotoSummary {
lon, lat: row.gps_latitude? as f64,
date_taken, lon: row.gps_longitude? as f64,
date_utc, path: row.file_path,
camera_offset_minutes, date_taken: row.date_taken,
date_taken_tz_source, date_utc: row.date_utc,
)| GpsPhotoSummary { camera_offset_minutes: row.camera_offset_minutes,
path, date_taken_tz_source: row.date_taken_tz_source,
lat, wall_clock,
lon, })
date_taken, })
date_utc,
camera_offset_minutes,
date_taken_tz_source,
},
)
.collect(); .collect();
// Sort by date_taken based on request, defaulting to ascending (oldest to newest) // Sort by date_taken based on request, defaulting to ascending (oldest to newest)
@@ -1708,18 +1706,7 @@ mod tests {
_context: &opentelemetry::Context, _context: &opentelemetry::Context,
_base_path: &str, _base_path: &str,
_recursive: bool, _recursive: bool,
) -> Result< ) -> Result<Vec<crate::database::models::ImageExif>, DbError> {
Vec<(
String,
f64,
f64,
Option<i64>,
Option<i64>,
Option<i32>,
Option<String>,
)>,
DbError,
> {
todo!() todo!()
} }
+40 -5
View File
@@ -530,11 +530,18 @@ pub async fn get_file_metadata(
.map(memories::FilenameDate::legacy_timestamp); .map(memories::FilenameDate::legacy_timestamp);
// Query EXIF data if available // Query EXIF data if available
if let Ok(mut dao) = exif_dao.lock() let row = exif_dao
&& let Ok(Some(exif)) = dao.get_exif(&span_context, &path.path) .lock()
{ .ok()
response.exif = Some(exif.into()); .and_then(|mut dao| dao.get_exif(&span_context, &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, &span_context),
crate::camera_tz::server_zone(),
);
response.exif = row.map(Into::into);
span.add_event( span.add_event(
"Metadata fetched", "Metadata fetched",
@@ -716,8 +723,16 @@ pub async fn set_image_gps(
filename_date: None, filename_date: None,
library_id: None, library_id: None,
library_name: 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, &span_context),
crate::camera_tz::server_zone(),
);
response.exif = Some(row.into()); response.exif = Some(row.into());
response.library_id = Some(resolved_library.id); response.library_id = Some(resolved_library.id);
response.library_name = Some(resolved_library.name.clone()); response.library_name = Some(resolved_library.name.clone());
@@ -860,6 +875,7 @@ fn build_metadata_response_for_date_mutation(
library: &libraries::Library, library: &libraries::Library,
rel_path: &str, rel_path: &str,
exif: ImageExif, exif: ImageExif,
display_zone: chrono_tz::Tz,
) -> MetadataResponse { ) -> MetadataResponse {
let full_path = is_valid_full_path(&library.root_path, &rel_path.to_string(), false); let full_path = is_valid_full_path(&library.root_path, &rel_path.to_string(), false);
let fs_meta = full_path let fs_meta = full_path
@@ -876,8 +892,16 @@ fn build_metadata_response_for_date_mutation(
filename_date: None, filename_date: None,
library_id: None, library_id: None,
library_name: 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.exif = Some(exif.into());
response.library_id = Some(library.id); response.library_id = Some(library.id);
response.library_name = Some(library.name.clone()); response.library_name = Some(library.name.clone());
@@ -886,6 +910,15 @@ fn build_metadata_response_for_date_mutation(
response 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")] #[post("/image/exif/date")]
pub async fn set_image_date( pub async fn set_image_date(
_: Claims, _: Claims,
@@ -946,6 +979,7 @@ pub async fn set_image_date(
library, library,
&normalized_path, &normalized_path,
row, row,
crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao, &span_context),
)) ))
} }
Err(e) => { Err(e) => {
@@ -998,6 +1032,7 @@ pub async fn clear_image_date(
library, library,
&normalized_path, &normalized_path,
row, row,
crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao, &span_context),
)) ))
} }
Err(e) => { Err(e) => {
+10
View File
@@ -164,6 +164,10 @@ pub struct MemoryItem {
pub date_utc: Option<i64>, pub date_utc: Option<i64>,
pub camera_offset_minutes: Option<i32>, pub camera_offset_minutes: Option<i32>,
pub date_taken_tz_source: Option<String>, 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)] #[derive(Debug, Serialize)]
@@ -648,6 +652,7 @@ pub fn gather_memory_items(
} }
let created = crate::date_view::row_true_instant(&row, display_zone, server_zone); 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(( memories_with_dates.push((
MemoryItem { MemoryItem {
path: row.file_path, path: row.file_path,
@@ -657,6 +662,7 @@ pub fn gather_memory_items(
date_utc: row.date_utc, date_utc: row.date_utc,
camera_offset_minutes: row.camera_offset_minutes, camera_offset_minutes: row.camera_offset_minutes,
date_taken_tz_source: row.date_taken_tz_source, date_taken_tz_source: row.date_taken_tz_source,
wall_clock,
}, },
display_day, display_day,
)); ));
@@ -1141,6 +1147,7 @@ mod tests {
date_utc: None, date_utc: None,
camera_offset_minutes: None, camera_offset_minutes: None,
date_taken_tz_source: None, date_taken_tz_source: None,
wall_clock: None,
}, },
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(), NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
), ),
@@ -1153,6 +1160,7 @@ mod tests {
date_utc: None, date_utc: None,
camera_offset_minutes: None, camera_offset_minutes: None,
date_taken_tz_source: None, date_taken_tz_source: None,
wall_clock: None,
}, },
NaiveDate::from_ymd_opt(2020, 1, 15).unwrap(), NaiveDate::from_ymd_opt(2020, 1, 15).unwrap(),
), ),
@@ -1165,6 +1173,7 @@ mod tests {
date_utc: None, date_utc: None,
camera_offset_minutes: None, camera_offset_minutes: None,
date_taken_tz_source: None, date_taken_tz_source: None,
wall_clock: None,
}, },
NaiveDate::from_ymd_opt(2021, 1, 16).unwrap(), NaiveDate::from_ymd_opt(2021, 1, 16).unwrap(),
), ),
@@ -1506,6 +1515,7 @@ mod tests {
date_utc: None, date_utc: None,
camera_offset_minutes: None, camera_offset_minutes: None,
date_taken_tz_source: None, date_taken_tz_source: None,
wall_clock: None,
}; };
let v: serde_json::Value = let v: serde_json::Value =
serde_json::to_value(&MemoriesResponse { items: vec![item] }).unwrap(); serde_json::to_value(&MemoriesResponse { items: vec![item] }).unwrap();
+4
View File
@@ -433,6 +433,7 @@ mod tests {
date_utc: None, date_utc: None,
camera_offset_minutes: None, camera_offset_minutes: None,
date_taken_tz_source: None, date_taken_tz_source: None,
wall_clock: None,
}, },
memories::MemoryItem { memories::MemoryItem {
path: "b".into(), path: "b".into(),
@@ -442,6 +443,7 @@ mod tests {
date_utc: None, date_utc: None,
camera_offset_minutes: None, camera_offset_minutes: None,
date_taken_tz_source: None, date_taken_tz_source: None,
wall_clock: None,
}, },
memories::MemoryItem { memories::MemoryItem {
path: "c".into(), path: "c".into(),
@@ -451,6 +453,7 @@ mod tests {
date_utc: None, date_utc: None,
camera_offset_minutes: None, camera_offset_minutes: None,
date_taken_tz_source: None, date_taken_tz_source: None,
wall_clock: None,
}, },
]; ];
assert_eq!( assert_eq!(
@@ -469,6 +472,7 @@ mod tests {
date_utc: None, date_utc: None,
camera_offset_minutes: None, camera_offset_minutes: None,
date_taken_tz_source: None, date_taken_tz_source: None,
wall_clock: None,
} }
} }
fn item_at(ts: i64, name: &str) -> memories::MemoryItem { fn item_at(ts: i64, name: &str) -> memories::MemoryItem {