Bij sprint-switch wordt de sprint-content server-side opgevraagd. Wanneer
de sprint precies één PBI (en die PBI exact één story binnen de sprint)
heeft, worden PBI en story automatisch geselecteerd. Alle drie keuzes
(sprint, pbi, story) worden atomair in user-settings opgeslagen zodat ze
cross-device blijven hangen.
- lib/user-settings.ts: layout krijgt nullable activePbis +
activeStories per product.
- lib/active-sprint.ts: setActiveSelectionInSettings schrijft de drie
keys atomair + notify pg_notify.
- actions/active-sprint.ts: switchActiveSprintAction(productId, sprintId)
doet de server-side auto-select-resolutie (single PBI → single story)
en returnt { sprintId, pbiId, storyId }.
- components/shared/sprint-switcher.tsx: handleSwitchSprint roept de
nieuwe action aan en synchroniseert de workspace-store gelijk zodat
de UI geen flash krijgt voor de SSR-refresh.
- components/backlog/active-selection-hydrator.tsx (nieuw): client-side
effect dat user-settings.activePbis/activeStories naar workspace-store
spiegelt; wint van de localStorage hint-restore.
- app/(app)/products/[id]/page.tsx: ActiveSelectionHydrator gemount
binnen BacklogHydrationWrapper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
114 lines
3.6 KiB
TypeScript
114 lines
3.6 KiB
TypeScript
import { z } from 'zod'
|
|
|
|
const PriorityFilter = z.union([
|
|
z.number().int().min(1).max(4),
|
|
z.literal('all'),
|
|
])
|
|
|
|
const SortDir = z.enum(['asc', 'desc'])
|
|
|
|
const SprintBacklogPrefs = z.object({
|
|
filterPriority: PriorityFilter.optional(),
|
|
filterStatus: z.enum(['OPEN', 'IN_SPRINT', 'DONE', 'all']).optional(),
|
|
sort: z.enum(['priority', 'status', 'code']).optional(),
|
|
sortDir: SortDir.optional(),
|
|
collapsedPbis: z.array(z.string()).optional(),
|
|
filterPopoverOpen: z.boolean().optional(),
|
|
}).strict()
|
|
|
|
const PbiListPrefs = z.object({
|
|
sort: z.enum(['priority', 'code', 'date']).optional(),
|
|
filterPriority: PriorityFilter.optional(),
|
|
filterStatus: z.enum(['ready', 'blocked', 'done', 'all']).optional(),
|
|
sortDir: SortDir.optional(),
|
|
}).strict()
|
|
|
|
const StoryPanelPrefs = z.object({
|
|
sort: z.enum(['priority', 'code', 'date']).optional(),
|
|
}).strict()
|
|
|
|
const JobsColumnPrefs = z.object({
|
|
kinds: z.array(z.string()),
|
|
statuses: z.array(z.string()),
|
|
}).strict()
|
|
|
|
const ViewsPrefs = z.object({
|
|
sprintBacklog: SprintBacklogPrefs.optional(),
|
|
pbiList: PbiListPrefs.optional(),
|
|
storyPanel: StoryPanelPrefs.optional(),
|
|
jobsColumns: z.record(z.string(), JobsColumnPrefs).optional(),
|
|
}).strict()
|
|
|
|
const DevToolsPrefs = z.object({
|
|
debugMode: z.boolean().optional(),
|
|
}).strict()
|
|
|
|
const LayoutPrefs = z.object({
|
|
splitPanePositions: z.record(z.string(), z.array(z.number())).optional(),
|
|
activeSprints: z.record(z.string(), z.string().nullable()).optional(),
|
|
activePbis: z.record(z.string(), z.string().nullable()).optional(),
|
|
activeStories: z.record(z.string(), z.string().nullable()).optional(),
|
|
}).strict()
|
|
|
|
const PbiIntent = z.enum(['all', 'none'])
|
|
|
|
const StoryOverrides = z.object({
|
|
add: z.array(z.string()),
|
|
remove: z.array(z.string()),
|
|
}).strict()
|
|
|
|
const PendingSprintDraftSchema = z.object({
|
|
goal: z.string().min(1),
|
|
startAt: z.string().date().optional(),
|
|
endAt: z.string().date().optional(),
|
|
pbiIntent: z.record(z.string(), PbiIntent).default({}),
|
|
storyOverrides: z.record(z.string(), StoryOverrides).default({}),
|
|
}).strict()
|
|
|
|
const WorkflowPrefs = z.object({
|
|
pendingSprintDraft: z.record(z.string(), PendingSprintDraftSchema).optional(),
|
|
}).strict()
|
|
|
|
export const UserSettingsSchema = z.object({
|
|
views: ViewsPrefs.optional(),
|
|
devTools: DevToolsPrefs.optional(),
|
|
layout: LayoutPrefs.optional(),
|
|
workflow: WorkflowPrefs.optional(),
|
|
}).strict()
|
|
|
|
export type UserSettings = z.infer<typeof UserSettingsSchema>
|
|
export type PendingSprintDraft = z.infer<typeof PendingSprintDraftSchema>
|
|
export type PbiIntent = z.infer<typeof PbiIntent>
|
|
export type StoryOverrides = z.infer<typeof StoryOverrides>
|
|
|
|
export const DEFAULT_USER_SETTINGS: UserSettings = {}
|
|
|
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
}
|
|
|
|
export function mergeSettings(
|
|
prev: UserSettings,
|
|
patch: Partial<UserSettings>,
|
|
): UserSettings {
|
|
const out: Record<string, unknown> = { ...prev }
|
|
for (const [key, patchValue] of Object.entries(patch)) {
|
|
if (patchValue === undefined) continue
|
|
const prevValue = (prev as Record<string, unknown>)[key]
|
|
if (isPlainObject(patchValue) && isPlainObject(prevValue)) {
|
|
out[key] = mergeSettings(
|
|
prevValue as UserSettings,
|
|
patchValue as Partial<UserSettings>,
|
|
)
|
|
} else {
|
|
out[key] = patchValue
|
|
}
|
|
}
|
|
return out as UserSettings
|
|
}
|
|
|
|
export function parseUserSettings(raw: unknown): UserSettings {
|
|
if (raw === null || raw === undefined) return DEFAULT_USER_SETTINGS
|
|
const result = UserSettingsSchema.safeParse(raw)
|
|
return result.success ? result.data : DEFAULT_USER_SETTINGS
|
|
}
|