smriti/models/timeline_group.rs
1//! Timeline date-grouping (pure logic, no UI deps).
2//!
3//! Lives under `models` rather than `views` so it can be exercised
4//! from integration tests that don't depend on iced / the view layer.
5
6use chrono::{DateTime, Datelike, Utc};
7
8use super::Photo;
9
10/// Zero-copy view of a contiguous slice of `photos: &[Photo]` taken
11/// on the same date. Built once per photos-load (not every render),
12/// stored in app state, and consumed by the timeline view to avoid
13/// re-grouping the entire library on every scroll event.
14#[derive(Debug, Clone)]
15pub struct DateGroupRange {
16 pub day_key: String,
17 pub display_date: String,
18 /// Half-open `[start, end)` range into the parent `photos` slice.
19 pub start: usize,
20 pub end: usize,
21}
22
23/// Pre-compute timeline date groups from a date-descending photo
24/// slice. Called by the `photos_loaded` handler once per load,
25/// NOT per render frame.
26///
27/// Photos must already be sorted by `date_taken DESC` (the loader
28/// guarantees this via SQL `ORDER BY date_taken DESC`). Photos
29/// without a `date_taken` cluster under a single "Unknown Date"
30/// group at the end.
31pub fn compute_groups(photos: &[Photo]) -> Vec<DateGroupRange> {
32 let mut out: Vec<DateGroupRange> = Vec::new();
33 let mut i = 0;
34 while i < photos.len() {
35 let head_date = photos[i].date_taken.map(|d| d.date_naive());
36
37 // Extend the group while subsequent photos share the same
38 // calendar date. Compare `NaiveDate` values directly — much
39 // cheaper than formatting both sides to strings, which would
40 // run `format!("%Y-%m-%d")` once per photo (~50K times for a
41 // 50K-photo library).
42 let mut j = i + 1;
43 while j < photos.len() {
44 let next_date = photos[j].date_taken.map(|d| d.date_naive());
45 if next_date != head_date {
46 break;
47 }
48 j += 1;
49 }
50
51 // Format only once per group.
52 let (day_key, display_date) = match photos[i].date_taken {
53 Some(d) => (d.format("%Y-%m-%d").to_string(), format_display_date(&d)),
54 None => ("0000-00-00".to_string(), "Unknown Date".to_string()),
55 };
56
57 out.push(DateGroupRange {
58 day_key,
59 display_date,
60 start: i,
61 end: j,
62 });
63 i = j;
64 }
65 out
66}
67
68/// Human-friendly display date relative to today.
69pub fn format_display_date(date: &DateTime<Utc>) -> String {
70 let now = Utc::now();
71 let today = now.date_naive();
72 let photo_date = date.date_naive();
73
74 if photo_date == today {
75 "Today".to_string()
76 } else if photo_date == today.pred_opt().unwrap_or(today) {
77 "Yesterday".to_string()
78 } else if photo_date.year() == today.year() {
79 date.format("%B %d").to_string() // "March 15"
80 } else {
81 date.format("%B %d, %Y").to_string() // "March 15, 2019"
82 }
83}