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

@ -0,0 +1,186 @@
'use client'
import { ArrowDown, ArrowUp } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import { debugProps } from '@/lib/debug'
export const PRIORITY_LABELS: Record<number, string> = {
1: 'Kritiek',
2: 'Hoog',
3: 'Gemiddeld',
4: 'Laag',
}
export const PRIORITY_OPTIONS: 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' },
]
export type SortDir = 'asc' | 'desc'
export 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>
)
}
interface BacklogFilterPopoverProps<S extends string, So extends string> {
open: boolean
onOpenChange: (open: boolean) => void
filterPriority: number | 'all'
onFilterPriorityChange: (v: number | 'all') => void
filterStatus: S
onFilterStatusChange: (v: S) => void
statusOptions: Array<{ value: S; label: string }>
sort: So
onSortChange: (v: So) => void
sortDir: SortDir
onSortDirChange: (v: SortDir) => void
sortOptions: Array<{ value: So; label: string }>
activeFilterCount: number
onReset: () => void
resetDisabled: boolean
}
export function BacklogFilterPopover<S extends string, So extends string>({
open,
onOpenChange,
filterPriority,
onFilterPriorityChange,
filterStatus,
onFilterStatusChange,
statusOptions,
sort,
onSortChange,
sortDir,
onSortDirChange,
sortOptions,
activeFilterCount,
onReset,
resetDisabled,
}: BacklogFilterPopoverProps<S, So>) {
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger
render={
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
{...debugProps('backlog-filter-popover__trigger')}
>
{`Filters${activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}`}
</Button>
}
/>
<PopoverContent
align="end"
className="w-72 space-y-4"
{...debugProps('backlog-filter-popover', 'BacklogFilterPopover', 'components/shared/backlog-filter-popover.tsx')}
>
<FilterPills
label="Prioriteit"
options={PRIORITY_OPTIONS}
value={filterPriority}
onChange={onFilterPriorityChange}
/>
<FilterPills
label="Status"
options={statusOptions}
value={filterStatus}
onChange={onFilterStatusChange}
/>
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">Sorteren op</p>
<div className="flex flex-wrap gap-1.5">
{sortOptions.map((opt) => {
const active = sort === opt.value
return (
<button
key={opt.value}
type="button"
onClick={() => {
if (active) {
onSortDirChange(sortDir === 'asc' ? 'desc' : 'asc')
} else {
onSortChange(opt.value)
onSortDirChange('asc')
}
}}
aria-label={
active
? `Sorteren op ${opt.label} ${sortDir === 'asc' ? 'oplopend' : 'aflopend'} — klik om te wisselen`
: `Sorteren op ${opt.label}`
}
className={cn(
'inline-flex items-center gap-1 text-xs px-2.5 py-1 rounded-full border transition-colors',
active
? 'bg-primary text-primary-foreground border-primary'
: 'bg-transparent border-border hover:bg-surface-container'
)}
>
<span>{opt.label}</span>
{active && (
sortDir === 'asc'
? <ArrowDown size={12} aria-hidden />
: <ArrowUp size={12} aria-hidden />
)}
</button>
)
})}
</div>
</div>
<div className="flex justify-end pt-1 border-t border-border">
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs"
disabled={resetDisabled}
onClick={onReset}
>
Wis filters
</Button>
</div>
</PopoverContent>
</Popover>
)
}

View file

@ -73,7 +73,7 @@ export function SprintSwitcher({
<TooltipProvider>
<Tooltip>
<TooltipTrigger
className="text-xs text-muted-foreground/50 px-2 cursor-not-allowed select-none"
className="text-sm text-muted-foreground/50 px-2 cursor-not-allowed select-none"
aria-disabled="true"
>
Geen sprints
@ -90,7 +90,7 @@ export function SprintSwitcher({
<DropdownMenu>
<DropdownMenuTrigger
disabled={isPending}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-1 rounded-md hover:bg-surface-container focus:outline-none"
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors px-2 py-1 rounded-md hover:bg-surface-container focus:outline-none"
>
<span className="truncate max-w-[160px]">
{activeSprint ? activeSprint.code : 'Selecteer sprint'}
@ -98,7 +98,7 @@ export function SprintSwitcher({
{activeSprint && (
<span
className={cn(
'text-[10px]',
'text-sm',
buildingSet.has(activeSprint.id) ? 'text-warning' : 'text-muted-foreground',
)}
>
@ -114,7 +114,7 @@ export function SprintSwitcher({
e.preventDefault()
setShowClosed(v => !v)
}}
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs text-muted-foreground hover:bg-surface-container rounded-md"
className="flex items-center gap-2 w-full px-2 py-1.5 text-sm text-muted-foreground hover:bg-surface-container rounded-md"
>
<span
className={cn(
@ -128,7 +128,7 @@ export function SprintSwitcher({
</button>
<DropdownMenuSeparator />
{visibleSprints.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground/70 italic">
<div className="px-2 py-2 text-sm text-muted-foreground/70 italic">
Geen open sprints
</div>
) : (
@ -141,11 +141,11 @@ export function SprintSwitcher({
s.id === activeSprint?.id && 'bg-primary-container text-primary-container-foreground font-medium',
)}
>
<span className="text-xs font-medium shrink-0">{s.code}</span>
<span className="text-xs truncate flex-1">{s.sprint_goal}</span>
<span className="text-sm font-medium shrink-0">{s.code}</span>
<span className="text-sm truncate flex-1">{s.sprint_goal}</span>
<span
className={cn(
'text-[10px] shrink-0',
'text-sm shrink-0',
buildingSet.has(s.id) ? 'text-warning' : 'text-muted-foreground',
)}
>