feat: By Date sidebar — year/month drilldown navigation
The "By Date" library node was decorative. Now it's a real
hierarchical navigator: expand to see year buckets (with photo
counts), expand a year to see its months, click any year or
month to filter the timeline to that date range.
Backend
- New GET /library/date_buckets aggregates non-discarded photos
by year+month from Photo.taken_at via SQLite strftime, returning
[{ year, count, months: [{ month, count }] }] sorted newest-
first. NULL taken_at rows are excluded.
Frontend
- New library.dateBuckets() helper + DateBucketYear / Month types.
- New hooks/useDateBucketsQuery.ts with a 60s staleTime.
- LeftSidebar builds the By Date subtree dynamically from the
query: each year is a tree node with month children. Year nodes
use the Calendar icon, months render as their full English name.
- applyLibraryNode handles two new id prefixes:
'date-year-{year}' → setDateFrom YYYY-01-01, setDateTo YYYY-12-31
'date-month-{Y}-{M}'→ setDateFrom YYYY-MM-01, setDateTo YYYY-MM-LL
where LL is the last day of the month (computed via Date trick
new Date(year, month, 0).getDate() — uses month-day=0 to roll
back into the previous month's last day).
- isItemActive recognises when the current dateFrom/dateTo matches
a year or month node so the sidebar selection highlight stays
in sync with the filter store (also when filters are set
externally via the filter bar or URL hydration).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -47,6 +47,50 @@ async def trigger_scan():
|
|||||||
|
|
||||||
return {"status": "success", "message": "Library scan started"}
|
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")
|
@router.get("/scan/status")
|
||||||
async def get_scan_status(db: AsyncSession = Depends(get_db)):
|
async def get_scan_status(db: AsyncSession = Depends(get_db)):
|
||||||
"""Get current scan status"""
|
"""Get current scan status"""
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ import clsx from 'clsx'
|
|||||||
import { AddSourceFolderDialog } from '../dialogs/AddSourceFolderDialog'
|
import { AddSourceFolderDialog } from '../dialogs/AddSourceFolderDialog'
|
||||||
import { sourceFolders, library, photos as photosApi } from '../../services/api'
|
import { sourceFolders, library, photos as photosApi } from '../../services/api'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { format } from 'date-fns'
|
||||||
import { toast } from '../ToastContainer'
|
import { toast } from '../ToastContainer'
|
||||||
import { useFilterStore } from '../../store/filterStore'
|
import { useFilterStore } from '../../store/filterStore'
|
||||||
import { HeapsPanel } from '../heaps/HeapsPanel'
|
import { HeapsPanel } from '../heaps/HeapsPanel'
|
||||||
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
|
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
|
||||||
|
import { useDateBucketsQuery } from '../../hooks/useDateBucketsQuery'
|
||||||
|
|
||||||
interface TreeItem {
|
interface TreeItem {
|
||||||
id: string
|
id: string
|
||||||
@@ -41,7 +43,12 @@ export function LeftSidebar() {
|
|||||||
const setRatingMin = useFilterStore((s) => s.setRatingMin)
|
const setRatingMin = useFilterStore((s) => s.setRatingMin)
|
||||||
const setFlag = useFilterStore((s) => s.setFlag)
|
const setFlag = useFilterStore((s) => s.setFlag)
|
||||||
const setFolderId = useFilterStore((s) => s.setFolderId)
|
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 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<string | null>(null)
|
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
|
||||||
|
|
||||||
// Bulk discard mutation for the drag-onto-Discarded interaction.
|
// Bulk discard mutation for the drag-onto-Discarded interaction.
|
||||||
@@ -115,6 +122,26 @@ export function LeftSidebar() {
|
|||||||
const folderId = id.slice('folder-'.length)
|
const folderId = id.slice('folder-'.length)
|
||||||
clearAllFilters()
|
clearAllFilters()
|
||||||
setFolderId(folderId)
|
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)
|
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: <Calendar className="h-4 w-4" />,
|
||||||
|
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[] = [
|
const libraryTree: TreeItem[] = [
|
||||||
{
|
{
|
||||||
id: 'library',
|
id: 'library',
|
||||||
@@ -191,7 +234,12 @@ export function LeftSidebar() {
|
|||||||
icon: <HardDrive className="h-4 w-4" />,
|
icon: <HardDrive className="h-4 w-4" />,
|
||||||
children: [
|
children: [
|
||||||
{ id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: 0 },
|
{ id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: 0 },
|
||||||
{ id: 'by-date', label: 'By Date', icon: <Calendar className="h-4 w-4" /> },
|
{
|
||||||
|
id: 'by-date',
|
||||||
|
label: 'By Date',
|
||||||
|
icon: <Calendar className="h-4 w-4" />,
|
||||||
|
children: byDateChildren,
|
||||||
|
},
|
||||||
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: 0 },
|
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: 0 },
|
||||||
{ id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: 0 },
|
{ id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: 0 },
|
||||||
],
|
],
|
||||||
@@ -213,7 +261,7 @@ export function LeftSidebar() {
|
|||||||
// Derive whether a tree item is currently the "active" filter target.
|
// Derive whether a tree item is currently the "active" filter target.
|
||||||
// Folder rows are selected when the filter store's folderId matches; the
|
// Folder rows are selected when the filter store's folderId matches; the
|
||||||
// library "All Photos" virtual node is selected when no folder/heap filter
|
// 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 => {
|
const isItemActive = (id: string): boolean => {
|
||||||
if (id.startsWith('folder-')) {
|
if (id.startsWith('folder-')) {
|
||||||
return filterFolderId === id.slice('folder-'.length)
|
return filterFolderId === id.slice('folder-'.length)
|
||||||
@@ -221,6 +269,18 @@ export function LeftSidebar() {
|
|||||||
if (id === 'all-photos') {
|
if (id === 'all-photos') {
|
||||||
return filterFolderId === null && selectedItem === '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
|
return selectedItem === id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
12
frontend/src/hooks/useDateBucketsQuery.ts
Normal file
12
frontend/src/hooks/useDateBucketsQuery.ts
Normal file
@@ -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<DateBucketYear[]>({
|
||||||
|
queryKey: DATE_BUCKETS_QUERY_KEY,
|
||||||
|
queryFn: library.dateBuckets,
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -113,6 +113,16 @@ export const photos = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Library API
|
// Library API
|
||||||
|
export interface DateBucketMonth {
|
||||||
|
month: number
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
export interface DateBucketYear {
|
||||||
|
year: number
|
||||||
|
count: number
|
||||||
|
months: DateBucketMonth[]
|
||||||
|
}
|
||||||
|
|
||||||
export const library = {
|
export const library = {
|
||||||
scan: async () => {
|
scan: async () => {
|
||||||
const response = await api.post('/library/scan')
|
const response = await api.post('/library/scan')
|
||||||
@@ -128,6 +138,12 @@ export const library = {
|
|||||||
const response = await api.get('/library/stats')
|
const response = await api.get('/library/stats')
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Year/month aggregation for the sidebar By Date drilldown. */
|
||||||
|
dateBuckets: async (): Promise<DateBucketYear[]> => {
|
||||||
|
const response = await api.get('/library/date_buckets')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Heaps API
|
// Heaps API
|
||||||
|
|||||||
Reference in New Issue
Block a user