Scrum4Me/components/jobs/jobs-column.tsx
Janpeter Visser 852945efa3
feat(PBI-76): migrate localStorage prefs to user-settings store (Phase 1) (#188)
* feat(PBI-76): one-shot localStorage→user-settings migration helper

Reads all legacy keys (sprint_pb_*, pbi_*, story_sort, debug-mode,
and dynamic *_filter_kind/*_filter_status for jobs columns) and
returns a typed UserSettings patch plus the keys to clear.
Idempotent via scrum4me:settings_migrated=v1 marker. Skips invalid
values silently so existing corrupt entries do not block migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(PBI-76): bridge runs one-shot localStorage migration

After hydrate, scans legacy localStorage keys via buildMigrationPatch
and, if any data is found, pushes one bulk patch to the server,
applies it locally, then removes the legacy keys. Demo accounts skip
the migration entirely. Cancellable on unmount to avoid setState on
unmounted component.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 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>

* feat(PBI-76): migrate pbi-list to user-settings store

Same pattern as sprint-backlog: replaces local useState +
localStorage hydration/persist with selectors from
useUserSettingsStore. filterPopoverOpen blijft lokaal — die
was nooit gepersisteerd in pbi-list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(PBI-76): migrate story-panel sort to user-settings store

Single pref (sortMode) — replaces sync localStorage useState
initializer with a selector. Default 'priority' applied at
the read site.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(PBI-76): migrate jobs-column to user-settings store

Per-instance filter state (kinds + statuses) now lives under
views.jobsColumns[storageKeyPrefix] in user-settings. Removes
the local CSV-encoding helpers — store keeps arrays natively.
A single persist() call writes both fields together so the
two arrays cannot drift in optimistic mid-flight updates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(PBI-76): migrate debug-mode to user-settings store

DebugToggle reads debugMode from user-settings.devTools and
toggles via setPref. Removes the standalone stores/debug-store.ts
(no consumers left). Body classlist update only fires after the
store is hydrated to avoid a flash on initial paint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(PBI-76): remove unused readLocalStoragePref helper

No consumers left after migrating sprint-backlog, pbi-list,
story-panel, jobs-column, and debug-store to user-settings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(PBI-76): mock user-settings action in backlog integration test

PbiList now imports the user-settings store, which transitively
loads actions/user-settings.ts → lib/prisma. The vitest jsdom
environment has no DATABASE_URL, so we add a mock alongside the
existing action mocks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(docs): allow balanced parens in markdown link URLs

Previously the link-checker regex stopped at the first ')',
breaking on Next.js route-group paths like `app/(app)/...`. The
new regex matches one level of balanced parens inside the URL.

Caught by CI on PR #188 — pre-existing breakage from PBI-78 plan
doc that was already merged on main.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:13:39 +02:00

258 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { Button } from '@/components/ui/button'
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
import JobCard from './job-card'
import { JOB_STATUS_LABELS } from '@/components/shared/job-status'
import { jobStatusToApi, type ClaudeJobStatusApi } from '@/lib/job-status'
import { useUserSettingsStore } from '@/stores/user-settings/store'
import { cn } from '@/lib/utils'
import { debugProps } from '@/lib/debug'
import type { JobWithRelations } from '@/actions/jobs-page'
import type { ClaudeJobKind } from '@prisma/client'
const KIND_LABELS: Record<ClaudeJobKind, string> = {
TASK_IMPLEMENTATION: 'TAAK',
SPRINT_IMPLEMENTATION: 'SPRINT',
IDEA_GRILL: 'GRILL',
IDEA_MAKE_PLAN: 'PLAN',
PLAN_CHAT: 'CHAT',
}
const KIND_OPTIONS: Array<{ value: ClaudeJobKind; label: string }> = [
{ value: 'TASK_IMPLEMENTATION', label: 'TAAK' },
{ value: 'SPRINT_IMPLEMENTATION', label: 'SPRINT' },
{ value: 'IDEA_GRILL', label: 'GRILL' },
{ value: 'IDEA_MAKE_PLAN', label: 'PLAN' },
{ value: 'PLAN_CHAT', label: 'CHAT' },
]
const KIND_VALUES = new Set<ClaudeJobKind>(KIND_OPTIONS.map((o) => o.value))
function MultiFilterPills<T extends string>({
label,
options,
selected,
onToggle,
onClear,
}: {
label: string
options: Array<{ value: T; label: string }>
selected: Set<T>
onToggle: (v: T) => void
onClear: () => void
}) {
const allActive = selected.size === 0
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">
<button
type="button"
onClick={onClear}
className={cn(
'text-xs px-2.5 py-1 rounded-full border transition-colors',
allActive
? 'bg-primary text-primary-foreground border-primary'
: 'bg-transparent border-border hover:bg-surface-container'
)}
>
Alle
</button>
{options.map((opt) => {
const active = selected.has(opt.value)
return (
<button
key={opt.value}
type="button"
onClick={() => onToggle(opt.value)}
className={cn(
'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'
)}
>
{opt.label}
</button>
)
})}
</div>
</div>
)
}
interface JobsColumnProps {
title: string
jobs: JobWithRelations[]
selectedJobId: string | null
onSelect: (id: string) => void
storageKeyPrefix: string
statusOptions: Array<{ value: ClaudeJobStatusApi; label: string }>
emptyText: string
}
export default function JobsColumn({
title,
jobs,
selectedJobId,
onSelect,
storageKeyPrefix,
statusOptions,
emptyText,
}: JobsColumnProps) {
const allowedStatuses = useMemo(
() => new Set<ClaudeJobStatusApi>(statusOptions.map((o) => o.value)),
[statusOptions],
)
const colPrefs = useUserSettingsStore(
useShallow((s) => s.entities.settings.views?.jobsColumns?.[storageKeyPrefix]),
)
const setPref = useUserSettingsStore((s) => s.setPref)
const filterKinds = useMemo<Set<ClaudeJobKind>>(() => {
const out = new Set<ClaudeJobKind>()
for (const v of colPrefs?.kinds ?? []) {
if (KIND_VALUES.has(v as ClaudeJobKind)) out.add(v as ClaudeJobKind)
}
return out
}, [colPrefs?.kinds])
const filterStatuses = useMemo<Set<ClaudeJobStatusApi>>(() => {
const out = new Set<ClaudeJobStatusApi>()
for (const v of colPrefs?.statuses ?? []) {
if (allowedStatuses.has(v as ClaudeJobStatusApi)) out.add(v as ClaudeJobStatusApi)
}
return out
}, [colPrefs?.statuses, allowedStatuses])
function persist(kinds: Set<ClaudeJobKind>, statuses: Set<ClaudeJobStatusApi>) {
void setPref(['views', 'jobsColumns', storageKeyPrefix], {
kinds: Array.from(kinds),
statuses: Array.from(statuses),
})
}
function toggleKind(v: ClaudeJobKind) {
const next = new Set(filterKinds)
if (next.has(v)) next.delete(v)
else next.add(v)
persist(next, filterStatuses)
}
function toggleStatus(v: ClaudeJobStatusApi) {
const next = new Set(filterStatuses)
if (next.has(v)) next.delete(v)
else next.add(v)
persist(filterKinds, next)
}
const filtered = jobs.filter((j) => {
if (filterKinds.size > 0 && !filterKinds.has(j.kind)) return false
if (filterStatuses.size > 0 && !filterStatuses.has(jobStatusToApi(j.status))) return false
return true
})
const activeFilterCount = filterKinds.size + filterStatuses.size
return (
<div className="flex flex-col h-full" {...debugProps('jobs-column', 'JobsColumn', 'components/jobs/jobs-column.tsx')}>
<div className="flex items-center justify-between gap-2 px-2 py-1.5 border-b border-border bg-surface-container-low shrink-0" data-debug-id="jobs-column__header">
<span className="text-xs font-medium text-muted-foreground px-1">{title}</span>
<div className="flex items-center gap-1.5 flex-wrap justify-end">
{Array.from(filterKinds).map((k) => (
<button
key={`k-${k}`}
type="button"
onClick={() => toggleKind(k)}
className="flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded border bg-muted text-muted-foreground hover:bg-surface-container font-mono"
aria-label={`Wis filter ${KIND_LABELS[k]}`}
>
<span>{KIND_LABELS[k]}</span>
<span aria-hidden>×</span>
</button>
))}
{Array.from(filterStatuses).map((s) => (
<button
key={`s-${s}`}
type="button"
onClick={() => toggleStatus(s)}
className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-full border bg-muted text-muted-foreground hover:bg-surface-container"
aria-label={`Wis filter ${JOB_STATUS_LABELS[s]}`}
>
<span>{JOB_STATUS_LABELS[s]}</span>
<span aria-hidden>×</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">
<MultiFilterPills
label="Soort"
options={KIND_OPTIONS}
selected={filterKinds}
onToggle={toggleKind}
onClear={() => persist(new Set(), filterStatuses)}
/>
<MultiFilterPills
label="Status"
options={statusOptions}
selected={filterStatuses}
onToggle={toggleStatus}
onClear={() => persist(filterKinds, new Set())}
/>
<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={() => persist(new Set(), new Set())}
>
Wis filters
</Button>
</div>
</PopoverContent>
</Popover>
</div>
</div>
<div className="overflow-y-auto flex-1 p-2 space-y-2" data-debug-id="jobs-column__items">
{filtered.map((j) => (
<JobCard
key={j.id}
id={j.id}
kind={j.kind}
status={j.status}
taskCode={j.taskCode}
taskTitle={j.taskTitle}
ideaCode={j.ideaCode}
ideaTitle={j.ideaTitle}
sprintGoal={j.sprintGoal}
sprintCode={j.sprintCode}
productName={j.productName}
branch={j.branch}
error={j.error}
summary={j.summary}
createdAt={j.createdAt}
isSelected={j.id === selectedJobId}
onClick={() => onSelect(j.id)}
/>
))}
{filtered.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
{jobs.length === 0 ? emptyText : 'Geen jobs voldoen aan filter'}
</p>
)}
</div>
</div>
)
}