feat: group rated view by star rating

Mirrors the tags view: rated section now buckets photos 5★→1★ with
an "Unrated" tail group, instead of a flat ratingMin=1 stream.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 20:30:44 +02:00
parent 03b99a40b0
commit 30cbcb8cd1
3 changed files with 53 additions and 4 deletions

View File

@@ -233,7 +233,7 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
navigateToSection('all-photos', {})
break
case 'rated':
navigateToSection('rated', { ratingMin: 1 })
navigateToSection('rated', { ratingMin: 1, groupBy: 'rating' })
break
case 'discarded':
navigateToSection('discarded', { flag: 'discarded' })

View File

@@ -26,9 +26,11 @@ type TimelineItem =
/**
* Build the flat header|row item array the virtualizer renders.
*
* Three modes:
* Four modes:
* - groupBy='tag': one bucket per unique tag (plus an "Untagged" bucket
* for photos with no tags). A photo with N tags appears in N buckets.
* - groupBy='rating': one bucket per star rating 5..1 (plus "Unrated"
* for rating 0). Each photo lands in exactly one bucket.
* - groupBy='date' AND sortBy is a date field: month buckets (existing).
* - otherwise: one un-headered stream.
*/
@@ -37,7 +39,7 @@ function buildItems(
columns: number,
rowHeight: number,
sortBy: string,
groupBy: 'date' | 'tag'
groupBy: 'date' | 'tag' | 'rating'
): TimelineItem[] {
if (photos.length === 0) return []
@@ -105,6 +107,53 @@ function buildItems(
return items
}
// ── Rating grouping ───────────────────────────────────────────────────
if (groupBy === 'rating') {
// Bucket by star rating. Each photo lands in exactly one bucket;
// rating 0 goes into "Unrated".
const ratingBuckets = new Map<number, PhotoCell[]>()
const unrated: PhotoCell[] = []
photos.forEach((photo, globalIndex) => {
const cell: PhotoCell = { photo, globalIndex }
if (photo.rating > 0) {
const arr = ratingBuckets.get(photo.rating) ?? []
arr.push(cell)
ratingBuckets.set(photo.rating, arr)
} else {
unrated.push(cell)
}
})
// Highest rating first; Unrated goes at the end.
const sortedRatings = Array.from(ratingBuckets.keys()).sort((a, b) => b - a)
let bucketIndex = 0
for (const rating of sortedRatings) {
items.push({
type: 'header',
key: `rating::${bucketIndex}::${rating}`,
label: '★'.repeat(rating),
height: HEADER_HEIGHT,
})
pushRowsForGroup(
`rating::${bucketIndex}::${rating}`,
ratingBuckets.get(rating)!
)
bucketIndex++
}
if (unrated.length > 0) {
items.push({
type: 'header',
key: `rating::${bucketIndex}::__unrated`,
label: 'Unrated',
height: HEADER_HEIGHT,
})
pushRowsForGroup(`rating::${bucketIndex}::unrated`, unrated)
}
return items
}
// ── Date grouping (existing) ──────────────────────────────────────────
const isDateSort = sortBy === 'taken_at' || sortBy === 'added_at'

View File

@@ -11,7 +11,7 @@ export type SortField =
| 'file_size'
| 'rating'
export type SortOrder = 'asc' | 'desc'
export type GroupBy = 'date' | 'tag'
export type GroupBy = 'date' | 'tag' | 'rating'
export interface FilterState {
q: string