From 3563c3de8d4efea66c253a242641d30641c3ca1e Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Fri, 9 Oct 2026 22:29:33 -0400 Subject: [PATCH] feat(memories): optional reference_date to browse relative to another day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /memories accepts reference_date=YYYY-MM-DD (a day in the owner's display zone). It re-anchors both the span match (day_in_span's "today") and the pre-filter window, which ends at the end of that day, so the 20-year lookback is measured from it and a future date surfaces everything taken so far. Omitted → today, unchanged. Malformed → 400. Reels stay anchored on now (MVP): the reel selector passes None. Co-Authored-By: Claude Opus 5.5 --- src/memories.rs | 187 ++++++++++++++++++++++++++++++++++++++++-- src/reels/selector.rs | 2 + 2 files changed, 180 insertions(+), 9 deletions(-) diff --git a/src/memories.rs b/src/memories.rs index 51a631a..2b5f335 100644 --- a/src/memories.rs +++ b/src/memories.rs @@ -144,6 +144,10 @@ pub struct MemoriesRequest { /// Optional library filter. Accepts a library id (e.g. "1") or name /// (e.g. "main"). When omitted, results span all libraries. pub library: Option, + /// Optional anchor date (`YYYY-MM-DD`, a calendar day in the owner's + /// display zone) to browse "on this day/week/month" relative to + /// instead of today. Omitted → today, unchanged behaviour. + pub reference_date: Option, } #[derive(Debug, Serialize, Clone)] @@ -175,6 +179,52 @@ pub struct MemoriesResponse { pub items: Vec, } +/// Resolve the memories anchor: the `now_ts` that bounds the pre-filter +/// window and the `today` that [`day_in_span`] matches against, both in +/// the owner's display zone. With a reference date `D`, the window ends +/// at the end of `D` (so a future `D` surfaces everything already taken, +/// and the years-back lookback is anchored on `D`, not on now). +pub fn memories_anchor( + reference_date: Option, + display_zone: chrono_tz::Tz, + now: DateTime, +) -> (i64, NaiveDate) { + let Some(d) = reference_date else { + let today = display_zone + .from_utc_datetime(&now.naive_utc()) + .date_naive(); + return (now.timestamp(), today); + }; + // End of D = start of D+1 minus a second. Midnight can fall in a DST + // gap in a few zones; fall back to 01:00, then to a UTC reading. + let next = d.succ_opt().unwrap_or(d); + let start_of_next = [0, 1] + .iter() + .find_map(|h| { + display_zone + .from_local_datetime(&next.and_hms_opt(*h, 0, 0).expect("valid hour")) + .earliest() + }) + .map(|dt| dt.timestamp()) + .unwrap_or_else(|| { + next.and_hms_opt(0, 0, 0) + .expect("midnight") + .and_utc() + .timestamp() + }); + (start_of_next - 1, d) +} + +/// Parse the `/memories` `reference_date` param (`YYYY-MM-DD`). +pub fn parse_reference_date(raw: Option<&str>) -> Result, String> { + match raw.map(str::trim).filter(|s| !s.is_empty()) { + None => Ok(None), + Some(s) => NaiveDate::parse_from_str(s, "%Y-%m-%d") + .map(Some) + .map_err(|_| format!("invalid reference_date {s:?} (expected YYYY-MM-DD)")), + } +} + /// Epoch windows for the memories SQL pre-filter (ADR-0001). /// /// The span feature matches the same calendar position (month-day / @@ -519,12 +569,21 @@ pub async fn list_memories( let display_zone = chrono_tz::Tz::from_str(&display_zone_name).unwrap_or(chrono_tz::Tz::America__New_York); + let reference_date = match parse_reference_date(q.reference_date.as_deref()) { + Ok(d) => d, + Err(msg) => { + warn!("Rejecting /memories request: {}", msg); + return HttpResponse::BadRequest().body(msg); + } + }; + let items = match gather_memory_items( &app_state, &exif_dao, span_mode, display_zone, q.library.as_deref(), + reference_date, ) { Ok(items) => items, Err(msg) => { @@ -540,6 +599,10 @@ pub async fn list_memories( KeyValue::new("years_back", DEFAULT_YEARS_BACK.to_string()), KeyValue::new("result_count", items.len().to_string()), KeyValue::new("display_tz", display_zone_name), + KeyValue::new( + "reference_date", + reference_date.map_or_else(|| "today".to_string(), |d| d.to_string()), + ), KeyValue::new("excluded_dirs", format!("{:?}", app_state.excluded_dirs)), ], ); @@ -561,20 +624,20 @@ pub async fn list_memories( /// unconverted EXIF wall clock both land in the same day a photo taken /// locally did. The SQL pass is an interval pre-filter; exact span-day /// matching happens here in Rust via [`date_view`]. +/// +/// `reference_date` shifts "today" to another day in the display zone +/// (see [`memories_anchor`]); `None` anchors on the real now. pub fn gather_memory_items( app_state: &AppState, exif_dao: &Mutex>, span_mode: MemoriesSpan, display_zone: chrono_tz::Tz, library_param: Option<&str>, + reference_date: Option, ) -> Result, String> { let years_back: i64 = DEFAULT_YEARS_BACK as i64; - let now = Utc::now(); - let now_ts = now.timestamp(); - let today_d = display_zone - .from_utc_datetime(&now.naive_utc()) - .date_naive(); + let (now_ts, today_d) = memories_anchor(reference_date, display_zone, Utc::now()); // The SQL pass is a coarse pre-filter over the FULL years-back window // (not the current span window) because this feature matches the same // month-day / week / month across every past year; [`day_in_span`] does @@ -583,8 +646,8 @@ pub fn gather_memory_items( let server_zone = crate::camera_tz::server_zone(); debug!( - "gather_memory_items: span={:?} display_zone={:?} years_back={}", - span_mode, display_zone, years_back + "gather_memory_items: span={:?} display_zone={:?} years_back={} today={}", + span_mode, display_zone, years_back, today_d ); let library = crate::libraries::resolve_library_param_state(app_state, library_param)?; @@ -1241,6 +1304,60 @@ mod tests { assert!(ulo <= clo && chi <= uhi); } + #[test] + fn memories_anchor_defaults_to_now_in_display_zone() { + let zone = chrono_tz::Tz::America__New_York; + // 2024-07-05 02:00 UTC is still Jul 4 in New York. + let now = Utc.with_ymd_and_hms(2024, 7, 5, 2, 0, 0).unwrap(); + let (ts, today) = memories_anchor(None, zone, now); + assert_eq!(ts, now.timestamp()); + assert_eq!(today, NaiveDate::from_ymd_opt(2024, 7, 4).unwrap()); + } + + #[test] + fn memories_anchor_reference_date_ends_at_end_of_that_day() { + let zone = chrono_tz::Tz::America__New_York; + let d = NaiveDate::from_ymd_opt(2015, 3, 10).unwrap(); + let (ts, today) = memories_anchor(Some(d), zone, Utc::now()); + assert_eq!(today, d); + // 2015-03-10 23:59:59 EDT (DST began Mar 8) = 03:59:59 UTC next day. + assert_eq!( + ts, + Utc.with_ymd_and_hms(2015, 3, 11, 3, 59, 59) + .unwrap() + .timestamp() + ); + } + + #[test] + fn memories_anchor_survives_midnight_dst_gap() { + // America/Santiago springs forward at 00:00 → 01:00 (2023-09-03). + let zone = chrono_tz::Tz::America__Santiago; + let d = NaiveDate::from_ymd_opt(2023, 9, 2).unwrap(); + let (ts, today) = memories_anchor(Some(d), zone, Utc::now()); + assert_eq!(today, d); + // Sep 3 01:00 -03 = 04:00 UTC; minus one second. + assert_eq!( + ts, + Utc.with_ymd_and_hms(2023, 9, 3, 3, 59, 59) + .unwrap() + .timestamp() + ); + } + + #[test] + fn parse_reference_date_accepts_absent_empty_and_iso() { + assert_eq!(parse_reference_date(None), Ok(None)); + assert_eq!(parse_reference_date(Some("")), Ok(None)); + assert_eq!( + parse_reference_date(Some("2015-03-10")), + Ok(NaiveDate::from_ymd_opt(2015, 3, 10)) + ); + assert!(parse_reference_date(Some("2015-13-01")).is_err()); + assert!(parse_reference_date(Some("03/10/2015")).is_err()); + assert!(parse_reference_date(Some("2015-02-30")).is_err()); + } + #[test] fn day_in_span_day_and_month() { let today = NaiveDate::from_ymd_opt(2024, 7, 4).unwrap(); @@ -1442,8 +1559,9 @@ mod tests { seed_row(&mut dao, "unconv-vid.mp4", vid_legacy, "exiftool"); let exif_mutex: Mutex> = Mutex::new(Box::new(dao)); - let items = gather_memory_items(&state, &exif_mutex, MemoriesSpan::Month, zone, None) - .expect("gather"); + let items = + gather_memory_items(&state, &exif_mutex, MemoriesSpan::Month, zone, None, None) + .expect("gather"); let by_path = |p: &str| items.iter().find(|it| it.path == p); // All three rows group into the same owner month (AC-2/AC-3). @@ -1501,6 +1619,57 @@ mod tests { assert_eq!(vid.created, Some(vid_expected)); } + /// Seed a converted row (true instant known), so grouping is + /// independent of the server zone's legacy convention. + fn seed_converted(dao: &mut SqliteExifDao, rel: &str, instant: NaiveDateTime) { + let ts = instant.and_utc().timestamp(); + seed_row(dao, rel, ts, "exif"); + dao.backfill_date_taken(1, rel, ts, "exif", Some(ts), None, Some("utc_direct")) + .expect("convert row"); + } + + #[actix_rt::test] + async fn memories_reference_date_anchors_day_and_lookback() { + let state = AppState::test_state(); + let zone = chrono_tz::Tz::America__New_York; + let noon = |y: i32, m: u32, d: u32| { + NaiveDate::from_ymd_opt(y, m, d) + .unwrap() + .and_hms_opt(12, 0, 0) + .unwrap() + }; + + let mut dao = SqliteExifDao::from_connection(in_memory_db_connection()); + seed_converted(&mut dao, "2010-same-day.jpg", noon(2010, 3, 10)); + seed_converted(&mut dao, "1996-same-day.jpg", noon(1996, 3, 10)); + seed_converted(&mut dao, "1994-too-old.jpg", noon(1994, 3, 10)); + seed_converted(&mut dao, "2016-after-ref.jpg", noon(2016, 3, 10)); + seed_converted(&mut dao, "2010-next-day.jpg", noon(2010, 3, 11)); + let exif_mutex: Mutex> = Mutex::new(Box::new(dao)); + + let paths = |d: NaiveDate| -> Vec { + gather_memory_items(&state, &exif_mutex, MemoriesSpan::Day, zone, None, Some(d)) + .expect("gather") + .into_iter() + .map(|m| m.path) + .collect() + }; + + // Past reference date: same month-day, within 20 years + // *before D*, nothing after D. + assert_eq!( + paths(NaiveDate::from_ymd_opt(2015, 3, 10).unwrap()), + vec!["1996-same-day.jpg", "2010-same-day.jpg"] + ); + + // Future reference date: lookback anchored on D, so 2016 is + // in range and 1996/2010 have aged out of the 20-year window. + assert_eq!( + paths(NaiveDate::from_ymd_opt(2030, 3, 10).unwrap()), + vec!["2016-after-ref.jpg"] + ); + } + /// AC-1 (response shape): the serialized `/memories` item carries /// the new-contract triple as explicit fields — null while the row /// is unconverted. diff --git a/src/reels/selector.rs b/src/reels/selector.rs index bcf3691..dbbb7b9 100644 --- a/src/reels/selector.rs +++ b/src/reels/selector.rs @@ -296,6 +296,8 @@ pub fn resolve( *span, zone, library.as_deref(), + // Reels are always "now" (MVP): no reference date. + None, )?; // Split into photos and video clips; anything that's neither is -- 2.54.0