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

@ -11,7 +11,6 @@ import {
import { SprintTaskDialogMount } from '@/components/sprint/sprint-task-dialog-mount'
import { SprintUrlTaskSync } from '@/components/sprint/sprint-url-task-sync'
import { SyncActiveSprintCookie } from '@/components/sprint/sync-active-sprint-cookie'
import { SprintSwitcher } from '@/components/shared/sprint-switcher'
import { getSprintSwitcherData } from '@/lib/sprint-switcher-data'
import { SprintHeader } from '@/components/sprint/sprint-header'
import { SprintRunControls } from '@/components/sprint/sprint-run-controls'
@ -182,22 +181,17 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
}
return (
<div className="flex flex-col h-full">
<div id="wrapper2" className="flex flex-col h-full">
<SyncActiveSprintCookie productId={id} sprintId={sprint.id} />
<div className="px-4 py-3 border-b border-border bg-surface-container-low shrink-0 flex items-center justify-center">
<SprintSwitcher
productId={id}
sprints={switcherData.sprintItems}
activeSprint={switcherData.activeSprintItem}
buildingSprintIds={switcherData.buildingSprintIds}
/>
</div>
<SprintHeader
productId={id}
productName={product.name}
sprint={sprint}
isDemo={isDemo}
sprintStories={sprintStoryItems}
switcherSprints={switcherData.sprintItems}
switcherActiveSprint={switcherData.activeSprintItem}
switcherBuildingSprintIds={switcherData.buildingSprintIds}
/>
<div className="border-b border-border bg-surface-container-low px-4 py-2 shrink-0">

View file

@ -24,8 +24,13 @@ import { toast } from 'sonner'
import { CheckSquare, Square } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import {
BacklogFilterPopover,
PRIORITY_LABELS,
type SortDir,
} from '@/components/shared/backlog-filter-popover'
import { useShallow } from 'zustand/react/shallow'
import { readLocalStoragePref } from '@/lib/use-local-storage-pref'
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
import { selectVisiblePbis } from '@/stores/product-workspace/selectors'
import type { BacklogPbi as WorkspacePbi } from '@/stores/product-workspace/types'
@ -42,14 +47,6 @@ import { PRIORITY_COLORS } from '@/components/shared/priority-select'
import { PBI_STATUS_LABELS, PBI_STATUS_COLORS } from '@/components/shared/pbi-status-select'
import type { PbiStatusApi } from '@/lib/task-status'
const PRIORITY_LABELS: Record<number, string> = {
1: 'Kritiek',
2: 'Hoog',
3: 'Gemiddeld',
4: 'Laag',
}
type SortMode = 'priority' | 'code' | 'date'
const SORT_OPTIONS: Array<{ value: SortMode; label: string }> = [
@ -58,56 +55,15 @@ const SORT_OPTIONS: Array<{ value: SortMode; label: string }> = [
{ value: 'date', label: 'Datum' },
]
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' },
]
type PbiStatusFilter = PbiStatusApi | 'all'
const STATUS_OPTIONS: Array<{ value: PbiStatusApi | 'all'; label: string }> = [
const STATUS_OPTIONS: Array<{ value: PbiStatusFilter; label: string }> = [
{ value: 'all', label: 'Alle' },
{ value: 'ready', label: 'Klaar' },
{ value: 'blocked', label: 'Geblokkeerd' },
{ value: 'done', label: 'Afgerond' },
]
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 Pbi {
id: string
code: string | null
@ -243,12 +199,11 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
// voorkomt re-render op ongerelateerde store-mutaties (G2).
const pbis = useProductWorkspaceStore(useShallow(selectVisiblePbis)) as WorkspacePbi[]
const selectedPbiId = useProductWorkspaceStore((s) => s.context.activePbiId)
// Defaults match SSR; persisted values applied post-mount in the loader effect below.
// This avoids hydration mismatch when localStorage holds non-default values.
const [filterPriority, setFilterPriority] = useState<number | 'all'>('all')
const [filterStatus, setFilterStatus] = useState<PbiStatusApi | 'all'>('all')
const [filterStatus, setFilterStatus] = useState<PbiStatusFilter>('all')
const [sortMode, setSortMode] = useState<SortMode>('priority')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
const [sortDir, setSortDir] = useState<SortDir>('asc')
const [filterPopoverOpen, setFilterPopoverOpen] = useState(false)
const [prefsLoaded, setPrefsLoaded] = useState(false)
const [dialogState, setDialogState] = useState<PbiDialogState | null>(null)
const [activeDragId, setActiveDragId] = useState<string | null>(null)
@ -271,29 +226,39 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
})
}
// Load persisted preferences once after mount (client-only).
// setState calls here are intentional: hydrating from localStorage on first paint.
// Hydrate prefs post-mount; SSR + first client render use defaults so no
// hydration mismatch. Users with saved == default see no change; others see
// one filter update right after hydration.
useEffect(() => {
const savedSort = localStorage.getItem('scrum4me:pbi_sort')
if (savedSort === 'priority' || savedSort === 'code' || savedSort === 'date') {
// eslint-disable-next-line react-hooks/set-state-in-effect
setSortMode(savedSort)
}
const savedPriority = localStorage.getItem('scrum4me:pbi_filter_priority')
if (savedPriority && savedPriority !== 'all') {
const n = parseInt(savedPriority, 10)
if (Number.isInteger(n) && n >= 1 && n <= 4) setFilterPriority(n)
}
const savedStatus = localStorage.getItem('scrum4me:pbi_filter_status')
if (savedStatus === 'ready' || savedStatus === 'blocked' || savedStatus === 'done') {
setFilterStatus(savedStatus)
}
const savedDir = localStorage.getItem('scrum4me:pbi_sort_dir')
if (savedDir === 'asc' || savedDir === 'desc') setSortDir(savedDir)
/* eslint-disable react-hooks/set-state-in-effect */
setSortMode(readLocalStoragePref<SortMode>(
'scrum4me:pbi_sort',
(raw) => (raw === 'priority' || raw === 'code' || raw === 'date') ? raw : null,
'priority',
))
setFilterPriority(readLocalStoragePref<number | 'all'>(
'scrum4me:pbi_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<PbiStatusFilter>(
'scrum4me:pbi_filter_status',
(raw) => (raw === 'ready' || raw === 'blocked' || raw === 'done' || raw === 'all') ? raw : null,
'all',
))
setSortDir(readLocalStoragePref<SortDir>(
'scrum4me:pbi_sort_dir',
(raw) => (raw === 'asc' || raw === 'desc') ? raw : null,
'asc',
))
setPrefsLoaded(true)
/* eslint-enable react-hooks/set-state-in-effect */
}, [])
// Persist on change, but skip the initial render so we don't overwrite saved values with defaults.
useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:pbi_sort', sortMode) }, [sortMode, prefsLoaded])
useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:pbi_filter_priority', String(filterPriority)) }, [filterPriority, prefsLoaded])
useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:pbi_filter_status', filterStatus) }, [filterStatus, prefsLoaded])
@ -317,14 +282,15 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
(sortDir !== 'asc' ? 1 : 0)
const filtered = [...base].sort((a, b) => {
let cmp = 0
if (sortMode === 'code') {
return (a.code ?? '').localeCompare(b.code ?? '', 'nl', { numeric: true })
cmp = (a.code ?? '').localeCompare(b.code ?? '', 'nl', { numeric: true })
} else if (sortMode === 'date') {
cmp = new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
} else {
cmp = a.priority !== b.priority ? a.priority - b.priority : 0
}
if (sortMode === 'date') {
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
}
// priority: sort by priority asc, then drag-and-drop sort_order within group
return a.priority !== b.priority ? a.priority - b.priority : 0
return sortDir === 'desc' ? -cmp : cmp
})
const sensors = useSensors(
@ -439,96 +405,28 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
<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">
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-muted-foreground">Sorteren op</p>
<div className="flex gap-1">
<button
type="button"
onClick={() => setSortDir('asc')}
className={cn(
'text-xs px-2 py-0.5 rounded border transition-colors',
sortDir === 'asc'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-transparent border-border hover:bg-surface-container'
)}
aria-label="Oplopend sorteren"
>
</button>
<button
type="button"
onClick={() => setSortDir('desc')}
className={cn(
'text-xs px-2 py-0.5 rounded border transition-colors',
sortDir === 'desc'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-transparent border-border hover:bg-surface-container'
)}
aria-label="Aflopend sorteren"
>
</button>
</div>
</div>
<div className="flex flex-wrap gap-1.5">
{SORT_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setSortMode(opt.value)}
className={cn(
'text-xs px-2.5 py-1 rounded-full border transition-colors',
sortMode === opt.value
? 'bg-primary text-primary-foreground border-primary'
: 'bg-transparent border-border hover:bg-surface-container'
)}
>
{opt.label}
</button>
))}
</div>
</div>
<FilterPills
label="Prioriteit"
options={PRIORITY_OPTIONS}
value={filterPriority}
onChange={setFilterPriority}
/>
<FilterPills
label="Status"
options={STATUS_OPTIONS}
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')
setSortMode('priority')
setSortDir('asc')
}}
>
Wis filters
</Button>
</div>
</PopoverContent>
</Popover>
<BacklogFilterPopover
open={filterPopoverOpen}
onOpenChange={setFilterPopoverOpen}
filterPriority={filterPriority}
onFilterPriorityChange={setFilterPriority}
filterStatus={filterStatus}
onFilterStatusChange={setFilterStatus}
statusOptions={STATUS_OPTIONS}
sort={sortMode}
onSortChange={setSortMode}
sortDir={sortDir}
onSortDirChange={setSortDir}
sortOptions={SORT_OPTIONS}
activeFilterCount={activeFilterCount}
resetDisabled={activeFilterCount === 0}
onReset={() => {
setFilterPriority('all')
setFilterStatus('all')
setSortMode('priority')
setSortDir('asc')
}}
/>
<DemoTooltip show={isDemo}>
<Button
size="sm"

View file

@ -115,10 +115,6 @@ export default function JobsColumn({
statusOptions,
emptyText,
}: JobsColumnProps) {
const [filterKinds, setFilterKinds] = useState<Set<ClaudeJobKind>>(() => new Set())
const [filterStatuses, setFilterStatuses] = useState<Set<ClaudeJobStatusApi>>(() => new Set())
const [prefsLoaded, setPrefsLoaded] = useState(false)
const kindKey = `${storageKeyPrefix}_filter_kind`
const statusKey = `${storageKeyPrefix}_filter_status`
@ -127,8 +123,11 @@ export default function JobsColumn({
[statusOptions]
)
const [filterKinds, setFilterKinds] = useState<Set<ClaudeJobKind>>(() => new Set())
const [filterStatuses, setFilterStatuses] = useState<Set<ClaudeJobStatusApi>>(() => new Set())
const [prefsLoaded, setPrefsLoaded] = useState(false)
useEffect(() => {
// Hydratie van localStorage post-mount: zetters intentioneel, voorkomt SSR-mismatch.
/* eslint-disable react-hooks/set-state-in-effect */
setFilterKinds(parseCsv<ClaudeJobKind>(localStorage.getItem(kindKey), KIND_VALUES))
setFilterStatuses(parseCsv<ClaudeJobStatusApi>(localStorage.getItem(statusKey), statusValues))
@ -136,13 +135,8 @@ export default function JobsColumn({
/* eslint-enable react-hooks/set-state-in-effect */
}, [kindKey, statusKey, statusValues])
useEffect(() => {
if (prefsLoaded) localStorage.setItem(kindKey, setToCsv(filterKinds))
}, [filterKinds, prefsLoaded, kindKey])
useEffect(() => {
if (prefsLoaded) localStorage.setItem(statusKey, setToCsv(filterStatuses))
}, [filterStatuses, prefsLoaded, statusKey])
useEffect(() => { if (prefsLoaded) localStorage.setItem(kindKey, setToCsv(filterKinds)) }, [filterKinds, prefsLoaded, kindKey])
useEffect(() => { if (prefsLoaded) localStorage.setItem(statusKey, setToCsv(filterStatuses)) }, [filterStatuses, prefsLoaded, statusKey])
function toggleKind(v: ClaudeJobKind) {
setFilterKinds((prev) => {

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',
)}
>

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

View file

@ -0,0 +1,22 @@
/**
* SSR-safe synchronous read of a localStorage value with a typed parser.
*
* Use inside `useState(() => readLocalStoragePref(...))` so the first render
* already has the persisted value no useEffect-driven re-render flicker.
*
* On the server `window` is undefined returns `fallback`. On the client the
* raw value is parsed; if the parser returns `null` the fallback is used.
* Hydration mismatches between server-rendered HTML (default) and the
* client-rendered tree (persisted) are accepted: React adapts the DOM in the
* same hydration pass without a visible flicker for matching values.
*/
export function readLocalStoragePref<T>(
key: string,
parse: (raw: string) => T | null,
fallback: T,
): T {
if (typeof window === 'undefined') return fallback
const raw = window.localStorage.getItem(key)
if (raw === null) return fallback
return parse(raw) ?? fallback
}

View file

@ -1,6 +1,6 @@
{
"name": "scrum4me",
"version": "1.3.2",
"version": "1.3.3",
"private": true,
"scripts": {
"predev": "npx --yes kill-port 3000 || exit 0",