feat(PBI-76): migrate sprint-backlog to user-settings store

Replaces six useState+useEffect+localStorage flows with selectors
from useUserSettingsStore. Defaults are applied at the selector
level (filterStatus 'OPEN', sort 'code', etc) so the component
matches its previous behaviour. The collapsed Set is derived from
the persisted array, falling back to auto-collapse-DONE when no
preference exists yet. setPref calls are fire-and-forget — the
optimistic flow handles the local state update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-10 12:52:36 +02:00
parent bf6bdf366f
commit c10def601b

View file

@ -1,6 +1,6 @@
'use client'
import { useState, useTransition, useEffect } from 'react'
import { useMemo, useState, useTransition } from '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'
@ -9,7 +9,7 @@ import { toast } from 'sonner'
import { useShallow } from 'zustand/react/shallow'
import { Badge } from '@/components/ui/badge'
import { CodeBadge } from '@/components/shared/code-badge'
import { readLocalStoragePref } from '@/lib/use-local-storage-pref'
import { useUserSettingsStore } from '@/stores/user-settings/store'
import {
BacklogFilterPopover,
PRIORITY_LABELS as SHARED_PRIORITY_LABELS,
@ -453,75 +453,43 @@ interface SprintBacklogRightProps {
}
export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, productId, onAdd }: SprintBacklogRightProps) {
const [collapsed, setCollapsed] = useState<Set<string>>(() => {
const prefs = useUserSettingsStore(
useShallow((s) => s.entities.settings.views?.sprintBacklog ?? {}),
)
const setPref = useUserSettingsStore((s) => s.setPref)
const filterPriority = prefs.filterPriority ?? 'all'
const filterStatus: StoryStatusFilter = prefs.filterStatus ?? 'OPEN'
const sort: PbiSort = prefs.sort ?? 'code'
const sortDir: SortDir = prefs.sortDir ?? 'asc'
const filterPopoverOpen = prefs.filterPopoverOpen ?? false
const collapsed = useMemo<Set<string>>(() => {
if (prefs.collapsedPbis !== undefined) return new Set(prefs.collapsedPbis)
// Default: auto-collapse PBIs whose stories are all DONE.
const auto = new Set<string>()
for (const pbi of pbisWithStories) {
if (pbi.stories.length > 0 && pbi.stories.every(s => s.status === 'DONE')) {
if (pbi.stories.length > 0 && pbi.stories.every((s) => s.status === 'DONE')) {
auto.add(pbi.id)
}
}
return auto
})
const [filterPriority, setFilterPriority] = useState<number | 'all'>('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)
}, [prefs.collapsedPbis, pbisWithStories])
const setFilterPriority = (v: number | 'all') =>
void setPref(['views', 'sprintBacklog', 'filterPriority'], v)
const setFilterStatus = (v: StoryStatusFilter) =>
void setPref(['views', 'sprintBacklog', 'filterStatus'], v)
const setSort = (v: PbiSort) => void setPref(['views', 'sprintBacklog', 'sort'], v)
const setSortDir = (v: SortDir) => void setPref(['views', 'sprintBacklog', 'sortDir'], v)
const setFilterPopoverOpen = (v: boolean) =>
void setPref(['views', 'sprintBacklog', 'filterPopoverOpen'], v)
const setCollapsedArray = (next: Set<string>) =>
void setPref(['views', 'sprintBacklog', 'collapsedPbis'], Array.from(next))
const [pbiDialogState, setPbiDialogState] = useState<PbiDialogState | null>(null)
const { setNodeRef, isOver } = useDroppable({ id: 'backlog-zone' })
// 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(() => {
/* 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 */ }
}
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 => ({
...pbi,
@ -538,19 +506,18 @@ export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, pr
(filterStatus !== 'OPEN' ? 1 : 0)
function toggle(pbiId: string) {
setCollapsed(prev => {
const next = new Set(prev)
if (next.has(pbiId)) { next.delete(pbiId) } else { next.add(pbiId) }
return next
})
const next = new Set(collapsed)
if (next.has(pbiId)) next.delete(pbiId)
else next.add(pbiId)
setCollapsedArray(next)
}
function collapseAll() {
setCollapsed(new Set(filteredPbis.map(p => p.id)))
setCollapsedArray(new Set(filteredPbis.map((p) => p.id)))
}
function expandAll() {
setCollapsed(new Set())
setCollapsedArray(new Set())
}
const headerActions = (