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"}
|
||||
|
||||
@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"""
|
||||
|
||||
Reference in New Issue
Block a user