From fc8dd370c297f84b66a33a5b249f2da073980571 Mon Sep 17 00:00:00 2001 From: dtoro Date: Sun, 12 Apr 2026 22:18:28 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20"On=20this=20day"=20memories=20?= =?UTF-8?q?=E2=80=94=20photos=20from=20previous=20years?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Memories view that surfaces photos taken on the current date in previous years (like Google Photos / Immich). Only uses EXIF-sourced dates to avoid false matches from filesystem timestamps. - Backend: GET /api/v1/photos/memories returns groups by year, up to 12 photos each, filtered to non-discarded/non-hidden EXIF dates - Frontend: MemoriesView with year-grouped thumbnail grid - Sidebar: new "Memories" nav item with clock icon Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/routers/photos.py | 68 ++++++++++++++++++ frontend/src/App.tsx | 3 + .../src/components/layout/LeftSidebar.tsx | 2 + .../src/components/memories/MemoriesView.tsx | 70 +++++++++++++++++++ frontend/src/services/api.ts | 31 ++++++++ 5 files changed, 174 insertions(+) create mode 100644 frontend/src/components/memories/MemoriesView.tsx diff --git a/backend/app/routers/photos.py b/backend/app/routers/photos.py index 282538d..2e54427 100644 --- a/backend/app/routers/photos.py +++ b/backend/app/routers/photos.py @@ -352,6 +352,74 @@ async def list_photos_with_gps( ] +@router.get("/memories") +async def get_memories( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """'On this day' — photos taken on this date in previous years. + + Returns groups keyed by year, each with up to 12 photos. Only + considers non-discarded, non-hidden photos with an EXIF-sourced + taken_at (no filesystem-guessed dates to avoid false matches). + """ + from sqlalchemy import extract + + today = datetime.now().date() + + result = await db.execute( + select( + Photo.id, + Photo.filename, + Photo.taken_at, + Photo.thumb_small, + Photo.thumb_medium, + Photo.media_type, + Photo.width, + Photo.height, + Photo.rating, + ) + .where( + Photo.user_id == current_user.id, + Photo.is_discarded.is_(False), + Photo.is_hidden.is_(False), + Photo.taken_at.is_not(None), + Photo.taken_at_source == "exif", + extract("month", Photo.taken_at) == today.month, + extract("day", Photo.taken_at) == today.day, + extract("year", Photo.taken_at) < today.year, + ) + .order_by(Photo.taken_at.desc()) + ) + rows = result.all() + + # Group by year + years: dict[int, list] = {} + for row in rows: + year = row.taken_at.year + group = years.setdefault(year, []) + if len(group) >= 12: + continue + group.append({ + "id": row.id, + "filename": row.filename, + "taken_at": row.taken_at.isoformat(), + "thumb_small": row.thumb_small, + "thumb_medium": row.thumb_medium, + "media_type": row.media_type, + "width": row.width, + "height": row.height, + "rating": row.rating, + }) + + memories = [ + {"year": year, "years_ago": today.year - year, "photos": photos} + for year, photos in sorted(years.items()) + ] + + return {"date": today.isoformat(), "memories": memories} + + @router.get("/{photo_id}") async def get_photo( photo_id: str, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 58bb4cd..646cd66 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { Timeline } from './components/timeline/Timeline' import { DuplicatesView } from './components/duplicates/DuplicatesView' import { MapView } from './components/map/MapView' +import { MemoriesView } from './components/memories/MemoriesView' import { PeopleView } from './components/people/PeopleView' import { TagsView } from './components/tags/TagsView' import { ColorsView } from './components/colors/ColorsView' @@ -87,6 +88,8 @@ function MainApp() { ) : currentSection === 'map' ? ( + ) : currentSection === 'memories' ? ( + ) : currentSection === 'duplicates' ? ( ) : currentSection === 'people' ? ( diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx index 7b27dd3..60fa146 100644 --- a/frontend/src/components/layout/LeftSidebar.tsx +++ b/frontend/src/components/layout/LeftSidebar.tsx @@ -23,6 +23,7 @@ import { User as UserIcon, LogOut, Shield, + Clock, } from 'lucide-react' import clsx from 'clsx' import { sourceFolders, photos as photosApi, type FolderTreeNode } from '../../services/api' @@ -407,6 +408,7 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) { { id: 'people', label: 'People', icon: , count: peopleTotalCount }, { id: 'colors', label: 'Colors', icon: , count: stats?.colored ?? 0 }, { id: 'map', label: 'Map', icon: , count: stats?.with_gps ?? 0 }, + { id: 'memories', label: 'Memories', icon: }, { id: 'duplicates', label: 'Duplicates', icon: , count: stats?.duplicates ?? 0 }, { id: 'discarded', label: 'Discarded', icon: , count: stats?.discarded ?? 0 }, ], diff --git a/frontend/src/components/memories/MemoriesView.tsx b/frontend/src/components/memories/MemoriesView.tsx new file mode 100644 index 0000000..5b269ad --- /dev/null +++ b/frontend/src/components/memories/MemoriesView.tsx @@ -0,0 +1,70 @@ +import { useQuery } from '@tanstack/react-query' +import { photos } from '../../services/api' +import type { MemoryGroup } from '../../services/api' + +export function MemoriesView() { + const { data, isLoading } = useQuery({ + queryKey: ['memories'], + queryFn: () => photos.memories(), + staleTime: 60_000 * 30, // 30 min — date doesn't change often + }) + + if (isLoading) { + return ( +
+ Loading memories... +
+ ) + } + + const memories = data?.memories ?? [] + + if (memories.length === 0) { + return ( +
+

No memories for today

+

Photos taken on this date in previous years will appear here.

+
+ ) + } + + return ( +
+

+ On This Day — {data?.date} +

+ + {memories.map((group: MemoryGroup) => ( +
+

+ {group.year} · {group.years_ago} year{group.years_ago !== 1 ? 's' : ''} ago +

+
+ {group.photos.map((photo) => ( +
+ {photo.thumb_small ? ( + {photo.filename} + ) : ( +
+ No thumb +
+ )} +
+

{photo.filename}

+
+
+ ))} +
+
+ ))} +
+ ) +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 04998fb..9b41d8b 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -328,6 +328,12 @@ export const photos = { getProxyUrl: (photoId: string) => { return `${API_BASE_URL}/photos/${photoId}/proxy` }, + + /** "On this day" memories — photos taken on this date in previous years. */ + memories: async (): Promise => { + const response = await api.get('/photos/memories') + return response.data + }, } // Library API @@ -577,6 +583,31 @@ export interface DuplicateGroupsResponse { total_members: number } +// ── Memories ("On this day") ──────────────────────────────────────────── + +export interface MemoryPhoto { + id: string + filename: string + taken_at: string + thumb_small: string | null + thumb_medium: string | null + media_type: string + width: number | null + height: number | null + rating: number +} + +export interface MemoryGroup { + year: number + years_ago: number + photos: MemoryPhoto[] +} + +export interface MemoriesResponse { + date: string + memories: MemoryGroup[] +} + export interface LibraryStats { all_photos: number rated: number