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>
177 lines
4.8 KiB
TypeScript
177 lines
4.8 KiB
TypeScript
import type { Prisma, SprintStatus } from '@prisma/client'
|
|
import { prisma } from '@/lib/prisma'
|
|
import {
|
|
mergeSettings,
|
|
parseUserSettings,
|
|
type UserSettings,
|
|
} from '@/lib/user-settings'
|
|
|
|
export type ActiveSprint = {
|
|
id: string
|
|
code: string
|
|
status: SprintStatus
|
|
}
|
|
|
|
async function readSettings(userId: string): Promise<UserSettings> {
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: { settings: true },
|
|
})
|
|
return parseUserSettings(user?.settings)
|
|
}
|
|
|
|
async function writeSettings(userId: string, next: UserSettings): Promise<void> {
|
|
await prisma.user.update({
|
|
where: { id: userId },
|
|
data: { settings: next as unknown as Prisma.InputJsonValue },
|
|
})
|
|
}
|
|
|
|
async function notifyUserSettings(
|
|
userId: string,
|
|
patch: Partial<UserSettings>,
|
|
): Promise<void> {
|
|
await prisma.$executeRaw`
|
|
SELECT pg_notify('scrum4me_changes', ${JSON.stringify({
|
|
kind: 'user_settings',
|
|
userId,
|
|
patch,
|
|
})}::text)
|
|
`
|
|
}
|
|
|
|
type StoredActiveSprintState =
|
|
| { kind: 'unset' }
|
|
| { kind: 'cleared' }
|
|
| { kind: 'set'; sprintId: string }
|
|
|
|
export function readStoredActiveSprintState(
|
|
settings: UserSettings,
|
|
productId: string,
|
|
): StoredActiveSprintState {
|
|
const map = settings.layout?.activeSprints
|
|
if (!map || !(productId in map)) return { kind: 'unset' }
|
|
const value = map[productId]
|
|
if (value === null) return { kind: 'cleared' }
|
|
return { kind: 'set', sprintId: value }
|
|
}
|
|
|
|
export async function setActiveSprintInSettings(
|
|
userId: string,
|
|
productId: string,
|
|
sprintId: string,
|
|
): Promise<void> {
|
|
const current = await readSettings(userId)
|
|
const patch: Partial<UserSettings> = {
|
|
layout: {
|
|
activeSprints: {
|
|
...(current.layout?.activeSprints ?? {}),
|
|
[productId]: sprintId,
|
|
},
|
|
},
|
|
}
|
|
await writeSettings(userId, mergeSettings(current, patch))
|
|
await notifyUserSettings(userId, patch)
|
|
}
|
|
|
|
export async function clearActiveSprintInSettings(
|
|
userId: string,
|
|
productId: string,
|
|
): Promise<void> {
|
|
const current = await readSettings(userId)
|
|
const nextActiveSprints: Record<string, string | null> = {
|
|
...(current.layout?.activeSprints ?? {}),
|
|
[productId]: null,
|
|
}
|
|
const next: UserSettings = {
|
|
...current,
|
|
layout: { ...current.layout, activeSprints: nextActiveSprints },
|
|
}
|
|
await writeSettings(userId, next)
|
|
await notifyUserSettings(userId, {
|
|
layout: { activeSprints: nextActiveSprints },
|
|
})
|
|
}
|
|
|
|
/**
|
|
* PBI-79: persisteer sprint-keuze + bijbehorende PBI/story-selectie atomair.
|
|
* Sprintkeuze blijft 'sleutel met null = bewust geen sprint'-contract trouw;
|
|
* activePbi/activeStory volgen dezelfde semantiek (null = expliciet leeg).
|
|
*/
|
|
export async function setActiveSelectionInSettings(
|
|
userId: string,
|
|
productId: string,
|
|
selection: {
|
|
sprintId: string | null
|
|
pbiId?: string | null
|
|
storyId?: string | null
|
|
},
|
|
): Promise<void> {
|
|
const current = await readSettings(userId)
|
|
const nextActiveSprints: Record<string, string | null> = {
|
|
...(current.layout?.activeSprints ?? {}),
|
|
[productId]: selection.sprintId,
|
|
}
|
|
const nextActivePbis: Record<string, string | null> = {
|
|
...(current.layout?.activePbis ?? {}),
|
|
}
|
|
if (selection.pbiId !== undefined) {
|
|
nextActivePbis[productId] = selection.pbiId
|
|
}
|
|
const nextActiveStories: Record<string, string | null> = {
|
|
...(current.layout?.activeStories ?? {}),
|
|
}
|
|
if (selection.storyId !== undefined) {
|
|
nextActiveStories[productId] = selection.storyId
|
|
}
|
|
|
|
const next: UserSettings = {
|
|
...current,
|
|
layout: {
|
|
...current.layout,
|
|
activeSprints: nextActiveSprints,
|
|
activePbis: nextActivePbis,
|
|
activeStories: nextActiveStories,
|
|
},
|
|
}
|
|
await writeSettings(userId, next)
|
|
await notifyUserSettings(userId, {
|
|
layout: {
|
|
activeSprints: nextActiveSprints,
|
|
activePbis: nextActivePbis,
|
|
activeStories: nextActiveStories,
|
|
},
|
|
})
|
|
}
|
|
|
|
export async function resolveActiveSprint(
|
|
productId: string,
|
|
userId: string,
|
|
): Promise<ActiveSprint | null> {
|
|
const settings = await readSettings(userId)
|
|
const state = readStoredActiveSprintState(settings, productId)
|
|
|
|
if (state.kind === 'cleared') return null
|
|
|
|
if (state.kind === 'set') {
|
|
const sprint = await prisma.sprint.findFirst({
|
|
where: { id: state.sprintId, product_id: productId },
|
|
select: { id: true, code: true, status: true },
|
|
})
|
|
if (sprint) return sprint
|
|
}
|
|
|
|
const open = await prisma.sprint.findFirst({
|
|
where: { product_id: productId, status: 'OPEN' },
|
|
orderBy: { created_at: 'desc' },
|
|
select: { id: true, code: true, status: true },
|
|
})
|
|
if (open) return open
|
|
|
|
const closed = await prisma.sprint.findFirst({
|
|
where: { product_id: productId, status: 'CLOSED' },
|
|
orderBy: { created_at: 'desc' },
|
|
select: { id: true, code: true, status: true },
|
|
})
|
|
return closed ?? null
|
|
}
|