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:
2026-04-08 00:14:57 +02:00
parent 16481730b7
commit 7d33e1688a
4 changed files with 134 additions and 2 deletions

View File

@@ -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<string | null>(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: <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[] = [
{
id: 'library',
@@ -191,7 +234,12 @@ export function LeftSidebar() {
icon: <HardDrive className="h-4 w-4" />,
children: [
{ 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: '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.
// 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
}