diff --git a/backend/app/routers/library.py b/backend/app/routers/library.py index 887b4fe..9ccf783 100644 --- a/backend/app/routers/library.py +++ b/backend/app/routers/library.py @@ -47,6 +47,50 @@ async def trigger_scan(): return {"status": "success", "message": "Library scan started"} +@router.get("/date_buckets") +async def get_date_buckets(db: AsyncSession = Depends(get_db)): + """Aggregate non-discarded photos by year and month based on taken_at. + Used by the LeftSidebar 'By Date' subtree to render a year-then-month + drilldown without fetching every photo. Returns: + [{ year: 2026, count: 5, months: [{ month: 4, count: 5 }] }] + """ + year_expr = func.strftime('%Y', Photo.taken_at) + month_expr = func.strftime('%m', Photo.taken_at) + + result = await db.execute( + select( + year_expr.label('year'), + month_expr.label('month'), + func.count(Photo.id).label('count'), + ) + .where( + Photo.is_discarded == False, # noqa: E712 + Photo.taken_at.is_not(None), + ) + .group_by(year_expr, month_expr) + .order_by(year_expr.desc(), month_expr.desc()) + ) + rows = result.all() + + years: dict[int, dict] = {} + for year_str, month_str, count in rows: + if not year_str or not month_str: + continue + try: + year = int(year_str) + month = int(month_str) + except ValueError: + continue + bucket = years.setdefault( + year, {'year': year, 'count': 0, 'months': []} + ) + bucket['count'] += int(count) + bucket['months'].append({'month': month, 'count': int(count)}) + + # Already ordered by the SQL ORDER BY (newest first), preserve insertion. + return list(years.values()) + + @router.get("/scan/status") async def get_scan_status(db: AsyncSession = Depends(get_db)): """Get current scan status""" diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx index ba3363c..ca4d6c8 100644 --- a/frontend/src/components/layout/LeftSidebar.tsx +++ b/frontend/src/components/layout/LeftSidebar.tsx @@ -16,10 +16,12 @@ import clsx from 'clsx' import { AddSourceFolderDialog } from '../dialogs/AddSourceFolderDialog' import { sourceFolders, library, photos as photosApi } from '../../services/api' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { format } from 'date-fns' import { toast } from '../ToastContainer' import { useFilterStore } from '../../store/filterStore' import { HeapsPanel } from '../heaps/HeapsPanel' import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail' +import { useDateBucketsQuery } from '../../hooks/useDateBucketsQuery' interface TreeItem { id: string @@ -41,7 +43,12 @@ export function LeftSidebar() { const setRatingMin = useFilterStore((s) => s.setRatingMin) const setFlag = useFilterStore((s) => s.setFlag) const setFolderId = useFilterStore((s) => s.setFolderId) + const setDateFrom = useFilterStore((s) => s.setDateFrom) + const setDateTo = useFilterStore((s) => s.setDateTo) const filterFolderId = useFilterStore((s) => s.folderId) + const filterDateFrom = useFilterStore((s) => s.dateFrom) + const filterDateTo = useFilterStore((s) => s.dateTo) + const { data: dateBuckets = [] } = useDateBucketsQuery() const [dropTargetId, setDropTargetId] = useState(null) // Bulk discard mutation for the drag-onto-Discarded interaction. @@ -115,6 +122,26 @@ export function LeftSidebar() { const folderId = id.slice('folder-'.length) clearAllFilters() setFolderId(folderId) + } else if (id.startsWith('date-year-')) { + const year = parseInt(id.slice('date-year-'.length), 10) + if (Number.isFinite(year)) { + clearAllFilters() + setDateFrom(`${year}-01-01`) + setDateTo(`${year}-12-31`) + } + } else if (id.startsWith('date-month-')) { + // Format: date-month-YYYY-MM + const parts = id.slice('date-month-'.length).split('-') + const year = parseInt(parts[0], 10) + const month = parseInt(parts[1], 10) + if (Number.isFinite(year) && Number.isFinite(month)) { + const lastDay = new Date(year, month, 0).getDate() + const mm = String(month).padStart(2, '0') + const dd = String(lastDay).padStart(2, '0') + clearAllFilters() + setDateFrom(`${year}-${mm}-01`) + setDateTo(`${year}-${mm}-${dd}`) + } } } } @@ -184,6 +211,22 @@ export function LeftSidebar() { setExpandedItems(newExpanded) } + // Build the dynamic By Date subtree from the date_buckets aggregation. + // Year nodes contain month nodes; clicking either sets the date range + // filter (handled in applyLibraryNode). Sorted newest-first by the + // backend already. + const byDateChildren: TreeItem[] = dateBuckets.map((year) => ({ + id: `date-year-${year.year}`, + label: String(year.year), + icon: , + count: year.count, + children: year.months.map((m) => ({ + id: `date-month-${year.year}-${m.month}`, + label: format(new Date(year.year, m.month - 1, 1), 'MMMM'), + count: m.count, + })), + })) + const libraryTree: TreeItem[] = [ { id: 'library', @@ -191,7 +234,12 @@ export function LeftSidebar() { icon: , children: [ { id: 'all-photos', label: 'All Photos', icon: , count: 0 }, - { id: 'by-date', label: 'By Date', icon: }, + { + id: 'by-date', + label: 'By Date', + icon: , + children: byDateChildren, + }, { id: 'rated', label: 'Rated', icon: , count: 0 }, { id: 'discarded', label: 'Discarded', icon: , count: 0 }, ], @@ -213,7 +261,7 @@ export function LeftSidebar() { // Derive whether a tree item is currently the "active" filter target. // Folder rows are selected when the filter store's folderId matches; the // library "All Photos" virtual node is selected when no folder/heap filter - // is set. + // is set; year/month nodes when their date range matches. const isItemActive = (id: string): boolean => { if (id.startsWith('folder-')) { return filterFolderId === id.slice('folder-'.length) @@ -221,6 +269,18 @@ export function LeftSidebar() { if (id === 'all-photos') { return filterFolderId === null && selectedItem === 'all-photos' } + if (id.startsWith('date-year-')) { + const year = id.slice('date-year-'.length) + return filterDateFrom === `${year}-01-01` && filterDateTo === `${year}-12-31` + } + if (id.startsWith('date-month-')) { + const parts = id.slice('date-month-'.length).split('-') + const year = parseInt(parts[0], 10) + const month = parseInt(parts[1], 10) + if (!Number.isFinite(year) || !Number.isFinite(month)) return false + const mm = String(month).padStart(2, '0') + return filterDateFrom === `${year}-${mm}-01` && filterDateTo?.startsWith(`${year}-${mm}-`) === true + } return selectedItem === id } diff --git a/frontend/src/hooks/useDateBucketsQuery.ts b/frontend/src/hooks/useDateBucketsQuery.ts new file mode 100644 index 0000000..b2281b4 --- /dev/null +++ b/frontend/src/hooks/useDateBucketsQuery.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query' +import { library, type DateBucketYear } from '../services/api' + +export const DATE_BUCKETS_QUERY_KEY = ['library', 'date_buckets'] as const + +export function useDateBucketsQuery() { + return useQuery({ + queryKey: DATE_BUCKETS_QUERY_KEY, + queryFn: library.dateBuckets, + staleTime: 60_000, + }) +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index b534b58..d70610d 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -113,6 +113,16 @@ export const photos = { } // Library API +export interface DateBucketMonth { + month: number + count: number +} +export interface DateBucketYear { + year: number + count: number + months: DateBucketMonth[] +} + export const library = { scan: async () => { const response = await api.post('/library/scan') @@ -128,6 +138,12 @@ export const library = { const response = await api.get('/library/stats') return response.data }, + + /** Year/month aggregation for the sidebar By Date drilldown. */ + dateBuckets: async (): Promise => { + const response = await api.get('/library/date_buckets') + return response.data + }, } // Heaps API