feat(memories): optional reference_date to browse relative to another day #159

Merged
cameron merged 1 commits from feature/memories-reference-date into master 2026-10-11 00:29:22 +00:00
2 changed files with 180 additions and 9 deletions

No files matched your search

+178 -9
View File
@@ -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<String>,
/// 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<String>,
}
#[derive(Debug, Serialize, Clone)]
@@ -175,6 +179,52 @@ pub struct MemoriesResponse {
pub items: Vec<MemoryItem>,
}
/// 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<NaiveDate>,
display_zone: chrono_tz::Tz,
now: DateTime<Utc>,
) -> (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<Option<NaiveDate>, 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<Box<dyn ExifDao>>,
span_mode: MemoriesSpan,
display_zone: chrono_tz::Tz,
library_param: Option<&str>,
reference_date: Option<NaiveDate>,
) -> Result<Vec<MemoryItem>, 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<Box<dyn ExifDao>> = 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<Box<dyn ExifDao>> = Mutex::new(Box::new(dao));
let paths = |d: NaiveDate| -> Vec<String> {
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.
+2
View File
@@ -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