feat: shared backlog filter popover + sprint header polish (v1.3.3) (#184)

- Move sprint switcher into sprint header, centered between title and actions
- Extract BacklogFilterPopover as shared component used by sprint and product backlog
- Add sort options (code/priority/status) with single-pill asc/desc toggle
- Default sprint backlog status filter to OPEN, remove "alleen niet klaar" button
- Persist collapsed state and filter popover open in localStorage
- Fix hydration flicker: defer localStorage read to useEffect with prefsLoaded gate for writes
- Increase sprint switcher text size for readability

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-10 11:12:04 +02:00 committed by GitHub
parent a9b53dedf0
commit 1f8cbacb0a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 424 additions and 330 deletions

View file

@ -1,16 +1,20 @@
'use client'
import { useState, useTransition, useEffect } from 'react'
import { Trash2, MoreHorizontal, ChevronsUp, ChevronsDown, ListFilter, Pencil } from 'lucide-react'
import { Trash2, MoreHorizontal, ChevronsUp, ChevronsDown, Pencil } from 'lucide-react'
import { useDroppable, useDraggable } from '@dnd-kit/core'
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { toast } from 'sonner'
import { useShallow } from 'zustand/react/shallow'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { CodeBadge } from '@/components/shared/code-badge'
import { readLocalStoragePref } from '@/lib/use-local-storage-pref'
import {
BacklogFilterPopover,
PRIORITY_LABELS as SHARED_PRIORITY_LABELS,
type SortDir,
} from '@/components/shared/backlog-filter-popover'
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent,
@ -338,21 +342,6 @@ export function SprintBacklogLeft({
// --- Right panel: Product Backlog grouped by PBI ---
const PRIORITY_LABELS_SPRINT: Record<number, string> = {
1: 'Kritiek',
2: 'Hoog',
3: 'Gemiddeld',
4: 'Laag',
}
const PRIORITY_OPTIONS_SPRINT: Array<{ value: number | 'all'; label: string }> = [
{ value: 'all', label: 'Alle' },
{ value: 1, label: 'Kritiek' },
{ value: 2, label: 'Hoog' },
{ value: 3, label: 'Gemiddeld' },
{ value: 4, label: 'Laag' },
]
type StoryStatusFilter = 'OPEN' | 'IN_SPRINT' | 'DONE' | 'all'
const STATUS_OPTIONS_SPRINT: Array<{ value: StoryStatusFilter; label: string }> = [
@ -362,39 +351,34 @@ const STATUS_OPTIONS_SPRINT: Array<{ value: StoryStatusFilter; label: string }>
{ value: 'DONE', label: 'Klaar' },
]
function FilterPills<T extends string | number>({
label,
options,
value,
onChange,
}: {
label: string
options: Array<{ value: T; label: string }>
value: T
onChange: (v: T) => void
}) {
return (
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<div className="flex flex-wrap gap-1.5">
{options.map((opt) => (
<button
key={String(opt.value)}
type="button"
onClick={() => onChange(opt.value)}
className={cn(
'text-xs px-2.5 py-1 rounded-full border transition-colors',
value === opt.value
? 'bg-primary text-primary-foreground border-primary'
: 'bg-transparent border-border hover:bg-surface-container'
)}
>
{opt.label}
</button>
))}
</div>
</div>
)
type PbiSort = 'code' | 'priority' | 'status'
const SORT_OPTIONS_SPRINT: Array<{ value: PbiSort; label: string }> = [
{ value: 'code', label: 'Code' },
{ value: 'priority', label: 'Prioriteit' },
{ value: 'status', label: 'Status' },
]
const PBI_STATUS_ORDER: Record<PbiStatusApi, number> = {
ready: 0,
blocked: 1,
failed: 2,
done: 3,
}
function comparePbis(a: PbiWithStories, b: PbiWithStories, sort: PbiSort): number {
const codeCmp = (a.code ?? '').localeCompare(b.code ?? '', undefined, { numeric: true })
if (sort === 'priority') {
if (a.priority !== b.priority) return a.priority - b.priority
return codeCmp
}
if (sort === 'status') {
const sa = PBI_STATUS_ORDER[a.status] ?? 99
const sb = PBI_STATUS_ORDER[b.status] ?? 99
if (sa !== sb) return sa - sb
return codeCmp
}
return codeCmp
}
function DraggablePbiStoryRow({
@ -479,31 +463,64 @@ export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, pr
return auto
})
const [filterPriority, setFilterPriority] = useState<number | 'all'>('all')
const [filterStatus, setFilterStatus] = useState<StoryStatusFilter>('all')
const [filterStatus, setFilterStatus] = useState<StoryStatusFilter>('OPEN')
const [sort, setSort] = useState<PbiSort>('code')
const [sortDir, setSortDir] = useState<SortDir>('asc')
const [filterPopoverOpen, setFilterPopoverOpen] = useState(false)
const [prefsLoaded, setPrefsLoaded] = useState(false)
const [pbiDialogState, setPbiDialogState] = useState<PbiDialogState | null>(null)
const { setNodeRef, isOver } = useDroppable({ id: 'backlog-zone' })
// Hydrate filter prefs from localStorage post-mount (avoids SSR mismatch).
// setState calls here are intentional: hydrating from localStorage on first paint.
// Hydrate prefs from localStorage post-mount. SSR & first client render use
// defaults — matched HTML so no hydration error. After mount we apply saved
// values; users with saved == default see no visible change, others see one
// filter update during hydration.
useEffect(() => {
const savedPriority = localStorage.getItem('scrum4me:sprint_pb_filter_priority')
if (savedPriority && savedPriority !== 'all') {
const n = parseInt(savedPriority, 10)
// eslint-disable-next-line react-hooks/set-state-in-effect
if (Number.isInteger(n) && n >= 1 && n <= 4) setFilterPriority(n)
/* eslint-disable react-hooks/set-state-in-effect */
setFilterPriority(readLocalStoragePref<number | 'all'>(
'scrum4me:sprint_pb_filter_priority',
(raw) => {
if (raw === 'all') return 'all'
const n = parseInt(raw, 10)
return Number.isInteger(n) && n >= 1 && n <= 4 ? n : null
},
'all',
))
setFilterStatus(readLocalStoragePref<StoryStatusFilter>(
'scrum4me:sprint_pb_filter_status',
(raw) => (raw === 'OPEN' || raw === 'IN_SPRINT' || raw === 'DONE' || raw === 'all') ? raw : null,
'OPEN',
))
setSort(readLocalStoragePref<PbiSort>(
'scrum4me:sprint_pb_sort',
(raw) => (raw === 'priority' || raw === 'status' || raw === 'code') ? raw : null,
'code',
))
setSortDir(readLocalStoragePref<SortDir>(
'scrum4me:sprint_pb_sort_dir',
(raw) => (raw === 'asc' || raw === 'desc') ? raw : null,
'asc',
))
const savedCollapsed = localStorage.getItem('scrum4me:sprint_pb_collapsed')
if (savedCollapsed) {
try {
const arr = JSON.parse(savedCollapsed)
if (Array.isArray(arr)) {
setCollapsed(new Set(arr.filter((x): x is string => typeof x === 'string')))
}
} catch { /* ignore malformed JSON */ }
}
const savedStatus = localStorage.getItem('scrum4me:sprint_pb_filter_status')
if (savedStatus === 'OPEN' || savedStatus === 'IN_SPRINT' || savedStatus === 'DONE') {
setFilterStatus(savedStatus)
}
setFilterPopoverOpen(localStorage.getItem('scrum4me:sprint_pb_filter_popover_open') === 'true')
setPrefsLoaded(true)
/* eslint-enable react-hooks/set-state-in-effect */
}, [])
useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:sprint_pb_filter_priority', String(filterPriority)) }, [filterPriority, prefsLoaded])
useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:sprint_pb_filter_status', filterStatus) }, [filterStatus, prefsLoaded])
useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:sprint_pb_sort', sort) }, [sort, prefsLoaded])
useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:sprint_pb_sort_dir', sortDir) }, [sortDir, prefsLoaded])
useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:sprint_pb_collapsed', JSON.stringify(Array.from(collapsed))) }, [collapsed, prefsLoaded])
useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:sprint_pb_filter_popover_open', String(filterPopoverOpen)) }, [filterPopoverOpen, prefsLoaded])
const filteredPbis = pbisWithStories
.map(pbi => ({
@ -514,10 +531,11 @@ export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, pr
),
}))
.filter(pbi => pbi.stories.length > 0)
.sort((a, b) => (sortDir === 'desc' ? -1 : 1) * comparePbis(a, b, sort))
const activeFilterCount =
(filterPriority !== 'all' ? 1 : 0) +
(filterStatus !== 'all' ? 1 : 0)
(filterStatus !== 'OPEN' ? 1 : 0)
function toggle(pbiId: string) {
setCollapsed(prev => {
@ -535,16 +553,6 @@ export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, pr
setCollapsed(new Set())
}
function onlyNotDone() {
const auto = new Set<string>()
for (const pbi of filteredPbis) {
if (pbi.stories.length > 0 && pbi.stories.every(s => s.status === 'DONE')) {
auto.add(pbi.id)
}
}
setCollapsed(auto)
}
const headerActions = (
<>
{filterPriority !== 'all' && (
@ -554,61 +562,45 @@ export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, pr
aria-label="Wis prioriteitsfilter"
>
<Badge className={cn('text-xs', PRIORITY_COLORS[filterPriority])}>
{PRIORITY_LABELS_SPRINT[filterPriority]}
{SHARED_PRIORITY_LABELS[filterPriority]}
</Badge>
<span>×</span>
</button>
)}
{filterStatus !== 'all' && (
{filterStatus !== 'OPEN' && (
<button
onClick={() => setFilterStatus('all')}
onClick={() => setFilterStatus('OPEN')}
className="flex items-center gap-1 text-xs text-primary hover:underline"
aria-label="Wis statusfilter"
>
<Badge className={cn('text-[10px] px-1.5 py-0 border', STATUS_COLORS[filterStatus])}>
{STATUS_LABELS[filterStatus]}
<Badge className={cn('text-[10px] px-1.5 py-0 border', filterStatus === 'all' ? '' : STATUS_COLORS[filterStatus])}>
{filterStatus === 'all' ? 'Alle' : STATUS_LABELS[filterStatus]}
</Badge>
<span>×</span>
</button>
)}
<Popover>
<PopoverTrigger
render={
<Button variant="outline" size="sm" className="h-7 text-xs">
{`Filters${activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}`}
</Button>
}
/>
<PopoverContent align="end" className="w-72 space-y-4">
<FilterPills
label="Prioriteit"
options={PRIORITY_OPTIONS_SPRINT}
value={filterPriority}
onChange={setFilterPriority}
/>
<FilterPills
label="Status"
options={STATUS_OPTIONS_SPRINT}
value={filterStatus}
onChange={setFilterStatus}
/>
<div className="flex justify-end pt-1 border-t border-border">
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs"
disabled={activeFilterCount === 0}
onClick={() => {
setFilterPriority('all')
setFilterStatus('all')
}}
>
Wis filters
</Button>
</div>
</PopoverContent>
</Popover>
<BacklogFilterPopover
open={filterPopoverOpen}
onOpenChange={setFilterPopoverOpen}
filterPriority={filterPriority}
onFilterPriorityChange={setFilterPriority}
filterStatus={filterStatus}
onFilterStatusChange={setFilterStatus}
statusOptions={STATUS_OPTIONS_SPRINT}
sort={sort}
onSortChange={setSort}
sortDir={sortDir}
onSortDirChange={setSortDir}
sortOptions={SORT_OPTIONS_SPRINT}
activeFilterCount={activeFilterCount}
resetDisabled={filterPriority === 'all' && filterStatus === 'OPEN' && sort === 'code' && sortDir === 'asc'}
onReset={() => {
setFilterPriority('all')
setFilterStatus('OPEN')
setSort('code')
setSortDir('asc')
}}
/>
<TooltipProvider>
<Tooltip>
<TooltipTrigger onClick={collapseAll} className="text-muted-foreground hover:text-foreground p-0.5 rounded" aria-label="Alles inklappen">
@ -622,12 +614,6 @@ export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, pr
</TooltipTrigger>
<TooltipContent>Alles uitklappen</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger onClick={onlyNotDone} className="text-muted-foreground hover:text-foreground p-0.5 rounded" aria-label="Alleen niet klaar">
<ListFilter size={14} />
</TooltipTrigger>
<TooltipContent>Alleen niet klaar</TooltipContent>
</Tooltip>
</TooltipProvider>
</>
)

View file

@ -33,6 +33,8 @@ import {
import { updateSprintGoalAction, updateSprintDatesAction, completeSprintAction, setAllSprintTasksDoneAction } from '@/actions/sprints'
import type { SprintStory } from './sprint-backlog'
import { debugProps } from '@/lib/debug'
import { SprintSwitcher } from '@/components/shared/sprint-switcher'
import type { SprintSwitcherItem } from '@/lib/sprint-switcher-data'
interface Sprint {
id: string
@ -49,6 +51,9 @@ interface SprintHeaderProps {
sprint: Sprint
isDemo: boolean
sprintStories: SprintStory[]
switcherSprints: SprintSwitcherItem[]
switcherActiveSprint: SprintSwitcherItem | null
switcherBuildingSprintIds: string[]
}
interface ActionResult {
@ -63,7 +68,7 @@ function toDateInputValue(d: Date | null) {
return d.toISOString().slice(0, 10)
}
export function SprintHeader({ productId: _productId, productName, sprint, isDemo, sprintStories }: SprintHeaderProps) {
export function SprintHeader({ productId, productName, sprint, isDemo, sprintStories, switcherSprints, switcherActiveSprint, switcherBuildingSprintIds }: SprintHeaderProps) {
const [editingGoal, setEditingGoal] = useState(false)
const [editingDates, setEditingDates] = useState(false)
const [completeOpen, setCompleteOpen] = useState(false)
@ -132,7 +137,7 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem
return (
<div className="px-4 py-3 border-b border-border bg-surface-container-low shrink-0" {...debugProps('sprint-header', 'SprintHeader', 'components/sprint/sprint-header.tsx')}>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{productName}</span>
@ -162,7 +167,16 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem
)}
</div>
<div className="flex items-center gap-2 shrink-0" data-debug-id="sprint-header__actions">
<div className="shrink-0">
<SprintSwitcher
productId={productId}
sprints={switcherSprints}
activeSprint={switcherActiveSprint}
buildingSprintIds={switcherBuildingSprintIds}
/>
</div>
<div className="flex items-center justify-end gap-2 flex-1 shrink-0" data-debug-id="sprint-header__actions">
<DemoTooltip show={isDemo}>
<Button size="sm" variant="ghost" disabled={isDemo} className="text-muted-foreground" data-debug-id="sprint-header__dates" onClick={() => !isDemo && setEditingDates(true)}>
{sprint.start_date && sprint.end_date