Skip to main content

smriti/services/
memories.rs

1//! Memories — "N years ago today" style rediscovery.
2//!
3//! Three generators (OnThisDay, FallbackWindow, SeasonalRecap), one ranker,
4//! one hero-selector, one block filter. Memories are computed per-day from
5//! `photos` / `faces` / `memory_blocks`; nothing is persisted except the
6//! user's block preferences.
7
8use std::collections::{HashMap, HashSet};
9use std::path::Path;
10
11use chrono::{Datelike, Duration, NaiveDate};
12use rusqlite::{params, Connection, Result as SqliteResult};
13
14use crate::services::semantic::{SemanticIndexCache, SemanticSearchService, SEMANTIC_MODEL_KEY};
15
16pub type MemoryId = String;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum MemoryKind {
20    OnThisDay,
21    FallbackWindow,
22    SeasonalRecap,
23    PersonStory,
24    PlaceStory,
25    VisualPattern,
26    /// Ultimate fallback: surfaces "X years ago" with photos from a prior
27    /// year when no other generator produced anything. Guarantees a sparse
28    /// library still sees something if it has any history at all.
29    YearRecap,
30}
31
32#[derive(Debug, Clone)]
33pub struct Memory {
34    pub id: MemoryId,
35    pub kind: MemoryKind,
36    pub title: String,
37    pub photo_ids: Vec<i64>,
38    pub hero_photo_id: i64,
39    /// Relative thumbnail path for the chosen hero (set during hero
40    /// selection). None if the hero photo hasn't had a thumbnail
41    /// generated yet.
42    pub hero_thumbnail_path: Option<String>,
43    pub score: f32,
44    pub year: i32,
45    pub has_faces: bool,
46}
47
48#[derive(Debug, Clone)]
49pub struct MemoryCard {
50    pub id: MemoryId,
51    /// Kind tag — used by tests and reserved for future filter UI.
52    #[allow(dead_code)]
53    pub kind: MemoryKind,
54    pub title: String,
55    pub hero_photo_id: i64,
56    pub hero_thumbnail_path: Option<String>,
57    pub photo_count: usize,
58    pub photo_ids: Vec<i64>,
59}
60
61impl From<Memory> for MemoryCard {
62    fn from(m: Memory) -> Self {
63        Self {
64            id: m.id,
65            kind: m.kind,
66            title: m.title,
67            hero_photo_id: m.hero_photo_id,
68            hero_thumbnail_path: m.hero_thumbnail_path,
69            photo_count: m.photo_ids.len(),
70            photo_ids: m.photo_ids,
71        }
72    }
73}
74
75/// Minimum library age before Memories surface. If the oldest non-trashed
76/// photo is newer than this many months ago, hide the feature.
77const MIN_LIBRARY_AGE_MONTHS: i64 = 3;
78
79/// Minimum age (in months) for a photo to appear in memories. Photos newer
80/// than this are too recent to be nostalgic.
81const MIN_PHOTO_AGE_MONTHS: i64 = 3;
82
83/// Maximum memory cards returned by the generator.
84const MAX_CARDS: usize = 20;
85
86/// SeasonalRecap threshold — month needs at least this many photos to
87/// surface as a recap. Lowered from 10 so sparse libraries (a few dozen
88/// photos a year) can still see monthly memories.
89const SEASONAL_MIN_PHOTOS: i64 = 5;
90
91/// Cap on photos per YearRecap card.
92const YEAR_RECAP_MAX_PHOTOS: usize = 50;
93const MAX_MEMORY_PHOTOS: usize = 500;
94
95/// Top entry point: full pipeline. Runs all three generators, scores,
96/// filters blocks, picks heroes, returns cards.
97pub fn generate_for_today(conn: &Connection, today: NaiveDate) -> Result<Vec<MemoryCard>, String> {
98    generate_for_today_inner(conn, today, None)
99}
100
101pub fn generate_for_today_with_semantic(
102    conn: &Connection,
103    today: NaiveDate,
104    drive_root: &Path,
105    cache: &mut SemanticIndexCache,
106) -> Result<Vec<MemoryCard>, String> {
107    let visual = visual_pattern(conn, today, drive_root, cache).unwrap_or_else(|err| {
108        tracing::debug!("visual-pattern memory skipped: {err}");
109        None
110    });
111    generate_for_today_inner(conn, today, visual)
112}
113
114fn generate_for_today_inner(
115    conn: &Connection,
116    today: NaiveDate,
117    visual: Option<Memory>,
118) -> Result<Vec<MemoryCard>, String> {
119    let current_year = today.year();
120
121    let mut all: Vec<Memory> = Vec::new();
122
123    let on_this_day_results = on_this_day(conn, today, current_year)
124        .map_err(|e| format!("OnThisDay query failed: {}", e))?;
125    let had_exact = !on_this_day_results.is_empty();
126    all.extend(on_this_day_results);
127
128    if !had_exact {
129        let fb = fallback_window(conn, today, current_year)
130            .map_err(|e| format!("FallbackWindow query failed: {}", e))?;
131        all.extend(fb);
132    }
133
134    let seasonal = seasonal_recap(conn, today, current_year)
135        .map_err(|e| format!("SeasonalRecap query failed: {}", e))?;
136    all.extend(seasonal);
137
138    if let Some(person) =
139        person_story(conn, today).map_err(|e| format!("PersonStory query failed: {e}"))?
140    {
141        all.push(person);
142    }
143    if let Some(place) =
144        place_story(conn, today).map_err(|e| format!("PlaceStory query failed: {e}"))?
145    {
146        all.push(place);
147    }
148    if let Some(visual) = visual {
149        all.push(visual);
150    }
151
152    // Ultimate fallback. Only fires when no specific anniversary or season
153    // qualifies; surfaces "X years ago" cards from prior years so a sparse
154    // library still has something to show.
155    if all.is_empty() {
156        let recaps = year_recap(conn, today, current_year)
157            .map_err(|e| format!("YearRecap query failed: {}", e))?;
158        all.extend(recaps);
159    }
160
161    if all.is_empty() {
162        return Ok(Vec::new());
163    }
164
165    populate_hero_and_faces(conn, &mut all).map_err(|e| format!("Hero selection failed: {}", e))?;
166    rank(&mut all, current_year);
167
168    let blocks = load_person_blocks(conn).map_err(|e| format!("Block load failed: {}", e))?;
169    if !blocks.is_empty() {
170        filter_blocked(conn, &mut all, &blocks)
171            .map_err(|e| format!("Block filter failed: {}", e))?;
172    }
173
174    if all.len() > MAX_CARDS {
175        all.truncate(MAX_CARDS);
176    }
177
178    Ok(all.into_iter().map(MemoryCard::from).collect())
179}
180
181/// Check whether the library has enough history to justify Memories.
182pub fn library_is_old_enough(conn: &Connection, today: NaiveDate) -> bool {
183    let oldest: Option<String> = conn
184        .query_row(
185            "SELECT MIN(date_taken) FROM photos WHERE is_trashed = FALSE AND date_taken IS NOT NULL",
186            [],
187            |row| row.get(0),
188        )
189        .ok()
190        .flatten();
191
192    let Some(oldest_str) = oldest else {
193        return false;
194    };
195
196    // Parse "YYYY-MM-DD HH:MM:SS" or "YYYY-MM-DD" prefix.
197    let parsed = chrono::NaiveDateTime::parse_from_str(&oldest_str, "%Y-%m-%d %H:%M:%S")
198        .map(|dt| dt.date())
199        .or_else(|_| {
200            NaiveDate::parse_from_str(&oldest_str[..oldest_str.len().min(10)], "%Y-%m-%d")
201        });
202
203    let Ok(oldest_date) = parsed else {
204        return false;
205    };
206
207    let age_days = today.signed_duration_since(oldest_date).num_days();
208    age_days >= MIN_LIBRARY_AGE_MONTHS * 30
209}
210
211fn memory_id(kind: MemoryKind, year: i32, today: NaiveDate) -> MemoryId {
212    format!(
213        "{}-{}-{:02}-{:02}",
214        match kind {
215            MemoryKind::OnThisDay => "otd",
216            MemoryKind::FallbackWindow => "fw",
217            MemoryKind::SeasonalRecap => "sr",
218            MemoryKind::PersonStory => "person",
219            MemoryKind::PlaceStory => "place",
220            MemoryKind::VisualPattern => "visual",
221            MemoryKind::YearRecap => "yr",
222        },
223        year,
224        today.month(),
225        today.day()
226    )
227}
228
229fn parse_ids(csv: &str) -> Vec<i64> {
230    csv.split(',')
231        .filter_map(|s| s.trim().parse::<i64>().ok())
232        .collect()
233}
234
235// ---------- Generators ----------
236
237fn on_this_day(
238    conn: &Connection,
239    today: NaiveDate,
240    _current_year: i32,
241) -> SqliteResult<Vec<Memory>> {
242    let today_md = format!("{:02}-{:02}", today.month(), today.day());
243    // Include same-year photos as long as they're old enough (>= MIN_PHOTO_AGE_MONTHS).
244    // The cutoff date excludes photos from the recent N months.
245    let cutoff = today - Duration::days(MIN_PHOTO_AGE_MONTHS * 30);
246    let cutoff_str = cutoff.format("%Y-%m-%d").to_string();
247    let mut stmt = conn.prepare(
248        r#"
249        WITH matched AS (
250            SELECT id,
251                   CAST(strftime('%Y', date_taken) AS INTEGER) AS yr,
252                   ROW_NUMBER() OVER (
253                       PARTITION BY CAST(strftime('%Y', date_taken) AS INTEGER)
254                       ORDER BY date_taken DESC, id DESC
255                   ) AS rn
256            FROM photos
257            WHERE is_trashed = FALSE
258              AND content_category = 'photo'
259              AND date_taken IS NOT NULL
260              AND strftime('%m-%d', date_taken) = ?1
261              AND date_taken < ?2
262        )
263        SELECT yr, GROUP_CONCAT(id) AS photo_ids
264        FROM matched
265        WHERE rn <= ?3
266        GROUP BY yr
267        ORDER BY yr DESC
268        LIMIT 10
269        "#,
270    )?;
271
272    let rows = stmt.query_map(
273        params![today_md, cutoff_str, MAX_MEMORY_PHOTOS as i64],
274        |row| Ok((row.get::<_, i32>(0)?, row.get::<_, String>(1)?)),
275    )?;
276
277    let mut out = Vec::new();
278    for row in rows {
279        let (yr, csv) = row?;
280        let photo_ids = parse_ids(&csv);
281        if photo_ids.is_empty() {
282            continue;
283        }
284        let photo_date = NaiveDate::from_ymd_opt(yr, today.month(), today.day())
285            .unwrap_or(NaiveDate::from_ymd_opt(yr, 1, 1).unwrap());
286        let title = format!("{} today", age_label(photo_date, today));
287        out.push(Memory {
288            id: memory_id(MemoryKind::OnThisDay, yr, today),
289            kind: MemoryKind::OnThisDay,
290            title,
291            photo_ids,
292            hero_photo_id: 0,
293            hero_thumbnail_path: None,
294            score: 0.0,
295            year: yr,
296            has_faces: false,
297        });
298    }
299    Ok(out)
300}
301
302fn fallback_window(
303    conn: &Connection,
304    today: NaiveDate,
305    _current_year: i32,
306) -> SqliteResult<Vec<Memory>> {
307    let mds: Vec<String> = (-3..=3)
308        .map(|offset| {
309            let d = today + Duration::days(offset);
310            format!("{:02}-{:02}", d.month(), d.day())
311        })
312        .collect();
313
314    let cutoff = today - Duration::days(MIN_PHOTO_AGE_MONTHS * 30);
315    let cutoff_str = cutoff.format("%Y-%m-%d").to_string();
316
317    let mut stmt = conn.prepare(
318        r#"
319        WITH matched AS (
320            SELECT id,
321                   CAST(strftime('%Y', date_taken) AS INTEGER) AS yr,
322                   ROW_NUMBER() OVER (
323                       PARTITION BY CAST(strftime('%Y', date_taken) AS INTEGER)
324                       ORDER BY date_taken DESC, id DESC
325                   ) AS rn
326            FROM photos
327            WHERE is_trashed = FALSE
328              AND content_category = 'photo'
329              AND date_taken IS NOT NULL
330              AND strftime('%m-%d', date_taken) IN (?1, ?2, ?3, ?4, ?5, ?6, ?7)
331              AND date_taken < ?8
332        )
333        SELECT yr, GROUP_CONCAT(id) AS photo_ids
334        FROM matched
335        WHERE rn <= ?9
336        GROUP BY yr
337        ORDER BY yr DESC
338        LIMIT 10
339        "#,
340    )?;
341
342    let rows = stmt.query_map(
343        params![
344            mds[0],
345            mds[1],
346            mds[2],
347            mds[3],
348            mds[4],
349            mds[5],
350            mds[6],
351            cutoff_str,
352            MAX_MEMORY_PHOTOS as i64
353        ],
354        |row| Ok((row.get::<_, i32>(0)?, row.get::<_, String>(1)?)),
355    )?;
356
357    let mut out = Vec::new();
358    for row in rows {
359        let (yr, csv) = row?;
360        let photo_ids = parse_ids(&csv);
361        if photo_ids.is_empty() {
362            continue;
363        }
364        let photo_date = NaiveDate::from_ymd_opt(yr, today.month(), today.day())
365            .unwrap_or(NaiveDate::from_ymd_opt(yr, 1, 1).unwrap());
366        let title = format!("{} this week", age_label(photo_date, today));
367        out.push(Memory {
368            id: memory_id(MemoryKind::FallbackWindow, yr, today),
369            kind: MemoryKind::FallbackWindow,
370            title,
371            photo_ids,
372            hero_photo_id: 0,
373            hero_thumbnail_path: None,
374            score: 0.0,
375            year: yr,
376            has_faces: false,
377        });
378    }
379    Ok(out)
380}
381
382fn seasonal_recap(
383    conn: &Connection,
384    today: NaiveDate,
385    _current_year: i32,
386) -> SqliteResult<Vec<Memory>> {
387    let month = format!("{:02}", today.month());
388    let cutoff = today - Duration::days(MIN_PHOTO_AGE_MONTHS * 30);
389    let cutoff_str = cutoff.format("%Y-%m-%d").to_string();
390
391    let mut stmt = conn.prepare(
392        r#"
393        WITH matched AS (
394            SELECT id,
395                   CAST(strftime('%Y', date_taken) AS INTEGER) AS yr,
396                   COUNT(*) OVER (
397                       PARTITION BY CAST(strftime('%Y', date_taken) AS INTEGER)
398                   ) AS total_count,
399                   ROW_NUMBER() OVER (
400                       PARTITION BY CAST(strftime('%Y', date_taken) AS INTEGER)
401                       ORDER BY date_taken DESC, id DESC
402                   ) AS rn
403            FROM photos
404            WHERE is_trashed = FALSE
405              AND content_category = 'photo'
406              AND date_taken IS NOT NULL
407              AND strftime('%m', date_taken) = ?1
408              AND date_taken < ?2
409        )
410        SELECT yr, GROUP_CONCAT(id) AS photo_ids
411        FROM matched
412        WHERE rn <= ?4
413        GROUP BY yr
414        HAVING MAX(total_count) >= ?3
415        ORDER BY yr DESC
416        LIMIT 5
417        "#,
418    )?;
419
420    let rows = stmt.query_map(
421        params![
422            month,
423            cutoff_str,
424            SEASONAL_MIN_PHOTOS,
425            MAX_MEMORY_PHOTOS as i64
426        ],
427        |row| Ok((row.get::<_, i32>(0)?, row.get::<_, String>(1)?)),
428    )?;
429
430    let month_name = month_name(today.month());
431    let mut out = Vec::new();
432    for row in rows {
433        let (yr, csv) = row?;
434        let photo_ids = parse_ids(&csv);
435        if photo_ids.is_empty() {
436            continue;
437        }
438        out.push(Memory {
439            id: memory_id(MemoryKind::SeasonalRecap, yr, today),
440            kind: MemoryKind::SeasonalRecap,
441            title: format!("{} {}, worth another look", month_name, yr),
442            photo_ids,
443            hero_photo_id: 0,
444            hero_thumbnail_path: None,
445            score: 0.0,
446            year: yr,
447            has_faces: false,
448        });
449    }
450    Ok(out)
451}
452
453/// Find one recurring visual pattern without trying to name it. The image
454/// embeddings do the discovery directly; dates make sure the result is a
455/// genuine rediscovery rather than a burst or duplicate set.
456fn visual_pattern(
457    conn: &Connection,
458    today: NaiveDate,
459    drive_root: &Path,
460    cache: &mut SemanticIndexCache,
461) -> Result<Option<Memory>, String> {
462    let cutoff = (today - Duration::days(MIN_PHOTO_AGE_MONTHS * 30))
463        .format("%Y-%m-%d")
464        .to_string();
465    let mut stmt = conn
466        .prepare(
467            r#"SELECT p.id
468               FROM photos p
469               JOIN semantic_index_state s
470                 ON s.photo_id = p.id AND s.model_key = ?1 AND s.status = 'indexed'
471               WHERE p.is_trashed = FALSE
472                 AND p.content_category = 'photo'
473                 AND p.date_taken IS NOT NULL AND p.date_taken < ?2
474               ORDER BY p.is_favorite DESC,
475                        p.date_taken DESC, p.id DESC
476               LIMIT 64"#,
477        )
478        .map_err(|e| e.to_string())?;
479    let seeds = stmt
480        .query_map(params![SEMANTIC_MODEL_KEY, cutoff], |row| {
481            row.get::<_, i64>(0)
482        })
483        .map_err(|e| e.to_string())?
484        .collect::<SqliteResult<Vec<_>>>()
485        .map_err(|e| e.to_string())?;
486    if seeds.is_empty() {
487        return Ok(None);
488    }
489
490    let service = SemanticSearchService::new(drive_root);
491    let start = today.ordinal0() as usize % seeds.len();
492    for offset in 0..seeds.len().min(12) {
493        let seed = seeds[(start + offset) % seeds.len()];
494        let neighbors = service
495            .similar_to_photo_cached(conn, cache, seed, 48)
496            .map_err(|e| e.to_string())?;
497        let Some(top_score) = neighbors.first().map(|candidate| candidate.score) else {
498            continue;
499        };
500        if top_score < 0.70 {
501            continue;
502        }
503        let threshold = (top_score - 0.04).max(top_score * 0.90);
504        let candidate_ids: Vec<i64> = std::iter::once(seed)
505            .chain(
506                neighbors
507                    .into_iter()
508                    .filter(|candidate| candidate.score >= threshold)
509                    .map(|candidate| candidate.photo_id),
510            )
511            .collect();
512        let dated = dated_active_photos(conn, &candidate_ids)?;
513        let mut seen_days = HashSet::new();
514        let mut photo_ids = Vec::new();
515        let mut years = HashSet::new();
516        let mut first_year = today.year();
517        for (photo_id, date) in dated {
518            if seen_days.insert(date) {
519                years.insert(date.year());
520                first_year = first_year.min(date.year());
521                photo_ids.push(photo_id);
522            }
523            if photo_ids.len() == 24 {
524                break;
525            }
526        }
527        if photo_ids.len() < 4 || years.len() < 2 {
528            continue;
529        }
530        if dominant_named_person_coverage(conn, &photo_ids)? >= 0.60 {
531            continue;
532        }
533        return Ok(Some(Memory {
534            id: format!("visual-{seed}-{}-{:03}", today.year(), today.ordinal()),
535            kind: MemoryKind::VisualPattern,
536            title: "Something you kept noticing".to_string(),
537            photo_ids,
538            hero_photo_id: 0,
539            hero_thumbnail_path: None,
540            score: 0.0,
541            year: first_year,
542            has_faces: false,
543        }));
544    }
545    Ok(None)
546}
547
548fn dated_active_photos(
549    conn: &Connection,
550    photo_ids: &[i64],
551) -> Result<Vec<(i64, NaiveDate)>, String> {
552    if photo_ids.is_empty() {
553        return Ok(Vec::new());
554    }
555    let rank: HashMap<i64, usize> = photo_ids
556        .iter()
557        .enumerate()
558        .map(|(index, id)| (*id, index))
559        .collect();
560    let mut out = Vec::new();
561    let mut seen_hashes = HashSet::new();
562    for chunk in photo_ids.chunks(900) {
563        let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(",");
564        let sql = format!(
565            "SELECT id, date_taken, file_hash FROM photos
566             WHERE is_trashed = FALSE AND content_category = 'photo'
567               AND date_taken IS NOT NULL AND id IN ({placeholders})
568               AND NOT EXISTS (
569                   SELECT 1
570                   FROM photo_stack_members psm
571                   JOIN photo_stacks ps ON ps.id = psm.stack_id
572                   WHERE psm.photo_id = photos.id
573                     AND ps.dismissed = FALSE
574                     AND psm.is_cover = FALSE
575               )"
576        );
577        let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
578        let rows = stmt
579            .query_map(rusqlite::params_from_iter(chunk.iter().copied()), |row| {
580                Ok((
581                    row.get::<_, i64>(0)?,
582                    row.get::<_, String>(1)?,
583                    row.get::<_, String>(2)?,
584                ))
585            })
586            .map_err(|e| e.to_string())?;
587        for row in rows {
588            let (id, raw, file_hash) = row.map_err(|e| e.to_string())?;
589            if !seen_hashes.insert(file_hash) {
590                continue;
591            }
592            if let Ok(date) =
593                NaiveDate::parse_from_str(raw.get(..10).unwrap_or_default(), "%Y-%m-%d")
594            {
595                out.push((id, date));
596            }
597        }
598    }
599    out.sort_by_key(|(id, _)| rank.get(id).copied().unwrap_or(usize::MAX));
600    Ok(out)
601}
602
603fn dominant_named_person_coverage(conn: &Connection, photo_ids: &[i64]) -> Result<f32, String> {
604    if photo_ids.is_empty() {
605        return Ok(0.0);
606    }
607    let placeholders = (0..photo_ids.len())
608        .map(|_| "?")
609        .collect::<Vec<_>>()
610        .join(",");
611    let sql = format!(
612        r#"SELECT COUNT(DISTINCT f.photo_id)
613           FROM faces f
614           JOIN face_clusters fc ON fc.id = f.cluster_id
615           WHERE f.photo_id IN ({placeholders})
616             AND fc.name IS NOT NULL AND TRIM(fc.name) != ''
617           GROUP BY f.cluster_id
618           ORDER BY COUNT(DISTINCT f.photo_id) DESC
619           LIMIT 1"#
620    );
621    let count: i64 = conn
622        .query_row(
623            &sql,
624            rusqlite::params_from_iter(photo_ids.iter().copied()),
625            |row| row.get(0),
626        )
627        .unwrap_or(0);
628    Ok(count as f32 / photo_ids.len() as f32)
629}
630
631/// Rotate one named person with a real history through the library. Requiring
632/// three separate years keeps this personal without turning every face cluster
633/// into a card.
634fn person_story(conn: &Connection, today: NaiveDate) -> SqliteResult<Option<Memory>> {
635    let mut stmt = conn.prepare(
636        r#"
637        WITH appearances AS (
638            SELECT DISTINCT f.cluster_id, fc.name, p.id AS photo_id,
639                   CAST(strftime('%Y', p.date_taken) AS INTEGER) AS yr,
640                   p.date_taken, p.is_favorite
641            FROM faces f
642            JOIN face_clusters fc ON fc.id = f.cluster_id
643            JOIN photos p ON p.id = f.photo_id
644            LEFT JOIN memory_blocks mb
645              ON mb.kind = 'person' AND mb.target_key = CAST(f.cluster_id AS TEXT)
646            WHERE p.is_trashed = FALSE
647              AND p.content_category = 'photo'
648              AND p.date_taken IS NOT NULL
649              AND fc.name IS NOT NULL AND TRIM(fc.name) != ''
650              AND mb.id IS NULL
651        ),
652        ranked AS (
653            SELECT *,
654                   ROW_NUMBER() OVER (
655                       PARTITION BY cluster_id, yr
656                       ORDER BY is_favorite DESC, date_taken DESC, photo_id DESC
657                   ) AS rn
658            FROM appearances
659        )
660        SELECT cluster_id, name, GROUP_CONCAT(photo_id), MIN(yr), MAX(yr),
661               COUNT(DISTINCT yr) AS year_count, COUNT(*) AS photo_count
662        FROM ranked
663        WHERE rn <= 8
664        GROUP BY cluster_id, name
665        HAVING year_count >= 3 AND photo_count >= 9
666        ORDER BY year_count DESC, photo_count DESC, name
667        LIMIT 24
668        "#,
669    )?;
670    let rows = stmt.query_map([], |row| {
671        Ok((
672            row.get::<_, i64>(0)?,
673            row.get::<_, String>(1)?,
674            row.get::<_, String>(2)?,
675            row.get::<_, i32>(3)?,
676            row.get::<_, i32>(4)?,
677        ))
678    })?;
679    let candidates = rows.collect::<SqliteResult<Vec<_>>>()?;
680    if candidates.is_empty() {
681        return Ok(None);
682    }
683    let index = today.ordinal0() as usize % candidates.len();
684    let (cluster_id, name, ids, first_year, last_year) = &candidates[index];
685    Ok(Some(Memory {
686        id: format!(
687            "person-{cluster_id}-{}-{:03}",
688            today.year(),
689            today.ordinal()
690        ),
691        kind: MemoryKind::PersonStory,
692        title: format!("{name}, through the years"),
693        photo_ids: parse_ids(ids),
694        hero_photo_id: 0,
695        hero_thumbnail_path: None,
696        score: 0.0,
697        year: *first_year.min(last_year),
698        has_faces: true,
699    }))
700}
701
702/// Rotate one non-home place photographed in at least three different years.
703fn place_story(conn: &Connection, today: NaiveDate) -> SqliteResult<Option<Memory>> {
704    let home_city: Option<String> = conn
705        .query_row(
706            r#"SELECT location_city
707               FROM photos
708               WHERE is_trashed = FALSE
709                 AND location_city IS NOT NULL AND location_city != ''
710                 AND date_taken IS NOT NULL
711               GROUP BY location_city
712               ORDER BY COUNT(DISTINCT strftime('%Y-%W', date_taken)) DESC
713               LIMIT 1"#,
714            [],
715            |row| row.get(0),
716        )
717        .ok();
718    let mut stmt = conn.prepare(
719        r#"
720        WITH ranked AS (
721            SELECT id, location_city AS city,
722                   CAST(strftime('%Y', date_taken) AS INTEGER) AS yr,
723                   ROW_NUMBER() OVER (
724                       PARTITION BY location_city, CAST(strftime('%Y', date_taken) AS INTEGER)
725                       ORDER BY is_favorite DESC, date_taken DESC, id DESC
726                   ) AS rn
727            FROM photos
728            WHERE is_trashed = FALSE
729              AND content_category = 'photo'
730              AND date_taken IS NOT NULL
731              AND location_city IS NOT NULL AND location_city != ''
732              AND (?1 IS NULL OR location_city != ?1)
733        )
734        SELECT city, GROUP_CONCAT(id), MIN(yr), MAX(yr),
735               COUNT(DISTINCT yr) AS year_count, COUNT(*) AS photo_count
736        FROM ranked
737        WHERE rn <= 8
738        GROUP BY city
739        HAVING year_count >= 3 AND photo_count >= 9
740        ORDER BY year_count DESC, photo_count DESC, city
741        LIMIT 24
742        "#,
743    )?;
744    let rows = stmt.query_map(params![home_city], |row| {
745        Ok((
746            row.get::<_, String>(0)?,
747            row.get::<_, String>(1)?,
748            row.get::<_, i32>(2)?,
749            row.get::<_, i32>(3)?,
750        ))
751    })?;
752    let candidates = rows.collect::<SqliteResult<Vec<_>>>()?;
753    if candidates.is_empty() {
754        return Ok(None);
755    }
756    let index = (today.ordinal0() as usize / 3) % candidates.len();
757    let (city, ids, first_year, last_year) = &candidates[index];
758    Ok(Some(Memory {
759        id: format!(
760            "place-{}-{}-{:03}",
761            stable_text_id(city),
762            today.year(),
763            today.ordinal()
764        ),
765        kind: MemoryKind::PlaceStory,
766        title: format!("Back to {city}, through the years"),
767        photo_ids: parse_ids(ids),
768        hero_photo_id: 0,
769        hero_thumbnail_path: None,
770        score: 0.0,
771        year: *first_year.min(last_year),
772        has_faces: false,
773    }))
774}
775
776fn stable_text_id(value: &str) -> u64 {
777    use std::hash::{DefaultHasher, Hash, Hasher};
778    let mut hasher = DefaultHasher::new();
779    value.hash(&mut hasher);
780    hasher.finish()
781}
782
783fn year_recap(
784    conn: &Connection,
785    today: NaiveDate,
786    _current_year: i32,
787) -> SqliteResult<Vec<Memory>> {
788    let cutoff = today - Duration::days(MIN_PHOTO_AGE_MONTHS * 30);
789    let cutoff_str = cutoff.format("%Y-%m-%d").to_string();
790
791    let mut stmt = conn.prepare(
792        r#"
793        WITH matched AS (
794            SELECT id,
795                   CAST(strftime('%Y', date_taken) AS INTEGER) AS yr,
796                   ROW_NUMBER() OVER (
797                       PARTITION BY CAST(strftime('%Y', date_taken) AS INTEGER)
798                       ORDER BY date_taken DESC, id DESC
799                   ) AS rn
800            FROM photos
801            WHERE is_trashed = FALSE
802              AND content_category = 'photo'
803              AND date_taken IS NOT NULL
804              AND date_taken < ?1
805        )
806        SELECT yr, GROUP_CONCAT(id) AS photo_ids
807        FROM matched
808        WHERE rn <= ?2
809        GROUP BY yr
810        ORDER BY yr DESC
811        LIMIT 5
812        "#,
813    )?;
814
815    let rows = stmt.query_map(params![cutoff_str, YEAR_RECAP_MAX_PHOTOS as i64], |row| {
816        Ok((row.get::<_, i32>(0)?, row.get::<_, String>(1)?))
817    })?;
818
819    let mut out = Vec::new();
820    for row in rows {
821        let (yr, csv) = row?;
822        let photo_ids = parse_ids(&csv);
823        if photo_ids.is_empty() {
824            continue;
825        }
826
827        // Use July 1 of that year as a reasonable midpoint for age calculation.
828        let photo_date =
829            NaiveDate::from_ymd_opt(yr, 7, 1).unwrap_or(NaiveDate::from_ymd_opt(yr, 1, 1).unwrap());
830        let title = age_label(photo_date, today);
831        out.push(Memory {
832            id: memory_id(MemoryKind::YearRecap, yr, today),
833            kind: MemoryKind::YearRecap,
834            title,
835            photo_ids,
836            hero_photo_id: 0,
837            hero_thumbnail_path: None,
838            score: 0.0,
839            year: yr,
840            has_faces: false,
841        });
842    }
843    Ok(out)
844}
845
846fn month_name(m: u32) -> &'static str {
847    match m {
848        1 => "January",
849        2 => "February",
850        3 => "March",
851        4 => "April",
852        5 => "May",
853        6 => "June",
854        7 => "July",
855        8 => "August",
856        9 => "September",
857        10 => "October",
858        11 => "November",
859        12 => "December",
860        _ => "",
861    }
862}
863
864/// Human-readable age string from a photo date to today.
865/// Uses months when < 24 months; years otherwise.
866fn age_label(photo_date: NaiveDate, today: NaiveDate) -> String {
867    if photo_date >= today {
868        return "Recent".to_string();
869    }
870
871    let mut months = (today.year() - photo_date.year()) * 12
872        + (today.month() as i32 - photo_date.month() as i32);
873    if today.day() < photo_date.day() {
874        months -= 1;
875    }
876    let months = months.max(0);
877
878    if months < 12 {
879        match months {
880            0 => "Recent".to_string(),
881            1 => "1 month ago".to_string(),
882            _ => format!("{} months ago", months),
883        }
884    } else {
885        let years = months / 12;
886        if years == 1 {
887            "1 year ago".to_string()
888        } else {
889            format!("{} years ago", years)
890        }
891    }
892}
893
894// ---------- Hero selection ----------
895
896/// Populate `hero_photo_id` and `has_faces` on each memory via a single
897/// batched query over all unique photo_ids.
898fn populate_hero_and_faces(conn: &Connection, memories: &mut [Memory]) -> SqliteResult<()> {
899    // Collect all unique photo ids.
900    let mut all_ids: HashSet<i64> = HashSet::new();
901    for m in memories.iter() {
902        all_ids.extend(m.photo_ids.iter().copied());
903    }
904    if all_ids.is_empty() {
905        for mem in memories.iter_mut() {
906            mem.hero_photo_id = 0;
907            mem.hero_thumbnail_path = None;
908            mem.has_faces = false;
909        }
910        return Ok(());
911    }
912
913    let ids_list: Vec<String> = all_ids.iter().map(|i| i.to_string()).collect();
914    let in_clause = ids_list.join(",");
915    let sql = format!(
916        r#"
917        SELECT p.id,
918               COALESCE(p.orientation, 1) AS orientation,
919               COALESCE(p.width, 0) AS width,
920               COALESCE(p.height, 0) AS height,
921               COALESCE((SELECT COUNT(*) FROM faces WHERE photo_id = p.id), 0) AS face_count,
922               p.thumbnail_path
923        FROM photos p
924        WHERE p.id IN ({}) AND p.is_trashed = FALSE
925        "#,
926        in_clause
927    );
928
929    #[derive(Clone)]
930    struct PhotoMeta {
931        orientation: i32,
932        width: i32,
933        height: i32,
934        face_count: i64,
935        thumbnail_path: Option<String>,
936    }
937
938    let mut stmt = conn.prepare(&sql)?;
939    let mut meta: HashMap<i64, PhotoMeta> = HashMap::new();
940    let rows = stmt.query_map([], |row| {
941        Ok((
942            row.get::<_, i64>(0)?,
943            PhotoMeta {
944                orientation: row.get(1)?,
945                width: row.get(2)?,
946                height: row.get(3)?,
947                face_count: row.get(4)?,
948                thumbnail_path: row.get(5)?,
949            },
950        ))
951    })?;
952    for row in rows {
953        let (id, m) = row?;
954        meta.insert(id, m);
955    }
956
957    for mem in memories.iter_mut() {
958        let Some(mut best_id) = mem.photo_ids.first().copied() else {
959            mem.hero_photo_id = 0;
960            mem.hero_thumbnail_path = None;
961            mem.has_faces = false;
962            continue;
963        };
964        let mut best_score = f32::MIN;
965        let mut any_faces = false;
966
967        for pid in &mem.photo_ids {
968            let Some(m) = meta.get(pid) else { continue };
969
970            // Landscape orientations are 1 (unrotated wide) and 3 (180).
971            // 6/8 are rotated portraits. Treat width >= height as landscape too.
972            let is_landscape = matches!(m.orientation, 1 | 3) && m.width >= m.height && m.width > 0;
973
974            let mut score = 0.0_f32;
975            if is_landscape {
976                score += 3.0;
977            }
978            if m.face_count > 0 {
979                score += 2.0;
980                any_faces = true;
981            }
982            if m.face_count >= 2 {
983                score += 1.0;
984            }
985            // Prefer photos that already have a thumbnail — avoids
986            // an empty card while on-demand generation runs in the
987            // background.
988            if m.thumbnail_path.is_some() {
989                score += 0.5;
990            }
991            // Tie-breaker toward smaller id (stable).
992            score -= (*pid as f32) * 1e-9;
993
994            if score > best_score {
995                best_score = score;
996                best_id = *pid;
997            }
998        }
999
1000        mem.hero_photo_id = best_id;
1001        mem.hero_thumbnail_path = meta.get(&best_id).and_then(|m| m.thumbnail_path.clone());
1002        mem.has_faces = any_faces;
1003    }
1004
1005    Ok(())
1006}
1007
1008// ---------- Ranking ----------
1009
1010fn rank(memories: &mut [Memory], current_year: i32) {
1011    for m in memories.iter_mut() {
1012        let years_ago = (current_year - m.year).max(0);
1013        let count_factor = ((m.photo_ids.len() as f32) + 1.0).log2();
1014        let age_factor = ((years_ago as f32) + 1.0).sqrt();
1015        let face_factor = if m.has_faces { 1.3 } else { 1.0 };
1016        let kind_factor = match m.kind {
1017            MemoryKind::SeasonalRecap => 1.5,
1018            MemoryKind::PersonStory | MemoryKind::PlaceStory | MemoryKind::VisualPattern => 1.8,
1019            _ => 1.0,
1020        };
1021        m.score = count_factor * age_factor * face_factor * kind_factor;
1022    }
1023    memories.sort_by(|a, b| {
1024        b.score
1025            .partial_cmp(&a.score)
1026            .unwrap_or(std::cmp::Ordering::Equal)
1027    });
1028}
1029
1030// ---------- Block filter ----------
1031
1032fn load_person_blocks(conn: &Connection) -> SqliteResult<HashSet<i64>> {
1033    let mut stmt = conn.prepare("SELECT target_key FROM memory_blocks WHERE kind = 'person'")?;
1034    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
1035    let mut out = HashSet::new();
1036    for row in rows {
1037        if let Ok(id) = row?.parse::<i64>() {
1038            out.insert(id);
1039        }
1040    }
1041    Ok(out)
1042}
1043
1044/// Drop memories where > 50% of photos contain a face in a blocked cluster.
1045fn filter_blocked(
1046    conn: &Connection,
1047    memories: &mut Vec<Memory>,
1048    blocks: &HashSet<i64>,
1049) -> SqliteResult<()> {
1050    if memories.is_empty() || blocks.is_empty() {
1051        return Ok(());
1052    }
1053
1054    // Collect all photo ids.
1055    let mut all_ids: HashSet<i64> = HashSet::new();
1056    for m in memories.iter() {
1057        all_ids.extend(m.photo_ids.iter().copied());
1058    }
1059    let ids_list: Vec<String> = all_ids.iter().map(|i| i.to_string()).collect();
1060    let blocks_list: Vec<String> = blocks.iter().map(|i| i.to_string()).collect();
1061
1062    let sql = format!(
1063        "SELECT DISTINCT photo_id FROM faces
1064         WHERE cluster_id IN ({}) AND photo_id IN ({})",
1065        blocks_list.join(","),
1066        ids_list.join(",")
1067    );
1068
1069    let mut stmt = conn.prepare(&sql)?;
1070    let rows = stmt.query_map([], |row| row.get::<_, i64>(0))?;
1071    let blocked_photos: HashSet<i64> = rows.collect::<SqliteResult<HashSet<_>>>()?;
1072
1073    memories.retain(|m| {
1074        let blocked_count = m
1075            .photo_ids
1076            .iter()
1077            .filter(|id| blocked_photos.contains(id))
1078            .count();
1079        blocked_count * 2 <= m.photo_ids.len() // keep if blocked photos are not majority
1080    });
1081
1082    Ok(())
1083}
1084
1085// ---------- Tests ----------
1086
1087#[cfg(test)]
1088mod tests {
1089    use super::*;
1090    use rusqlite::Connection;
1091    use tempfile::tempdir;
1092
1093    fn fresh_db() -> Connection {
1094        let conn = Connection::open_in_memory().unwrap();
1095        crate::db::schema::create_schema(&conn).unwrap();
1096        conn
1097    }
1098
1099    fn insert_photo(conn: &Connection, id: i64, date: &str) {
1100        conn.execute(
1101            "INSERT INTO photos (id, file_path, file_name, file_hash, file_size, date_taken, width, height, orientation) \
1102             VALUES (?1, ?2, ?3, 'h', 1, ?4, 1920, 1080, 1)",
1103            rusqlite::params![
1104                id,
1105                format!("photo{}.jpg", id),
1106                format!("photo{}.jpg", id),
1107                date,
1108            ],
1109        )
1110        .unwrap();
1111    }
1112
1113    #[test]
1114    fn empty_library_returns_empty() {
1115        let conn = fresh_db();
1116        let today = NaiveDate::from_ymd_opt(2026, 4, 15).unwrap();
1117        let cards = generate_for_today(&conn, today).unwrap();
1118        assert!(cards.is_empty());
1119    }
1120
1121    #[test]
1122    fn hero_selection_tolerates_empty_memory_photo_ids() {
1123        let conn = fresh_db();
1124        let mut memories = vec![Memory {
1125            id: "empty".into(),
1126            kind: MemoryKind::FallbackWindow,
1127            title: "Empty".into(),
1128            photo_ids: Vec::new(),
1129            hero_photo_id: 99,
1130            hero_thumbnail_path: Some("stale.jpg".into()),
1131            score: 0.0,
1132            year: 2020,
1133            has_faces: true,
1134        }];
1135
1136        populate_hero_and_faces(&conn, &mut memories).unwrap();
1137
1138        assert_eq!(memories[0].hero_photo_id, 0);
1139        assert!(memories[0].hero_thumbnail_path.is_none());
1140        assert!(!memories[0].has_faces);
1141    }
1142
1143    #[test]
1144    fn on_this_day_matches_prior_year() {
1145        let conn = fresh_db();
1146        insert_photo(&conn, 1, "2022-04-15 10:00:00");
1147        let today = NaiveDate::from_ymd_opt(2026, 4, 15).unwrap();
1148        let cards = generate_for_today(&conn, today).unwrap();
1149        assert_eq!(cards.len(), 1);
1150        assert_eq!(cards[0].kind, MemoryKind::OnThisDay);
1151        assert_eq!(cards[0].photo_count, 1);
1152    }
1153
1154    #[test]
1155    fn dense_memory_day_is_capped_before_detail_payload() {
1156        let conn = fresh_db();
1157        for id in 1..=(MAX_MEMORY_PHOTOS as i64 + 25) {
1158            insert_photo(&conn, id, "2022-04-15 10:00:00");
1159        }
1160
1161        let today = NaiveDate::from_ymd_opt(2026, 4, 15).unwrap();
1162        let cards = generate_for_today(&conn, today).unwrap();
1163        let on_this_day = cards
1164            .iter()
1165            .find(|card| card.kind == MemoryKind::OnThisDay)
1166            .unwrap();
1167
1168        assert_eq!(on_this_day.photo_ids.len(), MAX_MEMORY_PHOTOS);
1169        assert_eq!(on_this_day.photo_count, MAX_MEMORY_PHOTOS);
1170    }
1171
1172    #[test]
1173    fn seasonal_recap_respects_threshold() {
1174        let conn = fresh_db();
1175        // 4 photos in April 2020 — below SEASONAL_MIN_PHOTOS (5).
1176        for i in 0..4 {
1177            insert_photo(&conn, 100 + i, "2020-04-03 10:00:00");
1178        }
1179        let today = NaiveDate::from_ymd_opt(2026, 4, 15).unwrap();
1180        let cards = generate_for_today(&conn, today).unwrap();
1181        assert!(cards.iter().all(|c| c.kind != MemoryKind::SeasonalRecap));
1182    }
1183
1184    #[test]
1185    fn seasonal_recap_surfaces_above_threshold() {
1186        let conn = fresh_db();
1187        // 5 photos in April 2020 — exactly at threshold.
1188        for i in 0..5 {
1189            insert_photo(&conn, 200 + i, "2020-04-03 10:00:00");
1190        }
1191        let today = NaiveDate::from_ymd_opt(2026, 4, 15).unwrap();
1192        let cards = generate_for_today(&conn, today).unwrap();
1193        assert!(cards.iter().any(|c| c.kind == MemoryKind::SeasonalRecap));
1194    }
1195
1196    #[test]
1197    fn fallback_only_when_on_this_day_empty() {
1198        let conn = fresh_db();
1199        insert_photo(&conn, 1, "2023-04-13 10:00:00"); // 3 days before, NOT today
1200        let today = NaiveDate::from_ymd_opt(2026, 4, 15).unwrap();
1201        let cards = generate_for_today(&conn, today).unwrap();
1202        assert_eq!(cards.len(), 1);
1203        assert_eq!(cards[0].kind, MemoryKind::FallbackWindow);
1204    }
1205
1206    #[test]
1207    fn fallback_skipped_if_on_this_day_present() {
1208        let conn = fresh_db();
1209        insert_photo(&conn, 1, "2022-04-15 10:00:00"); // exact today
1210        insert_photo(&conn, 2, "2023-04-13 10:00:00"); // ±3 window
1211        let today = NaiveDate::from_ymd_opt(2026, 4, 15).unwrap();
1212        let cards = generate_for_today(&conn, today).unwrap();
1213        assert!(cards.iter().all(|c| c.kind != MemoryKind::FallbackWindow));
1214    }
1215
1216    #[test]
1217    fn year_recap_fires_when_nothing_else_qualifies() {
1218        let conn = fresh_db();
1219        // Sparse library: 5 photos all on Jan 5, 2022 - won't match today's
1220        // exact date, won't fill the ±3 day window of today (April 15), won't
1221        // hit the seasonal threshold for April. YearRecap should still fire.
1222        for i in 0..5 {
1223            insert_photo(&conn, 300 + i, "2022-01-05 10:00:00");
1224        }
1225        let today = NaiveDate::from_ymd_opt(2026, 4, 15).unwrap();
1226        let cards = generate_for_today(&conn, today).unwrap();
1227        assert!(cards.iter().any(|c| c.kind == MemoryKind::YearRecap));
1228        // And no on-this-day / fallback / seasonal should be present.
1229        assert!(cards.iter().all(|c| c.kind == MemoryKind::YearRecap));
1230    }
1231
1232    #[test]
1233    fn year_recap_does_not_fire_when_others_qualify() {
1234        let conn = fresh_db();
1235        // Photo from today's date 4 years ago — on_this_day will fire.
1236        insert_photo(&conn, 1, "2022-04-15 10:00:00");
1237        let today = NaiveDate::from_ymd_opt(2026, 4, 15).unwrap();
1238        let cards = generate_for_today(&conn, today).unwrap();
1239        assert!(cards.iter().any(|c| c.kind == MemoryKind::OnThisDay));
1240        assert!(cards.iter().all(|c| c.kind != MemoryKind::YearRecap));
1241    }
1242
1243    #[test]
1244    fn library_age_gate() {
1245        let conn = fresh_db();
1246        let today = NaiveDate::from_ymd_opt(2026, 4, 15).unwrap();
1247
1248        // No photos → not old enough.
1249        assert!(!library_is_old_enough(&conn, today));
1250
1251        // Photo from 2 months ago → still not old enough.
1252        insert_photo(&conn, 1, "2026-02-15 10:00:00");
1253        assert!(!library_is_old_enough(&conn, today));
1254
1255        // Photo from 1 year ago → old enough.
1256        insert_photo(&conn, 2, "2025-04-15 10:00:00");
1257        assert!(library_is_old_enough(&conn, today));
1258    }
1259
1260    #[test]
1261    fn memory_id_is_stable() {
1262        let today = NaiveDate::from_ymd_opt(2026, 4, 15).unwrap();
1263        let a = memory_id(MemoryKind::OnThisDay, 2022, today);
1264        let b = memory_id(MemoryKind::OnThisDay, 2022, today);
1265        assert_eq!(a, b);
1266        let c = memory_id(MemoryKind::OnThisDay, 2021, today);
1267        assert_ne!(a, c);
1268    }
1269
1270    #[test]
1271    fn named_person_across_three_years_becomes_a_personal_story() {
1272        let conn = fresh_db();
1273        conn.execute(
1274            "INSERT INTO face_clusters (id, name, is_user_named) VALUES (7, 'Asha', TRUE)",
1275            [],
1276        )
1277        .unwrap();
1278        let mut id = 1_i64;
1279        for year in 2021..=2023 {
1280            for day in 1..=3 {
1281                insert_photo(&conn, id, &format!("{year}-06-{day:02} 10:00:00"));
1282                conn.execute(
1283                    r#"INSERT INTO faces
1284                       (id, photo_id, bbox_x, bbox_y, bbox_width, bbox_height,
1285                        embedding, cluster_id, confidence)
1286                       VALUES (?1, ?1, 0.1, 0.1, 0.3, 0.3, X'00', 7, 0.99)"#,
1287                    [id],
1288                )
1289                .unwrap();
1290                id += 1;
1291            }
1292        }
1293
1294        let today = NaiveDate::from_ymd_opt(2026, 4, 15).unwrap();
1295        let cards = generate_for_today(&conn, today).unwrap();
1296        let story = cards
1297            .iter()
1298            .find(|card| card.kind == MemoryKind::PersonStory)
1299            .unwrap();
1300        assert_eq!(story.title, "Asha, through the years");
1301        assert_eq!(story.photo_count, 9);
1302    }
1303
1304    #[test]
1305    fn recurring_place_story_excludes_the_inferred_home_city() {
1306        let conn = fresh_db();
1307        let mut id = 1_i64;
1308        for year in 2021..=2023 {
1309            for month in 1..=4 {
1310                insert_photo(&conn, id, &format!("{year}-{month:02}-01 10:00:00"));
1311                conn.execute(
1312                    "UPDATE photos SET location_city = 'Delhi' WHERE id = ?1",
1313                    [id],
1314                )
1315                .unwrap();
1316                id += 1;
1317            }
1318            for day in 1..=3 {
1319                insert_photo(&conn, id, &format!("{year}-08-{day:02} 10:00:00"));
1320                conn.execute(
1321                    "UPDATE photos SET location_city = 'Goa' WHERE id = ?1",
1322                    [id],
1323                )
1324                .unwrap();
1325                id += 1;
1326            }
1327        }
1328
1329        let today = NaiveDate::from_ymd_opt(2026, 4, 15).unwrap();
1330        let cards = generate_for_today(&conn, today).unwrap();
1331        let story = cards
1332            .iter()
1333            .find(|card| card.kind == MemoryKind::PlaceStory)
1334            .unwrap();
1335        assert_eq!(story.title, "Back to Goa, through the years");
1336    }
1337
1338    #[test]
1339    fn semantic_neighbors_across_years_become_one_visual_pattern() {
1340        let mut conn = fresh_db();
1341        let dir = tempdir().unwrap();
1342        let service = SemanticSearchService::new(dir.path());
1343        for id in 1..=6_i64 {
1344            let year = 2020 + ((id - 1) / 2) as i32;
1345            let day = ((id - 1) % 2) + 1;
1346            insert_photo(&conn, id, &format!("{year}-09-{day:02} 10:00:00"));
1347            conn.execute(
1348                "UPDATE photos SET file_hash = ?1 WHERE id = ?2",
1349                params![format!("hash-{id}"), id],
1350            )
1351            .unwrap();
1352            let mut vector = vec![0.0_f32; crate::services::semantic::SEMANTIC_DIM];
1353            vector[0] = 1.0;
1354            vector[1] = id as f32 * 0.001;
1355            service.mark_indexed(&mut conn, id, &vector).unwrap();
1356        }
1357
1358        let today = NaiveDate::from_ymd_opt(2026, 4, 15).unwrap();
1359        let mut cache = SemanticIndexCache::default();
1360        let cards = generate_for_today_with_semantic(&conn, today, dir.path(), &mut cache).unwrap();
1361        let visual = cards
1362            .iter()
1363            .find(|card| card.kind == MemoryKind::VisualPattern)
1364            .unwrap();
1365        assert_eq!(visual.title, "Something you kept noticing");
1366        assert_eq!(visual.photo_count, 6);
1367    }
1368}