Switch read paths to the true-instant date contract (ticket #119)

- 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
This commit is contained in:
Cameron Cordes committed 2026-09-26 19:28:26 -04:00
1 parent cd375f18db
commit af87765bbb
13 files changed
+1590 -301

No files matched your search

+32 -25
View File
@@ -417,9 +417,23 @@ pub async fn create_reel_handler(
let span = req.span.unwrap_or(MemoriesSpan::Day);
let max_segments = req.max_segments.unwrap_or(selector::DEFAULT_MAX_SEGMENTS);
// Grouping zone: explicit request param → stored pref → app default
// (ADR-0001). The legacy device-offset param is still mirrored into the
// frozen prefs column by `capture_prefs` for rollback but no longer
// drives selection.
let display_zone_name = {
let req_tz = req.display_tz.clone().filter(|s| !s.trim().is_empty());
let stored = app_state
.user_ai_prefs_dao
.lock()
.ok()
.and_then(|mut dao| dao.get_prefs(&opentelemetry::Context::new()).ok().flatten())
.and_then(|p| p.display_tz);
crate::camera_tz::display_zone_for(req_tz.as_deref().or(stored.as_deref()))
};
let selector = ReelSelector::Memories {
span,
tz_offset_minutes: req.timezone_offset_minutes.unwrap_or(0),
display_tz: display_zone_name,
library: req.library.clone(),
max_segments,
};
@@ -965,23 +979,26 @@ pub(crate) fn secs_until_next_run_hour(now: chrono::DateTime<chrono::Local>, run
}
/// Load pre-gen parameters: tries the user_ai_prefs DB row first, falls back
/// to env vars, then to server-local defaults.
fn load_pregen_params(app_state: &AppState) -> (i32, Option<String>, String) {
/// to env vars, then to the app default display zone. Returns the display
/// zone as an IANA name (ADR-0001) — `REEL_PREGEN_DISPLAY_TZ` (e.g.
/// "America/New_York") overrides the stored pref when set. IANA zones are
/// DST-proof, unlike the old fixed-offset env vars.
fn load_pregen_params(app_state: &AppState) -> (String, Option<String>, String) {
// Try DB row first
if let Ok(mut dao) = app_state.user_ai_prefs_dao.lock() {
let ctx = opentelemetry::Context::new();
if let Ok(Some(prefs)) = dao.get_prefs(&ctx) {
let tz = prefs.tz_offset_minutes.unwrap_or_else(fixed_tz_offset);
let tz = crate::camera_tz::display_zone_for(prefs.display_tz.as_deref());
let voice = prefs.voice;
let library = prefs.library.unwrap_or_else(|| "all".to_string());
return (tz, voice, library);
}
}
// Fall back to env (explicit offset overrides auto-detect)
let tz = std::env::var("REEL_PREGEN_TZ_OFFSET_MINUTES")
// Fall back to env (explicit zone overrides auto-detect)
let tz = std::env::var("REEL_PREGEN_DISPLAY_TZ")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or_else(fixed_tz_offset);
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(|| crate::camera_tz::display_zone_for(None));
let voice = std::env::var("REEL_PREGEN_VOICE").ok();
let library = std::env::var("REEL_PREGEN_LIBRARY")
.ok()
@@ -989,17 +1006,6 @@ fn load_pregen_params(app_state: &AppState) -> (i32, Option<String>, String) {
(tz, voice, library)
}
/// Fixed timezone offset: reads `REEL_PREGEN_TZ_FIXED_MINUTES` (e.g. "-480"
/// for US Eastern) when set, falling back to the system local offset. Using
/// a fixed offset avoids DST shifts changing the pre-gen schedule halfway
/// through the year.
fn fixed_tz_offset() -> i32 {
std::env::var("REEL_PREGEN_TZ_FIXED_MINUTES")
.ok()
.and_then(|v| v.trim().parse().ok())
.unwrap_or_else(|| chrono::Local::now().offset().local_minus_utc())
}
/// Spawn the nightly pre-generation scheduler. Runs behind `REEL_PREGEN_ENABLED`.
pub(crate) async fn spawn_pregen_scheduler(app_state: web::Data<AppState>) {
if std::env::var("REEL_PREGEN_ENABLED").ok() != Some("1".to_string()) {
@@ -1044,7 +1050,7 @@ async fn run_pregen_batch(app_state: &AppState) -> anyhow::Result<()> {
let (tz, voice, library) = load_pregen_params(app_state);
for span in spans {
if let Err(e) = pregen_one(app_state, span, tz, voice.clone(), &library).await {
if let Err(e) = pregen_one(app_state, span, &tz, voice.clone(), &library).await {
log::error!("Pre-gen failed for span={}: {}", span, e);
}
}
@@ -1056,7 +1062,7 @@ async fn run_pregen_batch(app_state: &AppState) -> anyhow::Result<()> {
async fn pregen_one(
app_state: &AppState,
span: &str,
tz: i32,
display_tz: &str,
voice: Option<String>,
library: &str,
) -> anyhow::Result<()> {
@@ -1069,7 +1075,7 @@ async fn pregen_one(
let selector = ReelSelector::Memories {
span: memories_span,
tz_offset_minutes: tz,
display_tz: display_tz.to_string(),
library: if library == "all" {
None
} else {
@@ -1179,7 +1185,8 @@ async fn pregen_one(
title,
media_count,
render_version: RENDER_VERSION as i32,
tz_offset_minutes: tz,
tz_offset_minutes: crate::camera_tz::display_zone_offset_minutes(display_tz, now)
.unwrap_or(0) as i32,
voice: voice.clone(),
generated_at: now,
},
@@ -1346,7 +1353,7 @@ mod tests {
fn day_selector() -> ReelSelector {
ReelSelector::Memories {
span: MemoriesSpan::Day,
tz_offset_minutes: 0,
display_tz: "America/New_York".to_string(),
library: None,
max_segments: 24,
}
@@ -1379,7 +1386,7 @@ mod tests {
// Span matters.
let week = ReelSelector::Memories {
span: MemoriesSpan::Week,
tz_offset_minutes: 0,
display_tz: "America/New_York".to_string(),
library: None,
max_segments: 24,
};
+34 -18
View File
@@ -9,9 +9,10 @@
//! runs in the background job.
use std::path::Path;
use std::str::FromStr;
use std::sync::Mutex;
use chrono::{DateTime, Datelike, FixedOffset};
use chrono::{Datelike, TimeZone};
use super::{PlannedBeat, ReelMeta, SegmentMedia};
use crate::database::{ExifDao, InsightDao};
@@ -64,7 +65,10 @@ pub fn budget_segments(requested_max: usize) -> usize {
pub enum ReelSelector {
Memories {
span: MemoriesSpan,
tz_offset_minutes: i32,
/// Owner's display zone as an IANA name (ADR-0001) — grouping is
/// zone-anchored, not device-offset-anchored, so DST shifts are
/// absorbed instead of silently re-bucketing the reel.
display_tz: String,
library: Option<String>,
max_segments: usize,
},
@@ -78,13 +82,13 @@ impl ReelSelector {
match self {
ReelSelector::Memories {
span,
tz_offset_minutes,
display_tz,
library,
max_segments,
} => format!(
"memories:span={:?}:tz={}:lib={}:max={}",
span,
tz_offset_minutes,
display_tz,
library.as_deref().unwrap_or("all"),
max_segments
),
@@ -281,24 +285,24 @@ pub fn resolve(
match selector {
ReelSelector::Memories {
span,
tz_offset_minutes,
display_tz,
library,
max_segments,
} => {
let client_tz = FixedOffset::east_opt(tz_offset_minutes * 60);
let zone =
chrono_tz::Tz::from_str(display_tz).unwrap_or(chrono_tz::Tz::America__New_York);
let items = memories::gather_memory_items(
app_state,
exif_dao,
span_context,
*span,
*tz_offset_minutes,
client_tz,
zone,
library.as_deref(),
)?;
// Split into photos and video clips; anything that's neither is
// dropped. Years span both, computed before the budget narrows it.
let years = distinct_years(&items, client_tz);
let years = distinct_years(&items, zone);
let meta = ReelMeta { span: *span, years };
let (photos, videos): (Vec<_>, Vec<_>) = items
@@ -321,15 +325,12 @@ pub fn resolve(
/// Distinct calendar years represented by the selected media, in the client's
/// timezone, ascending. Used to tell the scripter how far back the reel reaches.
fn distinct_years(items: &[memories::MemoryItem], tz: Option<FixedOffset>) -> Vec<i32> {
fn distinct_years(items: &[memories::MemoryItem], zone: chrono_tz::Tz) -> Vec<i32> {
let mut years: Vec<i32> = items
.iter()
.filter_map(|it| it.created)
.filter_map(|ts| DateTime::from_timestamp(ts, 0))
.map(|dt| match tz {
Some(off) => dt.with_timezone(&off).year(),
None => dt.year(),
})
.filter_map(|ts| zone.timestamp_opt(ts, 0).single())
.map(|dt| dt.year())
.collect();
years.sort_unstable();
years.dedup();
@@ -406,13 +407,13 @@ mod tests {
fn descriptor_is_stable_and_distinguishes_inputs() {
let a = ReelSelector::Memories {
span: MemoriesSpan::Day,
tz_offset_minutes: -480,
display_tz: "America/New_York".to_string(),
library: None,
max_segments: 24,
};
let b = ReelSelector::Memories {
span: MemoriesSpan::Week,
tz_offset_minutes: -480,
display_tz: "America/New_York".to_string(),
library: None,
max_segments: 24,
};
@@ -429,21 +430,33 @@ mod tests {
created: Some(1_560_000_000), // 2019
modified: None,
library_id: 1,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
},
memories::MemoryItem {
path: "b".into(),
created: Some(1_560_086_400), // 2019
modified: None,
library_id: 1,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
},
memories::MemoryItem {
path: "c".into(),
created: Some(1_623_000_000), // 2021
modified: None,
library_id: 1,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
},
];
assert_eq!(distinct_years(&items, None), vec![2019, 2021]);
assert_eq!(
distinct_years(&items, chrono_tz::Tz::America__New_York),
vec![2019, 2021]
);
}
// Build an item at a given unix timestamp (seconds) with a chosen extension.
@@ -453,6 +466,9 @@ mod tests {
created: Some(ts),
modified: None,
library_id: 1,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
}
}
fn item_at(ts: i64, name: &str) -> memories::MemoryItem {