feat(PBI-79): Product Backlog sprint-membership via vinkjes (#190)
* feat(PBI-79/ST-1333): active-sprint null-contract + clearActiveSprintAction
- lib/user-settings.ts: activeSprints values nullable in Zod-schema.
Key-aanwezigheid heeft nu betekenis (key+null = bewust geen sprint;
key ontbreekt = fallback-cascade).
- lib/active-sprint.ts: nieuwe readStoredActiveSprintState helper +
resolveActiveSprint respecteert expliciet 'cleared' state zonder fallback.
clearActiveSprintInSettings schrijft null i.p.v. de key te verwijderen.
- actions/active-sprint.ts: nieuwe clearActiveSprintAction met auth +
membership-check.
- components/shared/sprint-switcher.tsx: '— Geen actieve sprint —'-optie
in dropdown, disabled wanneer er geen actieve sprint is.
- Tests: nieuwe active-sprint.test.ts (resolver-paden + clear),
active-sprint-action.test.ts (action-laag), uitbreiding user-settings.test.ts.
Plan: docs/plans/PBI-79-backlog-sprint-workflow.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79/ST-1334): user-settings pendingSprintDraft-slot
- lib/user-settings.ts: nieuw workflow.pendingSprintDraft veld met
compacte intent-shape (pbiIntent + per-PBI storyOverrides).
- actions/sprint-draft.ts: setPendingSprintDraftAction +
clearPendingSprintDraftAction met product-membership-check + Zod-validatie.
- stores/user-settings/store.ts: setPendingSprintDraft / clearPendingSprintDraft
optimistic acties + fine-grained mutators upsertPbiIntent / upsertStoryOverride.
Sprint-draft actions worden dynamisch geïmporteerd zodat jsdom-tests
zonder DATABASE_URL niet falen.
- Tests: nieuwe sprint-draft.test.ts (action-laag), uitbreiding
user-settings store-tests (5 nieuwe cases) en schema-tests (4 cases).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79/ST-1343): sprint-conflicts helper-library
- lib/sprint-conflicts.ts: drie pure/server-side helpers voor eligibility
+ cross-sprint detectie.
- isEligibleForSprint(story): sprint_id IS NULL en status != DONE
- partitionByEligibility(prisma, storyIds, excludeSprintId): split in
eligible / notEligible / crossSprint met reden per story
- getBlockingSprintMap(prisma, productId, storyIds, excludeSprintId):
map storyId → { sprintId, sprintName } voor stories in andere OPEN sprint
- Tests: __tests__/lib/sprint-conflicts.test.ts (16 cases) — alle eligibility
paden + cross-sprint scoping + CLOSED-sprint filtering.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79/ST-1335): sprint-membership-summary + cross-sprint-blocks endpoints
Twee nieuwe GET-route handlers, beide verplicht gescoped op pbiIds (geen
product-brede aanroepen).
- app/api/products/[id]/sprint-membership-summary/route.ts
Response: { [pbiId]: { total, inSprint } } via twee prisma.groupBy calls
(totaal + binnen actieve sprint). Voor state-B tri-state.
- app/api/products/[id]/cross-sprint-blocks/route.ts
Response: { [storyId]: { sprintId, sprintName } } voor stories in andere
OPEN sprints. UX-hint voor disabled-vinkjes; commit-acties blijven
autoritatief.
Tests: 13 cases dekken happy path, 400 zonder pbiIds, 400 zonder sprintId,
404 zonder product-access, auth-fail, en NOT-clause voor excludeSprintId.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79/ST-1336): product-workspace sprint-membership slice + selectors
Datalaag voor de vinkje-UI van state A′ en state B.
types.ts:
- PbiSummaryEntry, CrossSprintBlock, SprintMembershipSlice toegevoegd.
store.ts:
- Nieuwe slice `sprintMembership` met pbiSummary, crossSprintBlocks,
pending: { adds[], removes[] }, loadedSummaryForSprintId.
- Acties: setPbiSummary, setCrossSprintBlocks, toggleStorySprintMembership
(cancel-out logic), resetSprintMembershipPending, fetchSprintMembershipSummary,
fetchCrossSprintBlocks.
- hydrateSnapshot reset óók de membership-slice.
selectors.ts:
- selectPbiTriState (aggregate-only zolang stories niet geladen; rekent
pending mee bij loaded PBI's).
- selectStoryEffectiveInSprint (DB ⊕ pending).
- selectStoryIsBlocked (cross-sprint hint).
- selectIsDirty, selectPendingCount.
Tests: 25 cases in nieuwe sprint-membership.test.ts dekken alle selector-
paden, toggle-cancel-out, fetch-helpers, en pbiId-scoping.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79/ST-1337): state A′ UI — metadata dialog + sticky banner + PbiList ombouw
UI-laag voor de sprint-definitie-flow (state A′).
Nieuw:
- NewSprintMetadataDialog (stap 1): sprint_goal + optionele dates;
'Verder' schrijft via useUserSettingsStore.setPendingSprintDraft.
- SprintDefinitionBanner (sticky): toont doel + X PBI's / Y stories teller;
'Annuleren' → AlertDialog confirm → clearPendingSprintDraft;
'Sprint aanmaken' nog niet aangesloten (wacht op ST-1339).
- NewSprintTrigger: button in page header die de metadata-dialog opent;
verbergt zichzelf zolang er al een draft loopt.
- SprintDraftBanner: client-wrapper, rendert banner alleen als draft bestaat.
Wijzigingen:
- lib/user-settings.ts: pendingSprintDraft startAt/endAt → z.string().date().
- PbiList: oude selectionMode + selectedIds + NewSprintDialog vervangen door
hasDraft-afgeleide A′-mode met tri-state vinkjes; togglen muteert
upsertPbiIntent('all'|'none') en wist storyOverrides per PBI.
- StoryPanel: in A′-mode toont elke story een cherrypick-checkbox die
upsertStoryOverride('add'/'remove'/'clear') aanroept; cross-sprint-blocked
stories krijgen disabled-icoon met sprint-naam tooltip.
- app/(app)/products/[id]/page.tsx: StartSprintButton vervangen door
NewSprintTrigger; SprintDraftBanner gepositioneerd boven split-pane.
Tests: bestaande tests blijven groen (806 cases) — UI-specifieke component
tests volgen later. ST-1339 sluit createSprintWithSelectionAction aan.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79/ST-1339): createSprintWithSelectionAction + banner wire-up
actions/sprints.ts:
- Nieuwe createSprintWithSelectionAction(productId, metadata, pbiIntent,
storyOverrides).
- Server-side intent-resolve:
1. Voor elke PBI met intent='all': fetch child-story-IDs minus
storyOverrides[pbi].remove.
2. Plus storyOverrides[*].add (cross-PBI cherrypick toegestaan).
- Eligibility-filter via partitionByEligibility (sprint_id IS NULL + status
!= DONE; stories in andere OPEN sprint → conflicts.crossSprint).
- Transactie wrapt sprint.create + story.updateMany (status='IN_SPRINT') +
task.updateMany (sprint_id cascade) — alles atomair.
- setActiveSprintInSettings na success.
- Return: { success, sprintId, affectedStoryIds, affectedPbiIds,
affectedTaskIds, conflicts: { notEligible, crossSprint } } of error.
components/backlog/sprint-definition-banner.tsx:
- 'Sprint aanmaken'-knop sluit aan op createSprintWithSelectionAction;
toast bij conflicts, success-toast anders, router.refresh() voor SSR
cycle. Pending draft wordt door de action zelf nog niet expliciet gewist
— dat gebeurt via revalidatePath en kan in ST-1340 finetuned worden.
Tests: __tests__/actions/create-sprint-with-selection.test.ts (6 cases)
dekken intent-resolve, override-respect, cross-sprint conflict, transactie-
binding van story.status + task.sprint_id, return-shape, en error-pad.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79/ST-1340): commitSprintMembershipAction + gerichte client-store patches
actions/sprints.ts:
- Nieuwe commitSprintMembershipAction(activeSprintId, adds[], removes[]).
- Eligibility-filter voor adds via partitionByEligibility (sprint_id IS NULL
en niet DONE; cross-sprint conflicts → notEligible).
- Race-safety voor removes: alleen stories met huidige sprint_id ==
activeSprintId; rest → conflicts.alreadyRemoved.
- Transactie wrapt twee updateMany-paren (story status mee, task.sprint_id
cascade). Update-paren overgeslagen wanneer leeg.
- Return: { success, affectedStoryIds, affectedPbiIds, affectedTaskIds,
conflicts: { notEligible, alreadyRemoved } }.
stores/product-workspace/store.ts:
- applyMembershipCommitResult({ activeSprintId, addedStoryIds,
removedStoryIds }) patcht entities.storiesById met juiste sprint_id +
status; ledigt sprintMembership.pending. Geen task-veld omdat
BacklogTask geen sprint_id-kolom heeft in de store.
Tests: __tests__/actions/commit-sprint-membership.test.ts (8 cases) — happy
path, DONE-conflict, cross-sprint, race-safety voor removes, transactie-
inhoud (status='IN_SPRINT'/'OPEN'), task-cascade, return-shape, auth-fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79/ST-1338): state B vinkjes-UI + 'Sprint opslaan'-knop met teller
State B (actieve sprint geselecteerd, geen draft) hangt nu aan dezelfde
vinkje-UI als state A′, maar muteert de transient pending-buffer in plaats
van de draft.
- PbiList: nieuwe prop activeSprintId. selectionMode = hasDraft ||
stateBMode. togglePbiInDraft routeert naar upsertPbiIntent (A′) of bulk-
toggleStorySprintMembership over eligible child-stories (B, skip blocked).
- StoryPanel: idem prop activeSprintId. StoryBlockWithCherrypick muteert
draft via upsertStoryOverride in A′ of pending buffer via
toggleStorySprintMembership in B (cross-sprint blocked = disabled).
- SaveSprintButton (nieuw): client component in page header, alleen
zichtbaar als er een actieve sprint is. Disabled bij clean buffer,
enabled met teller bij dirty. Klikken calls commitSprintMembershipAction
→ applyMembershipCommitResult gericht in store + toast bij conflicts.
- page.tsx: activeSprintItem.id wordt doorgegeven aan PbiList, StoryPanel
en SaveSprintButton.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79/ST-1341+ST-1342): SprintEditDialog metadata-edit + multi-OPEN sprints
ST-1341 (T-946):
- actions/sprints.ts: nieuwe updateSprintAction(sprintId, fields) — JSON
input, accepteert optionele goal/startAt/endAt; auth + product-access
check, prisma.sprint.update, revalidatePath. Type-safe return.
- components/backlog/sprint-edit-dialog.tsx: Entity-Dialog-pattern voor
metadata-edit van een sprint. Velden: sprint_goal, start_date, end_date.
Link 'Sprint afronden… →' naar bestaande /products/[id]/sprint/[sprintId]
zodat de completion-flow (per-story DONE/OPEN beslissing + PBI-promotie)
niet wordt geduplicereerd. useDirtyCloseGuard.
ST-1342 (T-947):
- actions/sprints.ts: OPEN-uniqueness check in createSprintAction
verwijderd. Een product mag nu meerdere OPEN sprints tegelijk hebben;
cross-sprint-conflicts per story worden afgevangen door
partitionByEligibility in de membership-commit-flow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(PBI-79/ST-1344): updateSprintAction regression coverage
Audits van de geplande non-regressie-tests laten zien dat alle invarianten
uit het ST-1344 plan reeds gedekt zijn door eerder toegevoegde tests:
- clearActiveSprintAction null-not-delete → __tests__/lib/active-sprint.test.ts
+ __tests__/actions/active-sprint-action.test.ts
- Endpoints rejecten zonder pbiIds (400) → __tests__/api/sprint-membership-summary.test.ts
+ __tests__/api/cross-sprint-blocks.test.ts
- Status-mutaties story.status=IN_SPRINT/OPEN met task.sprint_id cascade
in dezelfde transactie → __tests__/actions/create-sprint-with-selection.test.ts
+ __tests__/actions/commit-sprint-membership.test.ts
- Cross-sprint conflicts + DONE-eligibility → __tests__/lib/sprint-conflicts.test.ts
Nieuw: __tests__/actions/update-sprint.test.ts (6 cases) dekt
updateSprintAction die nog geen tests had — goal alleen, dates alleen,
null-clear, 403 zonder access, lege goal weigering, leeg fields-object
weigering.
Handmatige E2E checklist (T-949) blijft staan voor menselijke browser-
validatie tijdens PR-review.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(PBI-79): PBI-rij selecteert weer in A′/B-modus; vinkje is aparte trigger
Voor PBI-79 maakte het hele PBI-kaartje in selectionMode (state A′ én B)
de toggle. Daardoor:
- klik op rij = bulk-toggle stories (teller liep op);
- geen setActivePbi, dus StoryPanel kreeg geen content.
Fix: in selectionMode wordt onClick = onSelect (PBI activeren → stories
laden) en de tri-state-iconen verhuizen naar een eigen <button> in de
actions-slot met stopPropagation. Toggle gedrag (bulk add/remove in B,
upsertPbiIntent in A′) blijft ongewijzigd via die knop.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(PBI-79): cascade-restore alleen als hint-story bij nieuwe PBI hoort
Bug: setActivePbi reset activeStoryId/activeTaskId, maar het cascade-
restore-pad zette daarna een hint-story actief zonder te valideren of die
story bij de nieuw-geselecteerde PBI hoort. Bij PBI-switch bleef daardoor
de task-kolom de taken van de vorige story tonen.
Fix: alleen setActiveStory(hint) als entities.storiesById[hint].pbi_id ===
pbiId. Bij mismatch blijft activeStoryId null en is de task-kolom leeg
totdat de gebruiker een story uit de nieuwe PBI kiest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79): sprint-switch auto-select PBI/story + user-settings persist
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>
* docs(PBI-79): plan-update met implementatie-stand + scope-aanpassing
Documenteert wat er sinds de eerste implementatie-pass is gebeurd:
- Tabel van 14 commits met hun rol.
- Twee bugs die tijdens testen boven kwamen (PBI-rij-klik, cascade-restore).
- Nieuwe feature sprint-switch auto-select (server resolveert single-PBI/
single-story; user-settings persist).
En kondigt scope-aanpassing aan voor de volgende implementatie-ronde:
- pendingSprintDraft wordt session-only (geen server-persist meer).
- useDirtyCloseGuard wist draft op leave-with-confirm.
- Sprint-switcher krijgt concept-entry zolang er een draft loopt.
De rest van het plan beneden blijft van kracht behalve waar deze sectie
het overruled.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79): pendingSprintDraft session-only + concept-entry + leave-guard
Scope-aanpassing uit plan-revisie: drafts persisten niet meer server-side.
Wijzigingen:
- stores/user-settings/store.ts:
- hydrate() strip nu workflow.pendingSprintDraft uit serverstate
(legacy DB-entries blijven harmless aanwezig maar worden niet
gehydreerd → effectief unreachable voor de UI).
- setPendingSprintDraft / clearPendingSprintDraft worden lokale-only;
geen import van sprint-draft-actions, geen server-roundtrip.
- upsertPbiIntent / upsertStoryOverride blijven via setPendingSprintDraft
routeren → ook session-only.
- components/shared/sprint-switcher.tsx: leest draft-goal uit user-settings
store en toont '⚙ Concept — [goal]' als niet-selecteerbare entry
bovenaan de dropdown zolang er een draft loopt.
- components/backlog/sprint-draft-leave-guard.tsx (nieuw): registreert
een beforeunload-listener zolang er een draft is. Browser-refresh,
tab-close en back-navigatie tonen daarmee de standaard confirm. In-app
route-changes blijven via de banner-Annuleren-knop lopen.
- app/(app)/products/[id]/page.tsx: SprintDraftLeaveGuard gemount naast
de banner.
- Tests: user-settings store-tests aangepast (geen server-call assert
meer, hydrate strip-assert toegevoegd; upsert-tests seed nu via
setPendingSprintDraft i.p.v. legacy hydrate).
setPendingSprintDraftAction + clearPendingSprintDraftAction blijven bestaan
voor eventuele toekomstige opruim-flows, maar worden niet meer aangeroepen
vanuit de UI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(PBI-79): mark scope-aanpassing afgerond + localStorage overzicht
- Drie open punten uit plan-revisie afgevinkt (commit 2a4ee6a).
- Sectie 'Bewust niet geïmplementeerd': server-persist van manuele
PBI/story-klikken — op vraag van user nu out-of-scope voor deze PR.
- Tabel localStorage-gebruik in de codebase voor toekomstige referentie.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bf7162a5fc
commit
d587be2fb3
39 changed files with 5404 additions and 133 deletions
103
__tests__/actions/active-sprint-action.test.ts
Normal file
103
__tests__/actions/active-sprint-action.test.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
||||||
|
vi.mock('next/headers', () => ({
|
||||||
|
cookies: vi.fn().mockResolvedValue({
|
||||||
|
set: vi.fn(),
|
||||||
|
get: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
vi.mock('iron-session', () => ({
|
||||||
|
getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }),
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/session', () => ({
|
||||||
|
sessionOptions: { cookieName: 'test', password: 'test' },
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/product-access', () => ({
|
||||||
|
productAccessFilter: vi.fn().mockReturnValue({}),
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
sprint: { findFirst: vi.fn() },
|
||||||
|
product: { findFirst: vi.fn() },
|
||||||
|
user: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
$executeRaw: vi.fn().mockResolvedValue(1),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { clearActiveSprintAction } from '@/actions/active-sprint'
|
||||||
|
|
||||||
|
const mockPrisma = prisma as unknown as {
|
||||||
|
product: { findFirst: ReturnType<typeof vi.fn> }
|
||||||
|
user: {
|
||||||
|
findUnique: ReturnType<typeof vi.fn>
|
||||||
|
update: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('clearActiveSprintAction', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('writes null instead of deleting the key', async () => {
|
||||||
|
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
|
||||||
|
mockPrisma.user.findUnique.mockResolvedValueOnce({
|
||||||
|
settings: { layout: { activeSprints: { p1: 'sprint-1', p2: 'sprint-2' } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await clearActiveSprintAction('p1')
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true })
|
||||||
|
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
|
||||||
|
data: { settings: { layout?: { activeSprints?: Record<string, string | null> } } }
|
||||||
|
}
|
||||||
|
expect(updateArg.data.settings.layout?.activeSprints).toEqual({
|
||||||
|
p1: null,
|
||||||
|
p2: 'sprint-2',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves other product keys when clearing one', async () => {
|
||||||
|
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
|
||||||
|
mockPrisma.user.findUnique.mockResolvedValueOnce({
|
||||||
|
settings: {
|
||||||
|
layout: {
|
||||||
|
activeSprints: { p1: 'sprint-1', p2: 'sprint-2', p3: null },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await clearActiveSprintAction('p1')
|
||||||
|
|
||||||
|
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
|
||||||
|
data: { settings: { layout?: { activeSprints?: Record<string, string | null> } } }
|
||||||
|
}
|
||||||
|
expect(updateArg.data.settings.layout?.activeSprints).toEqual({
|
||||||
|
p1: null,
|
||||||
|
p2: 'sprint-2',
|
||||||
|
p3: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects when product is not accessible', async () => {
|
||||||
|
mockPrisma.product.findFirst.mockResolvedValueOnce(null)
|
||||||
|
|
||||||
|
const result = await clearActiveSprintAction('p1')
|
||||||
|
|
||||||
|
expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' })
|
||||||
|
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid productId', async () => {
|
||||||
|
const result = await clearActiveSprintAction('')
|
||||||
|
|
||||||
|
expect(result).toEqual({ error: 'Ongeldig product-id' })
|
||||||
|
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
290
__tests__/actions/commit-sprint-membership.test.ts
Normal file
290
__tests__/actions/commit-sprint-membership.test.ts
Normal file
|
|
@ -0,0 +1,290 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
||||||
|
vi.mock('next/headers', () => ({
|
||||||
|
cookies: vi.fn().mockResolvedValue({
|
||||||
|
set: vi.fn(),
|
||||||
|
get: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
vi.mock('iron-session', () => ({
|
||||||
|
getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }),
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/session', () => ({
|
||||||
|
sessionOptions: { cookieName: 'test', password: 'test' },
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/product-access', () => ({
|
||||||
|
productAccessFilter: vi.fn().mockReturnValue({}),
|
||||||
|
getAccessibleProduct: vi.fn().mockResolvedValue({ id: 'product-1' }),
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/rate-limit', () => ({
|
||||||
|
enforceUserRateLimit: vi.fn().mockReturnValue(null),
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/code-server', () => ({
|
||||||
|
createWithCodeRetry: vi.fn(),
|
||||||
|
generateNextSprintCode: vi.fn(),
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/active-sprint', () => ({
|
||||||
|
setActiveSprintInSettings: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/prisma', () => {
|
||||||
|
const txClient = {
|
||||||
|
sprint: { create: vi.fn() },
|
||||||
|
story: { updateMany: vi.fn() },
|
||||||
|
task: { updateMany: vi.fn() },
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
prisma: {
|
||||||
|
sprint: { findFirst: vi.fn() },
|
||||||
|
story: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
updateMany: vi.fn(),
|
||||||
|
},
|
||||||
|
task: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
updateMany: vi.fn(),
|
||||||
|
},
|
||||||
|
$transaction: vi.fn(async (fn: (tx: typeof txClient) => unknown) => fn(txClient)),
|
||||||
|
__txClient: txClient,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { commitSprintMembershipAction } from '@/actions/sprints'
|
||||||
|
|
||||||
|
type Mocked = {
|
||||||
|
sprint: { findFirst: ReturnType<typeof vi.fn> }
|
||||||
|
story: {
|
||||||
|
findMany: ReturnType<typeof vi.fn>
|
||||||
|
updateMany: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
task: {
|
||||||
|
findMany: ReturnType<typeof vi.fn>
|
||||||
|
updateMany: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
$transaction: ReturnType<typeof vi.fn>
|
||||||
|
__txClient: {
|
||||||
|
sprint: { create: ReturnType<typeof vi.fn> }
|
||||||
|
story: { updateMany: ReturnType<typeof vi.fn> }
|
||||||
|
task: { updateMany: ReturnType<typeof vi.fn> }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const mockPrisma = prisma as unknown as Mocked
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockPrisma.sprint.findFirst.mockReset().mockResolvedValue({
|
||||||
|
id: 'sprint-active',
|
||||||
|
product_id: 'product-1',
|
||||||
|
})
|
||||||
|
mockPrisma.story.findMany.mockReset()
|
||||||
|
mockPrisma.story.updateMany.mockReset()
|
||||||
|
mockPrisma.task.findMany.mockReset()
|
||||||
|
mockPrisma.task.updateMany.mockReset()
|
||||||
|
mockPrisma.$transaction.mockImplementation(
|
||||||
|
async (fn: (tx: typeof mockPrisma.__txClient) => unknown) =>
|
||||||
|
fn(mockPrisma.__txClient),
|
||||||
|
)
|
||||||
|
mockPrisma.__txClient.story.updateMany.mockReset().mockResolvedValue({ count: 0 })
|
||||||
|
mockPrisma.__txClient.task.updateMany.mockReset().mockResolvedValue({ count: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('commitSprintMembershipAction', () => {
|
||||||
|
it('happy path: eligible adds + valid removes → transactie commits', async () => {
|
||||||
|
// adds-partition: alle eligible (sprint_id=null + niet DONE)
|
||||||
|
mockPrisma.story.findMany
|
||||||
|
// partition lookup
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ id: 's-add-1', sprint_id: null, status: 'OPEN', sprint: null },
|
||||||
|
])
|
||||||
|
// removes-filter (sprint_id == activeSprintId)
|
||||||
|
.mockResolvedValueOnce([{ id: 's-rem-1' }])
|
||||||
|
// affectedStories
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ pbi_id: 'pbiA' },
|
||||||
|
{ pbi_id: 'pbiB' },
|
||||||
|
])
|
||||||
|
mockPrisma.task.findMany.mockResolvedValueOnce([{ id: 't1' }])
|
||||||
|
|
||||||
|
const result = await commitSprintMembershipAction({
|
||||||
|
activeSprintId: 'sprint-active',
|
||||||
|
adds: ['s-add-1'],
|
||||||
|
removes: ['s-rem-1'],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect('success' in result).toBe(true)
|
||||||
|
if ('success' in result) {
|
||||||
|
expect(result.affectedStoryIds.sort()).toEqual(['s-add-1', 's-rem-1'])
|
||||||
|
expect(result.affectedPbiIds.sort()).toEqual(['pbiA', 'pbiB'])
|
||||||
|
expect(result.affectedTaskIds).toEqual(['t1'])
|
||||||
|
expect(result.conflicts.notEligible).toEqual([])
|
||||||
|
expect(result.conflicts.alreadyRemoved).toEqual([])
|
||||||
|
}
|
||||||
|
expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockPrisma.__txClient.story.updateMany).toHaveBeenCalledTimes(2)
|
||||||
|
expect(mockPrisma.__txClient.task.updateMany).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('add met status=DONE → conflicts.notEligible, story niet ge-update', async () => {
|
||||||
|
mockPrisma.story.findMany
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ id: 's-done', sprint_id: null, status: 'DONE', sprint: null },
|
||||||
|
])
|
||||||
|
// removes-filter (geen removes)
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
|
||||||
|
const result = await commitSprintMembershipAction({
|
||||||
|
activeSprintId: 'sprint-active',
|
||||||
|
adds: ['s-done'],
|
||||||
|
removes: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect('success' in result).toBe(true)
|
||||||
|
if ('success' in result) {
|
||||||
|
expect(result.affectedStoryIds).toEqual([])
|
||||||
|
expect(result.conflicts.notEligible).toEqual([
|
||||||
|
{ storyId: 's-done', reason: 'DONE' },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
// Geen transaction omdat er niets te commiten valt.
|
||||||
|
expect(mockPrisma.$transaction).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('add met sprint_id in andere OPEN sprint → conflicts.notEligible IN_OTHER_SPRINT', async () => {
|
||||||
|
mockPrisma.story.findMany
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
id: 's-elsewhere',
|
||||||
|
sprint_id: 'sprint-other',
|
||||||
|
status: 'IN_SPRINT',
|
||||||
|
sprint: { id: 'sprint-other', code: 'SP-O', status: 'OPEN' },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
|
||||||
|
const result = await commitSprintMembershipAction({
|
||||||
|
activeSprintId: 'sprint-active',
|
||||||
|
adds: ['s-elsewhere'],
|
||||||
|
removes: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
if ('success' in result) {
|
||||||
|
expect(result.conflicts.notEligible).toEqual([
|
||||||
|
{ storyId: 's-elsewhere', reason: 'IN_OTHER_SPRINT' },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('remove voor story die niet in actieve sprint zit → conflicts.alreadyRemoved', async () => {
|
||||||
|
mockPrisma.story.findMany
|
||||||
|
// adds-partition (geen adds)
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
// removes-filter — race scenario: story zit niet meer in active sprint
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
|
||||||
|
const result = await commitSprintMembershipAction({
|
||||||
|
activeSprintId: 'sprint-active',
|
||||||
|
adds: [],
|
||||||
|
removes: ['s-was-removed'],
|
||||||
|
})
|
||||||
|
|
||||||
|
if ('success' in result) {
|
||||||
|
expect(result.affectedStoryIds).toEqual([])
|
||||||
|
expect(result.conflicts.alreadyRemoved).toEqual(['s-was-removed'])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('transactie: story.status=IN_SPRINT bij add, =OPEN bij remove', async () => {
|
||||||
|
mockPrisma.story.findMany
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ id: 's-add', sprint_id: null, status: 'OPEN', sprint: null },
|
||||||
|
])
|
||||||
|
.mockResolvedValueOnce([{ id: 's-rem' }])
|
||||||
|
.mockResolvedValueOnce([{ pbi_id: 'pbiA' }])
|
||||||
|
mockPrisma.task.findMany.mockResolvedValueOnce([])
|
||||||
|
|
||||||
|
await commitSprintMembershipAction({
|
||||||
|
activeSprintId: 'sprint-active',
|
||||||
|
adds: ['s-add'],
|
||||||
|
removes: ['s-rem'],
|
||||||
|
})
|
||||||
|
|
||||||
|
const calls = mockPrisma.__txClient.story.updateMany.mock.calls
|
||||||
|
// Add: status=IN_SPRINT + sprint_id=sprint-active
|
||||||
|
expect(calls[0][0].data).toEqual({
|
||||||
|
sprint_id: 'sprint-active',
|
||||||
|
status: 'IN_SPRINT',
|
||||||
|
})
|
||||||
|
// Remove: status=OPEN + sprint_id=null
|
||||||
|
expect(calls[1][0].data).toEqual({ sprint_id: null, status: 'OPEN' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('task.sprint_id wordt in dezelfde transactie ge-update', async () => {
|
||||||
|
mockPrisma.story.findMany
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ id: 's-add', sprint_id: null, status: 'OPEN', sprint: null },
|
||||||
|
])
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
.mockResolvedValueOnce([{ pbi_id: 'pbiA' }])
|
||||||
|
mockPrisma.task.findMany.mockResolvedValueOnce([])
|
||||||
|
|
||||||
|
await commitSprintMembershipAction({
|
||||||
|
activeSprintId: 'sprint-active',
|
||||||
|
adds: ['s-add'],
|
||||||
|
removes: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockPrisma.__txClient.task.updateMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { story_id: { in: ['s-add'] } },
|
||||||
|
data: { sprint_id: 'sprint-active' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('return: affectedStoryIds + affectedPbiIds + affectedTaskIds + conflicts', async () => {
|
||||||
|
mockPrisma.story.findMany
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ id: 's-add', sprint_id: null, status: 'OPEN', sprint: null },
|
||||||
|
])
|
||||||
|
.mockResolvedValueOnce([{ id: 's-rem' }])
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ pbi_id: 'pbiA' },
|
||||||
|
{ pbi_id: 'pbiB' },
|
||||||
|
])
|
||||||
|
mockPrisma.task.findMany.mockResolvedValueOnce([
|
||||||
|
{ id: 't1' },
|
||||||
|
{ id: 't2' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await commitSprintMembershipAction({
|
||||||
|
activeSprintId: 'sprint-active',
|
||||||
|
adds: ['s-add'],
|
||||||
|
removes: ['s-rem'],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
affectedStoryIds: expect.arrayContaining(['s-add', 's-rem']),
|
||||||
|
affectedPbiIds: expect.arrayContaining(['pbiA', 'pbiB']),
|
||||||
|
affectedTaskIds: expect.arrayContaining(['t1', 't2']),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects when sprint is not accessible', async () => {
|
||||||
|
mockPrisma.sprint.findFirst.mockResolvedValue(null)
|
||||||
|
|
||||||
|
const result = await commitSprintMembershipAction({
|
||||||
|
activeSprintId: 'sprint-active',
|
||||||
|
adds: [],
|
||||||
|
removes: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect('error' in result).toBe(true)
|
||||||
|
if ('error' in result) {
|
||||||
|
expect(result.code).toBe(403)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
300
__tests__/actions/create-sprint-with-selection.test.ts
Normal file
300
__tests__/actions/create-sprint-with-selection.test.ts
Normal file
|
|
@ -0,0 +1,300 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
||||||
|
vi.mock('next/headers', () => ({
|
||||||
|
cookies: vi.fn().mockResolvedValue({
|
||||||
|
set: vi.fn(),
|
||||||
|
get: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
vi.mock('iron-session', () => ({
|
||||||
|
getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }),
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/session', () => ({
|
||||||
|
sessionOptions: { cookieName: 'test', password: 'test' },
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/product-access', () => ({
|
||||||
|
productAccessFilter: vi.fn().mockReturnValue({}),
|
||||||
|
getAccessibleProduct: vi.fn().mockResolvedValue({
|
||||||
|
id: 'product-1',
|
||||||
|
user_id: 'user-1',
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/rate-limit', () => ({
|
||||||
|
enforceUserRateLimit: vi.fn().mockReturnValue(null),
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/code-server', () => ({
|
||||||
|
createWithCodeRetry: vi.fn(async (_gen, fn) => fn('SP-1')),
|
||||||
|
generateNextSprintCode: vi.fn().mockResolvedValue('SP-1'),
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/active-sprint', () => ({
|
||||||
|
setActiveSprintInSettings: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/prisma', () => {
|
||||||
|
const txClient = {
|
||||||
|
sprint: { create: vi.fn() },
|
||||||
|
story: { updateMany: vi.fn() },
|
||||||
|
task: { updateMany: vi.fn() },
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
prisma: {
|
||||||
|
sprint: {
|
||||||
|
create: vi.fn(),
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
},
|
||||||
|
story: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
updateMany: vi.fn(),
|
||||||
|
},
|
||||||
|
task: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
updateMany: vi.fn(),
|
||||||
|
},
|
||||||
|
pbi: { findMany: vi.fn() },
|
||||||
|
user: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
},
|
||||||
|
$transaction: vi.fn(async (fn: (tx: typeof txClient) => unknown) => fn(txClient)),
|
||||||
|
__txClient: txClient,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import {
|
||||||
|
createSprintWithSelectionAction,
|
||||||
|
type CreateSprintWithSelectionInput,
|
||||||
|
} from '@/actions/sprints'
|
||||||
|
|
||||||
|
type Mocked = {
|
||||||
|
sprint: {
|
||||||
|
create: ReturnType<typeof vi.fn>
|
||||||
|
findFirst: ReturnType<typeof vi.fn>
|
||||||
|
update: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
story: {
|
||||||
|
findMany: ReturnType<typeof vi.fn>
|
||||||
|
updateMany: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
task: {
|
||||||
|
findMany: ReturnType<typeof vi.fn>
|
||||||
|
updateMany: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
$transaction: ReturnType<typeof vi.fn>
|
||||||
|
__txClient: {
|
||||||
|
sprint: { create: ReturnType<typeof vi.fn> }
|
||||||
|
story: { updateMany: ReturnType<typeof vi.fn> }
|
||||||
|
task: { updateMany: ReturnType<typeof vi.fn> }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const mockPrisma = prisma as unknown as Mocked
|
||||||
|
|
||||||
|
function baseInput(
|
||||||
|
overrides: Partial<CreateSprintWithSelectionInput> = {},
|
||||||
|
): CreateSprintWithSelectionInput {
|
||||||
|
return {
|
||||||
|
productId: 'product-1',
|
||||||
|
metadata: { goal: 'Sprint 1' },
|
||||||
|
pbiIntent: {},
|
||||||
|
storyOverrides: {},
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockPrisma.sprint.create.mockReset()
|
||||||
|
mockPrisma.story.findMany.mockReset()
|
||||||
|
mockPrisma.story.updateMany.mockReset()
|
||||||
|
mockPrisma.task.findMany.mockReset()
|
||||||
|
mockPrisma.task.updateMany.mockReset()
|
||||||
|
mockPrisma.$transaction.mockImplementation(
|
||||||
|
async (fn: (tx: typeof mockPrisma.__txClient) => unknown) =>
|
||||||
|
fn(mockPrisma.__txClient),
|
||||||
|
)
|
||||||
|
mockPrisma.__txClient.sprint.create
|
||||||
|
.mockReset()
|
||||||
|
.mockResolvedValue({ id: 'sprint-1', code: 'SP-1' })
|
||||||
|
mockPrisma.__txClient.story.updateMany
|
||||||
|
.mockReset()
|
||||||
|
.mockResolvedValue({ count: 0 })
|
||||||
|
mockPrisma.__txClient.task.updateMany
|
||||||
|
.mockReset()
|
||||||
|
.mockResolvedValue({ count: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('createSprintWithSelectionAction', () => {
|
||||||
|
it('resolves intent=all naar alle child-stories en weert overrides.remove', async () => {
|
||||||
|
// Stap 1: stories voor PBI-A (intent=all). Plus eligibility-fetch.
|
||||||
|
mockPrisma.story.findMany
|
||||||
|
// resolve step (only for pbis with intent='all')
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ id: 's1', pbi_id: 'pbiA' },
|
||||||
|
{ id: 's2', pbi_id: 'pbiA' },
|
||||||
|
{ id: 's3', pbi_id: 'pbiA' },
|
||||||
|
])
|
||||||
|
// partitionByEligibility — alle eligible
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ id: 's1', sprint_id: null, status: 'OPEN', sprint: null },
|
||||||
|
{ id: 's3', sprint_id: null, status: 'OPEN', sprint: null },
|
||||||
|
])
|
||||||
|
// affectedStories
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ pbi_id: 'pbiA' },
|
||||||
|
{ pbi_id: 'pbiA' },
|
||||||
|
])
|
||||||
|
mockPrisma.task.findMany.mockResolvedValueOnce([{ id: 't1' }])
|
||||||
|
|
||||||
|
const result = await createSprintWithSelectionAction(
|
||||||
|
baseInput({
|
||||||
|
pbiIntent: { pbiA: 'all' },
|
||||||
|
storyOverrides: { pbiA: { add: [], remove: ['s2'] } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect('success' in result).toBe(true)
|
||||||
|
if ('success' in result) {
|
||||||
|
expect(result.affectedStoryIds).toEqual(['s1', 's3'])
|
||||||
|
expect(result.conflicts.notEligible).toEqual([])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('voegt storyOverrides.add toe over PBI heen (zelfs intent=none)', async () => {
|
||||||
|
// Geen PBI met intent=all → stap 1 wordt niet uitgevoerd.
|
||||||
|
mockPrisma.story.findMany
|
||||||
|
// partition
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ id: 's10', sprint_id: null, status: 'OPEN', sprint: null },
|
||||||
|
])
|
||||||
|
// affectedStories
|
||||||
|
.mockResolvedValueOnce([{ pbi_id: 'pbiB' }])
|
||||||
|
mockPrisma.task.findMany.mockResolvedValueOnce([])
|
||||||
|
|
||||||
|
const result = await createSprintWithSelectionAction(
|
||||||
|
baseInput({
|
||||||
|
pbiIntent: { pbiB: 'none' },
|
||||||
|
storyOverrides: { pbiB: { add: ['s10'], remove: [] } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect('success' in result).toBe(true)
|
||||||
|
if ('success' in result) {
|
||||||
|
expect(result.affectedStoryIds).toEqual(['s10'])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('eligibility-filter classificeert DONE en cross-sprint stories', async () => {
|
||||||
|
mockPrisma.story.findMany
|
||||||
|
// resolve
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ id: 's1', pbi_id: 'pbiA' },
|
||||||
|
{ id: 's2', pbi_id: 'pbiA' },
|
||||||
|
{ id: 's3', pbi_id: 'pbiA' },
|
||||||
|
])
|
||||||
|
// partition: s1=DONE, s2=eligible, s3=in andere OPEN sprint
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ id: 's1', sprint_id: null, status: 'DONE', sprint: null },
|
||||||
|
{ id: 's2', sprint_id: null, status: 'OPEN', sprint: null },
|
||||||
|
{
|
||||||
|
id: 's3',
|
||||||
|
sprint_id: 'sprint-other',
|
||||||
|
status: 'IN_SPRINT',
|
||||||
|
sprint: { id: 'sprint-other', code: 'SP-O', status: 'OPEN' },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
// affectedStories
|
||||||
|
.mockResolvedValueOnce([{ pbi_id: 'pbiA' }])
|
||||||
|
mockPrisma.task.findMany.mockResolvedValueOnce([])
|
||||||
|
|
||||||
|
const result = await createSprintWithSelectionAction(
|
||||||
|
baseInput({ pbiIntent: { pbiA: 'all' } }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect('success' in result).toBe(true)
|
||||||
|
if ('success' in result) {
|
||||||
|
expect(result.affectedStoryIds).toEqual(['s2'])
|
||||||
|
expect(result.conflicts.notEligible.map((n) => n.storyId).sort()).toEqual(
|
||||||
|
['s1', 's3'],
|
||||||
|
)
|
||||||
|
expect(result.conflicts.crossSprint.map((c) => c.storyId)).toEqual(['s3'])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('zet story.status=IN_SPRINT en task.sprint_id mee in dezelfde transactie', async () => {
|
||||||
|
mockPrisma.story.findMany
|
||||||
|
.mockResolvedValueOnce([{ id: 's1', pbi_id: 'pbiA' }])
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ id: 's1', sprint_id: null, status: 'OPEN', sprint: null },
|
||||||
|
])
|
||||||
|
.mockResolvedValueOnce([{ pbi_id: 'pbiA' }])
|
||||||
|
mockPrisma.task.findMany.mockResolvedValueOnce([{ id: 't1' }])
|
||||||
|
|
||||||
|
await createSprintWithSelectionAction(
|
||||||
|
baseInput({ pbiIntent: { pbiA: 'all' } }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockPrisma.__txClient.story.updateMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
sprint_id: 'sprint-1',
|
||||||
|
status: 'IN_SPRINT',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(mockPrisma.__txClient.task.updateMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: { sprint_id: 'sprint-1' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returnt affectedStoryIds + affectedPbiIds + affectedTaskIds', async () => {
|
||||||
|
mockPrisma.story.findMany
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ id: 's1', pbi_id: 'pbiA' },
|
||||||
|
{ id: 's2', pbi_id: 'pbiB' },
|
||||||
|
])
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ id: 's1', sprint_id: null, status: 'OPEN', sprint: null },
|
||||||
|
{ id: 's2', sprint_id: null, status: 'OPEN', sprint: null },
|
||||||
|
])
|
||||||
|
.mockResolvedValueOnce([{ pbi_id: 'pbiA' }, { pbi_id: 'pbiB' }])
|
||||||
|
mockPrisma.task.findMany.mockResolvedValueOnce([
|
||||||
|
{ id: 't1' },
|
||||||
|
{ id: 't2' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await createSprintWithSelectionAction(
|
||||||
|
baseInput({ pbiIntent: { pbiA: 'all', pbiB: 'all' } }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect('success' in result).toBe(true)
|
||||||
|
if ('success' in result) {
|
||||||
|
expect(result.affectedStoryIds.sort()).toEqual(['s1', 's2'])
|
||||||
|
expect(result.affectedPbiIds.sort()).toEqual(['pbiA', 'pbiB'])
|
||||||
|
expect(result.affectedTaskIds.sort()).toEqual(['t1', 't2'])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returnt error wanneer geen eligible stories overblijven', async () => {
|
||||||
|
mockPrisma.story.findMany
|
||||||
|
.mockResolvedValueOnce([{ id: 's1', pbi_id: 'pbiA' }])
|
||||||
|
// s1 is DONE → notEligible
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ id: 's1', sprint_id: null, status: 'DONE', sprint: null },
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await createSprintWithSelectionAction(
|
||||||
|
baseInput({ pbiIntent: { pbiA: 'all' } }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect('error' in result).toBe(true)
|
||||||
|
if ('error' in result) {
|
||||||
|
expect(result.code).toBe(422)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
167
__tests__/actions/sprint-draft.test.ts
Normal file
167
__tests__/actions/sprint-draft.test.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
||||||
|
vi.mock('next/headers', () => ({
|
||||||
|
cookies: vi.fn().mockResolvedValue({
|
||||||
|
set: vi.fn(),
|
||||||
|
get: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
vi.mock('iron-session', () => ({
|
||||||
|
getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }),
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/session', () => ({
|
||||||
|
sessionOptions: { cookieName: 'test', password: 'test' },
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/product-access', () => ({
|
||||||
|
productAccessFilter: vi.fn().mockReturnValue({}),
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
product: { findFirst: vi.fn() },
|
||||||
|
user: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
$executeRaw: vi.fn().mockResolvedValue(1),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import {
|
||||||
|
clearPendingSprintDraftAction,
|
||||||
|
setPendingSprintDraftAction,
|
||||||
|
} from '@/actions/sprint-draft'
|
||||||
|
import type { PendingSprintDraft, UserSettings } from '@/lib/user-settings'
|
||||||
|
|
||||||
|
const mockPrisma = prisma as unknown as {
|
||||||
|
product: { findFirst: ReturnType<typeof vi.fn> }
|
||||||
|
user: {
|
||||||
|
findUnique: ReturnType<typeof vi.fn>
|
||||||
|
update: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validDraft: PendingSprintDraft = {
|
||||||
|
goal: 'Sprint 1',
|
||||||
|
pbiIntent: { pbiA: 'all' },
|
||||||
|
storyOverrides: { pbiA: { add: [], remove: ['story-1'] } },
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('setPendingSprintDraftAction', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockPrisma.product.findFirst.mockReset()
|
||||||
|
mockPrisma.user.findUnique.mockReset()
|
||||||
|
mockPrisma.user.update.mockReset().mockResolvedValue({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists draft for accessible product', async () => {
|
||||||
|
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
|
||||||
|
mockPrisma.user.findUnique.mockResolvedValueOnce({ settings: {} })
|
||||||
|
|
||||||
|
const result = await setPendingSprintDraftAction('p1', validDraft)
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true })
|
||||||
|
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
|
||||||
|
data: { settings: UserSettings }
|
||||||
|
}
|
||||||
|
expect(updateArg.data.settings.workflow?.pendingSprintDraft?.p1).toMatchObject({
|
||||||
|
goal: 'Sprint 1',
|
||||||
|
pbiIntent: { pbiA: 'all' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves drafts for other products', async () => {
|
||||||
|
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
|
||||||
|
mockPrisma.user.findUnique.mockResolvedValueOnce({
|
||||||
|
settings: {
|
||||||
|
workflow: {
|
||||||
|
pendingSprintDraft: {
|
||||||
|
p2: { goal: 'P2 draft', pbiIntent: {}, storyOverrides: {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await setPendingSprintDraftAction('p1', validDraft)
|
||||||
|
|
||||||
|
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
|
||||||
|
data: { settings: UserSettings }
|
||||||
|
}
|
||||||
|
const drafts = updateArg.data.settings.workflow?.pendingSprintDraft
|
||||||
|
expect(Object.keys(drafts ?? {})).toEqual(expect.arrayContaining(['p1', 'p2']))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid draft (empty goal)', async () => {
|
||||||
|
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
|
||||||
|
|
||||||
|
const result = await setPendingSprintDraftAction('p1', {
|
||||||
|
...validDraft,
|
||||||
|
goal: '',
|
||||||
|
} as PendingSprintDraft)
|
||||||
|
|
||||||
|
expect(result).toHaveProperty('error')
|
||||||
|
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects when product not accessible', async () => {
|
||||||
|
mockPrisma.product.findFirst.mockResolvedValueOnce(null)
|
||||||
|
|
||||||
|
const result = await setPendingSprintDraftAction('p1', validDraft)
|
||||||
|
|
||||||
|
expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' })
|
||||||
|
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('clearPendingSprintDraftAction', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockPrisma.product.findFirst.mockReset()
|
||||||
|
mockPrisma.user.findUnique.mockReset()
|
||||||
|
mockPrisma.user.update.mockReset().mockResolvedValue({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes draft key for product', async () => {
|
||||||
|
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
|
||||||
|
mockPrisma.user.findUnique.mockResolvedValueOnce({
|
||||||
|
settings: {
|
||||||
|
workflow: {
|
||||||
|
pendingSprintDraft: {
|
||||||
|
p1: { goal: 'gone', pbiIntent: {}, storyOverrides: {} },
|
||||||
|
p2: { goal: 'keep', pbiIntent: {}, storyOverrides: {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await clearPendingSprintDraftAction('p1')
|
||||||
|
|
||||||
|
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
|
||||||
|
data: { settings: UserSettings }
|
||||||
|
}
|
||||||
|
expect(updateArg.data.settings.workflow?.pendingSprintDraft).toEqual({
|
||||||
|
p2: { goal: 'keep', pbiIntent: {}, storyOverrides: {} },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is a no-op when there is no draft for the product', async () => {
|
||||||
|
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
|
||||||
|
mockPrisma.user.findUnique.mockResolvedValueOnce({ settings: {} })
|
||||||
|
|
||||||
|
const result = await clearPendingSprintDraftAction('p1')
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true })
|
||||||
|
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects when product not accessible', async () => {
|
||||||
|
mockPrisma.product.findFirst.mockResolvedValueOnce(null)
|
||||||
|
|
||||||
|
const result = await clearPendingSprintDraftAction('p1')
|
||||||
|
|
||||||
|
expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' })
|
||||||
|
})
|
||||||
|
})
|
||||||
148
__tests__/actions/update-sprint.test.ts
Normal file
148
__tests__/actions/update-sprint.test.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
||||||
|
vi.mock('next/headers', () => ({
|
||||||
|
cookies: vi.fn().mockResolvedValue({
|
||||||
|
set: vi.fn(),
|
||||||
|
get: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
vi.mock('iron-session', () => ({
|
||||||
|
getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }),
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/session', () => ({
|
||||||
|
sessionOptions: { cookieName: 'test', password: 'test' },
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/product-access', () => ({
|
||||||
|
productAccessFilter: vi.fn().mockReturnValue({}),
|
||||||
|
getAccessibleProduct: vi.fn().mockResolvedValue({ id: 'product-1' }),
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/rate-limit', () => ({
|
||||||
|
enforceUserRateLimit: vi.fn().mockReturnValue(null),
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/code-server', () => ({
|
||||||
|
createWithCodeRetry: vi.fn(),
|
||||||
|
generateNextSprintCode: vi.fn(),
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/active-sprint', () => ({
|
||||||
|
setActiveSprintInSettings: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
sprint: {
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
},
|
||||||
|
story: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
updateMany: vi.fn(),
|
||||||
|
},
|
||||||
|
task: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
updateMany: vi.fn(),
|
||||||
|
},
|
||||||
|
$transaction: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { updateSprintAction } from '@/actions/sprints'
|
||||||
|
|
||||||
|
type Mocked = {
|
||||||
|
sprint: {
|
||||||
|
findFirst: ReturnType<typeof vi.fn>
|
||||||
|
update: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const mockPrisma = prisma as unknown as Mocked
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockPrisma.sprint.findFirst.mockReset().mockResolvedValue({
|
||||||
|
id: 'sprint-1',
|
||||||
|
product_id: 'product-1',
|
||||||
|
})
|
||||||
|
mockPrisma.sprint.update.mockReset().mockResolvedValue({})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('updateSprintAction', () => {
|
||||||
|
it('updates sprint_goal alone', async () => {
|
||||||
|
const result = await updateSprintAction({
|
||||||
|
sprintId: 'sprint-1',
|
||||||
|
fields: { goal: 'Nieuw doel' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect('success' in result).toBe(true)
|
||||||
|
expect(mockPrisma.sprint.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'sprint-1' },
|
||||||
|
data: { sprint_goal: 'Nieuw doel' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates dates only', async () => {
|
||||||
|
await updateSprintAction({
|
||||||
|
sprintId: 'sprint-1',
|
||||||
|
fields: { startAt: '2026-06-01', endAt: '2026-06-14' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockPrisma.sprint.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'sprint-1' },
|
||||||
|
data: {
|
||||||
|
start_date: new Date('2026-06-01'),
|
||||||
|
end_date: new Date('2026-06-14'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts null to clear a date', async () => {
|
||||||
|
await updateSprintAction({
|
||||||
|
sprintId: 'sprint-1',
|
||||||
|
fields: { startAt: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockPrisma.sprint.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'sprint-1' },
|
||||||
|
data: { start_date: null },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects when sprint not accessible', async () => {
|
||||||
|
mockPrisma.sprint.findFirst.mockResolvedValue(null)
|
||||||
|
|
||||||
|
const result = await updateSprintAction({
|
||||||
|
sprintId: 'sprint-1',
|
||||||
|
fields: { goal: 'x' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect('error' in result).toBe(true)
|
||||||
|
if ('error' in result) {
|
||||||
|
expect(result.code).toBe(403)
|
||||||
|
}
|
||||||
|
expect(mockPrisma.sprint.update).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects empty goal', async () => {
|
||||||
|
const result = await updateSprintAction({
|
||||||
|
sprintId: 'sprint-1',
|
||||||
|
fields: { goal: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect('error' in result).toBe(true)
|
||||||
|
expect(mockPrisma.sprint.update).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects when no fields are supplied', async () => {
|
||||||
|
const result = await updateSprintAction({
|
||||||
|
sprintId: 'sprint-1',
|
||||||
|
fields: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Schema-refine should reject; OR action treats empty data as no-op success.
|
||||||
|
// Current implementation: refine forces minstens één veld → 422 error.
|
||||||
|
expect('error' in result).toBe(true)
|
||||||
|
if ('error' in result) {
|
||||||
|
expect(result.code).toBe(422)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
120
__tests__/api/cross-sprint-blocks.test.ts
Normal file
120
__tests__/api/cross-sprint-blocks.test.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('@/lib/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
product: { findFirst: vi.fn() },
|
||||||
|
story: { findMany: vi.fn() },
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/api-auth', () => ({
|
||||||
|
authenticateApiRequest: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/product-access', () => ({
|
||||||
|
productAccessFilter: vi.fn().mockReturnValue({}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { authenticateApiRequest } from '@/lib/api-auth'
|
||||||
|
import { GET } from '@/app/api/products/[id]/cross-sprint-blocks/route'
|
||||||
|
|
||||||
|
const mockPrisma = prisma as unknown as {
|
||||||
|
product: { findFirst: ReturnType<typeof vi.fn> }
|
||||||
|
story: { findMany: ReturnType<typeof vi.fn> }
|
||||||
|
}
|
||||||
|
const mockAuth = authenticateApiRequest as unknown as ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
function makeRequest(url: string) {
|
||||||
|
return new Request(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GET /api/products/[id]/cross-sprint-blocks', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockPrisma.product.findFirst.mockReset()
|
||||||
|
mockPrisma.story.findMany.mockReset()
|
||||||
|
mockAuth.mockReset().mockResolvedValue({ userId: 'user-1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns blocking sprint info per story for happy path', async () => {
|
||||||
|
mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' })
|
||||||
|
mockPrisma.story.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'story-1',
|
||||||
|
sprint: { id: 'sprint-x', code: 'SP-X' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'story-2',
|
||||||
|
sprint: { id: 'sprint-y', code: 'SP-Y' },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const req = makeRequest(
|
||||||
|
'http://localhost/api/products/p1/cross-sprint-blocks?excludeSprintId=sp-1&pbiIds=pbiA',
|
||||||
|
)
|
||||||
|
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body).toEqual({
|
||||||
|
'story-1': { sprintId: 'sprint-x', sprintName: 'SP-X' },
|
||||||
|
'story-2': { sprintId: 'sprint-y', sprintName: 'SP-Y' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects when pbiIds is missing', async () => {
|
||||||
|
const req = makeRequest(
|
||||||
|
'http://localhost/api/products/p1/cross-sprint-blocks?excludeSprintId=sp-1',
|
||||||
|
)
|
||||||
|
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects when pbiIds is empty', async () => {
|
||||||
|
const req = makeRequest(
|
||||||
|
'http://localhost/api/products/p1/cross-sprint-blocks?pbiIds=',
|
||||||
|
)
|
||||||
|
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 404 when product is not accessible', async () => {
|
||||||
|
mockPrisma.product.findFirst.mockResolvedValue(null)
|
||||||
|
const req = makeRequest(
|
||||||
|
'http://localhost/api/products/p1/cross-sprint-blocks?pbiIds=pbiA',
|
||||||
|
)
|
||||||
|
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
||||||
|
expect(res.status).toBe(404)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns auth error when authenticate fails', async () => {
|
||||||
|
mockAuth.mockResolvedValue({ error: 'Niet ingelogd', status: 401 })
|
||||||
|
const req = makeRequest(
|
||||||
|
'http://localhost/api/products/p1/cross-sprint-blocks?pbiIds=pbiA',
|
||||||
|
)
|
||||||
|
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes NOT excludeSprintId to prisma when provided', async () => {
|
||||||
|
mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' })
|
||||||
|
mockPrisma.story.findMany.mockResolvedValue([])
|
||||||
|
|
||||||
|
const req = makeRequest(
|
||||||
|
'http://localhost/api/products/p1/cross-sprint-blocks?excludeSprintId=sp-active&pbiIds=pbiA',
|
||||||
|
)
|
||||||
|
await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
||||||
|
|
||||||
|
const callArg = mockPrisma.story.findMany.mock.calls[0][0] as {
|
||||||
|
where: Record<string, unknown>
|
||||||
|
}
|
||||||
|
expect(callArg.where).toMatchObject({
|
||||||
|
pbi_id: { in: ['pbiA'] },
|
||||||
|
product_id: 'p1',
|
||||||
|
sprint_id: { not: null },
|
||||||
|
NOT: { sprint_id: 'sp-active' },
|
||||||
|
sprint: { status: 'OPEN' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
121
__tests__/api/sprint-membership-summary.test.ts
Normal file
121
__tests__/api/sprint-membership-summary.test.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('@/lib/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
product: { findFirst: vi.fn() },
|
||||||
|
story: { groupBy: vi.fn() },
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/api-auth', () => ({
|
||||||
|
authenticateApiRequest: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/product-access', () => ({
|
||||||
|
productAccessFilter: vi.fn().mockReturnValue({}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { authenticateApiRequest } from '@/lib/api-auth'
|
||||||
|
import { GET } from '@/app/api/products/[id]/sprint-membership-summary/route'
|
||||||
|
|
||||||
|
const mockPrisma = prisma as unknown as {
|
||||||
|
product: { findFirst: ReturnType<typeof vi.fn> }
|
||||||
|
story: { groupBy: ReturnType<typeof vi.fn> }
|
||||||
|
}
|
||||||
|
const mockAuth = authenticateApiRequest as unknown as ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
function makeRequest(url: string) {
|
||||||
|
return new Request(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GET /api/products/[id]/sprint-membership-summary', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockPrisma.product.findFirst.mockReset()
|
||||||
|
mockPrisma.story.groupBy.mockReset()
|
||||||
|
mockAuth.mockReset().mockResolvedValue({ userId: 'user-1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns counts per PBI for happy path', async () => {
|
||||||
|
mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' })
|
||||||
|
mockPrisma.story.groupBy
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ pbi_id: 'pbiA', _count: { _all: 5 } },
|
||||||
|
{ pbi_id: 'pbiB', _count: { _all: 3 } },
|
||||||
|
])
|
||||||
|
.mockResolvedValueOnce([{ pbi_id: 'pbiA', _count: { _all: 2 } }])
|
||||||
|
|
||||||
|
const req = makeRequest(
|
||||||
|
'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA,pbiB',
|
||||||
|
)
|
||||||
|
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body).toEqual({
|
||||||
|
pbiA: { total: 5, inSprint: 2 },
|
||||||
|
pbiB: { total: 3, inSprint: 0 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects when pbiIds is missing', async () => {
|
||||||
|
const req = makeRequest(
|
||||||
|
'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1',
|
||||||
|
)
|
||||||
|
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects when pbiIds is empty', async () => {
|
||||||
|
const req = makeRequest(
|
||||||
|
'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=',
|
||||||
|
)
|
||||||
|
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects when sprintId is missing', async () => {
|
||||||
|
const req = makeRequest(
|
||||||
|
'http://localhost/api/products/p1/sprint-membership-summary?pbiIds=pbiA',
|
||||||
|
)
|
||||||
|
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 404 when product is not accessible', async () => {
|
||||||
|
mockPrisma.product.findFirst.mockResolvedValue(null)
|
||||||
|
const req = makeRequest(
|
||||||
|
'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA',
|
||||||
|
)
|
||||||
|
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
||||||
|
expect(res.status).toBe(404)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns auth error when authenticate fails', async () => {
|
||||||
|
mockAuth.mockResolvedValue({ error: 'Niet ingelogd', status: 401 })
|
||||||
|
const req = makeRequest(
|
||||||
|
'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA',
|
||||||
|
)
|
||||||
|
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns zero counts for PBIs without stories', async () => {
|
||||||
|
mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' })
|
||||||
|
mockPrisma.story.groupBy
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
|
||||||
|
const req = makeRequest(
|
||||||
|
'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA,pbiB',
|
||||||
|
)
|
||||||
|
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
||||||
|
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body).toEqual({
|
||||||
|
pbiA: { total: 0, inSprint: 0 },
|
||||||
|
pbiB: { total: 0, inSprint: 0 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
190
__tests__/lib/active-sprint.test.ts
Normal file
190
__tests__/lib/active-sprint.test.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('@/lib/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
sprint: { findFirst: vi.fn() },
|
||||||
|
user: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
$executeRaw: vi.fn().mockResolvedValue(1),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import type { UserSettings } from '@/lib/user-settings'
|
||||||
|
import {
|
||||||
|
clearActiveSprintInSettings,
|
||||||
|
readStoredActiveSprintState,
|
||||||
|
resolveActiveSprint,
|
||||||
|
} from '@/lib/active-sprint'
|
||||||
|
|
||||||
|
const mockPrisma = prisma as unknown as {
|
||||||
|
sprint: { findFirst: ReturnType<typeof vi.fn> }
|
||||||
|
user: {
|
||||||
|
findUnique: ReturnType<typeof vi.fn>
|
||||||
|
update: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
$executeRaw: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
|
||||||
|
function withSettings(settings: UserSettings) {
|
||||||
|
mockPrisma.user.findUnique.mockResolvedValueOnce({ settings })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('readStoredActiveSprintState', () => {
|
||||||
|
it('returns unset when activeSprints map is absent', () => {
|
||||||
|
expect(readStoredActiveSprintState({}, 'p1')).toEqual({ kind: 'unset' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns unset when productId key is absent', () => {
|
||||||
|
const settings: UserSettings = {
|
||||||
|
layout: { activeSprints: { p2: 'sprint-2' } },
|
||||||
|
}
|
||||||
|
expect(readStoredActiveSprintState(settings, 'p1')).toEqual({
|
||||||
|
kind: 'unset',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns cleared when key is present with null value', () => {
|
||||||
|
const settings: UserSettings = {
|
||||||
|
layout: { activeSprints: { p1: null } },
|
||||||
|
}
|
||||||
|
expect(readStoredActiveSprintState(settings, 'p1')).toEqual({
|
||||||
|
kind: 'cleared',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns set when key is present with string value', () => {
|
||||||
|
const settings: UserSettings = {
|
||||||
|
layout: { activeSprints: { p1: 'sprint-1' } },
|
||||||
|
}
|
||||||
|
expect(readStoredActiveSprintState(settings, 'p1')).toEqual({
|
||||||
|
kind: 'set',
|
||||||
|
sprintId: 'sprint-1',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resolveActiveSprint', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null without fallback when key is explicitly null (cleared)', async () => {
|
||||||
|
withSettings({ layout: { activeSprints: { p1: null } } })
|
||||||
|
|
||||||
|
const result = await resolveActiveSprint('p1', 'user-1')
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
expect(mockPrisma.sprint.findFirst).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the stored sprint when key is set and sprint exists', async () => {
|
||||||
|
withSettings({ layout: { activeSprints: { p1: 'sprint-1' } } })
|
||||||
|
mockPrisma.sprint.findFirst.mockResolvedValueOnce({
|
||||||
|
id: 'sprint-1',
|
||||||
|
code: 'SP-1',
|
||||||
|
status: 'OPEN',
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await resolveActiveSprint('p1', 'user-1')
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: 'sprint-1', code: 'SP-1', status: 'OPEN' })
|
||||||
|
expect(mockPrisma.sprint.findFirst).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back when stored sprint is not found in DB', async () => {
|
||||||
|
withSettings({ layout: { activeSprints: { p1: 'stale-id' } } })
|
||||||
|
mockPrisma.sprint.findFirst
|
||||||
|
.mockResolvedValueOnce(null) // stored lookup misses
|
||||||
|
.mockResolvedValueOnce({ id: 'sprint-open', code: 'SP-O', status: 'OPEN' })
|
||||||
|
|
||||||
|
const result = await resolveActiveSprint('p1', 'user-1')
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
id: 'sprint-open',
|
||||||
|
code: 'SP-O',
|
||||||
|
status: 'OPEN',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to first OPEN sprint when key is absent', async () => {
|
||||||
|
withSettings({})
|
||||||
|
mockPrisma.sprint.findFirst.mockResolvedValueOnce({
|
||||||
|
id: 'sprint-open',
|
||||||
|
code: 'SP-O',
|
||||||
|
status: 'OPEN',
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await resolveActiveSprint('p1', 'user-1')
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
id: 'sprint-open',
|
||||||
|
code: 'SP-O',
|
||||||
|
status: 'OPEN',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to recent CLOSED sprint when no OPEN exists', async () => {
|
||||||
|
withSettings({})
|
||||||
|
mockPrisma.sprint.findFirst
|
||||||
|
.mockResolvedValueOnce(null) // no OPEN
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
id: 'sprint-closed',
|
||||||
|
code: 'SP-C',
|
||||||
|
status: 'CLOSED',
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await resolveActiveSprint('p1', 'user-1')
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
id: 'sprint-closed',
|
||||||
|
code: 'SP-C',
|
||||||
|
status: 'CLOSED',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when key absent and no sprints exist', async () => {
|
||||||
|
withSettings({})
|
||||||
|
mockPrisma.sprint.findFirst.mockResolvedValue(null)
|
||||||
|
|
||||||
|
const result = await resolveActiveSprint('p1', 'user-1')
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('clearActiveSprintInSettings', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('writes null instead of deleting the key', async () => {
|
||||||
|
withSettings({
|
||||||
|
layout: { activeSprints: { p1: 'sprint-1', p2: 'sprint-2' } },
|
||||||
|
})
|
||||||
|
|
||||||
|
await clearActiveSprintInSettings('user-1', 'p1')
|
||||||
|
|
||||||
|
expect(mockPrisma.user.update).toHaveBeenCalledTimes(1)
|
||||||
|
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
|
||||||
|
data: { settings: UserSettings }
|
||||||
|
}
|
||||||
|
expect(updateArg.data.settings.layout?.activeSprints).toEqual({
|
||||||
|
p1: null,
|
||||||
|
p2: 'sprint-2',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds the key with null when previously unset', async () => {
|
||||||
|
withSettings({})
|
||||||
|
|
||||||
|
await clearActiveSprintInSettings('user-1', 'p1')
|
||||||
|
|
||||||
|
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
|
||||||
|
data: { settings: UserSettings }
|
||||||
|
}
|
||||||
|
expect(updateArg.data.settings.layout?.activeSprints).toEqual({ p1: null })
|
||||||
|
})
|
||||||
|
})
|
||||||
195
__tests__/lib/sprint-conflicts.test.ts
Normal file
195
__tests__/lib/sprint-conflicts.test.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import type { StoryStatus } from '@prisma/client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
getBlockingSprintMap,
|
||||||
|
isEligibleForSprint,
|
||||||
|
partitionByEligibility,
|
||||||
|
} from '@/lib/sprint-conflicts'
|
||||||
|
|
||||||
|
function mockPrisma(stories: Array<Record<string, unknown>>) {
|
||||||
|
return {
|
||||||
|
story: {
|
||||||
|
findMany: vi.fn().mockResolvedValue(stories),
|
||||||
|
},
|
||||||
|
} as unknown as Parameters<typeof partitionByEligibility>[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('isEligibleForSprint', () => {
|
||||||
|
it('returns true for OPEN story without sprint', () => {
|
||||||
|
expect(
|
||||||
|
isEligibleForSprint({ sprint_id: null, status: 'OPEN' as StoryStatus }),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns true for IN_SPRINT story without sprint_id (edge: restoration)', () => {
|
||||||
|
expect(
|
||||||
|
isEligibleForSprint({
|
||||||
|
sprint_id: null,
|
||||||
|
status: 'IN_SPRINT' as StoryStatus,
|
||||||
|
}),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false for DONE story without sprint', () => {
|
||||||
|
expect(
|
||||||
|
isEligibleForSprint({ sprint_id: null, status: 'DONE' as StoryStatus }),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when story is in any sprint (open status)', () => {
|
||||||
|
expect(
|
||||||
|
isEligibleForSprint({
|
||||||
|
sprint_id: 'abc',
|
||||||
|
status: 'OPEN' as StoryStatus,
|
||||||
|
}),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when story is in any sprint (done status)', () => {
|
||||||
|
expect(
|
||||||
|
isEligibleForSprint({
|
||||||
|
sprint_id: 'abc',
|
||||||
|
status: 'DONE' as StoryStatus,
|
||||||
|
}),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('partitionByEligibility', () => {
|
||||||
|
it('returns empty partition for empty input', async () => {
|
||||||
|
const prisma = mockPrisma([])
|
||||||
|
const result = await partitionByEligibility(prisma, [])
|
||||||
|
expect(result).toEqual({ eligible: [], notEligible: [], crossSprint: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('classifies all eligible when stories are free + OPEN', async () => {
|
||||||
|
const prisma = mockPrisma([
|
||||||
|
{ id: 's1', sprint_id: null, status: 'OPEN', sprint: null },
|
||||||
|
{ id: 's2', sprint_id: null, status: 'IN_SPRINT', sprint: null },
|
||||||
|
])
|
||||||
|
const result = await partitionByEligibility(prisma, ['s1', 's2'])
|
||||||
|
expect(result.eligible).toEqual(['s1', 's2'])
|
||||||
|
expect(result.notEligible).toEqual([])
|
||||||
|
expect(result.crossSprint).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks DONE stories as notEligible with reason=DONE', async () => {
|
||||||
|
const prisma = mockPrisma([
|
||||||
|
{ id: 's1', sprint_id: null, status: 'DONE', sprint: null },
|
||||||
|
])
|
||||||
|
const result = await partitionByEligibility(prisma, ['s1'])
|
||||||
|
expect(result.eligible).toEqual([])
|
||||||
|
expect(result.notEligible).toEqual([{ storyId: 's1', reason: 'DONE' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks stories in other OPEN sprint as crossSprint + notEligible', async () => {
|
||||||
|
const prisma = mockPrisma([
|
||||||
|
{
|
||||||
|
id: 's1',
|
||||||
|
sprint_id: 'sprint-other',
|
||||||
|
status: 'IN_SPRINT',
|
||||||
|
sprint: { id: 'sprint-other', code: 'SP-2', status: 'OPEN' },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const result = await partitionByEligibility(prisma, ['s1'])
|
||||||
|
expect(result.crossSprint).toEqual([
|
||||||
|
{ storyId: 's1', sprintId: 'sprint-other', sprintName: 'SP-2' },
|
||||||
|
])
|
||||||
|
expect(result.notEligible).toEqual([
|
||||||
|
{ storyId: 's1', reason: 'IN_OTHER_SPRINT' },
|
||||||
|
])
|
||||||
|
expect(result.eligible).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('classifies story in CLOSED sprint with status=OPEN as eligible (status reset already happened)', async () => {
|
||||||
|
const prisma = mockPrisma([
|
||||||
|
{
|
||||||
|
id: 's1',
|
||||||
|
sprint_id: null,
|
||||||
|
status: 'OPEN',
|
||||||
|
sprint: null,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const result = await partitionByEligibility(prisma, ['s1'])
|
||||||
|
expect(result.eligible).toEqual(['s1'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does NOT mark crossSprint for stories in CLOSED other sprint', async () => {
|
||||||
|
const prisma = mockPrisma([
|
||||||
|
{
|
||||||
|
id: 's1',
|
||||||
|
sprint_id: 'sprint-closed',
|
||||||
|
status: 'DONE',
|
||||||
|
sprint: { id: 'sprint-closed', code: 'SP-C', status: 'CLOSED' },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const result = await partitionByEligibility(prisma, ['s1'])
|
||||||
|
expect(result.crossSprint).toEqual([])
|
||||||
|
expect(result.notEligible).toEqual([
|
||||||
|
{ storyId: 's1', reason: 'IN_OTHER_SPRINT' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('respects excludeSprintId — story in same sprint is eligible', async () => {
|
||||||
|
const prisma = mockPrisma([
|
||||||
|
{
|
||||||
|
id: 's1',
|
||||||
|
sprint_id: 'sprint-active',
|
||||||
|
status: 'IN_SPRINT',
|
||||||
|
sprint: { id: 'sprint-active', code: 'SP-A', status: 'OPEN' },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const result = await partitionByEligibility(prisma, ['s1'], 'sprint-active')
|
||||||
|
expect(result.eligible).toEqual(['s1'])
|
||||||
|
expect(result.crossSprint).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getBlockingSprintMap', () => {
|
||||||
|
it('returns empty map for empty input', async () => {
|
||||||
|
const prisma = mockPrisma([])
|
||||||
|
const result = await getBlockingSprintMap(prisma, 'p1', [])
|
||||||
|
expect(result.size).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns blocking sprint info for stories in OPEN sprints', async () => {
|
||||||
|
const prisma = mockPrisma([
|
||||||
|
{
|
||||||
|
id: 's1',
|
||||||
|
sprint_id: 'sprint-x',
|
||||||
|
sprint: { id: 'sprint-x', code: 'SP-X', status: 'OPEN' },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const result = await getBlockingSprintMap(prisma, 'p1', ['s1'])
|
||||||
|
expect(result.get('s1')).toEqual({
|
||||||
|
sprintId: 'sprint-x',
|
||||||
|
sprintName: 'SP-X',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes the active sprint from blocking', async () => {
|
||||||
|
const prisma = mockPrisma([
|
||||||
|
{
|
||||||
|
id: 's1',
|
||||||
|
sprint_id: 'sprint-active',
|
||||||
|
sprint: { id: 'sprint-active', code: 'SP-A', status: 'OPEN' },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const result = await getBlockingSprintMap(
|
||||||
|
prisma,
|
||||||
|
'p1',
|
||||||
|
['s1'],
|
||||||
|
'sprint-active',
|
||||||
|
)
|
||||||
|
expect(result.size).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not include CLOSED sprints (filtered at DB query level)', async () => {
|
||||||
|
// The prisma mock receives WHERE sprint.status='OPEN' so CLOSED stories
|
||||||
|
// are already filtered out before reaching this function's mapping logic.
|
||||||
|
const prisma = mockPrisma([])
|
||||||
|
const result = await getBlockingSprintMap(prisma, 'p1', ['s1'])
|
||||||
|
expect(result.size).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -122,4 +122,65 @@ describe('UserSettingsSchema', () => {
|
||||||
layout: { splitPanePositions: { x: [50, 50] }, activeSprints: { p: 's' } },
|
layout: { splitPanePositions: { x: [50, 50] }, activeSprints: { p: 's' } },
|
||||||
}).success).toBe(true)
|
}).success).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('accepts null values in activeSprints (explicit "no active sprint")', () => {
|
||||||
|
const result = UserSettingsSchema.safeParse({
|
||||||
|
layout: { activeSprints: { 'product-1': null, 'product-2': 'sprint-2' } },
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data.layout?.activeSprints).toEqual({
|
||||||
|
'product-1': null,
|
||||||
|
'product-2': 'sprint-2',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts pendingSprintDraft with per-PBI intent and overrides', () => {
|
||||||
|
const result = UserSettingsSchema.safeParse({
|
||||||
|
workflow: {
|
||||||
|
pendingSprintDraft: {
|
||||||
|
'product-1': {
|
||||||
|
goal: 'Sprint goal',
|
||||||
|
pbiIntent: { pbiA: 'all', pbiB: 'none' },
|
||||||
|
storyOverrides: {
|
||||||
|
pbiA: { add: [], remove: ['story-1'] },
|
||||||
|
pbiB: { add: ['story-2'], remove: [] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fills empty defaults for pbiIntent and storyOverrides in draft', () => {
|
||||||
|
const result = UserSettingsSchema.safeParse({
|
||||||
|
workflow: { pendingSprintDraft: { 'product-1': { goal: 'g' } } },
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
const draft = result.data.workflow?.pendingSprintDraft?.['product-1']
|
||||||
|
expect(draft?.pbiIntent).toEqual({})
|
||||||
|
expect(draft?.storyOverrides).toEqual({})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects pendingSprintDraft with empty goal', () => {
|
||||||
|
const result = UserSettingsSchema.safeParse({
|
||||||
|
workflow: { pendingSprintDraft: { 'p': { goal: '' } } },
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects unknown intent value', () => {
|
||||||
|
const result = UserSettingsSchema.safeParse({
|
||||||
|
workflow: {
|
||||||
|
pendingSprintDraft: {
|
||||||
|
p: { goal: 'x', pbiIntent: { a: 'partial' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
341
__tests__/stores/product-workspace/sprint-membership.test.ts
Normal file
341
__tests__/stores/product-workspace/sprint-membership.test.ts
Normal file
|
|
@ -0,0 +1,341 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
||||||
|
import {
|
||||||
|
selectIsDirty,
|
||||||
|
selectPbiTriState,
|
||||||
|
selectPendingCount,
|
||||||
|
selectStoryEffectiveInSprint,
|
||||||
|
selectStoryIsBlocked,
|
||||||
|
} from '@/stores/product-workspace/selectors'
|
||||||
|
import type { BacklogStory } from '@/stores/product-workspace/types'
|
||||||
|
|
||||||
|
function resetMembership() {
|
||||||
|
useProductWorkspaceStore.setState((s) => {
|
||||||
|
s.entities.storiesById = {}
|
||||||
|
s.relations.storyIdsByPbi = {}
|
||||||
|
s.sprintMembership = {
|
||||||
|
pbiSummary: {},
|
||||||
|
crossSprintBlocks: {},
|
||||||
|
pending: { adds: [], removes: [] },
|
||||||
|
loadedSummaryForSprintId: null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedStory(id: string, pbiId: string, sprintId: string | null): BacklogStory {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
code: id,
|
||||||
|
title: id,
|
||||||
|
description: null,
|
||||||
|
acceptance_criteria: null,
|
||||||
|
priority: 2,
|
||||||
|
sort_order: 1,
|
||||||
|
status: sprintId ? 'IN_SPRINT' : 'OPEN',
|
||||||
|
pbi_id: pbiId,
|
||||||
|
sprint_id: sprintId,
|
||||||
|
created_at: new Date('2026-01-01'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetMembership()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('toggleStorySprintMembership', () => {
|
||||||
|
it('adds storyId to pending.adds when currently not in sprint', () => {
|
||||||
|
useProductWorkspaceStore.getState().toggleStorySprintMembership('s1', false)
|
||||||
|
const pending = useProductWorkspaceStore.getState().sprintMembership.pending
|
||||||
|
expect(pending.adds).toEqual(['s1'])
|
||||||
|
expect(pending.removes).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds storyId to pending.removes when currently in sprint', () => {
|
||||||
|
useProductWorkspaceStore.getState().toggleStorySprintMembership('s1', true)
|
||||||
|
const pending = useProductWorkspaceStore.getState().sprintMembership.pending
|
||||||
|
expect(pending.removes).toEqual(['s1'])
|
||||||
|
expect(pending.adds).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancels out: toggle add → toggle remove same story (in-sprint) clears pending', () => {
|
||||||
|
const store = useProductWorkspaceStore.getState()
|
||||||
|
store.toggleStorySprintMembership('s1', false) // adds
|
||||||
|
// Story now appears to be "in sprint" via pending; calling with true should cancel
|
||||||
|
store.toggleStorySprintMembership('s1', false) // second click with same baseline
|
||||||
|
const pending = useProductWorkspaceStore.getState().sprintMembership.pending
|
||||||
|
expect(pending.adds).toEqual([])
|
||||||
|
expect(pending.removes).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes from pending.removes when toggled back', () => {
|
||||||
|
const store = useProductWorkspaceStore.getState()
|
||||||
|
store.toggleStorySprintMembership('s1', true)
|
||||||
|
store.toggleStorySprintMembership('s1', true)
|
||||||
|
const pending = useProductWorkspaceStore.getState().sprintMembership.pending
|
||||||
|
expect(pending.removes).toEqual([])
|
||||||
|
expect(pending.adds).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resetSprintMembershipPending empties both arrays', () => {
|
||||||
|
const store = useProductWorkspaceStore.getState()
|
||||||
|
store.toggleStorySprintMembership('s1', false)
|
||||||
|
store.toggleStorySprintMembership('s2', true)
|
||||||
|
store.resetSprintMembershipPending()
|
||||||
|
const pending = useProductWorkspaceStore.getState().sprintMembership.pending
|
||||||
|
expect(pending.adds).toEqual([])
|
||||||
|
expect(pending.removes).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('selectPbiTriState', () => {
|
||||||
|
function seedSummary(pbiId: string, total: number, inSprint: number) {
|
||||||
|
useProductWorkspaceStore.setState((s) => {
|
||||||
|
s.sprintMembership.pbiSummary[pbiId] = {
|
||||||
|
totalStoryCount: total,
|
||||||
|
inActiveSprintStoryCount: inSprint,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns empty for PBI without summary', () => {
|
||||||
|
expect(
|
||||||
|
selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'),
|
||||||
|
).toBe('empty')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty when totalStoryCount == 0', () => {
|
||||||
|
seedSummary('pbi-1', 0, 0)
|
||||||
|
expect(
|
||||||
|
selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'),
|
||||||
|
).toBe('empty')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns full when all stories in sprint (no pending)', () => {
|
||||||
|
seedSummary('pbi-1', 3, 3)
|
||||||
|
expect(
|
||||||
|
selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'),
|
||||||
|
).toBe('full')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns partial when some stories in sprint', () => {
|
||||||
|
seedSummary('pbi-1', 3, 2)
|
||||||
|
expect(
|
||||||
|
selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'),
|
||||||
|
).toBe('partial')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty when inSprint == 0', () => {
|
||||||
|
seedSummary('pbi-1', 3, 0)
|
||||||
|
expect(
|
||||||
|
selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'),
|
||||||
|
).toBe('empty')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies pending adds when stories are loaded for the PBI', () => {
|
||||||
|
seedSummary('pbi-1', 3, 1)
|
||||||
|
useProductWorkspaceStore.setState((s) => {
|
||||||
|
s.relations.storyIdsByPbi['pbi-1'] = ['s1', 's2', 's3']
|
||||||
|
s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', 'sprint-1')
|
||||||
|
s.entities.storiesById['s2'] = seedStory('s2', 'pbi-1', null)
|
||||||
|
s.entities.storiesById['s3'] = seedStory('s3', 'pbi-1', null)
|
||||||
|
s.sprintMembership.pending.adds = ['s2', 's3']
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'),
|
||||||
|
).toBe('full')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies pending removes when stories are loaded for the PBI', () => {
|
||||||
|
seedSummary('pbi-1', 3, 3)
|
||||||
|
useProductWorkspaceStore.setState((s) => {
|
||||||
|
s.relations.storyIdsByPbi['pbi-1'] = ['s1', 's2', 's3']
|
||||||
|
s.sprintMembership.pending.removes = ['s2']
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'),
|
||||||
|
).toBe('partial')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores pending entries for stories of other PBIs', () => {
|
||||||
|
seedSummary('pbi-1', 3, 3)
|
||||||
|
useProductWorkspaceStore.setState((s) => {
|
||||||
|
s.relations.storyIdsByPbi['pbi-1'] = ['s1', 's2', 's3']
|
||||||
|
s.sprintMembership.pending.removes = ['s99'] // not in pbi-1
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'),
|
||||||
|
).toBe('full')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('selectStoryEffectiveInSprint', () => {
|
||||||
|
it('returns true when story.sprint_id matches activeSprintId and no pending', () => {
|
||||||
|
useProductWorkspaceStore.setState((s) => {
|
||||||
|
s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', 'sprint-A')
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
selectStoryEffectiveInSprint(
|
||||||
|
useProductWorkspaceStore.getState(),
|
||||||
|
's1',
|
||||||
|
'sprint-A',
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when story.sprint_id is null', () => {
|
||||||
|
useProductWorkspaceStore.setState((s) => {
|
||||||
|
s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', null)
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
selectStoryEffectiveInSprint(
|
||||||
|
useProductWorkspaceStore.getState(),
|
||||||
|
's1',
|
||||||
|
'sprint-A',
|
||||||
|
),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns true when story in pending.adds even if DB says no', () => {
|
||||||
|
useProductWorkspaceStore.setState((s) => {
|
||||||
|
s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', null)
|
||||||
|
s.sprintMembership.pending.adds = ['s1']
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
selectStoryEffectiveInSprint(
|
||||||
|
useProductWorkspaceStore.getState(),
|
||||||
|
's1',
|
||||||
|
'sprint-A',
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when story in pending.removes even if DB says yes', () => {
|
||||||
|
useProductWorkspaceStore.setState((s) => {
|
||||||
|
s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', 'sprint-A')
|
||||||
|
s.sprintMembership.pending.removes = ['s1']
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
selectStoryEffectiveInSprint(
|
||||||
|
useProductWorkspaceStore.getState(),
|
||||||
|
's1',
|
||||||
|
'sprint-A',
|
||||||
|
),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when activeSprintId is null', () => {
|
||||||
|
useProductWorkspaceStore.setState((s) => {
|
||||||
|
s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', 'sprint-A')
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
selectStoryEffectiveInSprint(
|
||||||
|
useProductWorkspaceStore.getState(),
|
||||||
|
's1',
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('selectStoryIsBlocked', () => {
|
||||||
|
it('returns null when no block', () => {
|
||||||
|
expect(
|
||||||
|
selectStoryIsBlocked(useProductWorkspaceStore.getState(), 's1'),
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns block info when story is in another sprint', () => {
|
||||||
|
useProductWorkspaceStore.setState((s) => {
|
||||||
|
s.sprintMembership.crossSprintBlocks['s1'] = {
|
||||||
|
sprintId: 'sprint-x',
|
||||||
|
sprintName: 'SP-X',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
selectStoryIsBlocked(useProductWorkspaceStore.getState(), 's1'),
|
||||||
|
).toEqual({ sprintId: 'sprint-x', sprintName: 'SP-X' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('selectIsDirty + selectPendingCount', () => {
|
||||||
|
it('clean by default', () => {
|
||||||
|
expect(selectIsDirty(useProductWorkspaceStore.getState())).toBe(false)
|
||||||
|
expect(selectPendingCount(useProductWorkspaceStore.getState())).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('counts adds + removes', () => {
|
||||||
|
useProductWorkspaceStore.setState((s) => {
|
||||||
|
s.sprintMembership.pending = {
|
||||||
|
adds: ['a1', 'a2'],
|
||||||
|
removes: ['r1'],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(selectIsDirty(useProductWorkspaceStore.getState())).toBe(true)
|
||||||
|
expect(selectPendingCount(useProductWorkspaceStore.getState())).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fetch helpers', () => {
|
||||||
|
it('fetchSprintMembershipSummary populates store and gates by sprintId', async () => {
|
||||||
|
const originalFetch = globalThis.fetch
|
||||||
|
const responseBody = {
|
||||||
|
pbiA: { totalStoryCount: 5, inActiveSprintStoryCount: 2 },
|
||||||
|
}
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify(responseBody), { status: 200 }),
|
||||||
|
) as unknown as typeof fetch
|
||||||
|
try {
|
||||||
|
await useProductWorkspaceStore
|
||||||
|
.getState()
|
||||||
|
.fetchSprintMembershipSummary('prod-1', 'sprint-A', ['pbiA'])
|
||||||
|
|
||||||
|
const slice = useProductWorkspaceStore.getState().sprintMembership
|
||||||
|
expect(slice.pbiSummary.pbiA).toEqual({
|
||||||
|
totalStoryCount: 5,
|
||||||
|
inActiveSprintStoryCount: 2,
|
||||||
|
})
|
||||||
|
expect(slice.loadedSummaryForSprintId).toBe('sprint-A')
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetchCrossSprintBlocks populates store', async () => {
|
||||||
|
const originalFetch = globalThis.fetch
|
||||||
|
const responseBody = {
|
||||||
|
's1': { sprintId: 'sprint-x', sprintName: 'SP-X' },
|
||||||
|
}
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify(responseBody), { status: 200 }),
|
||||||
|
) as unknown as typeof fetch
|
||||||
|
try {
|
||||||
|
await useProductWorkspaceStore
|
||||||
|
.getState()
|
||||||
|
.fetchCrossSprintBlocks('prod-1', 'sprint-A', ['pbiA'])
|
||||||
|
|
||||||
|
const slice = useProductWorkspaceStore.getState().sprintMembership
|
||||||
|
expect(slice.crossSprintBlocks['s1']).toEqual({
|
||||||
|
sprintId: 'sprint-x',
|
||||||
|
sprintName: 'SP-X',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetchSprintMembershipSummary is a no-op for empty pbiIds', async () => {
|
||||||
|
const fetchSpy = vi.fn()
|
||||||
|
const originalFetch = globalThis.fetch
|
||||||
|
globalThis.fetch = fetchSpy as unknown as typeof fetch
|
||||||
|
try {
|
||||||
|
await useProductWorkspaceStore
|
||||||
|
.getState()
|
||||||
|
.fetchSprintMembershipSummary('prod-1', 'sprint-A', [])
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled()
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -56,6 +56,12 @@ function resetStore() {
|
||||||
s.sync.lastResyncAt = null
|
s.sync.lastResyncAt = null
|
||||||
s.sync.resyncReason = null
|
s.sync.resyncReason = null
|
||||||
s.pendingMutations = {}
|
s.pendingMutations = {}
|
||||||
|
s.sprintMembership = {
|
||||||
|
pbiSummary: {},
|
||||||
|
crossSprintBlocks: {},
|
||||||
|
pending: { adds: [], removes: [] },
|
||||||
|
loadedSummaryForSprintId: null,
|
||||||
|
}
|
||||||
Object.assign(s, originalActions)
|
Object.assign(s, originalActions)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,21 @@
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
const updateAction = vi.fn()
|
const updateAction = vi.fn()
|
||||||
|
const setDraftAction = vi.fn()
|
||||||
|
const clearDraftAction = vi.fn()
|
||||||
|
|
||||||
vi.mock('@/actions/user-settings', () => ({
|
vi.mock('@/actions/user-settings', () => ({
|
||||||
updateUserSettingsAction: (...args: unknown[]) => updateAction(...args),
|
updateUserSettingsAction: (...args: unknown[]) => updateAction(...args),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/actions/sprint-draft', () => ({
|
||||||
|
setPendingSprintDraftAction: (...args: unknown[]) => setDraftAction(...args),
|
||||||
|
clearPendingSprintDraftAction: (...args: unknown[]) =>
|
||||||
|
clearDraftAction(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||||
|
import type { PendingSprintDraft } from '@/lib/user-settings'
|
||||||
|
|
||||||
function resetStore() {
|
function resetStore() {
|
||||||
useUserSettingsStore.setState((s) => {
|
useUserSettingsStore.setState((s) => {
|
||||||
|
|
@ -20,6 +29,8 @@ function resetStore() {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetStore()
|
resetStore()
|
||||||
updateAction.mockReset()
|
updateAction.mockReset()
|
||||||
|
setDraftAction.mockReset()
|
||||||
|
clearDraftAction.mockReset()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -85,6 +96,130 @@ describe('useUserSettingsStore', () => {
|
||||||
expect(updateAction).not.toHaveBeenCalled()
|
expect(updateAction).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('setPendingSprintDraft persists draft lokaal (session-only, geen server-call)', async () => {
|
||||||
|
useUserSettingsStore.getState().hydrate({}, false)
|
||||||
|
|
||||||
|
const draft: PendingSprintDraft = {
|
||||||
|
goal: 'Sprint 1',
|
||||||
|
pbiIntent: { pbiA: 'all' },
|
||||||
|
storyOverrides: {},
|
||||||
|
}
|
||||||
|
await useUserSettingsStore
|
||||||
|
.getState()
|
||||||
|
.setPendingSprintDraft('product-1', draft)
|
||||||
|
|
||||||
|
const s = useUserSettingsStore.getState()
|
||||||
|
expect(
|
||||||
|
s.entities.settings.workflow?.pendingSprintDraft?.['product-1'],
|
||||||
|
).toMatchObject({ goal: 'Sprint 1' })
|
||||||
|
expect(setDraftAction).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hydrate strips workflow.pendingSprintDraft uit legacy server-state', () => {
|
||||||
|
useUserSettingsStore.getState().hydrate(
|
||||||
|
{
|
||||||
|
workflow: {
|
||||||
|
pendingSprintDraft: {
|
||||||
|
'product-1': {
|
||||||
|
goal: 'Legacy draft',
|
||||||
|
pbiIntent: {},
|
||||||
|
storyOverrides: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
|
const s = useUserSettingsStore.getState()
|
||||||
|
expect(s.entities.settings.workflow?.pendingSprintDraft).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clearPendingSprintDraft verwijdert de key lokaal zonder server-call', async () => {
|
||||||
|
useUserSettingsStore.getState().hydrate({}, false)
|
||||||
|
await useUserSettingsStore.getState().setPendingSprintDraft('product-1', {
|
||||||
|
goal: 'Old',
|
||||||
|
pbiIntent: {},
|
||||||
|
storyOverrides: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
await useUserSettingsStore
|
||||||
|
.getState()
|
||||||
|
.clearPendingSprintDraft('product-1')
|
||||||
|
|
||||||
|
const s = useUserSettingsStore.getState()
|
||||||
|
expect(
|
||||||
|
s.entities.settings.workflow?.pendingSprintDraft?.['product-1'],
|
||||||
|
).toBeUndefined()
|
||||||
|
expect(clearDraftAction).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('upsertPbiIntent updates intent and wipes storyOverrides for that PBI', async () => {
|
||||||
|
useUserSettingsStore.getState().hydrate({}, false)
|
||||||
|
await useUserSettingsStore.getState().setPendingSprintDraft('product-1', {
|
||||||
|
goal: 'g',
|
||||||
|
pbiIntent: { pbiA: 'none' },
|
||||||
|
storyOverrides: {
|
||||||
|
pbiA: { add: ['s-1'], remove: [] },
|
||||||
|
pbiB: { add: [], remove: ['s-2'] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await useUserSettingsStore
|
||||||
|
.getState()
|
||||||
|
.upsertPbiIntent('product-1', 'pbiA', 'all')
|
||||||
|
|
||||||
|
const draft =
|
||||||
|
useUserSettingsStore.getState().entities.settings.workflow
|
||||||
|
?.pendingSprintDraft?.['product-1']
|
||||||
|
expect(draft?.pbiIntent.pbiA).toBe('all')
|
||||||
|
expect(draft?.storyOverrides.pbiA).toBeUndefined()
|
||||||
|
expect(draft?.storyOverrides.pbiB).toEqual({ add: [], remove: ['s-2'] })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('upsertStoryOverride add adds to add[] and removes from remove[]', async () => {
|
||||||
|
useUserSettingsStore.getState().hydrate({}, false)
|
||||||
|
await useUserSettingsStore.getState().setPendingSprintDraft('product-1', {
|
||||||
|
goal: 'g',
|
||||||
|
pbiIntent: {},
|
||||||
|
storyOverrides: {
|
||||||
|
pbiA: { add: [], remove: ['story-1'] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await useUserSettingsStore
|
||||||
|
.getState()
|
||||||
|
.upsertStoryOverride('product-1', 'pbiA', 'story-1', 'add')
|
||||||
|
|
||||||
|
const draft =
|
||||||
|
useUserSettingsStore.getState().entities.settings.workflow
|
||||||
|
?.pendingSprintDraft?.['product-1']
|
||||||
|
expect(draft?.storyOverrides.pbiA).toEqual({
|
||||||
|
add: ['story-1'],
|
||||||
|
remove: [],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('upsertStoryOverride clear removes from both arrays and drops empty entry', async () => {
|
||||||
|
useUserSettingsStore.getState().hydrate({}, false)
|
||||||
|
await useUserSettingsStore.getState().setPendingSprintDraft('product-1', {
|
||||||
|
goal: 'g',
|
||||||
|
pbiIntent: {},
|
||||||
|
storyOverrides: {
|
||||||
|
pbiA: { add: ['story-1'], remove: [] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await useUserSettingsStore
|
||||||
|
.getState()
|
||||||
|
.upsertStoryOverride('product-1', 'pbiA', 'story-1', 'clear')
|
||||||
|
|
||||||
|
const draft =
|
||||||
|
useUserSettingsStore.getState().entities.settings.workflow
|
||||||
|
?.pendingSprintDraft?.['product-1']
|
||||||
|
expect(draft?.storyOverrides.pbiA).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
it('applyServerPatch merges without optimistic state', () => {
|
it('applyServerPatch merges without optimistic state', () => {
|
||||||
useUserSettingsStore.getState().hydrate(
|
useUserSettingsStore.getState().hydrate(
|
||||||
{ views: { sprintBacklog: { sort: 'code' } } },
|
{ views: { sprintBacklog: { sort: 'code' } } },
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,11 @@ import { z } from 'zod'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { SessionData, sessionOptions } from '@/lib/session'
|
import { SessionData, sessionOptions } from '@/lib/session'
|
||||||
import { productAccessFilter } from '@/lib/product-access'
|
import { productAccessFilter } from '@/lib/product-access'
|
||||||
import { setActiveSprintInSettings } from '@/lib/active-sprint'
|
import {
|
||||||
|
clearActiveSprintInSettings,
|
||||||
|
setActiveSelectionInSettings,
|
||||||
|
setActiveSprintInSettings,
|
||||||
|
} from '@/lib/active-sprint'
|
||||||
|
|
||||||
async function getSession() {
|
async function getSession() {
|
||||||
return getIronSession<SessionData>(await cookies(), sessionOptions)
|
return getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||||
|
|
@ -18,6 +22,10 @@ const setSchema = z.object({
|
||||||
sprintId: z.string().min(1),
|
sprintId: z.string().min(1),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const clearSchema = z.object({
|
||||||
|
productId: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
export async function setActiveSprintAction(productId: string, sprintId: string) {
|
export async function setActiveSprintAction(productId: string, sprintId: string) {
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||||
|
|
@ -41,6 +49,99 @@ export async function setActiveSprintAction(productId: string, sprintId: string)
|
||||||
return { success: true, sprintId: parsed.data.sprintId }
|
return { success: true, sprintId: parsed.data.sprintId }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function clearActiveSprintAction(productId: string) {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||||
|
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
||||||
|
|
||||||
|
const parsed = clearSchema.safeParse({ productId })
|
||||||
|
if (!parsed.success) return { error: 'Ongeldig product-id' }
|
||||||
|
|
||||||
|
const product = await prisma.product.findFirst({
|
||||||
|
where: { id: parsed.data.productId, ...productAccessFilter(session.userId) },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (!product) return { error: 'Product niet gevonden of niet toegankelijk' }
|
||||||
|
|
||||||
|
await clearActiveSprintInSettings(session.userId, parsed.data.productId)
|
||||||
|
revalidatePath('/', 'layout')
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectionSchema = z.object({
|
||||||
|
productId: z.string().min(1),
|
||||||
|
sprintId: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PBI-79: kies een sprint en auto-select zijn enige PBI/story (indien
|
||||||
|
* singleton). Resultaat wordt server-side bepaald + atomair in user-settings
|
||||||
|
* weggeschreven (sprint+pbi+story) zodat cross-device-restore klopt.
|
||||||
|
*/
|
||||||
|
export async function switchActiveSprintAction(
|
||||||
|
productId: string,
|
||||||
|
sprintId: string,
|
||||||
|
): Promise<
|
||||||
|
| {
|
||||||
|
success: true
|
||||||
|
sprintId: string
|
||||||
|
pbiId: string | null
|
||||||
|
storyId: string | null
|
||||||
|
}
|
||||||
|
| { error: string }
|
||||||
|
> {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||||
|
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
||||||
|
|
||||||
|
const parsed = selectionSchema.safeParse({ productId, sprintId })
|
||||||
|
if (!parsed.success) return { error: 'Ongeldig product- of sprint-id' }
|
||||||
|
|
||||||
|
const sprint = await prisma.sprint.findFirst({
|
||||||
|
where: {
|
||||||
|
id: parsed.data.sprintId,
|
||||||
|
product_id: parsed.data.productId,
|
||||||
|
product: productAccessFilter(session.userId),
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (!sprint) return { error: 'Sprint niet gevonden of niet toegankelijk' }
|
||||||
|
|
||||||
|
// Auto-select: alleen wanneer sprint exact één PBI heeft. Story-auto-select
|
||||||
|
// alleen wanneer die PBI exact één story binnen deze sprint heeft.
|
||||||
|
const sprintStories = await prisma.story.findMany({
|
||||||
|
where: {
|
||||||
|
sprint_id: parsed.data.sprintId,
|
||||||
|
product_id: parsed.data.productId,
|
||||||
|
},
|
||||||
|
select: { id: true, pbi_id: true },
|
||||||
|
})
|
||||||
|
const uniquePbiIds = Array.from(new Set(sprintStories.map((s) => s.pbi_id)))
|
||||||
|
let autoPbiId: string | null = null
|
||||||
|
let autoStoryId: string | null = null
|
||||||
|
if (uniquePbiIds.length === 1) {
|
||||||
|
autoPbiId = uniquePbiIds[0]
|
||||||
|
const storiesForPbi = sprintStories.filter((s) => s.pbi_id === autoPbiId)
|
||||||
|
if (storiesForPbi.length === 1) {
|
||||||
|
autoStoryId = storiesForPbi[0].id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await setActiveSelectionInSettings(session.userId, parsed.data.productId, {
|
||||||
|
sprintId: parsed.data.sprintId,
|
||||||
|
pbiId: autoPbiId,
|
||||||
|
storyId: autoStoryId,
|
||||||
|
})
|
||||||
|
revalidatePath('/', 'layout')
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
sprintId: parsed.data.sprintId,
|
||||||
|
pbiId: autoPbiId,
|
||||||
|
storyId: autoStoryId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function syncActiveSprintCookieAction(productId: string, sprintId: string) {
|
export async function syncActiveSprintCookieAction(productId: string, sprintId: string) {
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
if (!session.userId) return
|
if (!session.userId) return
|
||||||
|
|
|
||||||
121
actions/sprint-draft.ts
Normal file
121
actions/sprint-draft.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
'use server'
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import { getIronSession } from 'iron-session'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import type { Prisma } from '@prisma/client'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { SessionData, sessionOptions } from '@/lib/session'
|
||||||
|
import { productAccessFilter } from '@/lib/product-access'
|
||||||
|
import {
|
||||||
|
mergeSettings,
|
||||||
|
parseUserSettings,
|
||||||
|
type PendingSprintDraft,
|
||||||
|
type UserSettings,
|
||||||
|
} from '@/lib/user-settings'
|
||||||
|
|
||||||
|
async function getSession() {
|
||||||
|
return getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
const StoryOverridesSchema = z.object({
|
||||||
|
add: z.array(z.string()),
|
||||||
|
remove: z.array(z.string()),
|
||||||
|
}).strict()
|
||||||
|
|
||||||
|
const DraftSchema = z.object({
|
||||||
|
goal: z.string().min(1),
|
||||||
|
startAt: z.string().date().optional(),
|
||||||
|
endAt: z.string().date().optional(),
|
||||||
|
pbiIntent: z.record(z.string(), z.enum(['all', 'none'])).default({}),
|
||||||
|
storyOverrides: z.record(z.string(), StoryOverridesSchema).default({}),
|
||||||
|
}).strict()
|
||||||
|
|
||||||
|
const SetSchema = z.object({
|
||||||
|
productId: z.string().min(1),
|
||||||
|
draft: DraftSchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
const ClearSchema = z.object({
|
||||||
|
productId: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
async function ensureProductAccess(userId: string, productId: string) {
|
||||||
|
return prisma.product.findFirst({
|
||||||
|
where: { id: productId, ...productAccessFilter(userId) },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readUserSettings(userId: string): Promise<UserSettings> {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { settings: true },
|
||||||
|
})
|
||||||
|
return parseUserSettings(user?.settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeUserSettings(userId: string, next: UserSettings) {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { settings: next as unknown as Prisma.InputJsonValue },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setPendingSprintDraftAction(
|
||||||
|
productId: string,
|
||||||
|
draft: PendingSprintDraft,
|
||||||
|
) {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||||
|
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
||||||
|
|
||||||
|
const parsed = SetSchema.safeParse({ productId, draft })
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { error: 'Ongeldige draft', issues: parsed.error.issues }
|
||||||
|
}
|
||||||
|
|
||||||
|
const product = await ensureProductAccess(session.userId, parsed.data.productId)
|
||||||
|
if (!product) return { error: 'Product niet gevonden of niet toegankelijk' }
|
||||||
|
|
||||||
|
const current = await readUserSettings(session.userId)
|
||||||
|
const patch: Partial<UserSettings> = {
|
||||||
|
workflow: {
|
||||||
|
pendingSprintDraft: {
|
||||||
|
...(current.workflow?.pendingSprintDraft ?? {}),
|
||||||
|
[parsed.data.productId]: parsed.data.draft,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await writeUserSettings(session.userId, mergeSettings(current, patch))
|
||||||
|
revalidatePath('/', 'layout')
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearPendingSprintDraftAction(productId: string) {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||||
|
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
||||||
|
|
||||||
|
const parsed = ClearSchema.safeParse({ productId })
|
||||||
|
if (!parsed.success) return { error: 'Ongeldig product-id' }
|
||||||
|
|
||||||
|
const product = await ensureProductAccess(session.userId, parsed.data.productId)
|
||||||
|
if (!product) return { error: 'Product niet gevonden of niet toegankelijk' }
|
||||||
|
|
||||||
|
const current = await readUserSettings(session.userId)
|
||||||
|
const existingMap = current.workflow?.pendingSprintDraft
|
||||||
|
if (!existingMap || !(parsed.data.productId in existingMap)) {
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
const nextMap = { ...existingMap }
|
||||||
|
delete nextMap[parsed.data.productId]
|
||||||
|
const next: UserSettings = {
|
||||||
|
...current,
|
||||||
|
workflow: { ...current.workflow, pendingSprintDraft: nextMap },
|
||||||
|
}
|
||||||
|
await writeUserSettings(session.userId, next)
|
||||||
|
revalidatePath('/', 'layout')
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
@ -15,8 +15,358 @@ import { enforceUserRateLimit } from '@/lib/rate-limit'
|
||||||
import { propagateStatusUpwards } from '@/lib/tasks-status-update'
|
import { propagateStatusUpwards } from '@/lib/tasks-status-update'
|
||||||
import { createWithCodeRetry, generateNextSprintCode } from '@/lib/code-server'
|
import { createWithCodeRetry, generateNextSprintCode } from '@/lib/code-server'
|
||||||
import { setActiveSprintInSettings } from '@/lib/active-sprint'
|
import { setActiveSprintInSettings } from '@/lib/active-sprint'
|
||||||
|
import { partitionByEligibility } from '@/lib/sprint-conflicts'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const StoryOverrideSchema = z.object({
|
||||||
|
add: z.array(z.string()),
|
||||||
|
remove: z.array(z.string()),
|
||||||
|
})
|
||||||
|
|
||||||
|
const createSprintWithSelectionSchema = z.object({
|
||||||
|
productId: z.string().min(1),
|
||||||
|
metadata: z.object({
|
||||||
|
goal: z.string().min(1).max(2000),
|
||||||
|
startAt: z.string().date().optional(),
|
||||||
|
endAt: z.string().date().optional(),
|
||||||
|
}),
|
||||||
|
pbiIntent: z.record(z.string(), z.enum(['all', 'none'])).default({}),
|
||||||
|
storyOverrides: z.record(z.string(), StoryOverrideSchema).default({}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type CreateSprintWithSelectionInput = z.infer<
|
||||||
|
typeof createSprintWithSelectionSchema
|
||||||
|
>
|
||||||
|
|
||||||
|
type SprintCreateConflicts = {
|
||||||
|
notEligible: { storyId: string; reason: 'DONE' | 'IN_OTHER_SPRINT' }[]
|
||||||
|
crossSprint: { storyId: string; sprintId: string; sprintName: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateSprintWithSelectionResult =
|
||||||
|
| {
|
||||||
|
success: true
|
||||||
|
sprintId: string
|
||||||
|
affectedStoryIds: string[]
|
||||||
|
affectedPbiIds: string[]
|
||||||
|
affectedTaskIds: string[]
|
||||||
|
conflicts: SprintCreateConflicts
|
||||||
|
}
|
||||||
|
| { error: string; code: number }
|
||||||
|
|
||||||
|
const updateSprintSchema = z.object({
|
||||||
|
sprintId: z.string().min(1),
|
||||||
|
fields: z
|
||||||
|
.object({
|
||||||
|
goal: z.string().min(1).max(2000).optional(),
|
||||||
|
startAt: z.string().date().nullable().optional(),
|
||||||
|
endAt: z.string().date().nullable().optional(),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(data) => Object.keys(data).length > 0,
|
||||||
|
'Minstens één veld vereist',
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type UpdateSprintInput = z.infer<typeof updateSprintSchema>
|
||||||
|
|
||||||
|
export type UpdateSprintResult =
|
||||||
|
| { success: true; sprintId: string }
|
||||||
|
| { error: string; code: number }
|
||||||
|
|
||||||
|
export async function updateSprintAction(
|
||||||
|
input: UpdateSprintInput,
|
||||||
|
): Promise<UpdateSprintResult> {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session.userId) return { error: 'Niet ingelogd', code: 403 }
|
||||||
|
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
|
||||||
|
|
||||||
|
const parsed = updateSprintSchema.safeParse(input)
|
||||||
|
if (!parsed.success) return { error: 'Validatie mislukt', code: 422 }
|
||||||
|
|
||||||
|
const sprint = await prisma.sprint.findFirst({
|
||||||
|
where: {
|
||||||
|
id: parsed.data.sprintId,
|
||||||
|
product: productAccessFilter(session.userId),
|
||||||
|
},
|
||||||
|
select: { id: true, product_id: true },
|
||||||
|
})
|
||||||
|
if (!sprint) return { error: 'Sprint niet gevonden', code: 403 }
|
||||||
|
|
||||||
|
const data: { sprint_goal?: string; start_date?: Date | null; end_date?: Date | null } = {}
|
||||||
|
if (parsed.data.fields.goal !== undefined) {
|
||||||
|
data.sprint_goal = parsed.data.fields.goal
|
||||||
|
}
|
||||||
|
if (parsed.data.fields.startAt !== undefined) {
|
||||||
|
data.start_date = parseDate(parsed.data.fields.startAt)
|
||||||
|
}
|
||||||
|
if (parsed.data.fields.endAt !== undefined) {
|
||||||
|
data.end_date = parseDate(parsed.data.fields.endAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.sprint.update({
|
||||||
|
where: { id: parsed.data.sprintId },
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
revalidatePath(`/products/${sprint.product_id}`, 'layout')
|
||||||
|
|
||||||
|
return { success: true, sprintId: parsed.data.sprintId }
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitSprintMembershipSchema = z.object({
|
||||||
|
activeSprintId: z.string().min(1),
|
||||||
|
adds: z.array(z.string()),
|
||||||
|
removes: z.array(z.string()),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type CommitSprintMembershipInput = z.infer<
|
||||||
|
typeof commitSprintMembershipSchema
|
||||||
|
>
|
||||||
|
|
||||||
|
type CommitConflicts = {
|
||||||
|
notEligible: { storyId: string; reason: 'DONE' | 'IN_OTHER_SPRINT' }[]
|
||||||
|
alreadyRemoved: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CommitSprintMembershipResult =
|
||||||
|
| {
|
||||||
|
success: true
|
||||||
|
affectedStoryIds: string[]
|
||||||
|
affectedPbiIds: string[]
|
||||||
|
affectedTaskIds: string[]
|
||||||
|
conflicts: CommitConflicts
|
||||||
|
}
|
||||||
|
| { error: string; code: number }
|
||||||
|
|
||||||
|
export async function commitSprintMembershipAction(
|
||||||
|
input: CommitSprintMembershipInput,
|
||||||
|
): Promise<CommitSprintMembershipResult> {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session.userId) return { error: 'Niet ingelogd', code: 403 }
|
||||||
|
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
|
||||||
|
|
||||||
|
const parsed = commitSprintMembershipSchema.safeParse(input)
|
||||||
|
if (!parsed.success) return { error: 'Validatie mislukt', code: 422 }
|
||||||
|
|
||||||
|
// Sprint moet bestaan en bereikbaar zijn via product-access.
|
||||||
|
const sprint = await prisma.sprint.findFirst({
|
||||||
|
where: {
|
||||||
|
id: parsed.data.activeSprintId,
|
||||||
|
product: productAccessFilter(session.userId),
|
||||||
|
},
|
||||||
|
select: { id: true, product_id: true },
|
||||||
|
})
|
||||||
|
if (!sprint) {
|
||||||
|
return { error: 'Sprint niet gevonden of niet toegankelijk', code: 403 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter adds via eligibility (sprint_id IS NULL en niet DONE; andere OPEN
|
||||||
|
// sprint → conflicts.notEligible + crossSprint).
|
||||||
|
const addPartition = await partitionByEligibility(
|
||||||
|
prisma,
|
||||||
|
parsed.data.adds,
|
||||||
|
parsed.data.activeSprintId,
|
||||||
|
)
|
||||||
|
const eligibleAdds = addPartition.eligible
|
||||||
|
const notEligibleAdds = addPartition.notEligible
|
||||||
|
|
||||||
|
// Race-safety voor removes: alleen stories die feitelijk in de actieve
|
||||||
|
// sprint zitten worden verwijderd.
|
||||||
|
const removeRows =
|
||||||
|
parsed.data.removes.length > 0
|
||||||
|
? await prisma.story.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: parsed.data.removes },
|
||||||
|
sprint_id: parsed.data.activeSprintId,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
const validRemoves = removeRows.map((r) => r.id)
|
||||||
|
const validRemoveSet = new Set(validRemoves)
|
||||||
|
const alreadyRemoved = parsed.data.removes.filter(
|
||||||
|
(id) => !validRemoveSet.has(id),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (eligibleAdds.length === 0 && validRemoves.length === 0) {
|
||||||
|
// Geen werk te doen — geef toch een success-shape terug zodat de client
|
||||||
|
// pending buffer kan resetten + conflicts kan tonen.
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
affectedStoryIds: [],
|
||||||
|
affectedPbiIds: [],
|
||||||
|
affectedTaskIds: [],
|
||||||
|
conflicts: { notEligible: notEligibleAdds, alreadyRemoved },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
if (eligibleAdds.length > 0) {
|
||||||
|
await tx.story.updateMany({
|
||||||
|
where: { id: { in: eligibleAdds } },
|
||||||
|
data: { sprint_id: parsed.data.activeSprintId, status: 'IN_SPRINT' },
|
||||||
|
})
|
||||||
|
await tx.task.updateMany({
|
||||||
|
where: { story_id: { in: eligibleAdds } },
|
||||||
|
data: { sprint_id: parsed.data.activeSprintId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (validRemoves.length > 0) {
|
||||||
|
await tx.story.updateMany({
|
||||||
|
where: { id: { in: validRemoves } },
|
||||||
|
data: { sprint_id: null, status: 'OPEN' },
|
||||||
|
})
|
||||||
|
await tx.task.updateMany({
|
||||||
|
where: { story_id: { in: validRemoves } },
|
||||||
|
data: { sprint_id: null },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const affectedStoryIds = [...eligibleAdds, ...validRemoves]
|
||||||
|
const affectedStories =
|
||||||
|
affectedStoryIds.length > 0
|
||||||
|
? await prisma.story.findMany({
|
||||||
|
where: { id: { in: affectedStoryIds } },
|
||||||
|
select: { pbi_id: true },
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
const affectedPbiIds = Array.from(
|
||||||
|
new Set(affectedStories.map((s) => s.pbi_id)),
|
||||||
|
)
|
||||||
|
const affectedTasks =
|
||||||
|
affectedStoryIds.length > 0
|
||||||
|
? await prisma.task.findMany({
|
||||||
|
where: { story_id: { in: affectedStoryIds } },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
const affectedTaskIds = affectedTasks.map((t) => t.id)
|
||||||
|
|
||||||
|
revalidatePath(`/products/${sprint.product_id}`, 'layout')
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
affectedStoryIds,
|
||||||
|
affectedPbiIds,
|
||||||
|
affectedTaskIds,
|
||||||
|
conflicts: { notEligible: notEligibleAdds, alreadyRemoved },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSprintWithSelectionAction(
|
||||||
|
input: CreateSprintWithSelectionInput,
|
||||||
|
): Promise<CreateSprintWithSelectionResult> {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session.userId) return { error: 'Niet ingelogd', code: 403 }
|
||||||
|
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
|
||||||
|
|
||||||
|
const limited = enforceUserRateLimit('create-sprint', session.userId)
|
||||||
|
if (limited) return { error: limited.error, code: limited.code }
|
||||||
|
|
||||||
|
const parsed = createSprintWithSelectionSchema.safeParse(input)
|
||||||
|
if (!parsed.success) return { error: 'Validatie mislukt', code: 422 }
|
||||||
|
|
||||||
|
const product = await getAccessibleProduct(parsed.data.productId, session.userId)
|
||||||
|
if (!product) return { error: 'Product niet gevonden', code: 403 }
|
||||||
|
|
||||||
|
// Resolveer intent + per-PBI overrides naar concrete story-IDs.
|
||||||
|
const allPbiAllIds = Object.entries(parsed.data.pbiIntent)
|
||||||
|
.filter(([, intent]) => intent === 'all')
|
||||||
|
.map(([pbiId]) => pbiId)
|
||||||
|
|
||||||
|
// Stap 1: alle child-stories voor PBI's met intent='all'.
|
||||||
|
let candidate: string[] = []
|
||||||
|
if (allPbiAllIds.length > 0) {
|
||||||
|
const rows = await prisma.story.findMany({
|
||||||
|
where: { pbi_id: { in: allPbiAllIds }, product_id: parsed.data.productId },
|
||||||
|
select: { id: true, pbi_id: true },
|
||||||
|
})
|
||||||
|
const removedSet = new Set<string>()
|
||||||
|
for (const [pbiId, override] of Object.entries(parsed.data.storyOverrides)) {
|
||||||
|
for (const id of override.remove) removedSet.add(`${pbiId}:${id}`)
|
||||||
|
}
|
||||||
|
candidate = rows
|
||||||
|
.filter((row) => !removedSet.has(`${row.pbi_id}:${row.id}`))
|
||||||
|
.map((row) => row.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stap 2: storyOverrides.add — werkt voor zowel intent='none' als 'all' (extra
|
||||||
|
// toevoegingen). Dedupliceren met candidates uit stap 1.
|
||||||
|
const candidateSet = new Set(candidate)
|
||||||
|
for (const override of Object.values(parsed.data.storyOverrides)) {
|
||||||
|
for (const id of override.add) candidateSet.add(id)
|
||||||
|
}
|
||||||
|
const candidateIds = Array.from(candidateSet)
|
||||||
|
|
||||||
|
// Eligibility-filter (incl. cross-sprint guard).
|
||||||
|
const partition = await partitionByEligibility(prisma, candidateIds)
|
||||||
|
|
||||||
|
if (partition.eligible.length === 0) {
|
||||||
|
return {
|
||||||
|
error: 'Geen eligible stories voor deze sprint',
|
||||||
|
code: 422,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sprint = await createWithCodeRetry(
|
||||||
|
() => generateNextSprintCode(parsed.data.productId),
|
||||||
|
(code) =>
|
||||||
|
prisma.$transaction(async (tx) => {
|
||||||
|
const created = await tx.sprint.create({
|
||||||
|
data: {
|
||||||
|
product_id: parsed.data.productId,
|
||||||
|
code,
|
||||||
|
sprint_goal: parsed.data.metadata.goal,
|
||||||
|
status: 'OPEN',
|
||||||
|
start_date: parseDate(parsed.data.metadata.startAt),
|
||||||
|
end_date: parseDate(parsed.data.metadata.endAt),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await tx.story.updateMany({
|
||||||
|
where: { id: { in: partition.eligible } },
|
||||||
|
data: { sprint_id: created.id, status: 'IN_SPRINT' },
|
||||||
|
})
|
||||||
|
await tx.task.updateMany({
|
||||||
|
where: { story_id: { in: partition.eligible } },
|
||||||
|
data: { sprint_id: created.id },
|
||||||
|
})
|
||||||
|
return created
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Snapshot affected pbi/task IDs voor client-store patches.
|
||||||
|
const affectedStories = await prisma.story.findMany({
|
||||||
|
where: { id: { in: partition.eligible } },
|
||||||
|
select: { pbi_id: true },
|
||||||
|
})
|
||||||
|
const affectedPbiIds = Array.from(new Set(affectedStories.map((s) => s.pbi_id)))
|
||||||
|
const affectedTasks = await prisma.task.findMany({
|
||||||
|
where: { story_id: { in: partition.eligible } },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
const affectedTaskIds = affectedTasks.map((t) => t.id)
|
||||||
|
|
||||||
|
await setActiveSprintInSettings(
|
||||||
|
session.userId,
|
||||||
|
parsed.data.productId,
|
||||||
|
sprint.id,
|
||||||
|
)
|
||||||
|
revalidatePath(`/products/${parsed.data.productId}`, 'layout')
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
sprintId: sprint.id,
|
||||||
|
affectedStoryIds: partition.eligible,
|
||||||
|
affectedPbiIds,
|
||||||
|
affectedTaskIds,
|
||||||
|
conflicts: {
|
||||||
|
notEligible: partition.notEligible,
|
||||||
|
crossSprint: partition.crossSprint,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function getSession() {
|
async function getSession() {
|
||||||
return getIronSession<SessionData>(await cookies(), sessionOptions)
|
return getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||||
}
|
}
|
||||||
|
|
@ -53,10 +403,10 @@ export async function createSprintAction(_prevState: unknown, formData: FormData
|
||||||
const product = await getAccessibleProduct(parsed.data.productId, session.userId)
|
const product = await getAccessibleProduct(parsed.data.productId, session.userId)
|
||||||
if (!product) return { error: 'Product niet gevonden', code: 403 }
|
if (!product) return { error: 'Product niet gevonden', code: 403 }
|
||||||
|
|
||||||
const existing = await prisma.sprint.findFirst({
|
// PBI-79 / ST-1342: multi-OPEN sprints toegestaan. Bestaande OPEN sprints
|
||||||
where: { product_id: parsed.data.productId, status: 'OPEN' },
|
// op hetzelfde product zijn geen reden meer om aanmaak te blokkeren —
|
||||||
})
|
// cross-sprint-conflicts worden per-story afgevangen in de membership-
|
||||||
if (existing) return { error: 'Er is al een actieve Sprint voor dit product', sprintId: existing.id, code: 422 }
|
// commit-flow.
|
||||||
|
|
||||||
const sprint = await createWithCodeRetry(
|
const sprint = await createWithCodeRetry(
|
||||||
() => generateNextSprintCode(parsed.data.productId),
|
() => generateNextSprintCode(parsed.data.productId),
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,11 @@ import { UrlTaskSync } from '@/components/backlog/url-task-sync'
|
||||||
import { TaskDialog } from '@/app/_components/tasks/task-dialog'
|
import { TaskDialog } from '@/app/_components/tasks/task-dialog'
|
||||||
import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader'
|
import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader'
|
||||||
import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton'
|
import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton'
|
||||||
import { StartSprintButton } from '@/components/sprint/start-sprint-button'
|
import { NewSprintTrigger } from '@/components/backlog/new-sprint-trigger'
|
||||||
|
import { SprintDraftBanner } from '@/components/backlog/sprint-draft-banner'
|
||||||
|
import { SprintDraftLeaveGuard } from '@/components/backlog/sprint-draft-leave-guard'
|
||||||
|
import { SaveSprintButton } from '@/components/backlog/save-sprint-button'
|
||||||
|
import { ActiveSelectionHydrator } from '@/components/backlog/active-selection-hydrator'
|
||||||
import { ActivateProductButton } from '@/components/shared/activate-product-button'
|
import { ActivateProductButton } from '@/components/shared/activate-product-button'
|
||||||
import { EditProductButton } from '@/components/products/edit-product-button'
|
import { EditProductButton } from '@/components/products/edit-product-button'
|
||||||
import { SprintSwitcher } from '@/components/shared/sprint-switcher'
|
import { SprintSwitcher } from '@/components/shared/sprint-switcher'
|
||||||
|
|
@ -118,13 +122,15 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
|
||||||
{!isActiveProduct && (
|
{!isActiveProduct && (
|
||||||
<ActivateProductButton productId={id} isDemo={isDemo} redirectTo={`/products/${id}`} />
|
<ActivateProductButton productId={id} isDemo={isDemo} redirectTo={`/products/${id}`} />
|
||||||
)}
|
)}
|
||||||
{hasOpenSprint ? (
|
{hasOpenSprint && (
|
||||||
<Link href={`/products/${id}/sprint`} className="text-xs text-primary hover:underline font-medium">
|
<Link href={`/products/${id}/sprint`} className="text-xs text-primary hover:underline font-medium">
|
||||||
Sprint actief →
|
Sprint actief →
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
|
||||||
!isDemo && <StartSprintButton productId={id} />
|
|
||||||
)}
|
)}
|
||||||
|
{activeSprintItem && !isDemo && (
|
||||||
|
<SaveSprintButton activeSprintId={activeSprintItem.id} />
|
||||||
|
)}
|
||||||
|
{!isDemo && <NewSprintTrigger productId={id} isDemo={isDemo} />}
|
||||||
{!isDemo && product.user_id === session.userId && (
|
{!isDemo && product.user_id === session.userId && (
|
||||||
<EditProductButton
|
<EditProductButton
|
||||||
product={{
|
product={{
|
||||||
|
|
@ -147,6 +153,10 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sprint definition banner (state A′) + beforeunload-guard */}
|
||||||
|
<SprintDraftBanner productId={id} />
|
||||||
|
<SprintDraftLeaveGuard productId={id} />
|
||||||
|
|
||||||
{/* Split pane */}
|
{/* Split pane */}
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<BacklogHydrationWrapper
|
<BacklogHydrationWrapper
|
||||||
|
|
@ -159,6 +169,7 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<UrlTaskSync />
|
<UrlTaskSync />
|
||||||
|
<ActiveSelectionHydrator productId={id} />
|
||||||
<BacklogSplitPane
|
<BacklogSplitPane
|
||||||
cookieKey={`backlog-${id}`}
|
cookieKey={`backlog-${id}`}
|
||||||
defaultSplit={[20, 45, 35]}
|
defaultSplit={[20, 45, 35]}
|
||||||
|
|
@ -168,11 +179,13 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
|
||||||
key="pbi"
|
key="pbi"
|
||||||
productId={id}
|
productId={id}
|
||||||
isDemo={isDemo}
|
isDemo={isDemo}
|
||||||
|
activeSprintId={activeSprintItem?.id ?? null}
|
||||||
/>,
|
/>,
|
||||||
<StoryPanel
|
<StoryPanel
|
||||||
key="story"
|
key="story"
|
||||||
productId={id}
|
productId={id}
|
||||||
isDemo={isDemo}
|
isDemo={isDemo}
|
||||||
|
activeSprintId={activeSprintItem?.id ?? null}
|
||||||
/>,
|
/>,
|
||||||
<TaskPanel
|
<TaskPanel
|
||||||
key="tasks"
|
key="tasks"
|
||||||
|
|
|
||||||
74
app/api/products/[id]/cross-sprint-blocks/route.ts
Normal file
74
app/api/products/[id]/cross-sprint-blocks/route.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
// PBI-79 / T-929: GET /api/products/:id/cross-sprint-blocks
|
||||||
|
//
|
||||||
|
// Lichte UX-hint voor disabled-vinkjes: welke stories binnen pbiIds zitten in
|
||||||
|
// een andere OPEN sprint (excludeSprintId expliciet uitgesloten). Server-side
|
||||||
|
// commit-actions blijven autoritatief — dit endpoint is alleen voor UI.
|
||||||
|
import { authenticateApiRequest } from '@/lib/api-auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { productAccessFilter } from '@/lib/product-access'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
function parsePbiIds(raw: string | null): string[] | null {
|
||||||
|
if (!raw) return null
|
||||||
|
const ids = raw
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
return ids.length === 0 ? null : ids
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const auth = await authenticateApiRequest(request)
|
||||||
|
if ('error' in auth) {
|
||||||
|
return Response.json({ error: auth.error }, { status: auth.status })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: productId } = await params
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const excludeSprintId = url.searchParams.get('excludeSprintId') ?? undefined
|
||||||
|
const pbiIds = parsePbiIds(url.searchParams.get('pbiIds'))
|
||||||
|
|
||||||
|
if (!pbiIds) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: 'pbiIds is verplicht (comma-separated)' },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const product = await prisma.product.findFirst({
|
||||||
|
where: { id: productId, ...productAccessFilter(auth.userId) },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (!product) {
|
||||||
|
return Response.json({ error: 'Product niet gevonden' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const stories = await prisma.story.findMany({
|
||||||
|
where: {
|
||||||
|
pbi_id: { in: pbiIds },
|
||||||
|
product_id: productId,
|
||||||
|
sprint_id: { not: null },
|
||||||
|
...(excludeSprintId ? { NOT: { sprint_id: excludeSprintId } } : {}),
|
||||||
|
sprint: { status: 'OPEN' },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
sprint: { select: { id: true, code: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result: Record<string, { sprintId: string; sprintName: string }> = {}
|
||||||
|
for (const story of stories) {
|
||||||
|
if (!story.sprint) continue
|
||||||
|
result[story.id] = {
|
||||||
|
sprintId: story.sprint.id,
|
||||||
|
sprintName: story.sprint.code,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(result)
|
||||||
|
}
|
||||||
87
app/api/products/[id]/sprint-membership-summary/route.ts
Normal file
87
app/api/products/[id]/sprint-membership-summary/route.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
// PBI-79 / T-928: GET /api/products/:id/sprint-membership-summary
|
||||||
|
//
|
||||||
|
// Levert per PBI {total, inSprint} counts, gescoped op de doorgegeven pbiIds.
|
||||||
|
// Endpoint weigert product-brede aanroepen (pbiIds is verplicht). Eén groupBy
|
||||||
|
// + één count-by-sprint waar pbi_id IN (pbiIds).
|
||||||
|
import { authenticateApiRequest } from '@/lib/api-auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { productAccessFilter } from '@/lib/product-access'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
function parsePbiIds(raw: string | null): string[] | null {
|
||||||
|
if (!raw) return null
|
||||||
|
const ids = raw
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
return ids.length === 0 ? null : ids
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const auth = await authenticateApiRequest(request)
|
||||||
|
if ('error' in auth) {
|
||||||
|
return Response.json({ error: auth.error }, { status: auth.status })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: productId } = await params
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const sprintId = url.searchParams.get('sprintId')
|
||||||
|
const pbiIds = parsePbiIds(url.searchParams.get('pbiIds'))
|
||||||
|
|
||||||
|
if (!sprintId) {
|
||||||
|
return Response.json({ error: 'sprintId is verplicht' }, { status: 400 })
|
||||||
|
}
|
||||||
|
if (!pbiIds) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: 'pbiIds is verplicht (comma-separated)' },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const product = await prisma.product.findFirst({
|
||||||
|
where: { id: productId, ...productAccessFilter(auth.userId) },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (!product) {
|
||||||
|
return Response.json({ error: 'Product niet gevonden' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const [totals, inSprint] = await Promise.all([
|
||||||
|
prisma.story.groupBy({
|
||||||
|
by: ['pbi_id'],
|
||||||
|
where: { pbi_id: { in: pbiIds }, product_id: productId },
|
||||||
|
_count: { _all: true },
|
||||||
|
}),
|
||||||
|
prisma.story.groupBy({
|
||||||
|
by: ['pbi_id'],
|
||||||
|
where: {
|
||||||
|
pbi_id: { in: pbiIds },
|
||||||
|
product_id: productId,
|
||||||
|
sprint_id: sprintId,
|
||||||
|
},
|
||||||
|
_count: { _all: true },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const inSprintByPbi = new Map<string, number>()
|
||||||
|
for (const row of inSprint) {
|
||||||
|
inSprintByPbi.set(row.pbi_id, row._count._all)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Record<string, { total: number; inSprint: number }> = {}
|
||||||
|
for (const pbiId of pbiIds) {
|
||||||
|
result[pbiId] = { total: 0, inSprint: inSprintByPbi.get(pbiId) ?? 0 }
|
||||||
|
}
|
||||||
|
for (const row of totals) {
|
||||||
|
result[row.pbi_id] = {
|
||||||
|
total: row._count._all,
|
||||||
|
inSprint: inSprintByPbi.get(row.pbi_id) ?? 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(result)
|
||||||
|
}
|
||||||
53
components/backlog/active-selection-hydrator.tsx
Normal file
53
components/backlog/active-selection-hydrator.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||||
|
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
||||||
|
|
||||||
|
interface ActiveSelectionHydratorProps {
|
||||||
|
productId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PBI-79: hydrateert de workspace-store met de actieve PBI/story die in
|
||||||
|
* user-settings staan opgeslagen. Loopt na elke (re)hydratatie en bij
|
||||||
|
* mutaties van de user-settings (bv. na sprint-switch). Wint van de
|
||||||
|
* localStorage hint-restore — user-settings is de cross-device source of
|
||||||
|
* truth.
|
||||||
|
*/
|
||||||
|
export function ActiveSelectionHydrator({ productId }: ActiveSelectionHydratorProps) {
|
||||||
|
const hydrated = useUserSettingsStore((s) => s.context.hydrated)
|
||||||
|
const persistedPbiId = useUserSettingsStore(
|
||||||
|
(s) => s.entities.settings.layout?.activePbis?.[productId] ?? undefined,
|
||||||
|
)
|
||||||
|
const persistedStoryId = useUserSettingsStore(
|
||||||
|
(s) => s.entities.settings.layout?.activeStories?.[productId] ?? undefined,
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hydrated) return
|
||||||
|
const store = useProductWorkspaceStore.getState()
|
||||||
|
// Schrijf alleen wanneer user-settings expliciet iets gekozen heeft
|
||||||
|
// (key aanwezig met string-waarde). null-key betekent 'bewust leeg' →
|
||||||
|
// we wissen lokale state. undefined-key (geen voorkeur) → niets doen.
|
||||||
|
if (persistedPbiId === undefined && persistedStoryId === undefined) return
|
||||||
|
|
||||||
|
if (persistedPbiId === null) {
|
||||||
|
store.setActivePbi(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (persistedPbiId && store.context.activePbiId !== persistedPbiId) {
|
||||||
|
store.setActivePbi(persistedPbiId)
|
||||||
|
}
|
||||||
|
if (persistedStoryId && store.context.activeStoryId !== persistedStoryId) {
|
||||||
|
// setActivePbi triggert async cascade-restore die de oude hint kan
|
||||||
|
// herstellen; de daarop volgende setActiveStory bumpt activeRequestId
|
||||||
|
// en ongeldigt de cascade.
|
||||||
|
store.setActiveStory(persistedStoryId)
|
||||||
|
} else if (persistedStoryId === null) {
|
||||||
|
store.setActiveStory(null)
|
||||||
|
}
|
||||||
|
}, [hydrated, persistedPbiId, persistedStoryId])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
203
components/backlog/new-sprint-metadata-dialog.tsx
Normal file
203
components/backlog/new-sprint-metadata-dialog.tsx
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useRef, useState, useTransition } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
useDirtyCloseGuard,
|
||||||
|
DirtyCloseGuardDialog,
|
||||||
|
} from '@/components/shared/use-dirty-close-guard'
|
||||||
|
import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut'
|
||||||
|
import {
|
||||||
|
entityDialogContentClasses,
|
||||||
|
entityDialogFooterClasses,
|
||||||
|
entityDialogHeaderClasses,
|
||||||
|
} from '@/components/shared/entity-dialog-layout'
|
||||||
|
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||||
|
import { debugProps } from '@/lib/debug'
|
||||||
|
|
||||||
|
interface NewSprintMetadataDialogProps {
|
||||||
|
open: boolean
|
||||||
|
productId: string
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayLocalDate(): string {
|
||||||
|
return new Date().toLocaleDateString('en-CA')
|
||||||
|
}
|
||||||
|
|
||||||
|
function plusWeeks(weeks: number): string {
|
||||||
|
const d = new Date()
|
||||||
|
d.setDate(d.getDate() + weeks * 7)
|
||||||
|
return d.toLocaleDateString('en-CA')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewSprintMetadataDialog({
|
||||||
|
open,
|
||||||
|
productId,
|
||||||
|
onOpenChange,
|
||||||
|
}: NewSprintMetadataDialogProps) {
|
||||||
|
const [sprintGoal, setSprintGoal] = useState('')
|
||||||
|
const [startDate, setStartDate] = useState(todayLocalDate())
|
||||||
|
const [endDate, setEndDate] = useState(plusWeeks(2))
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [dirty, setDirty] = useState(false)
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
|
const formRef = useRef<HTMLFormElement>(null)
|
||||||
|
const setPendingSprintDraft = useUserSettingsStore(
|
||||||
|
(s) => s.setPendingSprintDraft,
|
||||||
|
)
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setSprintGoal('')
|
||||||
|
setStartDate(todayLocalDate())
|
||||||
|
setEndDate(plusWeeks(2))
|
||||||
|
setError(null)
|
||||||
|
setDirty(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeGuard = useDirtyCloseGuard(dirty, () => {
|
||||||
|
onOpenChange(false)
|
||||||
|
reset()
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
const goal = sprintGoal.trim()
|
||||||
|
if (!goal) return
|
||||||
|
setError(null)
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await setPendingSprintDraft(productId, {
|
||||||
|
goal,
|
||||||
|
startAt: startDate || undefined,
|
||||||
|
endAt: endDate || undefined,
|
||||||
|
pbiIntent: {},
|
||||||
|
storyOverrides: {},
|
||||||
|
})
|
||||||
|
reset()
|
||||||
|
onOpenChange(false)
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message : 'Onbekende fout bij opslaan'
|
||||||
|
setError(message)
|
||||||
|
toast.error(message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = useDialogSubmitShortcut(() =>
|
||||||
|
formRef.current?.requestSubmit(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) closeGuard.attemptClose()
|
||||||
|
else onOpenChange(o)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
showCloseButton={false}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className={entityDialogContentClasses}
|
||||||
|
{...debugProps(
|
||||||
|
'new-sprint-metadata-dialog',
|
||||||
|
'NewSprintMetadataDialog',
|
||||||
|
'components/backlog/new-sprint-metadata-dialog.tsx',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={entityDialogHeaderClasses}>
|
||||||
|
<DialogTitle className="text-xl font-semibold">
|
||||||
|
Nieuwe sprint
|
||||||
|
</DialogTitle>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Geef het sprint-doel en periode op. Je selecteert daarna PBI's
|
||||||
|
en stories via vinkjes in de backlog.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
ref={formRef}
|
||||||
|
id="new-sprint-metadata-form"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onChange={() => setDirty(true)}
|
||||||
|
className="flex-1 overflow-y-auto px-6 py-6 space-y-6"
|
||||||
|
>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-foreground">
|
||||||
|
Sprint Goal <span className="text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
value={sprintGoal}
|
||||||
|
onChange={(e) => setSprintGoal(e.target.value)}
|
||||||
|
required
|
||||||
|
rows={3}
|
||||||
|
placeholder="Wat wil je aan het einde van deze Sprint bereikt hebben?"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-foreground">
|
||||||
|
Startdatum
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-foreground">
|
||||||
|
Einddatum
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-error-container text-error-container-foreground rounded-lg px-3 py-2 text-sm border-l-4 border-error">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className={entityDialogFooterClasses}>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={closeGuard.attemptClose}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
Annuleren
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="new-sprint-metadata-form"
|
||||||
|
disabled={isPending || !sprintGoal.trim()}
|
||||||
|
data-debug-id="new-sprint-metadata-dialog__submit"
|
||||||
|
>
|
||||||
|
{isPending ? 'Opslaan…' : 'Verder'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<DirtyCloseGuardDialog guard={closeGuard} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
46
components/backlog/new-sprint-trigger.tsx
Normal file
46
components/backlog/new-sprint-trigger.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||||
|
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||||
|
import { NewSprintMetadataDialog } from './new-sprint-metadata-dialog'
|
||||||
|
|
||||||
|
interface NewSprintTriggerProps {
|
||||||
|
productId: string
|
||||||
|
isDemo: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PBI-79 / ST-1337: trigger-knop voor de nieuwe sprint-flow.
|
||||||
|
* Verbergt zichzelf wanneer er al een pendingSprintDraft loopt — dan
|
||||||
|
* staat de SprintDefinitionBanner zelf de afronding te regelen.
|
||||||
|
*/
|
||||||
|
export function NewSprintTrigger({ productId, isDemo }: NewSprintTriggerProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const hasDraft = useUserSettingsStore(
|
||||||
|
(s) => !!s.entities.settings.workflow?.pendingSprintDraft?.[productId],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (hasDraft) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DemoTooltip show={isDemo}>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
disabled={isDemo}
|
||||||
|
data-debug-id="new-sprint-trigger"
|
||||||
|
>
|
||||||
|
Nieuwe sprint
|
||||||
|
</Button>
|
||||||
|
</DemoTooltip>
|
||||||
|
<NewSprintMetadataDialog
|
||||||
|
open={open}
|
||||||
|
productId={productId}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -21,7 +21,7 @@ import {
|
||||||
} from '@dnd-kit/sortable'
|
} from '@dnd-kit/sortable'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { CheckSquare, Square } from 'lucide-react'
|
import { CheckSquare, MinusSquare, Square } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import {
|
import {
|
||||||
|
|
@ -32,7 +32,11 @@ import {
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||||
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
||||||
import { selectVisiblePbis } from '@/stores/product-workspace/selectors'
|
import {
|
||||||
|
selectPbiTriState,
|
||||||
|
selectVisiblePbis,
|
||||||
|
type PbiTriState,
|
||||||
|
} from '@/stores/product-workspace/selectors'
|
||||||
import type { BacklogPbi as WorkspacePbi } from '@/stores/product-workspace/types'
|
import type { BacklogPbi as WorkspacePbi } from '@/stores/product-workspace/types'
|
||||||
import { deletePbiAction } from '@/actions/pbis'
|
import { deletePbiAction } from '@/actions/pbis'
|
||||||
import { reorderPbisAction, updatePbiPriorityAction } from '@/actions/stories'
|
import { reorderPbisAction, updatePbiPriorityAction } from '@/actions/stories'
|
||||||
|
|
@ -41,7 +45,6 @@ import { debugProps } from '@/lib/debug'
|
||||||
import { PbiDialog, type PbiDialogState } from './pbi-dialog'
|
import { PbiDialog, type PbiDialogState } from './pbi-dialog'
|
||||||
import { BacklogCard } from './backlog-card'
|
import { BacklogCard } from './backlog-card'
|
||||||
import { EmptyPanel } from './empty-panel'
|
import { EmptyPanel } from './empty-panel'
|
||||||
import { NewSprintDialog } from '@/components/sprint/new-sprint-dialog'
|
|
||||||
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||||
import { PRIORITY_COLORS } from '@/components/shared/priority-select'
|
import { PRIORITY_COLORS } from '@/components/shared/priority-select'
|
||||||
import { PBI_STATUS_LABELS, PBI_STATUS_COLORS } from '@/components/shared/pbi-status-select'
|
import { PBI_STATUS_LABELS, PBI_STATUS_COLORS } from '@/components/shared/pbi-status-select'
|
||||||
|
|
@ -77,15 +80,24 @@ interface Pbi {
|
||||||
interface PbiListProps {
|
interface PbiListProps {
|
||||||
productId: string
|
productId: string
|
||||||
isDemo: boolean
|
isDemo: boolean
|
||||||
|
activeSprintId?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Sortable PBI row ---
|
// --- Sortable PBI row ---
|
||||||
|
function TriStateIcon({ state }: { state: PbiTriState }) {
|
||||||
|
if (state === 'full')
|
||||||
|
return <CheckSquare size={18} className="text-primary" />
|
||||||
|
if (state === 'partial')
|
||||||
|
return <MinusSquare size={18} className="text-primary" />
|
||||||
|
return <Square size={18} />
|
||||||
|
}
|
||||||
|
|
||||||
function SortablePbiRow({
|
function SortablePbiRow({
|
||||||
pbi,
|
pbi,
|
||||||
isSelected,
|
isSelected,
|
||||||
isDemo,
|
isDemo,
|
||||||
selectionMode,
|
selectionMode,
|
||||||
isChecked,
|
triState,
|
||||||
onSelect,
|
onSelect,
|
||||||
onToggleCheck,
|
onToggleCheck,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
|
@ -95,7 +107,7 @@ function SortablePbiRow({
|
||||||
isSelected: boolean
|
isSelected: boolean
|
||||||
isDemo: boolean
|
isDemo: boolean
|
||||||
selectionMode: boolean
|
selectionMode: boolean
|
||||||
isChecked: boolean
|
triState: PbiTriState
|
||||||
onSelect: () => void
|
onSelect: () => void
|
||||||
onToggleCheck: () => void
|
onToggleCheck: () => void
|
||||||
onEdit: () => void
|
onEdit: () => void
|
||||||
|
|
@ -119,24 +131,39 @@ function SortablePbiRow({
|
||||||
title={pbi.title}
|
title={pbi.title}
|
||||||
code={pbi.code}
|
code={pbi.code}
|
||||||
priority={pbi.priority}
|
priority={pbi.priority}
|
||||||
isSelected={isChecked}
|
isSelected={isSelected}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-pressed={isChecked}
|
aria-pressed={isSelected}
|
||||||
onClick={onToggleCheck}
|
onClick={onSelect}
|
||||||
onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggleCheck() } }}
|
onKeyDown={(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
onSelect()
|
||||||
|
}
|
||||||
|
}}
|
||||||
badge={
|
badge={
|
||||||
<Badge className={cn('text-xs font-normal', PBI_STATUS_COLORS[pbi.status])}>
|
<Badge className={cn('text-xs font-normal', PBI_STATUS_COLORS[pbi.status])}>
|
||||||
{PBI_STATUS_LABELS[pbi.status]}
|
{PBI_STATUS_LABELS[pbi.status]}
|
||||||
</Badge>
|
</Badge>
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
<div
|
<button
|
||||||
className="inline-flex items-center justify-center min-h-7 min-w-7 text-muted-foreground"
|
type="button"
|
||||||
aria-hidden="true"
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onToggleCheck()
|
||||||
|
}}
|
||||||
|
aria-pressed={triState !== 'empty'}
|
||||||
|
aria-label={
|
||||||
|
triState === 'full'
|
||||||
|
? 'Stories uit sprint halen'
|
||||||
|
: 'Stories aan sprint toevoegen'
|
||||||
|
}
|
||||||
|
className="inline-flex items-center justify-center min-h-7 min-w-7 text-muted-foreground hover:text-foreground rounded transition-colors"
|
||||||
>
|
>
|
||||||
{isChecked ? <CheckSquare size={18} className="text-primary" /> : <Square size={18} />}
|
<TriStateIcon state={triState} />
|
||||||
</div>
|
</button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
@ -194,7 +221,7 @@ function SortablePbiRow({
|
||||||
// --- Main component ---
|
// --- Main component ---
|
||||||
// PBI-74 / T-849: leest pbis + actieve selectie uit workspace-store via
|
// PBI-74 / T-849: leest pbis + actieve selectie uit workspace-store via
|
||||||
// useShallow-selector. DnD-mutaties via applyOptimisticMutation/rollback/settle.
|
// useShallow-selector. DnD-mutaties via applyOptimisticMutation/rollback/settle.
|
||||||
export function PbiList({ productId, isDemo }: PbiListProps) {
|
export function PbiList({ productId, isDemo, activeSprintId = null }: PbiListProps) {
|
||||||
// selectVisiblePbis is gesorteerd op priority/sort_order; useShallow
|
// selectVisiblePbis is gesorteerd op priority/sort_order; useShallow
|
||||||
// voorkomt re-render op ongerelateerde store-mutaties (G2).
|
// voorkomt re-render op ongerelateerde store-mutaties (G2).
|
||||||
const pbis = useProductWorkspaceStore(useShallow(selectVisiblePbis)) as WorkspacePbi[]
|
const pbis = useProductWorkspaceStore(useShallow(selectVisiblePbis)) as WorkspacePbi[]
|
||||||
|
|
@ -216,23 +243,49 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
|
||||||
const [filterPopoverOpen, setFilterPopoverOpen] = useState(false)
|
const [filterPopoverOpen, setFilterPopoverOpen] = useState(false)
|
||||||
const [dialogState, setDialogState] = useState<PbiDialogState | null>(null)
|
const [dialogState, setDialogState] = useState<PbiDialogState | null>(null)
|
||||||
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
||||||
const [selectionMode, setSelectionMode] = useState(false)
|
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
|
||||||
const [newSprintOpen, setNewSprintOpen] = useState(false)
|
|
||||||
const [, startTransition] = useTransition()
|
const [, startTransition] = useTransition()
|
||||||
|
|
||||||
function exitSelection() {
|
// PBI-79 / ST-1337+ST-1338: selectionMode is afgeleid uit drie staten:
|
||||||
setSelectionMode(false)
|
// A′ (pendingSprintDraft) → vinkjes muteren de draft via upsertPbiIntent.
|
||||||
setSelectedIds(new Set())
|
// B (activeSprintId zonder draft) → vinkjes muteren de membership-buffer
|
||||||
}
|
// via toggleStorySprintMembership per child story (bulk).
|
||||||
|
// A (geen sprint, geen draft) → geen vinkjes.
|
||||||
|
const hasDraft = useUserSettingsStore(
|
||||||
|
(s) => !!s.entities.settings.workflow?.pendingSprintDraft?.[productId],
|
||||||
|
)
|
||||||
|
const upsertPbiIntent = useUserSettingsStore((s) => s.upsertPbiIntent)
|
||||||
|
const toggleStorySprintMembership = useProductWorkspaceStore(
|
||||||
|
(s) => s.toggleStorySprintMembership,
|
||||||
|
)
|
||||||
|
const stateBMode = !hasDraft && !!activeSprintId
|
||||||
|
const selectionMode = hasDraft || stateBMode
|
||||||
|
|
||||||
function toggleCheck(id: string) {
|
function togglePbiInDraft(id: string, currentState: PbiTriState) {
|
||||||
setSelectedIds(prev => {
|
if (hasDraft) {
|
||||||
const next = new Set(prev)
|
// A′: empty/partial → all; full → none.
|
||||||
if (next.has(id)) next.delete(id)
|
const nextIntent = currentState === 'full' ? 'none' : 'all'
|
||||||
else next.add(id)
|
void upsertPbiIntent(productId, id, nextIntent)
|
||||||
return next
|
return
|
||||||
})
|
}
|
||||||
|
if (stateBMode && activeSprintId) {
|
||||||
|
// State B: bulk-toggle alle child-stories naar/uit de pending buffer.
|
||||||
|
const store = useProductWorkspaceStore.getState()
|
||||||
|
const storyIds = store.relations.storyIdsByPbi[id] ?? []
|
||||||
|
const goingFull = currentState !== 'full'
|
||||||
|
for (const storyId of storyIds) {
|
||||||
|
const story = store.entities.storiesById[storyId]
|
||||||
|
if (!story) continue
|
||||||
|
const blocked = store.sprintMembership.crossSprintBlocks[storyId]
|
||||||
|
if (blocked) continue
|
||||||
|
const inSprint = story.sprint_id === activeSprintId
|
||||||
|
if (goingFull && !inSprint) {
|
||||||
|
toggleStorySprintMembership(storyId, false)
|
||||||
|
}
|
||||||
|
if (!goingFull && inSprint) {
|
||||||
|
toggleStorySprintMembership(storyId, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// pbis komen al gesorteerd binnen via selectVisiblePbis (priority + sort_order).
|
// pbis komen al gesorteerd binnen via selectVisiblePbis (priority + sort_order).
|
||||||
|
|
@ -398,21 +451,6 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
|
||||||
setSortDir('asc')
|
setSortDir('asc')
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<DemoTooltip show={isDemo}>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={selectionMode ? 'default' : 'outline'}
|
|
||||||
className="h-7 text-xs"
|
|
||||||
disabled={isDemo}
|
|
||||||
onClick={() => {
|
|
||||||
if (isDemo) return
|
|
||||||
if (selectionMode) exitSelection()
|
|
||||||
else setSelectionMode(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectionMode ? 'Selecteren stoppen' : "Selecteer PBI's"}
|
|
||||||
</Button>
|
|
||||||
</DemoTooltip>
|
|
||||||
<DemoTooltip show={isDemo}>
|
<DemoTooltip show={isDemo}>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -445,15 +483,15 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
|
||||||
>
|
>
|
||||||
<div className="p-3 flex flex-col gap-2" {...debugProps('pbi-list__items')}>
|
<div className="p-3 flex flex-col gap-2" {...debugProps('pbi-list__items')}>
|
||||||
{filtered.map(pbi => (
|
{filtered.map(pbi => (
|
||||||
<SortablePbiRow
|
<SortablePbiRowWithTriState
|
||||||
key={pbi.id}
|
key={pbi.id}
|
||||||
pbi={pbi}
|
pbi={pbi}
|
||||||
isSelected={selectedPbiId === pbi.id}
|
isSelected={selectedPbiId === pbi.id}
|
||||||
isDemo={isDemo}
|
isDemo={isDemo}
|
||||||
selectionMode={selectionMode}
|
selectionMode={selectionMode}
|
||||||
isChecked={selectedIds.has(pbi.id)}
|
productId={productId}
|
||||||
onSelect={() => useProductWorkspaceStore.getState().setActivePbi(pbi.id)}
|
onSelect={() => useProductWorkspaceStore.getState().setActivePbi(pbi.id)}
|
||||||
onToggleCheck={() => toggleCheck(pbi.id)}
|
onToggle={togglePbiInDraft}
|
||||||
onEdit={() => setDialogState({ mode: 'edit', productId, pbi })}
|
onEdit={() => setDialogState({ mode: 'edit', productId, pbi })}
|
||||||
onDelete={() => handleDelete(pbi.id)}
|
onDelete={() => handleDelete(pbi.id)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -474,53 +512,72 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectionMode && (
|
|
||||||
<div className="border-t border-border bg-surface-container px-4 py-2 flex items-center justify-between gap-2 shrink-0">
|
|
||||||
<span className="text-sm text-foreground">
|
|
||||||
{selectedIds.size} geselecteerd
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-7 text-xs"
|
|
||||||
onClick={exitSelection}
|
|
||||||
>
|
|
||||||
Annuleer
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="h-7 text-xs"
|
|
||||||
disabled={selectedIds.size === 0}
|
|
||||||
onClick={() => setNewSprintOpen(true)}
|
|
||||||
>
|
|
||||||
Nieuwe sprint
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<PbiDialog
|
<PbiDialog
|
||||||
state={dialogState}
|
state={dialogState}
|
||||||
onClose={() => setDialogState(null)}
|
onClose={() => setDialogState(null)}
|
||||||
isDemo={isDemo}
|
isDemo={isDemo}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NewSprintDialog
|
|
||||||
open={newSprintOpen}
|
|
||||||
productId={productId}
|
|
||||||
pbiIds={Array.from(selectedIds)}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setNewSprintOpen(open)
|
|
||||||
if (!open) {
|
|
||||||
// Sluit selectie bij geslaagde aanmaak; bij annuleren laat de selectie staan
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onCreated={() => {
|
|
||||||
setNewSprintOpen(false)
|
|
||||||
exitSelection()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PBI-79 / ST-1337: wrapper rond SortablePbiRow die zijn tri-state uit de
|
||||||
|
// workspace-store leest. Subscribed per PBI zodat alleen de relevante rij
|
||||||
|
// re-rendert bij pbiIntent/storyOverrides-mutaties.
|
||||||
|
function SortablePbiRowWithTriState({
|
||||||
|
pbi,
|
||||||
|
isSelected,
|
||||||
|
isDemo,
|
||||||
|
selectionMode,
|
||||||
|
productId,
|
||||||
|
onSelect,
|
||||||
|
onToggle,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
pbi: Pbi
|
||||||
|
isSelected: boolean
|
||||||
|
isDemo: boolean
|
||||||
|
selectionMode: boolean
|
||||||
|
productId: string
|
||||||
|
onSelect: () => void
|
||||||
|
onToggle: (id: string, currentState: PbiTriState) => void
|
||||||
|
onEdit: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
}) {
|
||||||
|
// Tri-state uit pendingSprintDraft (state A′) of pbiSummary (state B).
|
||||||
|
// Wanneer geen draft: leid af van pbiSummary; wanneer wel: uit pbiIntent.
|
||||||
|
const triState = useUserSettingsStore((s) => {
|
||||||
|
const draft = s.entities.settings.workflow?.pendingSprintDraft?.[productId]
|
||||||
|
if (draft) {
|
||||||
|
const intent = draft.pbiIntent[pbi.id] ?? 'none'
|
||||||
|
const override = draft.storyOverrides[pbi.id]
|
||||||
|
if (intent === 'all') {
|
||||||
|
if (override?.remove.length) return 'partial'
|
||||||
|
return 'full'
|
||||||
|
}
|
||||||
|
if (override?.add.length) return 'partial'
|
||||||
|
return 'empty'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
const summaryTriState = useProductWorkspaceStore((s) =>
|
||||||
|
selectPbiTriState(s, pbi.id),
|
||||||
|
)
|
||||||
|
const effectiveTriState: PbiTriState =
|
||||||
|
triState ?? (selectionMode ? summaryTriState : 'empty')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SortablePbiRow
|
||||||
|
pbi={pbi}
|
||||||
|
isSelected={isSelected}
|
||||||
|
isDemo={isDemo}
|
||||||
|
selectionMode={selectionMode}
|
||||||
|
triState={effectiveTriState}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onToggleCheck={() => onToggle(pbi.id, effectiveTriState)}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
89
components/backlog/save-sprint-button.tsx
Normal file
89
components/backlog/save-sprint-button.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useTransition } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
||||||
|
import {
|
||||||
|
selectIsDirty,
|
||||||
|
selectPendingCount,
|
||||||
|
} from '@/stores/product-workspace/selectors'
|
||||||
|
import { commitSprintMembershipAction } from '@/actions/sprints'
|
||||||
|
|
||||||
|
interface SaveSprintButtonProps {
|
||||||
|
activeSprintId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PBI-79 / ST-1338 / T-940: 'Sprint opslaan'-knop voor state B.
|
||||||
|
* Altijd zichtbaar zolang er een actieve sprint is. Disabled bij clean,
|
||||||
|
* enabled met teller bij dirty. Commit gebeurt via
|
||||||
|
* commitSprintMembershipAction; client patcht gericht via
|
||||||
|
* applyMembershipCommitResult. Geen router.refresh.
|
||||||
|
*/
|
||||||
|
export function SaveSprintButton({ activeSprintId }: SaveSprintButtonProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const isDirty = useProductWorkspaceStore(selectIsDirty)
|
||||||
|
const count = useProductWorkspaceStore(selectPendingCount)
|
||||||
|
const adds = useProductWorkspaceStore((s) => s.sprintMembership.pending.adds)
|
||||||
|
const removes = useProductWorkspaceStore(
|
||||||
|
(s) => s.sprintMembership.pending.removes,
|
||||||
|
)
|
||||||
|
const applyMembershipCommitResult = useProductWorkspaceStore(
|
||||||
|
(s) => s.applyMembershipCommitResult,
|
||||||
|
)
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await commitSprintMembershipAction({
|
||||||
|
activeSprintId,
|
||||||
|
adds: [...adds],
|
||||||
|
removes: [...removes],
|
||||||
|
})
|
||||||
|
if ('error' in result) {
|
||||||
|
toast.error(result.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applyMembershipCommitResult({
|
||||||
|
activeSprintId,
|
||||||
|
addedStoryIds: adds.filter((id) =>
|
||||||
|
result.affectedStoryIds.includes(id),
|
||||||
|
),
|
||||||
|
removedStoryIds: removes.filter((id) =>
|
||||||
|
result.affectedStoryIds.includes(id),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
const skipped =
|
||||||
|
result.conflicts.notEligible.length +
|
||||||
|
result.conflicts.alreadyRemoved.length
|
||||||
|
if (skipped > 0) {
|
||||||
|
toast.warning(
|
||||||
|
`${skipped} wijziging${skipped === 1 ? '' : 'en'} overgeslagen — story al in andere sprint of inmiddels verwijderd.`,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
toast.success('Sprint opgeslagen')
|
||||||
|
}
|
||||||
|
// Gericht patchen voldoende voor lokale UI; refresh haalt server-side
|
||||||
|
// counts opnieuw op zodat tri-state in volgende renders klopt.
|
||||||
|
router.refresh()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!isDirty || isPending}
|
||||||
|
data-debug-id="save-sprint-button"
|
||||||
|
>
|
||||||
|
{isPending
|
||||||
|
? 'Opslaan…'
|
||||||
|
: isDirty
|
||||||
|
? `Sprint opslaan (${count})`
|
||||||
|
: 'Sprint opslaan'}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
202
components/backlog/sprint-definition-banner.tsx
Normal file
202
components/backlog/sprint-definition-banner.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useMemo, useState, useTransition } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||||
|
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
||||||
|
import type { PendingSprintDraft } from '@/lib/user-settings'
|
||||||
|
import { createSprintWithSelectionAction } from '@/actions/sprints'
|
||||||
|
import { debugProps } from '@/lib/debug'
|
||||||
|
|
||||||
|
interface SprintDefinitionBannerProps {
|
||||||
|
productId: string
|
||||||
|
draft: PendingSprintDraft
|
||||||
|
}
|
||||||
|
|
||||||
|
type DraftCounts = {
|
||||||
|
pbiCount: number
|
||||||
|
storyCount: number
|
||||||
|
hasUnknownTotal: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeCounts(
|
||||||
|
draft: PendingSprintDraft,
|
||||||
|
pbiSummary: Record<
|
||||||
|
string,
|
||||||
|
{ totalStoryCount: number; inActiveSprintStoryCount: number }
|
||||||
|
>,
|
||||||
|
): DraftCounts {
|
||||||
|
let pbiCount = 0
|
||||||
|
let storyCount = 0
|
||||||
|
let hasUnknownTotal = false
|
||||||
|
|
||||||
|
const seenPbis = new Set<string>()
|
||||||
|
|
||||||
|
for (const [pbiId, intent] of Object.entries(draft.pbiIntent)) {
|
||||||
|
if (intent === 'all') {
|
||||||
|
seenPbis.add(pbiId)
|
||||||
|
const summary = pbiSummary[pbiId]
|
||||||
|
const override = draft.storyOverrides[pbiId]
|
||||||
|
if (!summary) {
|
||||||
|
hasUnknownTotal = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const removed = override?.remove.length ?? 0
|
||||||
|
storyCount += Math.max(0, summary.totalStoryCount - removed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [pbiId, override] of Object.entries(draft.storyOverrides)) {
|
||||||
|
if (override.add.length === 0) continue
|
||||||
|
seenPbis.add(pbiId)
|
||||||
|
storyCount += override.add.length
|
||||||
|
}
|
||||||
|
|
||||||
|
pbiCount = seenPbis.size
|
||||||
|
return { pbiCount, storyCount, hasUnknownTotal }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SprintDefinitionBanner({
|
||||||
|
productId,
|
||||||
|
draft,
|
||||||
|
}: SprintDefinitionBannerProps) {
|
||||||
|
const clearPendingSprintDraft = useUserSettingsStore(
|
||||||
|
(s) => s.clearPendingSprintDraft,
|
||||||
|
)
|
||||||
|
const pbiSummary = useProductWorkspaceStore((s) => s.sprintMembership.pbiSummary)
|
||||||
|
const router = useRouter()
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
|
const [confirmCancel, setConfirmCancel] = useState(false)
|
||||||
|
|
||||||
|
const counts = useMemo(
|
||||||
|
() => computeCounts(draft, pbiSummary),
|
||||||
|
[draft, pbiSummary],
|
||||||
|
)
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
setConfirmCancel(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmCancelAction() {
|
||||||
|
setConfirmCancel(false)
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await clearPendingSprintDraft(productId)
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message : 'Annuleren mislukt'
|
||||||
|
toast.error(message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreate() {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await createSprintWithSelectionAction({
|
||||||
|
productId,
|
||||||
|
metadata: {
|
||||||
|
goal: draft.goal,
|
||||||
|
startAt: draft.startAt,
|
||||||
|
endAt: draft.endAt,
|
||||||
|
},
|
||||||
|
pbiIntent: draft.pbiIntent,
|
||||||
|
storyOverrides: draft.storyOverrides,
|
||||||
|
})
|
||||||
|
if ('error' in result) {
|
||||||
|
toast.error(result.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { conflicts } = result
|
||||||
|
if (conflicts.notEligible.length > 0) {
|
||||||
|
toast.warning(
|
||||||
|
`${conflicts.notEligible.length} stor${
|
||||||
|
conflicts.notEligible.length === 1 ? 'y is' : 'ies zijn'
|
||||||
|
} overgeslagen (al in een andere sprint of afgerond).`,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
toast.success('Sprint aangemaakt')
|
||||||
|
}
|
||||||
|
router.refresh()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const storyLabel = counts.hasUnknownTotal
|
||||||
|
? `${counts.storyCount}+`
|
||||||
|
: counts.storyCount
|
||||||
|
const pbiSuffix = counts.pbiCount === 1 ? '' : "'s"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="sticky top-0 z-30 bg-tertiary-container text-tertiary-container-foreground border-b border-tertiary px-4 py-2.5 flex items-center gap-4"
|
||||||
|
{...debugProps('sprint-definition-banner')}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-sm font-medium shrink-0">
|
||||||
|
Sprint definiëren —
|
||||||
|
</span>
|
||||||
|
<span className="text-sm truncate" title={draft.goal}>
|
||||||
|
{draft.goal}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs opacity-80 mt-0.5">
|
||||||
|
{counts.pbiCount} PBI{pbiSuffix} · {storyLabel} stor
|
||||||
|
{counts.storyCount === 1 ? 'y' : 'ies'} geselecteerd
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={isPending}
|
||||||
|
data-debug-id="sprint-definition-banner__cancel"
|
||||||
|
>
|
||||||
|
Annuleren
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={isPending || counts.pbiCount === 0}
|
||||||
|
data-debug-id="sprint-definition-banner__create"
|
||||||
|
>
|
||||||
|
Sprint aanmaken
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<AlertDialog open={confirmCancel} onOpenChange={setConfirmCancel}>
|
||||||
|
<AlertDialogContent size="sm">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Sprint-definitie annuleren?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Je conceptselectie gaat verloren. Het sprint-doel en de
|
||||||
|
gemarkeerde PBI/stories worden verwijderd.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={() => setConfirmCancel(false)}>
|
||||||
|
Doorgaan
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant="destructive"
|
||||||
|
onClick={confirmCancelAction}
|
||||||
|
>
|
||||||
|
Ja, annuleren
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
components/backlog/sprint-draft-banner.tsx
Normal file
22
components/backlog/sprint-draft-banner.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||||
|
import { SprintDefinitionBanner } from './sprint-definition-banner'
|
||||||
|
|
||||||
|
interface SprintDraftBannerProps {
|
||||||
|
productId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PBI-79 / ST-1337: client-wrapper die de SprintDefinitionBanner alleen rendert
|
||||||
|
* als er een pendingSprintDraft voor dit product staat. Hydratatie loopt via
|
||||||
|
* UserSettingsBridge — dit component subscribt op die store en is daarmee
|
||||||
|
* automatisch reactief op draft-mutaties (set/clear).
|
||||||
|
*/
|
||||||
|
export function SprintDraftBanner({ productId }: SprintDraftBannerProps) {
|
||||||
|
const draft = useUserSettingsStore(
|
||||||
|
(s) => s.entities.settings.workflow?.pendingSprintDraft?.[productId],
|
||||||
|
)
|
||||||
|
if (!draft) return null
|
||||||
|
return <SprintDefinitionBanner productId={productId} draft={draft} />
|
||||||
|
}
|
||||||
37
components/backlog/sprint-draft-leave-guard.tsx
Normal file
37
components/backlog/sprint-draft-leave-guard.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||||
|
|
||||||
|
interface SprintDraftLeaveGuardProps {
|
||||||
|
productId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PBI-79: window.beforeunload-waarschuwing zolang er een pendingSprintDraft
|
||||||
|
* loopt voor dit product. De draft is session-only en gaat verloren bij
|
||||||
|
* refresh/close — deze guard zorgt dat de gebruiker dat eerst bevestigt.
|
||||||
|
* Voor in-app route-changes (klikken op een andere product) doet Next.js
|
||||||
|
* geen onbeforeunload; daar vangen we het op via de banner-Annuleren-flow.
|
||||||
|
*/
|
||||||
|
export function SprintDraftLeaveGuard({
|
||||||
|
productId,
|
||||||
|
}: SprintDraftLeaveGuardProps) {
|
||||||
|
const hasDraft = useUserSettingsStore(
|
||||||
|
(s) => !!s.entities.settings.workflow?.pendingSprintDraft?.[productId],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasDraft) return
|
||||||
|
function handler(e: BeforeUnloadEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
// Moderne browsers tonen een eigen vertaalde tekst; returnValue is
|
||||||
|
// alleen nodig voor legacy compat.
|
||||||
|
e.returnValue = ''
|
||||||
|
}
|
||||||
|
window.addEventListener('beforeunload', handler)
|
||||||
|
return () => window.removeEventListener('beforeunload', handler)
|
||||||
|
}, [hasDraft])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
217
components/backlog/sprint-edit-dialog.tsx
Normal file
217
components/backlog/sprint-edit-dialog.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useRef, useState, useTransition } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
useDirtyCloseGuard,
|
||||||
|
DirtyCloseGuardDialog,
|
||||||
|
} from '@/components/shared/use-dirty-close-guard'
|
||||||
|
import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut'
|
||||||
|
import {
|
||||||
|
entityDialogContentClasses,
|
||||||
|
entityDialogFooterClasses,
|
||||||
|
entityDialogHeaderClasses,
|
||||||
|
} from '@/components/shared/entity-dialog-layout'
|
||||||
|
import { updateSprintAction } from '@/actions/sprints'
|
||||||
|
import { debugProps } from '@/lib/debug'
|
||||||
|
|
||||||
|
interface SprintEditDialogProps {
|
||||||
|
open: boolean
|
||||||
|
productId: string
|
||||||
|
sprint: {
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
sprint_goal: string
|
||||||
|
start_date?: string | null
|
||||||
|
end_date?: string | null
|
||||||
|
}
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateInput(value: string | null | undefined): string {
|
||||||
|
if (!value) return ''
|
||||||
|
// Accept ISO datetime or YYYY-MM-DD; output YYYY-MM-DD.
|
||||||
|
const d = new Date(value)
|
||||||
|
if (Number.isNaN(d.getTime())) return ''
|
||||||
|
return d.toLocaleDateString('en-CA')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SprintEditDialog({
|
||||||
|
open,
|
||||||
|
productId,
|
||||||
|
sprint,
|
||||||
|
onOpenChange,
|
||||||
|
}: SprintEditDialogProps) {
|
||||||
|
const [goal, setGoal] = useState(sprint.sprint_goal)
|
||||||
|
const [startDate, setStartDate] = useState(toDateInput(sprint.start_date))
|
||||||
|
const [endDate, setEndDate] = useState(toDateInput(sprint.end_date))
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [dirty, setDirty] = useState(false)
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
|
const formRef = useRef<HTMLFormElement>(null)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setGoal(sprint.sprint_goal)
|
||||||
|
setStartDate(toDateInput(sprint.start_date))
|
||||||
|
setEndDate(toDateInput(sprint.end_date))
|
||||||
|
setError(null)
|
||||||
|
setDirty(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeGuard = useDirtyCloseGuard(dirty, () => {
|
||||||
|
onOpenChange(false)
|
||||||
|
reset()
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
const trimmed = goal.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
setError(null)
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await updateSprintAction({
|
||||||
|
sprintId: sprint.id,
|
||||||
|
fields: {
|
||||||
|
goal: trimmed,
|
||||||
|
startAt: startDate || null,
|
||||||
|
endAt: endDate || null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if ('error' in result) {
|
||||||
|
setError(result.error)
|
||||||
|
toast.error(result.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.success('Sprint bijgewerkt')
|
||||||
|
onOpenChange(false)
|
||||||
|
router.refresh()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = useDialogSubmitShortcut(() =>
|
||||||
|
formRef.current?.requestSubmit(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) closeGuard.attemptClose()
|
||||||
|
else onOpenChange(o)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
showCloseButton={false}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className={entityDialogContentClasses}
|
||||||
|
{...debugProps(
|
||||||
|
'sprint-edit-dialog',
|
||||||
|
'SprintEditDialog',
|
||||||
|
'components/backlog/sprint-edit-dialog.tsx',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={entityDialogHeaderClasses}>
|
||||||
|
<DialogTitle className="text-xl font-semibold">
|
||||||
|
Sprint {sprint.code} bewerken
|
||||||
|
</DialogTitle>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Wijzig sprint-doel en datums. Voor afronding (per-story DONE/OPEN
|
||||||
|
beslissing) ga naar de sprint-pagina.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
ref={formRef}
|
||||||
|
id="sprint-edit-form"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onChange={() => setDirty(true)}
|
||||||
|
className="flex-1 overflow-y-auto px-6 py-6 space-y-6"
|
||||||
|
>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-foreground">
|
||||||
|
Sprint Goal <span className="text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
value={goal}
|
||||||
|
onChange={(e) => setGoal(e.target.value)}
|
||||||
|
required
|
||||||
|
rows={3}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-foreground">
|
||||||
|
Startdatum
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-foreground">
|
||||||
|
Einddatum
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-border">
|
||||||
|
<Link
|
||||||
|
href={`/products/${productId}/sprint/${sprint.id}`}
|
||||||
|
className="text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Sprint afronden… →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-error-container text-error-container-foreground rounded-lg px-3 py-2 text-sm border-l-4 border-error">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className={entityDialogFooterClasses}>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={closeGuard.attemptClose}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
Annuleren
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="sprint-edit-form"
|
||||||
|
disabled={isPending || !goal.trim()}
|
||||||
|
data-debug-id="sprint-edit-dialog__submit"
|
||||||
|
>
|
||||||
|
{isPending ? 'Opslaan…' : 'Opslaan'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<DirtyCloseGuardDialog guard={closeGuard} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,13 @@ import {
|
||||||
} from '@dnd-kit/sortable'
|
} from '@dnd-kit/sortable'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { CheckSquare, Square } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
|
@ -28,7 +35,10 @@ import { PanelNavBar } from '@/components/shared/panel-nav-bar'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||||
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
||||||
import { selectStoriesForActivePbi } from '@/stores/product-workspace/selectors'
|
import {
|
||||||
|
selectStoriesForActivePbi,
|
||||||
|
selectStoryIsBlocked,
|
||||||
|
} from '@/stores/product-workspace/selectors'
|
||||||
import type { BacklogStory as WorkspaceStory } from '@/stores/product-workspace/types'
|
import type { BacklogStory as WorkspaceStory } from '@/stores/product-workspace/types'
|
||||||
import { reorderStoriesAction } from '@/actions/stories'
|
import { reorderStoriesAction } from '@/actions/stories'
|
||||||
import { StoryDialog, type StoryDialogState } from './story-dialog'
|
import { StoryDialog, type StoryDialogState } from './story-dialog'
|
||||||
|
|
@ -67,17 +77,24 @@ export interface Story {
|
||||||
interface StoryPanelProps {
|
interface StoryPanelProps {
|
||||||
productId: string
|
productId: string
|
||||||
isDemo: boolean
|
isDemo: boolean
|
||||||
|
activeSprintId?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Sortable story block ---
|
// --- Sortable story block ---
|
||||||
function SortableStoryBlock({
|
function SortableStoryBlock({
|
||||||
story,
|
story,
|
||||||
isSelected,
|
isSelected,
|
||||||
|
cherrypick,
|
||||||
onSelect,
|
onSelect,
|
||||||
onEdit,
|
onEdit,
|
||||||
}: {
|
}: {
|
||||||
story: Story
|
story: Story
|
||||||
isSelected: boolean
|
isSelected: boolean
|
||||||
|
cherrypick: {
|
||||||
|
checked: boolean
|
||||||
|
blocked: { sprintName: string } | null
|
||||||
|
onToggle: () => void
|
||||||
|
} | null
|
||||||
onSelect: () => void
|
onSelect: () => void
|
||||||
onEdit: () => void
|
onEdit: () => void
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -109,6 +126,8 @@ function SortableStoryBlock({
|
||||||
</Badge>
|
</Badge>
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{cherrypick && <StoryCherrypickButton {...cherrypick} />}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onEdit() }}
|
onClick={(e) => { e.stopPropagation(); onEdit() }}
|
||||||
className="inline-flex items-center justify-center min-h-7 min-w-7 text-muted-foreground hover:text-foreground rounded transition-colors"
|
className="inline-flex items-center justify-center min-h-7 min-w-7 text-muted-foreground hover:text-foreground rounded transition-colors"
|
||||||
|
|
@ -119,15 +138,67 @@ function SortableStoryBlock({
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StoryCherrypickButton({
|
||||||
|
checked,
|
||||||
|
blocked,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
checked: boolean
|
||||||
|
blocked: { sprintName: string } | null
|
||||||
|
onToggle: () => void
|
||||||
|
}) {
|
||||||
|
const icon = checked ? (
|
||||||
|
<CheckSquare size={16} className="text-primary" />
|
||||||
|
) : (
|
||||||
|
<Square size={16} />
|
||||||
|
)
|
||||||
|
if (blocked) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
data-disabled="true"
|
||||||
|
aria-disabled="true"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="inline-flex items-center justify-center min-h-7 min-w-7 rounded opacity-40 cursor-not-allowed text-muted-foreground"
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Zit in sprint {blocked.sprintName}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onToggle()
|
||||||
|
}}
|
||||||
|
aria-pressed={checked}
|
||||||
|
aria-label={
|
||||||
|
checked ? 'Story uit sprint halen' : 'Story aan sprint toevoegen'
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center min-h-7 min-w-7 rounded transition-colors',
|
||||||
|
'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Main component ---
|
// --- Main component ---
|
||||||
// PBI-74 / T-850: leest stories voor active PBI via selectStoriesForActivePbi
|
// PBI-74 / T-850: leest stories voor active PBI via selectStoriesForActivePbi
|
||||||
// (useShallow). DnD via applyOptimisticMutation('story-order').
|
// (useShallow). DnD via applyOptimisticMutation('story-order').
|
||||||
export function StoryPanel({ productId, isDemo }: StoryPanelProps) {
|
export function StoryPanel({ productId, isDemo, activeSprintId = null }: StoryPanelProps) {
|
||||||
const selectedPbiId = useProductWorkspaceStore((s) => s.context.activePbiId)
|
const selectedPbiId = useProductWorkspaceStore((s) => s.context.activePbiId)
|
||||||
const selectedStoryId = useProductWorkspaceStore((s) => s.context.activeStoryId)
|
const selectedStoryId = useProductWorkspaceStore((s) => s.context.activeStoryId)
|
||||||
const rawStories = useProductWorkspaceStore(useShallow(selectStoriesForActivePbi)) as WorkspaceStory[]
|
const rawStories = useProductWorkspaceStore(useShallow(selectStoriesForActivePbi)) as WorkspaceStory[]
|
||||||
|
|
@ -300,9 +371,11 @@ export function StoryPanel({ productId, isDemo }: StoryPanelProps) {
|
||||||
<SortableContext items={filtered.map(s => s.id)} strategy={rectSortingStrategy}>
|
<SortableContext items={filtered.map(s => s.id)} strategy={rectSortingStrategy}>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{filtered.map(story => (
|
{filtered.map(story => (
|
||||||
<SortableStoryBlock
|
<StoryBlockWithCherrypick
|
||||||
key={story.id}
|
key={story.id}
|
||||||
story={story}
|
story={story}
|
||||||
|
productId={productId}
|
||||||
|
activeSprintId={activeSprintId}
|
||||||
isSelected={selectedStoryId === story.id}
|
isSelected={selectedStoryId === story.id}
|
||||||
onSelect={() => useProductWorkspaceStore.getState().setActiveStory(story.id)}
|
onSelect={() => useProductWorkspaceStore.getState().setActiveStory(story.id)}
|
||||||
onEdit={() => setStoryDialogState({ mode: 'edit', story, productId })}
|
onEdit={() => setStoryDialogState({ mode: 'edit', story, productId })}
|
||||||
|
|
@ -332,3 +405,96 @@ export function StoryPanel({ productId, isDemo }: StoryPanelProps) {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PBI-79 / ST-1337: wrapper rond SortableStoryBlock met cherrypick-handling.
|
||||||
|
// Subscribed per story zodat enkel de relevante rij re-rendert bij draft- of
|
||||||
|
// crossSprintBlocks-mutaties.
|
||||||
|
function StoryBlockWithCherrypick({
|
||||||
|
story,
|
||||||
|
productId,
|
||||||
|
activeSprintId,
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
|
onEdit,
|
||||||
|
}: {
|
||||||
|
story: Story
|
||||||
|
productId: string
|
||||||
|
activeSprintId: string | null
|
||||||
|
isSelected: boolean
|
||||||
|
onSelect: () => void
|
||||||
|
onEdit: () => void
|
||||||
|
}) {
|
||||||
|
const draft = useUserSettingsStore(
|
||||||
|
(s) => s.entities.settings.workflow?.pendingSprintDraft?.[productId],
|
||||||
|
)
|
||||||
|
const upsertStoryOverride = useUserSettingsStore((s) => s.upsertStoryOverride)
|
||||||
|
const toggleStorySprintMembership = useProductWorkspaceStore(
|
||||||
|
(s) => s.toggleStorySprintMembership,
|
||||||
|
)
|
||||||
|
const pending = useProductWorkspaceStore((s) => s.sprintMembership.pending)
|
||||||
|
const blocked = useProductWorkspaceStore((s) =>
|
||||||
|
selectStoryIsBlocked(s, story.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
let cherrypick: {
|
||||||
|
checked: boolean
|
||||||
|
blocked: { sprintName: string } | null
|
||||||
|
onToggle: () => void
|
||||||
|
} | null = null
|
||||||
|
|
||||||
|
if (draft) {
|
||||||
|
// State A′: muteer draft via per-PBI overrides.
|
||||||
|
const intent = draft.pbiIntent[story.pbi_id] ?? 'none'
|
||||||
|
const override = draft.storyOverrides[story.pbi_id] ?? {
|
||||||
|
add: [],
|
||||||
|
remove: [],
|
||||||
|
}
|
||||||
|
const checked =
|
||||||
|
(intent === 'all' && !override.remove.includes(story.id)) ||
|
||||||
|
override.add.includes(story.id)
|
||||||
|
cherrypick = {
|
||||||
|
checked,
|
||||||
|
blocked: blocked ? { sprintName: blocked.sprintName } : null,
|
||||||
|
onToggle: () => {
|
||||||
|
if (intent === 'all') {
|
||||||
|
void upsertStoryOverride(
|
||||||
|
productId,
|
||||||
|
story.pbi_id,
|
||||||
|
story.id,
|
||||||
|
checked ? 'remove' : 'clear',
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
void upsertStoryOverride(
|
||||||
|
productId,
|
||||||
|
story.pbi_id,
|
||||||
|
story.id,
|
||||||
|
checked ? 'clear' : 'add',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else if (activeSprintId) {
|
||||||
|
// State B: muteer pending buffer via toggleStorySprintMembership.
|
||||||
|
const inSprintDb = story.sprint_id === activeSprintId
|
||||||
|
const inAdds = pending.adds.includes(story.id)
|
||||||
|
const inRemoves = pending.removes.includes(story.id)
|
||||||
|
const checked = inAdds || (inSprintDb && !inRemoves)
|
||||||
|
cherrypick = {
|
||||||
|
checked,
|
||||||
|
blocked: blocked ? { sprintName: blocked.sprintName } : null,
|
||||||
|
onToggle: () => {
|
||||||
|
toggleStorySprintMembership(story.id, inSprintDb)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SortableStoryBlock
|
||||||
|
story={story}
|
||||||
|
isSelected={isSelected}
|
||||||
|
cherrypick={cherrypick}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onEdit={onEdit}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,12 @@ import {
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { setActiveSprintAction } from '@/actions/active-sprint'
|
import {
|
||||||
|
clearActiveSprintAction,
|
||||||
|
switchActiveSprintAction,
|
||||||
|
} from '@/actions/active-sprint'
|
||||||
|
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
||||||
|
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||||
import type { SprintStatusApi } from '@/lib/task-status'
|
import type { SprintStatusApi } from '@/lib/task-status'
|
||||||
import { debugProps } from '@/lib/debug'
|
import { debugProps } from '@/lib/debug'
|
||||||
|
|
||||||
|
|
@ -45,6 +50,13 @@ export function SprintSwitcher({
|
||||||
const [showClosed, setShowClosed] = useState(false)
|
const [showClosed, setShowClosed] = useState(false)
|
||||||
const buildingSet = new Set(buildingSprintIds)
|
const buildingSet = new Set(buildingSprintIds)
|
||||||
|
|
||||||
|
// PBI-79: zolang er een sprint-draft loopt tonen we 'Concept — [goal]'
|
||||||
|
// bovenaan de dropdown. De draft staat alleen in deze session-store; bij
|
||||||
|
// page-refresh/leave is hij weg.
|
||||||
|
const draftGoal = useUserSettingsStore(
|
||||||
|
(s) => s.entities.settings.workflow?.pendingSprintDraft?.[productId]?.goal ?? null,
|
||||||
|
)
|
||||||
|
|
||||||
const visibleSprints = sprints.filter(s => {
|
const visibleSprints = sprints.filter(s => {
|
||||||
if (showClosed) return true
|
if (showClosed) return true
|
||||||
if (s.id === activeSprint?.id) return true
|
if (s.id === activeSprint?.id) return true
|
||||||
|
|
@ -54,13 +66,43 @@ export function SprintSwitcher({
|
||||||
function handleSwitchSprint(sprintId: string) {
|
function handleSwitchSprint(sprintId: string) {
|
||||||
if (sprintId === activeSprint?.id) return
|
if (sprintId === activeSprint?.id) return
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await setActiveSprintAction(productId, sprintId)
|
const result = await switchActiveSprintAction(productId, sprintId)
|
||||||
|
if ('error' in result) {
|
||||||
|
toast.error(
|
||||||
|
typeof result.error === 'string' ? result.error : 'Wisselen mislukt',
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Synchroniseer de client-side workspace-store met de auto-select die
|
||||||
|
// server-side is bepaald — voorkomt korte flash van vorige selectie
|
||||||
|
// voordat router.refresh de SSR-render binnenhaalt.
|
||||||
|
const store = useProductWorkspaceStore.getState()
|
||||||
|
if (result.pbiId) {
|
||||||
|
store.setActivePbi(result.pbiId)
|
||||||
|
if (result.storyId) {
|
||||||
|
store.setActiveStory(result.storyId)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
store.setActivePbi(null)
|
||||||
|
}
|
||||||
|
if (pathname.includes('/sprint')) {
|
||||||
|
router.push(`/products/${productId}/sprint/${sprintId}`)
|
||||||
|
} else {
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClearActiveSprint() {
|
||||||
|
if (!activeSprint) return
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await clearActiveSprintAction(productId)
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
toast.error(typeof result.error === 'string' ? result.error : 'Wisselen mislukt')
|
toast.error(typeof result.error === 'string' ? result.error : 'Wisselen mislukt')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (pathname.includes('/sprint')) {
|
if (pathname.includes('/sprint')) {
|
||||||
router.push(`/products/${productId}/sprint/${sprintId}`)
|
router.push(`/products/${productId}`)
|
||||||
} else {
|
} else {
|
||||||
router.refresh()
|
router.refresh()
|
||||||
}
|
}
|
||||||
|
|
@ -127,6 +169,30 @@ export function SprintSwitcher({
|
||||||
Toon afgeronde sprints
|
Toon afgeronde sprints
|
||||||
</button>
|
</button>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
{draftGoal && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem
|
||||||
|
disabled
|
||||||
|
className="italic text-tertiary opacity-90 cursor-default"
|
||||||
|
data-debug-id="sprint-switcher__concept"
|
||||||
|
>
|
||||||
|
<span className="shrink-0">⚙ Concept —</span>
|
||||||
|
<span className="truncate">{draftGoal}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={handleClearActiveSprint}
|
||||||
|
disabled={!activeSprint || isPending}
|
||||||
|
className={cn(
|
||||||
|
'italic text-muted-foreground',
|
||||||
|
!activeSprint && 'opacity-50 cursor-not-allowed',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
— Geen actieve sprint —
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
{visibleSprints.length === 0 ? (
|
{visibleSprints.length === 0 ? (
|
||||||
<div className="px-2 py-2 text-sm text-muted-foreground/70 italic">
|
<div className="px-2 py-2 text-sm text-muted-foreground/70 italic">
|
||||||
Geen open sprints
|
Geen open sprints
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
# Documentation Index
|
# Documentation Index
|
||||||
|
|
||||||
Auto-generated on 2026-05-10 from front-matter and headings.
|
Auto-generated on 2026-05-11 from front-matter and headings.
|
||||||
|
|
||||||
## Architecture Decision Records
|
## Architecture Decision Records
|
||||||
|
|
||||||
|
|
@ -55,6 +55,7 @@ Auto-generated on 2026-05-10 from front-matter and headings.
|
||||||
| [PBI-11 — Mobile-shell met landscape-lock (settings + backlog + solo)](./plans/PBI-11-mobile-shell.md) | — | — |
|
| [PBI-11 — Mobile-shell met landscape-lock (settings + backlog + solo)](./plans/PBI-11-mobile-shell.md) | — | — |
|
||||||
| [PBI-75 — Sprint task-edit client-side via workspace-store](./plans/PBI-75-sprint-task-edit-store.md) | — | — |
|
| [PBI-75 — Sprint task-edit client-side via workspace-store](./plans/PBI-75-sprint-task-edit-store.md) | — | — |
|
||||||
| [PBI-78 — Cost-analyse widget op Insights-pagina](./plans/PBI-78-cost-analysis-widget.md) | — | — |
|
| [PBI-78 — Cost-analyse widget op Insights-pagina](./plans/PBI-78-cost-analysis-widget.md) | — | — |
|
||||||
|
| [PBI-79: Product Backlog workflow — sprint-membership via vinkjes](./plans/PBI-79-backlog-sprint-workflow.md) | — | — |
|
||||||
| [Queue-loop verplaatsen van Claude naar runner](./plans/queue-loop-extraction.md) | — | — |
|
| [Queue-loop verplaatsen van Claude naar runner](./plans/queue-loop-extraction.md) | — | — |
|
||||||
| [Advies - SprintRun, PR en worktree lifecycle als state machines](./plans/sprint-pr-worktree-state-machines.md) | draft | 2026-05-06 |
|
| [Advies - SprintRun, PR en worktree lifecycle als state machines](./plans/sprint-pr-worktree-state-machines.md) | draft | 2026-05-06 |
|
||||||
| [ST-1109 — PBI krijgt een status (Ready / Blocked / Done)](./plans/ST-1109-pbi-status.md) | active | 2026-05-03 |
|
| [ST-1109 — PBI krijgt een status (Ready / Blocked / Done)](./plans/ST-1109-pbi-status.md) | active | 2026-05-03 |
|
||||||
|
|
|
||||||
649
docs/plans/PBI-79-backlog-sprint-workflow.md
Normal file
649
docs/plans/PBI-79-backlog-sprint-workflow.md
Normal file
|
|
@ -0,0 +1,649 @@
|
||||||
|
# PBI-79: Product Backlog workflow — sprint-membership via vinkjes
|
||||||
|
|
||||||
|
> **MCP:** PBI-79 (`cmp13vrxd0001m017ta9aflg9`) in Scrum4Me product (`cmohrysyj0000rd17clnjy4tc`).
|
||||||
|
>
|
||||||
|
> **Review verwerkt:** Dit plan is een herziene versie na de review in [`product-backlog-workflow-plan-review.md`](product-backlog-workflow-plan-review.md). De vier P1-bevindingen zijn allemaal geadresseerd, evenals de vijf P2-punten. Zie de sectie *"Reactie op review"* onderaan voor de mapping.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementatie-stand & scope-aanpassingen (post-testing)
|
||||||
|
|
||||||
|
> Deze sectie documenteert wat er sinds de eerste implementatie-pass is bijgewerkt op basis van gebruikerstests + nieuwe inzichten. De rest van het plan beneden geldt **behalve waar dit kopje dat overrulet**.
|
||||||
|
|
||||||
|
### Gerealiseerde commits (in volgorde)
|
||||||
|
|
||||||
|
| # | Commit | Story | Inhoud |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | 2af6f24 | ST-1333 | Active-sprint null-contract + clearActiveSprintAction |
|
||||||
|
| 2 | 56c55e1 | ST-1334 | pendingSprintDraft slot (compacte intent-shape) |
|
||||||
|
| 3 | b4a515e | ST-1343 | `lib/sprint-conflicts.ts` eligibility helpers |
|
||||||
|
| 4 | e89fb71 | ST-1335 | Gescoped endpoints (`sprint-membership-summary`, `cross-sprint-blocks`) |
|
||||||
|
| 5 | 89c2356 | ST-1336 | `sprintMembership`-slice + selectors in product-workspace-store |
|
||||||
|
| 6 | 947d970 | ST-1337 | State A′ UI (metadata-dialog + sticky banner + PbiList ombouw) |
|
||||||
|
| 7 | d21011c | ST-1339 | `createSprintWithSelectionAction` + banner wire-up |
|
||||||
|
| 8 | 4c6e999 | ST-1340 | `commitSprintMembershipAction` + gerichte client-store patches |
|
||||||
|
| 9 | 117616f | ST-1338 | State B vinkjes-UI + "Sprint opslaan"-knop |
|
||||||
|
| 10 | b91d92a | ST-1341+1342 | `SprintEditDialog` + multi-OPEN sprints |
|
||||||
|
| 11 | 0c36f4e | ST-1344 | `updateSprintAction` regression tests |
|
||||||
|
| 12 | 8d6fbdf | bugfix | PBI-rij weer klikbaar voor selectie; vinkje als aparte trigger |
|
||||||
|
| 13 | 35c6404 | bugfix | Cascade-restore alleen wanneer hint-story bij nieuwe PBI hoort |
|
||||||
|
| 14 | d7d1112 | feat | Sprint-switch auto-select PBI/story + user-settings persist (3 keys) |
|
||||||
|
|
||||||
|
### Bugs gevonden tijdens testen (afgehandeld)
|
||||||
|
|
||||||
|
1. **Hele PBI-rij was de toggle in selectionMode.** Gevolg: rij-klik bulk-toggled stories en update de teller, maar PBI werd niet als focus geselecteerd → story-kolom bleef leeg.
|
||||||
|
*Fix (8d6fbdf):* in `SortablePbiRow` selectionMode-branch wordt onClick weer `onSelect`; het tri-state icoon zit in een eigen `<button>` met `stopPropagation`.
|
||||||
|
2. **Cascade-restore overschrijft PBI-switch.** Bij wisselen naar een andere PBI bleef de oude story (en dus zijn taken) zichtbaar omdat `setActivePbi`'s async hint-restore de vorige story-id terugzette zonder PBI-validatie.
|
||||||
|
*Fix (35c6404):* hint wordt alleen toegepast als `storiesById[hint].pbi_id === pbiId`.
|
||||||
|
3. **Tooltip-API mismatch.** `TooltipTrigger` van base-ui accepteert geen `asChild`; geprobeerd via render-prop maar uiteindelijk de hele knop in selectionMode in de Tooltip gewikkeld.
|
||||||
|
|
||||||
|
### Nieuwe feature (na implementatie toegevoegd) — sprint-switch auto-select
|
||||||
|
|
||||||
|
Bij wisselen van sprint via de switcher wordt **server-side** de inhoud van de sprint geresolved en als deze precies één PBI heeft (en die PBI exact één story binnen de sprint), worden beide automatisch geselecteerd. Alle drie selectie-velden worden atomair in user-settings weggeschreven zodat cross-device-restore klopt.
|
||||||
|
|
||||||
|
- Schema: `layout.activePbis` + `layout.activeStories` per product (beide nullable).
|
||||||
|
- Helper: `setActiveSelectionInSettings(userId, productId, { sprintId, pbiId?, storyId? })`.
|
||||||
|
- Server-action: `switchActiveSprintAction(productId, sprintId)` doet de auto-select-resolutie en returnt het tripel.
|
||||||
|
- Sprint-switcher: roept de nieuwe action aan en synchroniseert de client-store gelijk (geen flash).
|
||||||
|
- `ActiveSelectionHydrator` (nieuw): client-side effect dat user-settings-activePbi/activeStory naar de workspace-store spiegelt; wint van de bestaande localStorage hint-restore.
|
||||||
|
|
||||||
|
### Scope-aanpassing — pendingSprintDraft wordt **session-only**
|
||||||
|
|
||||||
|
**Was:** de draft (sprint-doel + per-PBI intent + per-PBI overrides) staat persistent in `user-settings.workflow.pendingSprintDraft` zodat de gebruiker na navigatie kan hervatten.
|
||||||
|
|
||||||
|
**Wordt:** de draft leeft alleen in de Zustand-store van de sessie. Bij wegnavigeren krijgt de gebruiker een `useDirtyCloseGuard`-confirm; bij doorgaan wordt de draft **weggegooid** (niet hervat-baar). Reden: de user geeft expliciet aan dat ongeslagen sprints geen rest-state mogen achterlaten in de DB.
|
||||||
|
|
||||||
|
Concrete wijzigingen:
|
||||||
|
- `lib/user-settings.ts`: `workflow.pendingSprintDraft` kan blijven bestaan voor type-compatibiliteit maar wordt niet meer geschreven door de UI.
|
||||||
|
- Actions `setPendingSprintDraftAction` + `clearPendingSprintDraftAction` worden gedeprecieerd (of behouden voor migratie van eventueel oude entries) maar **niet meer aangeroepen** door de UI.
|
||||||
|
- Store `useUserSettingsStore.setPendingSprintDraft` / `upsertPbiIntent` / `upsertStoryOverride` blijven bestaan maar de server-roundtrip eruit; lokale state-only.
|
||||||
|
- `useDirtyCloseGuard` op het banner-niveau triggert een confirm bij browser-back / route-wissel; bevestigen → `clearPendingSprintDraftAction` (om eventuele oude DB-entries op te ruimen) **+** lokale state-reset.
|
||||||
|
|
||||||
|
### Nieuwe feature — draft-sprint zichtbaar in sprint-switcher
|
||||||
|
|
||||||
|
Tijdens state A′ (er is een draft) toont de sprint-switcher de **draft-naam** (= `draft.goal`, ingekort) als extra entry bovenaan de dropdown met markering "Concept" of italic-styling. Hij is niet selecteerbaar als "actieve" sprint (want geen sprintId); klikken erop opent de banner-actie of doet niets bijzonders. Doel: visueel feedback geven dat er een onafgemaakte sprint loopt zonder die in de DB op te slaan.
|
||||||
|
|
||||||
|
Concreet:
|
||||||
|
- Sprint-switcher krijgt prop `pendingDraftGoal?: string | null` (server-side leesbaar via user-settings store na hydration, of via `useUserSettingsStore` in de switcher-component).
|
||||||
|
- Render bovenaan de dropdown (boven "— Geen actieve sprint —") wanneer aanwezig: *"⚙ Concept — [goal-prefix]"*.
|
||||||
|
|
||||||
|
### Wat blijft staan uit de oorspronkelijke ontwerpkeuzes
|
||||||
|
|
||||||
|
- Schema `layout.activeSprints` blijft nullable (key+null = bewust geen sprint).
|
||||||
|
- Drie-states-model (A / A′ / B) blijft.
|
||||||
|
- Tri-state PBI-vinkje, story-binair-vinkje, cross-sprint disabled blijven.
|
||||||
|
- "Sprint opslaan"-knop met teller (state B) blijft.
|
||||||
|
- Eligibility-filter + status-mutaties in dezelfde transactie blijven.
|
||||||
|
- Endpoints gescoped op `pbiIds` blijven.
|
||||||
|
- Multi-OPEN sprints toegestaan blijft.
|
||||||
|
|
||||||
|
### Wat nog te doen (na deze plan-update)
|
||||||
|
|
||||||
|
> Alle drie punten **afgerond** in commit `2a4ee6a`.
|
||||||
|
|
||||||
|
1. ~~**Implementeer scope-aanpassing**~~ — `setPendingSprintDraft` / `clearPendingSprintDraft` zijn nu local-only; `hydrate()` strip eventuele legacy DB-entries.
|
||||||
|
2. ~~**Sprint-switcher concept-entry**~~ — `⚙ Concept — [goal]` verschijnt bovenaan de dropdown zodra er een draft loopt.
|
||||||
|
3. ~~**Verifieer**~~ — `npm run verify` groen (826 tests). `SprintDraftLeaveGuard` registreert `beforeunload`-listener voor browser-refresh/close. In-app route-changes blijven via banner-Annuleren lopen.
|
||||||
|
|
||||||
|
### Bewust niet geïmplementeerd
|
||||||
|
|
||||||
|
- **Server-side persist van manuele PBI/story-klikken.** Vraag: "wordt de geselecteerde pbi ook opgeslagen". Antwoord: nee, momenteel alleen via sprint-switch auto-select. Manuele klikken gaan naar localStorage. Cross-device parity voor manuele klikken vereist extra server-roundtrips per klik; de helpers `setActivePbiInSettings` / `setActiveStoryInSettings` zijn voorbereid maar niet gewired. Op verzoek opnieuw oppakken in een vervolg-PBI.
|
||||||
|
|
||||||
|
### localStorage-gebruik (overzicht)
|
||||||
|
|
||||||
|
| Locatie | Doel |
|
||||||
|
|---|---|
|
||||||
|
| [stores/product-workspace/restore.ts](stores/product-workspace/restore.ts) | Per-browser hints `lastActivePbiId` / `lastActiveStoryId` / `lastActiveTaskId` per product. |
|
||||||
|
| [stores/sprint-workspace/restore.ts](stores/sprint-workspace/restore.ts) | Idem voor de sprint-pagina. |
|
||||||
|
| [lib/user-settings-migration.ts](lib/user-settings-migration.ts) | One-shot migratie van legacy prefs (PBI-76) naar user-settings. |
|
||||||
|
| [components/ideas/idea-md-editor.tsx](components/ideas/idea-md-editor.tsx) | Auto-save van idee-markdown-draft (niet PBI-79-gerelateerd). |
|
||||||
|
|
||||||
|
`ActiveSelectionHydrator` (PBI-79) wint van de localStorage-hints voor PBI/story-selectie zodra user-settings expliciet iets bevat.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
De Product Backlog-pagina (`/products/[id]`) is het hart van Scrum4Me. De **lazy-load-basis bestaat al** (filter-first/background-remaining-PBI's + lazy stories/tasks per klik via [lib/product-backlog-pbis.ts](lib/product-backlog-pbis.ts), `ensurePbiLoaded`, `ensureStoryLoaded`). Dit plan bouwt daarop voort, het herontwerpt dat fundament niet.
|
||||||
|
|
||||||
|
Wat nog ontbreekt:
|
||||||
|
|
||||||
|
1. **Geen uniforme sprint-samenstelling-UI**. Sprint-aanmaak loopt nu via twee flows: `createSprintAction` (één pbi_id) en `createSprintWithPbisAction` (array, via `NewSprintDialog`). Geen UI-feedback over welke PBI's al in welke mate "in de huidige sprint zitten".
|
||||||
|
2. **Stories aan/uit sprint per stuk** kan alleen via de Sprint-pagina, niet vanuit de backlog.
|
||||||
|
3. **Geen pending/dirty-flow** voor sprint-mutaties — alle huidige acties zijn direct gecommit, wat zware multi-toggle-flows omslachtig maakt.
|
||||||
|
|
||||||
|
We bouwen een vinkje-gebaseerde workflow met drie states. Geen schemamutatie op de DB — `sprint_id` blijft op Story en Task. PBI-vinkjes zijn puur afgeleid. `task.sprint_id` blijft denormalisatie van `story.sprint_id` en wordt cascade-meegeupdate bij bulk-mutaties.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Beslissingen (samenvatting)
|
||||||
|
|
||||||
|
| Onderdeel | Keuze |
|
||||||
|
|---|---|
|
||||||
|
| **Datamodel** | Ongewijzigd. `story.sprint_id` is unit-of-truth; PBI/task vinkjes afgeleid |
|
||||||
|
| **Cross-sprint conflict** | Disabled vinkje + tooltip; **alleen** tegen andere OPEN sprints |
|
||||||
|
| **State A** (geen sprint) | Alle PBI's, geen vinkjes, klassieke 3-koloms inspect |
|
||||||
|
| **State A′ vorm** | Two-step: kleine modal (metadata) → sticky banner + inline vinkjes |
|
||||||
|
| **State A′ annuleren** | Dirty-close confirm (`useDirtyCloseGuard`-pattern) |
|
||||||
|
| **State A′ persistentie** | `user-settings.pendingSprintDraft[productId]` — compacte intent (zie hieronder), niet alle story-IDs |
|
||||||
|
| **Lege sprint** | Toegestaan |
|
||||||
|
| **State B vinkjes** | Tri-state op PBI (selector-afgeleid), binair op story; klikken muteert pending buffer |
|
||||||
|
| **State B pending scope** | Alleen sprint-membership toggles |
|
||||||
|
| **State B dirty-UI** | "Sprint opslaan"-knop altijd zichtbaar, disabled bij clean, met teller bij dirty |
|
||||||
|
| **State B navigatie bij dirty** | Confirm-dialog |
|
||||||
|
| **Sprint-switcher** | OPEN sprints + "Geen actieve sprint"-optie. CLOSED via bestaande sprint-pagina |
|
||||||
|
| **Sprint-scope** | Per-user (huidig `user-settings.activeSprints[productId]`) |
|
||||||
|
| **Multiple OPEN sprints** | Toegestaan — `createSprintAction`-uniqueness-check vervalt |
|
||||||
|
| **Nieuwe story in state B** | `sprint_id = activeSprintId` direct bij aanmaak |
|
||||||
|
| **Tasks-niveau** | Geen vinkjes. Cascade-meegeupdated met story |
|
||||||
|
| **Sprint metadata edit** | `SprintEditDialog` (goal, dates) via edit-icoon |
|
||||||
|
| **Sprint afsluiten** | Hergebruik bestaande `completeSprintAction` (per-story DONE/OPEN beslissing + PBI-promotie) — **niet** een nieuwe `closeSprintAction` |
|
||||||
|
| **`story.status` bij membership-mutaties** | Add: `status='IN_SPRINT'` (én `sprint_id` gezet). Remove: `status='OPEN'` (én `sprint_id=NULL`). `task.sprint_id` cascadeert in **dezelfde transactie** |
|
||||||
|
| **Eligibility voor toevoegen** | Server-resolve mag alleen stories met `sprint_id IS NULL` **en** `status != 'DONE'` toevoegen. Stories uit CLOSED/ARCHIVED/FAILED sprints met DONE-status zijn dus niet eligible — moeten eerst handmatig op OPEN gezet worden (of via re-open flow) |
|
||||||
|
| **Active-sprint null-contract** | Schema nullable maken — `activeSprints[productId]: string \| null`. **Key-aanwezigheid heeft betekenis**: key ontbreekt → fallback-cascade (eerste OPEN, dan recent CLOSED). Key met `null`-waarde → expliciet *geen* actieve sprint, géén fallback |
|
||||||
|
| **PBI-selectie-flow migratie** | Bestaande `selectionMode` + `NewSprintDialog` + `createSprintWithPbisAction` worden **omgebouwd** tot A′-draft-mode. Eén flow, geen feature-flag-parallellisme |
|
||||||
|
| **Initial server-side load** | Bestaande `getProductBacklogPbis(productId, query, 'matching')` blijft basis — geen counts in deze call. Geen stories, geen taken |
|
||||||
|
| **Background remaining-load** | Behoud huidige patroon: client laadt `?mode=remaining` via route handler |
|
||||||
|
| **PBI-counts (state B tri-state)** | Aparte lazy summary-endpoint `GET /api/products/[id]/sprint-membership-summary?sprintId=X&pbiIds=<ids>` — **expliciet gescoped op pbiIds** (visible/loaded batch), nooit product-breed. Alleen aangeroepen in state B |
|
||||||
|
| **Story-detail (description + taken)** | Lazy bij PBI-klik via bestaande `ensurePbiLoaded`/`ensureStoryLoaded` route handlers |
|
||||||
|
| **Story-IDs voor A′ tri-state** | **Niet** brede `getStoryIdsByPbi(productId)`-fetch. Per PBI lazy via dezelfde `ensurePbiLoaded` als state A |
|
||||||
|
| **Cross-sprint conflict-detectie** | Server-side bij commit (autoritatief). Client-hint via lichte `GET /api/products/[id]/cross-sprint-blocks?excludeSprintId=X&pbiIds=<ids>` — **gescoped op pbiIds** voor disabled-vinkjes |
|
||||||
|
| **Data-access stijl** | Blijven bij **route handlers + `cache: 'no-store'` + `revalidatePath`** (huidige stijl). Géén Cache Components / `'use cache'` / `cacheTag` in dit plan |
|
||||||
|
| **Sync na commit** | Server action retourneert affected ids → client patcht workspace-store gericht. **Geen `router.refresh()` of full page rehydration** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State A — geen actieve sprint geselecteerd
|
||||||
|
|
||||||
|
**UI:** bestaande 3-koloms layout uit [components/backlog/backlog-split-pane.tsx](components/backlog/backlog-split-pane.tsx) onveranderd. PBI-lijst | Story-panel | Task-panel. Geen vinkjes.
|
||||||
|
|
||||||
|
**Header-acties:** sprint-switcher toont "Geen actieve sprint" + dropdown van OPEN sprints + "— Geen actieve sprint —"-optie. Naast switcher: knop **"Nieuwe sprint"** → start A′ door metadata-modal te openen.
|
||||||
|
|
||||||
|
**Wijzigingen t.o.v. huidig gedrag:**
|
||||||
|
- Sprint-switcher in [components/shared/sprint-switcher.tsx](components/shared/sprint-switcher.tsx) krijgt expliciete optie "— Geen actieve sprint —"; selectie roept (nieuwe) `clearActiveSprintAction(productId)` aan → schrijft `null` in user-settings.
|
||||||
|
- De huidige "Start Sprint"-knop in [app/(app)/products/[id]/page.tsx](app/(app)/products/[id]/page.tsx) wordt "Nieuwe sprint" en triggert A′-flow i.p.v. direct `NewSprintDialog`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State A′ — sprint definiëren (ombouw van huidige selectionMode)
|
||||||
|
|
||||||
|
### Migratie-uitgangspunt
|
||||||
|
|
||||||
|
De bestaande PBI-selectie-flow in [components/backlog/pbi-list.tsx:219-523](components/backlog/pbi-list.tsx) heeft al:
|
||||||
|
- `selectionMode` boolean en `selectedIds: Set<string>`
|
||||||
|
- `toggleCheck(id)` voor PBI-toggles
|
||||||
|
- `exitSelection()` voor cleanup
|
||||||
|
- `NewSprintDialog` aanroep met `pbiIds`-array
|
||||||
|
- Server-action `createSprintWithPbisAction` die alle stories van geselecteerde PBI's bulk-update
|
||||||
|
|
||||||
|
We **bouwen dit om** tot A′. Het oude `NewSprintDialog` wordt vervangen door de two-step flow (metadata-modal → banner). De selectie-state wordt uitgebreid van "PBI's only" naar "PBI's én individuele stories (overrides)". `createSprintWithPbisAction` wordt aangepast om óók override-lijsten te accepteren.
|
||||||
|
|
||||||
|
### Stap 1: metadata-modal
|
||||||
|
|
||||||
|
Klik "Nieuwe sprint" → kleine `Dialog` (Entity-Dialog-pattern uit [docs/patterns/dialog.md](docs/patterns/dialog.md)):
|
||||||
|
- **Sprint-doel** (`sprint_goal`, verplicht)
|
||||||
|
- **Startdatum** (optioneel, default = vandaag)
|
||||||
|
- **Einddatum** (optioneel, default = +2 weken)
|
||||||
|
- Knoppen: "Annuleren" | "Verder"
|
||||||
|
|
||||||
|
"Verder" valideert (Zod) en schrijft via `setPendingSprintDraftAction` naar user-settings. **Geen sprint in DB.**
|
||||||
|
|
||||||
|
### Stap 2: vinkjes + sticky banner (compacte intent-state)
|
||||||
|
|
||||||
|
Op de pagina verschijnt een **sticky banner**:
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Sprint definiëren — [doel] · X PBI's, Y stories │
|
||||||
|
│ [Annuleren] [Sprint aanmaken] │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Op alle PBI-rijen en story-rijen verschijnen vinkjes — story-vinkjes pas zichtbaar als de PBI is geopend (via bestaande `ensurePbiLoaded`).
|
||||||
|
|
||||||
|
**Pending draft-state (compact, overrides per PBI):**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
pendingSprintDraft: {
|
||||||
|
goal: string
|
||||||
|
startAt?: string
|
||||||
|
endAt?: string
|
||||||
|
// Per-PBI bulk-intent:
|
||||||
|
pbiIntent: {
|
||||||
|
[pbiId]: 'all' | 'none' // default 'none' tot user PBI aanvinkt
|
||||||
|
}
|
||||||
|
// Per-PBI overrides (story-ids die afwijken van de PBI-intent):
|
||||||
|
storyOverrides: {
|
||||||
|
[pbiId]: {
|
||||||
|
add: string[] // expliciet aan, ook al staat PBI op 'none'
|
||||||
|
remove: string[] // expliciet uit, ook al staat PBI op 'all'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Waarom per-PBI overrides (i.p.v. één globale add/remove):** bij PBI-toggle (`'all' → 'none'`) of bij sessie-restore moet je zonder brede story-fetch betrouwbaar weten welke overrides bij welke PBI horen. Globale lijsten dwingen je tot een product-breed `getStoryIdsByPbi` om op te schonen — dat is precies wat we niet willen. Met per-PBI overrides is opruimen lokaal: bij PBI-toggle wis je `storyOverrides[pbiId]`, klaar.
|
||||||
|
|
||||||
|
**Tri-state-resolutie (selector, niet opgeslagen):**
|
||||||
|
- PBI-vinkje weergave: bereken uit `pbiIntent[pbiId]` + de subset van zijn child-stories die geladen is + `storyOverrides[pbiId]`. Bij `intent='all'` en geen `remove` → ✓. Bij `intent='none'` en geen `add` → ☐. Anders ◐.
|
||||||
|
- Story-vinkje: `(pbiIntent[pbiId] == 'all' || storyOverrides[pbiId]?.add?.includes(storyId)) && !storyOverrides[pbiId]?.remove?.includes(storyId)`.
|
||||||
|
|
||||||
|
**Toggle-semantiek:**
|
||||||
|
- Klik PBI-vinkje ☐→✓: `pbiIntent[pbi] = 'all'`, wis `storyOverrides[pbi]`.
|
||||||
|
- Klik PBI-vinkje ✓→☐: `pbiIntent[pbi] = 'none'`, wis `storyOverrides[pbi]`.
|
||||||
|
- Klik story-vinkje (in geopende PBI): voeg toe aan `storyOverrides[pbi].add` of `.remove`, met cancel-out tegen de tegenoverliggende lijst van diezelfde PBI.
|
||||||
|
|
||||||
|
**Voordelen:** geen N×K JSON-blob per draft. Per-PBI scoping maakt cleanup lokaal en restore deterministisch.
|
||||||
|
|
||||||
|
**Annuleren** → dirty-close confirm → `clearPendingSprintDraftAction` → banner verdwijnt.
|
||||||
|
|
||||||
|
**Sprint aanmaken** → server action `createSprintWithSelectionAction(productId, metadata, pbiIntent, storyOverrides)`:
|
||||||
|
1. Server resolveert intent → concrete `storyIdsToAddToSprint: string[]`:
|
||||||
|
- Voor elke PBI met `intent = 'all'`: alle child-stories minus `storyOverrides[pbi].remove`
|
||||||
|
- Plus alle stories in `storyOverrides[pbi].add` (over alle PBI's)
|
||||||
|
2. **Eligibility-filter (server, autoritatief):** behoud alleen stories waarvoor `sprint_id IS NULL` **en** `status != 'DONE'`. Stories die niet voldoen (in andere sprint, of al DONE) komen in `conflicts.notEligible[]` met reden.
|
||||||
|
3. **Cross-sprint-check** (gedekt door eligibility, maar separately rapporteren): geblokkeerde stories → `conflicts.crossSprint[]` met `{ storyId, sprintId, sprintName }`.
|
||||||
|
4. Transactie:
|
||||||
|
- Insert Sprint (status=OPEN)
|
||||||
|
- `story.sprint_id = newSprintId, story.status = 'IN_SPRINT' WHERE id IN (eligibleStoryIds)`
|
||||||
|
- `task.sprint_id = newSprintId WHERE story_id IN (eligibleStoryIds)` (cascade — task.status onveranderd)
|
||||||
|
5. `clearPendingSprintDraftAction` + `setActiveSprintInSettings(productId, newSprintId)`
|
||||||
|
6. Realtime-event broadcasting
|
||||||
|
7. **Return:** `{ sprintId, affectedStoryIds, affectedPbiIds, conflicts: { notEligible, crossSprint } }`
|
||||||
|
8. Client patcht workspace-store gericht: voeg sprintId toe aan stories/tasks, zet `story.status = 'IN_SPRINT'`, invalidate `pbiSummary`-counts voor affected PBI's via lazy summary-refetch (gescoped). Toast voor conflicts. **Geen page-refresh.**
|
||||||
|
|
||||||
|
### Persistent draft
|
||||||
|
|
||||||
|
Verlaten van de pagina/sessie tijdens A′ → `pendingSprintDraft` blijft in user-settings. Volgende bezoek: pagina detecteert draft → banner + vinkjes verschijnen automatisch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State B — actieve sprint geselecteerd
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
- **Header**: sprint-switcher toont actieve sprint. Edit-icoon ernaast → opent `SprintEditDialog` (alleen metadata: goal + dates).
|
||||||
|
- **"Sprint opslaan"-knop**: altijd zichtbaar, disabled bij clean, geactiveerd met teller bij dirty: *"Sprint opslaan (3)"*.
|
||||||
|
- **Sprint afsluiten**: bestaande `completeSprintAction`-flow blijft op de sprint-pagina (`/products/[id]/sprint/[sprintId]`); SprintEditDialog krijgt een link "Sprint afronden…" die naar die pagina navigeert. Geen duplicate flow.
|
||||||
|
- **3-koloms layout**: ongewijzigd. PBI-vinkjes (tri-state via selector), story-vinkjes (binair, disabled-bij-conflict), geen task-vinkjes.
|
||||||
|
|
||||||
|
### Pending buffer (state B)
|
||||||
|
|
||||||
|
In [stores/product-workspace/store.ts](stores/product-workspace/store.ts) toevoegen — **arrays, niet Sets**:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
sprintMembershipPending: {
|
||||||
|
adds: string[] // story-ids die in actieve sprint moeten
|
||||||
|
removes: string[] // story-ids die uit actieve sprint moeten
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `isDirty` selector: `adds.length + removes.length > 0`
|
||||||
|
- Teller selector: `adds.length + removes.length`
|
||||||
|
- Cancel-out: bij toggle terug wordt het ID uit de tegenoverliggende lijst gehaald
|
||||||
|
|
||||||
|
Arrays zijn JSON-serialiseerbaar (handig voor debugging/devtools) en spelen netjes met Zustand/Immer (geen mutable Set-valkuil).
|
||||||
|
|
||||||
|
### Tri-state vinkjes via selectors (geen opgeslagen state)
|
||||||
|
|
||||||
|
In [stores/product-workspace/store.ts](stores/product-workspace/store.ts):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Primitieven (opgeslagen):
|
||||||
|
pbiSummary: {
|
||||||
|
[pbiId]: {
|
||||||
|
totalStoryCount: number // uit summary-endpoint
|
||||||
|
inActiveSprintStoryCount: number // uit summary-endpoint, of 0 in state A
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadedStoryIdsByPbi: { [pbiId]: string[] } // alleen voor stories die al geladen zijn
|
||||||
|
storiesByPbi: { [pbiId]: Story[] | undefined }
|
||||||
|
tasksByStory: { [storyId]: Task[] | undefined }
|
||||||
|
sprintMembershipPending: { adds: string[], removes: string[] }
|
||||||
|
crossSprintBlocks: { [storyId]: { sprintId: string, sprintName: string } } // lazy
|
||||||
|
|
||||||
|
// Selectors (afgeleid, gememoized):
|
||||||
|
selectPbiTriState(pbiId): 'empty' | 'partial' | 'full'
|
||||||
|
selectStoryEffectiveInSprint(storyId): boolean
|
||||||
|
selectStoryIsBlocked(storyId): { sprintId, sprintName } | null
|
||||||
|
```
|
||||||
|
|
||||||
|
`selectPbiTriState` rekent met `inActiveSprintStoryCount` + pending adds/removes voor stories van deze PBI (waarvan we de mapping kennen via `loadedStoryIdsByPbi` of via een lichte query bij PBI-load). Als de PBI niet geladen is, kan tri-state worden afgeleid uit de counts alleen (full = count==total, empty = count==0, partial = anders).
|
||||||
|
|
||||||
|
### Sprint opslaan
|
||||||
|
|
||||||
|
Server action `commitSprintMembershipAction(activeSprintId, adds[], removes[])`:
|
||||||
|
1. **Eligibility-filter voor `adds` (server, autoritatief):** behoud alleen stories met `sprint_id IS NULL` **en** `status != 'DONE'`. Niet-eligible stories (cross-sprint-conflict, of DONE) komen in `conflicts.notEligible[]`.
|
||||||
|
2. **`removes`-filter:** behoud alleen stories die feitelijk `sprint_id = activeSprintId` hebben (race-safety; story kan ondertussen al ergens anders heen verplaatst zijn).
|
||||||
|
3. Transactie:
|
||||||
|
- **Add**: `story.sprint_id = activeSprintId, story.status = 'IN_SPRINT' WHERE id IN (eligibleAdds)`
|
||||||
|
- **Add**: `task.sprint_id = activeSprintId WHERE story_id IN (eligibleAdds)` (cascade, task.status onveranderd)
|
||||||
|
- **Remove**: `story.sprint_id = NULL, story.status = 'OPEN' WHERE id IN (validRemoves)`
|
||||||
|
- **Remove**: `task.sprint_id = NULL WHERE story_id IN (validRemoves)` (cascade)
|
||||||
|
4. Realtime-events broadcasten
|
||||||
|
5. **Return:** `{ affectedStoryIds, affectedPbiIds, affectedTaskIds, conflicts: { notEligible, alreadyRemoved } }`
|
||||||
|
6. Client patcht store gericht:
|
||||||
|
- Update `story.sprint_id` + `story.status` voor affected stories in `storiesById` / `storiesByPbi`
|
||||||
|
- Update `task.sprint_id` voor affected tasks
|
||||||
|
- Debounced refetch van `sprint-membership-summary` voor affected PBI's (**gescoped op `pbiIds=affectedPbiIds`**)
|
||||||
|
- Wis pending buffer
|
||||||
|
- Toast voor conflicts
|
||||||
|
- **Geen `router.refresh()`.**
|
||||||
|
|
||||||
|
### Andere mutaties in state B
|
||||||
|
|
||||||
|
- **Story aanmaken** (StoryDialog): `sprint_id = activeSprintId` direct bij create. Verschijnt direct in sprint.
|
||||||
|
- **PBI/Story/Task field-edit** (bestaande Entity Dialogs): onveranderd.
|
||||||
|
- **Sprint-switcher wisselt bij dirty**: confirm-dialog.
|
||||||
|
- **Wegnavigeren met dirty**: `useDirtyCloseGuard` → confirm-dialog.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cross-sprint conflict — afhandeling
|
||||||
|
|
||||||
|
**Client (hint-laag):** lazy fetch `GET /api/products/[id]/cross-sprint-blocks?excludeSprintId=X` bij state-B-load. Vult `crossSprintBlocks` in de store. Story-rij met `crossSprintBlocks[storyId] != null` → vinkje disabled, tooltip "Zit in Sprint [naam]".
|
||||||
|
|
||||||
|
**Server (autoritatieve check):** in `commitSprintMembershipAction` en `createSprintWithSelectionAction` opnieuw checken — race-conditie wordt afgevangen, conflicts worden geretourneerd als warning. Client toont toast voor geskippte stories.
|
||||||
|
|
||||||
|
Helper `lib/sprint-conflicts.ts` (nieuw) doet de check op een set story-IDs en geeft `{ allowed: string[], blocked: { storyId, sprintId, sprintName }[] }`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SprintEditDialog (nieuw)
|
||||||
|
|
||||||
|
`components/backlog/sprint-edit-dialog.tsx` — Entity-Dialog-pattern:
|
||||||
|
- Velden: `sprint_goal`, `start_at`, `end_at`
|
||||||
|
- Knop "Opslaan" → `updateSprintAction(sprintId, fields)`
|
||||||
|
- Link "Sprint afronden…" → navigeert naar `/products/[id]/sprint/[sprintId]` (bestaande sprint-page met `completeSprintAction`)
|
||||||
|
- **Geen** "Sprint afsluiten"-knop hier — hergebruik bestaande completion-flow met per-story DONE/OPEN beslissing en PBI-promotie.
|
||||||
|
|
||||||
|
Server action `updateSprintAction(sprintId, { goal?, start_at?, end_at? })`: validate met Zod, update Sprint-record, `revalidatePath('/products/[id]')`, retourneert affected sprint. Client patcht sprint-record in store.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dataflow
|
||||||
|
|
||||||
|
### Uitgangspunten
|
||||||
|
|
||||||
|
- **Blijf bij route handlers + `cache: 'no-store'`** (huidige patroon). Geen `'use cache'`/`cacheTag` in deze migratie — review's P2 zegt: meng deze stijlen niet half. Migratie naar Cache Components is een eigen project.
|
||||||
|
- **Filter-first respecteren**: initial render levert alleen *matching* PBI-metadata; *remaining* op de achtergrond — beide via bestaande [getProductBacklogPbis](lib/product-backlog-pbis.ts).
|
||||||
|
- **Geen aggregaten in initial query**: dat zou bij groei alsnog brede story-aggregaties bij elke render forceren.
|
||||||
|
- **Counts apart via lazy endpoint**: alleen voor state B, alleen voor zichtbare PBI's (of bulk per sprint — beheerbaar omdat #PBI's per product bescheiden blijft).
|
||||||
|
- **Geen brede `getStoryIdsByPbi`**: hergebruik bestaande `ensurePbiLoaded`/`ensureStoryLoaded` lazy-loads. Tri-state werkt op counts (uit summary-endpoint) zolang de PBI dichtgeklapt is; pas bij open-klik komen story-IDs in beeld voor accurate selector-state.
|
||||||
|
- **Sync-model**: SSE-patches (al aanwezig) voor reactieve updates + `revalidatePath` na server-actions (huidige patroon) + gerichte client-store patches met de affected-IDs uit action-returns.
|
||||||
|
|
||||||
|
### Initial server-side load (page render)
|
||||||
|
|
||||||
|
Onveranderd t.o.v. huidige flow — geen nieuwe loader:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/(app)/products/[id]/page.tsx (huidige code, behouden):
|
||||||
|
const initialPbiQuery = productBacklogPbiQueryFromSettings(...)
|
||||||
|
const pbis = await getProductBacklogPbis(id, initialPbiQuery, 'matching')
|
||||||
|
// Geen stories, geen taken in initial render.
|
||||||
|
```
|
||||||
|
|
||||||
|
Plus parallel:
|
||||||
|
- `activeSprint = resolveActiveSprint(productId, userId)` — gewijzigd om explicit `null` te respecteren (zie hieronder).
|
||||||
|
- `pendingSprintDraft = getUserSettings(userId).pendingSprintDraft?.[productId] ?? null`.
|
||||||
|
|
||||||
|
### Background remaining-load
|
||||||
|
|
||||||
|
Bestaande route handler `GET /api/products/[id]/backlog?mode=remaining` blijft. Client triggert na initial render om de overige PBI-metadata in de store te krijgen (zonder stories/tasks).
|
||||||
|
|
||||||
|
### Lazy per PBI-klik
|
||||||
|
|
||||||
|
Bestaande `ensurePbiLoaded(pbiId)` in [stores/product-workspace/store.ts](stores/product-workspace/store.ts) blijft. Fetch via route handler met `cache: 'no-store'`. Vult `storiesByPbi[pbiId]` + `loadedStoryIdsByPbi[pbiId]`.
|
||||||
|
|
||||||
|
### Lazy per story-klik
|
||||||
|
|
||||||
|
Bestaande `ensureStoryLoaded(storyId)` blijft (laadt taken).
|
||||||
|
|
||||||
|
### Sprint-membership summary (NIEUW — alleen state B, gescoped)
|
||||||
|
|
||||||
|
Nieuw route handler `GET /api/products/[id]/sprint-membership-summary?sprintId=X&pbiIds=<comma-separated>`:
|
||||||
|
```ts
|
||||||
|
// Response:
|
||||||
|
{
|
||||||
|
[pbiId: string]: { total: number, inSprint: number }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`pbiIds` is verplicht** — endpoint weigert product-brede aanroepen. Client geeft alleen visible/loaded PBI-IDs door.
|
||||||
|
- Eén `groupBy` op `Story` waar `pbi_id IN (pbiIds)` (matching-filter werkt nog: we vragen alleen counts voor PBI's die al in viewport-batch staan).
|
||||||
|
- Verwaarloosbare belasting omdat de query begrensd is op de doorgegeven set.
|
||||||
|
|
||||||
|
Aangeroepen door client wanneer state B actief wordt OF na sprint-switch, OF na een commit (gescoped op affected pbi-ids). Vult `pbiSummary` in de store.
|
||||||
|
|
||||||
|
In state A wordt **niet** aangeroepen.
|
||||||
|
|
||||||
|
### Cross-sprint blocks (NIEUW — alleen state B, gescoped)
|
||||||
|
|
||||||
|
Nieuw route handler `GET /api/products/[id]/cross-sprint-blocks?excludeSprintId=X&pbiIds=<comma-separated>`:
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
[storyId: string]: { sprintId: string, sprintName: string }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`pbiIds` verplicht** — endpoint weigert product-brede scans. Begrenzing op visible/loaded batch.
|
||||||
|
- Aangeroepen bij state B-load + na elke PBI-batch-load (zodat nieuwe PBI's hun blocks krijgen).
|
||||||
|
- Vult `crossSprintBlocks` in de store voor disabled-vinkjes.
|
||||||
|
- Server-side check bij commit blijft autoritatief — dit endpoint is alleen UX-hint.
|
||||||
|
|
||||||
|
### Active-sprint resolver (gewijzigd)
|
||||||
|
|
||||||
|
**Schema-contract (cruciaal, zit in [lib/user-settings.ts](lib/user-settings.ts)):**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Zod schema wijziging:
|
||||||
|
activeSprints: z.record(z.string(), z.string().nullable()).optional()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Drie distincte states per `productId`:**
|
||||||
|
|
||||||
|
| Settings-staat | Betekenis |
|
||||||
|
|---|---|
|
||||||
|
| Key ontbreekt | Geen voorkeur ingesteld — fallback-cascade actief (eerste OPEN, dan recent CLOSED, dan `null`) |
|
||||||
|
| Key bestaat met `string` | Die specifieke sprint is gekozen (mits gevonden in DB; anders fallback) |
|
||||||
|
| Key bestaat met `null` | **Bewust geen actieve sprint** — geen fallback, blijft "Geen actieve sprint" |
|
||||||
|
|
||||||
|
**Wijzigingen in [lib/active-sprint.ts](lib/active-sprint.ts):**
|
||||||
|
- `resolveActiveSprint(productId, userId)` checkt `key in activeSprints` (niet alleen truthy):
|
||||||
|
- Key niet aanwezig → fallback-cascade
|
||||||
|
- Key aanwezig, value=null → return null
|
||||||
|
- Key aanwezig, value=string → die sprint
|
||||||
|
- `setActiveSprintInSettings(productId, sprintId)` ongewijzigd (schrijft string).
|
||||||
|
- **`clearActiveSprintInSettings(productId)` wordt aangepast**: i.p.v. de key te `delete`, schrijft het nu `null`. Dat is het verschil tussen "geen voorkeur" en "expliciet geen actieve sprint".
|
||||||
|
|
||||||
|
**[actions/active-sprint.ts](actions/active-sprint.ts):**
|
||||||
|
- Nieuw: `clearActiveSprintAction(productId)` — gebruikt de aangepaste `clearActiveSprintInSettings` (schrijft null).
|
||||||
|
- Bestaande `setActiveSprintAction` ongewijzigd.
|
||||||
|
|
||||||
|
### Sync na commit — gerichte client-store patches
|
||||||
|
|
||||||
|
Server actions retourneren expliciet affected IDs:
|
||||||
|
```ts
|
||||||
|
return { affectedStoryIds, affectedPbiIds, affectedTaskIds, conflicts }
|
||||||
|
```
|
||||||
|
|
||||||
|
Client (na await):
|
||||||
|
1. Patch `storiesById` + `tasksById` met nieuwe `sprint_id`-waarden.
|
||||||
|
2. Voor elke `affectedPbiId`: fire-and-forget refetch van `sprint-membership-summary` (debounced 100ms) om counts te actualiseren.
|
||||||
|
3. Wis pending buffer.
|
||||||
|
4. **Geen `router.refresh()`.**
|
||||||
|
|
||||||
|
`revalidatePath` blijft in de server-actie voor andere users / lossely-coupled views, maar de huidige user's UI updateert via de gerichte patches.
|
||||||
|
|
||||||
|
### Data-load-volgorde overzicht
|
||||||
|
|
||||||
|
| Moment | Wat | Wie |
|
||||||
|
|---|---|---|
|
||||||
|
| Page render | Matching PBI's (metadata) + activeSprint + draft | Server (SSR) — bestaande flow |
|
||||||
|
| Na hydratie | Remaining PBI's (metadata) | Client → bestaande `/api/.../backlog?mode=remaining` |
|
||||||
|
| State B activeert | Sprint-membership-summary + cross-sprint-blocks | Client → nieuwe endpoints |
|
||||||
|
| PBI-klik | Stories voor die PBI (full) | Client → bestaande `ensurePbiLoaded` |
|
||||||
|
| Story-klik | Taken voor die story | Client → bestaande `ensureStoryLoaded` |
|
||||||
|
| A→A′ start | Geen extra fetch — werk met `pendingSprintDraft` (compact) | |
|
||||||
|
| A′ stories cherrypicken | Klik PBI → bestaande lazy-load voor die PBI | |
|
||||||
|
| Sprint-switch | Refetch membership-summary + cross-sprint-blocks voor nieuwe sprint | Client |
|
||||||
|
| SSE event | Patch lokale store | Client |
|
||||||
|
| Na server-action commit | Affected IDs uit return → gerichte store-patches + debounced summary-refetch | Client |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical files
|
||||||
|
|
||||||
|
### Te wijzigen
|
||||||
|
|
||||||
|
- [app/(app)/products/[id]/page.tsx](app/(app)/products/[id]/page.tsx) — state-detectie (A/A′/B); banner-rendering; "Nieuwe sprint"-knop opent metadata-modal (i.p.v. direct `NewSprintDialog`). **Initial query blijft `getProductBacklogPbis(id, query, 'matching')`** — geen counts hier.
|
||||||
|
- [components/backlog/pbi-list.tsx](components/backlog/pbi-list.tsx) — bestaande `selectionMode` ombouwen tot A′-modus: vinkjes worden tri-state, lezen uit `pendingSprintDraft.pbiIntent` of (in state B) uit `selectPbiTriState`-selector. Verwijder de directe `NewSprintDialog`-trigger.
|
||||||
|
- [components/backlog/story-panel.tsx](components/backlog/story-panel.tsx) — vinkje per story; lees uit selectors (`selectStoryEffectiveInSprint`, `selectStoryIsBlocked`); klik muteert `pendingSprintDraft.storyOverrides` of `sprintMembershipPending`.
|
||||||
|
- [components/backlog/task-panel.tsx](components/backlog/task-panel.tsx) — geen wijzigingen aan task-flow.
|
||||||
|
- [components/shared/sprint-switcher.tsx](components/shared/sprint-switcher.tsx) — "— Geen actieve sprint —"-optie; dirty-check bij wissel.
|
||||||
|
- [stores/product-workspace/store.ts](stores/product-workspace/store.ts) — uitbreidingen: `pbiSummary`, `loadedStoryIdsByPbi`, `crossSprintBlocks`, `sprintMembershipPending` (arrays), selectors voor tri-state, gerichte patch-helpers voor server-action-returns.
|
||||||
|
- [stores/user-settings/store.ts](stores/user-settings/store.ts) — `pendingSprintDraft[productId]: { goal, startAt?, endAt?, pbiIntent, storyOverrides: { [pbiId]: { add, remove } } } | null`; `activeSprints[productId]: string | null` (zie ook user-settings.ts hieronder).
|
||||||
|
- **[lib/user-settings.ts](lib/user-settings.ts)** — Zod-schema strictness: `activeSprints` value nullable; `pendingSprintDraft` als optionele key per productId met de hier-gespecificeerde shape; migratie-tests aanpassen.
|
||||||
|
- [actions/sprints.ts](actions/sprints.ts):
|
||||||
|
- `createSprintAction` — drop OPEN-uniqueness-check (multi-OPEN toegestaan)
|
||||||
|
- **`createSprintWithPbisAction` → uitbreiden naar `createSprintWithSelectionAction(productId, metadata, pbiIntent, storyOverrides)`**. Server resolveert intent → concrete story-IDs. Returnt affected IDs.
|
||||||
|
- Nieuw: `commitSprintMembershipAction(sprintId, adds[], removes[])` — transactional, retourneert affected + conflicts.
|
||||||
|
- Nieuw: `updateSprintAction(sprintId, { goal?, startAt?, endAt? })` — alleen metadata.
|
||||||
|
- **GEEN** nieuwe `closeSprintAction` — `completeSprintAction` blijft de afrond-flow.
|
||||||
|
- [actions/active-sprint.ts](actions/active-sprint.ts) — nieuwe `clearActiveSprintAction(productId)` (schrijft null). `setActiveSprintAction` ongewijzigd voor non-null.
|
||||||
|
- [lib/active-sprint.ts](lib/active-sprint.ts) — `resolveActiveSprint` checkt key-aanwezigheid (niet truthy): key+null → return null zonder fallback; key+string → sprint; key ontbreekt → fallback-cascade. **`clearActiveSprintInSettings` schrijft nu `null` i.p.v. key te verwijderen** (essentieel voor het null-contract).
|
||||||
|
|
||||||
|
### Nieuw
|
||||||
|
|
||||||
|
- `app/api/products/[id]/sprint-membership-summary/route.ts` — lazy counts endpoint
|
||||||
|
- `app/api/products/[id]/cross-sprint-blocks/route.ts` — lazy cross-sprint hint endpoint
|
||||||
|
- `components/backlog/sprint-definition-banner.tsx` — sticky banner voor A′
|
||||||
|
- `components/backlog/new-sprint-metadata-dialog.tsx` — stap 1 van A′
|
||||||
|
- `components/backlog/sprint-edit-dialog.tsx` — metadata-edit in B
|
||||||
|
- `lib/sprint-conflicts.ts` — cross-sprint check helpers
|
||||||
|
- `actions/sprint-draft.ts` — `setPendingSprintDraftAction`, `clearPendingSprintDraftAction`
|
||||||
|
|
||||||
|
### Niet aangeraakt
|
||||||
|
|
||||||
|
- [prisma/schema.prisma](prisma/schema.prisma) — geen schemawijziging
|
||||||
|
- Bestaande `completeSprintAction` en de sprint-pagina `/products/[id]/sprint/[sprintId]` — sprint-afronding-flow blijft daar
|
||||||
|
- [components/backlog/task-panel.tsx](components/backlog/task-panel.tsx), task-dialog, pbi-dialog, story-dialog — Entity Dialogs onveranderd
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hergebruik bestaande patronen
|
||||||
|
|
||||||
|
- **Entity-Dialog-pattern**: metadata-modal + sprint-edit-dialog
|
||||||
|
- **useDirtyCloseGuard**: A′-annulering, B-navigatie
|
||||||
|
- **Zustand optimistic pattern**: pending buffer + gerichte server-action-return-patches
|
||||||
|
- **Realtime NOTIFY-payload**: sprint-membership events
|
||||||
|
- **Server-action-pattern**: auth + Zod
|
||||||
|
- **Filter-first/background-remaining**: blijft via [getProductBacklogPbis](lib/product-backlog-pbis.ts) en bestaande `/api/products/[id]/backlog?mode=X` route handler
|
||||||
|
- **MD3-tokens + shadcn `<Checkbox>`** (tri-state via custom mapping)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verificatie
|
||||||
|
|
||||||
|
### End-to-end checks (handmatig + dev-server)
|
||||||
|
|
||||||
|
1. **State A pad**: zonder actieve sprint → geen vinkjes, switcher toont "Geen actieve sprint", klik PBI → stories tonen, klik story → taken tonen, Entity-Dialog edits direct gecommit.
|
||||||
|
|
||||||
|
2. **A → A′ → B happy path**: "Nieuwe sprint" → metadata-modal → "Verder" → banner verschijnt, vinkjes verschijnen op PBI's. Vink 2 PBI's met 5 child-stories totaal → banner toont "2 PBI's, 5 stories". Open één PBI en deselecteer 1 story (storyOverride.remove). Banner: "2 PBI's, 4 stories". Klik "Sprint aanmaken" → sprint actief, state B met afgeleide vinkjes, **geen page refresh** (controle via DevTools Network: alleen affected updates).
|
||||||
|
|
||||||
|
3. **A′ persistente draft**: start A′, vink dingen aan, navigeer weg → confirm-dialog → bevestig. Kom terug op pagina → banner + vinkjes hersteld.
|
||||||
|
|
||||||
|
4. **State B pending buffer**: vink een story aan → "Sprint opslaan (1)". Vink een story in sprint weg → "Sprint opslaan (2)". Vink eerste weer uit → "Sprint opslaan (1)" (cancel-out). Klik opslaan → store-patches, geen full reload.
|
||||||
|
|
||||||
|
5. **Cross-sprint blokkade**: maak twee OPEN sprints, story X in sprint A. Switch naar sprint B → story X heeft disabled vinkje, tooltip "Zit in Sprint [A]". Verplaats story X via sprint A's sprint-page → cross-sprint-blocks updaten via SSE-patch.
|
||||||
|
|
||||||
|
6. **Sprint metadata-edit**: edit-icoon → SprintEditDialog → wijzig goal → opslaan → direct gecommit, geen page-state-wijziging.
|
||||||
|
|
||||||
|
7. **Sprint afronden**: SprintEditDialog toont link "Sprint afronden…" → navigeert naar `/products/[id]/sprint/[sprintId]` → bestaande completion-flow ongewijzigd.
|
||||||
|
|
||||||
|
8. **Switcher-wissel bij dirty**: state B met pending toggles → wissel sprint → confirm-dialog. Cancel → blijft, buffer intact. Bevestig → buffer leeg, switch.
|
||||||
|
|
||||||
|
9. **"Geen actieve sprint" persistentie**: kies "— Geen actieve sprint —" in switcher → schrijf null. Refresh pagina → blijft state A, valt **niet** terug op nieuwste OPEN sprint.
|
||||||
|
|
||||||
|
### Geautomatiseerde tests (Vitest)
|
||||||
|
|
||||||
|
- `lib/sprint-conflicts.test.ts`: vrij, in-zelfde-sprint, in-andere-OPEN, in-CLOSED (niet blokkerend voor commit-laag).
|
||||||
|
- `stores/product-workspace.test.ts`: pending buffer (arrays) toggle-cancel-out; tri-state-selector op verschillende load-staten (PBI niet geladen / geladen / met per-PBI overrides).
|
||||||
|
- `actions/sprints.test.ts`:
|
||||||
|
- `createSprintWithSelectionAction` resolve van per-PBI intent + per-PBI storyOverrides
|
||||||
|
- **Eligibility-filter**: stories met `status='DONE'` of `sprint_id != NULL` worden geweigerd en komen in `conflicts.notEligible`
|
||||||
|
- **Status-mutatie**: na add zijn betroffen stories `IN_SPRINT`; na remove zijn ze `OPEN`
|
||||||
|
- **Task.sprint_id in dezelfde transactie** — assert via mock prisma dat beide updates één tx delen
|
||||||
|
- Returns met `affectedStoryIds`, `affectedPbiIds`, `affectedTaskIds`, `conflicts`
|
||||||
|
- `actions/commit-sprint-membership.test.ts`:
|
||||||
|
- Race-conditie: story die ondertussen in andere sprint zit, eindigt in conflicts en wordt niet ge-update
|
||||||
|
- Removes met onverwachte sprint_id (al verwijderd) eindigen in `conflicts.alreadyRemoved`
|
||||||
|
- `lib/active-sprint.test.ts`:
|
||||||
|
- Key+null → return null (geen fallback)
|
||||||
|
- Key+string → die sprint (mits gevonden)
|
||||||
|
- Key ontbreekt → fallback-cascade actief
|
||||||
|
- `lib/user-settings.test.ts`:
|
||||||
|
- Zod-schema accepteert nullable values in `activeSprints`
|
||||||
|
- `pendingSprintDraft` met per-PBI overrides round-trippt
|
||||||
|
- `actions/active-sprint.test.ts`:
|
||||||
|
- `clearActiveSprintAction` schrijft `null`, **delete niet** de key — assert dat key blijft bestaan met null-value
|
||||||
|
- Endpoint-tests voor de twee nieuwe route handlers:
|
||||||
|
- `sprint-membership-summary` zonder `pbiIds`-param → 400
|
||||||
|
- `cross-sprint-blocks` zonder `pbiIds`-param → 400
|
||||||
|
- **Initial render doet géén story/task query** — assert via mock dat alleen `getProductBacklogPbis(_, _, 'matching')` is aangeroepen
|
||||||
|
- **A′ start doet géén brede story-ID query** — assert dat geen call met product-wide scope uitgaat; per-PBI overrides cleanup werkt zonder fetch
|
||||||
|
|
||||||
|
### Code-validatie
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run verify && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reactie op review
|
||||||
|
|
||||||
|
### Eerste review
|
||||||
|
|
||||||
|
| Review-punt | Hoe geadresseerd |
|
||||||
|
|---|---|
|
||||||
|
| **P1 — Initial summary kan te zwaar worden** | Geen counts in initial render. Bestaande `getProductBacklogPbis(_, _, 'matching')` blijft. Counts apart via lazy summary-endpoint, alleen in state B, gescoped op `pbiIds`. |
|
||||||
|
| **P1 — `getStoryIdsByPbi(productId)` breekt lazy-loading** | Verwijderd. Hergebruik `ensurePbiLoaded` lazy per PBI. Pending draft-state is compact (per-PBI `pbiIntent` + per-PBI `storyOverrides`), niet alle story-IDs. |
|
||||||
|
| **P1 — "Page herhydrateert" introduceert dure refresh** | Server actions retourneren `affectedStoryIds`/`affectedPbiIds`/`affectedTaskIds`. Client patcht workspace-store gericht. Geen `router.refresh()`. |
|
||||||
|
| **P1 — `Sprint afsluiten` mag completion-semantiek niet overslaan** | `closeSprintAction` geschrapt. SprintEditDialog doet alleen metadata. Sprint-afronden gaat via bestaande `completeSprintAction` op sprint-page; SprintEditDialog krijgt link daarheen. |
|
||||||
|
| **P2 — "Geen actieve sprint"-contract** | Schema nullable: `activeSprints[productId]: string \| null`. Sleutel-aanwezigheid heeft betekenis (key ontbreekt = fallback; key=null = bewust geen). `clearActiveSprintInSettings` schrijft null. |
|
||||||
|
| **P2 — Cache Components vs huidige stijl** | Beslist: blijven bij route handlers + `cache: 'no-store'` + `revalidatePath`. Géén `'use cache'`/`cacheTag` in dit plan. |
|
||||||
|
| **P2 — Bestaande PBI-selectieflow** | Ombouwen naar A′-mode. Eén flow, geen feature-flag-parallellisme. `createSprintWithPbisAction` wordt `createSprintWithSelectionAction`. |
|
||||||
|
| **P2 — Store moet primitives bewaren** | `pbiSummary` slaat alleen `totalStoryCount`/`inActiveSprintStoryCount` op. Tri-state is een selector. `sprintMembershipPending` gebruikt arrays, geen Sets. |
|
||||||
|
| **P2 — Filter-first/background-remaining ontbreekt** | Expliciet opgenomen: initial = matching, background = remaining via bestaand route-handler-patroon. |
|
||||||
|
| **Tests die review zou toevoegen** | Allemaal opgenomen in test-sectie hierboven. |
|
||||||
|
|
||||||
|
### Tweede review (deze ronde)
|
||||||
|
|
||||||
|
| Punt | Hoe geadresseerd |
|
||||||
|
|---|---|
|
||||||
|
| **P1 — `story.status` bij membership-mutaties** | Add: `sprint_id=X` **én** `status='IN_SPRINT'`. Remove: `sprint_id=NULL` **én** `status='OPEN'`. Task.sprint_id mee in **dezelfde transactie**. Expliciet in pseudocode van `commitSprintMembershipAction` en `createSprintWithSelectionAction`. |
|
||||||
|
| **P1 — Eligibility voor toevoegen** | Server-resolve filtert vóór mutatie: alleen stories met `sprint_id IS NULL` **en** `status != 'DONE'`. Niet-eligible → `conflicts.notEligible[]` in return, toast op client. Stories uit CLOSED/ARCHIVED/FAILED sprints met DONE-status zijn dus geblokkeerd. |
|
||||||
|
| **P1 — A′ draft-shape moet per-PBI** | `storyOverrides` herstructureerd naar `{ [pbiId]: { add, remove } }`. Cleanup bij PBI-toggle is lokaal; restore is deterministisch zonder brede story-fetch. |
|
||||||
|
| **P1 — Endpoint scoping** | `sprint-membership-summary` en `cross-sprint-blocks` vereisen verplichte `pbiIds`-query-parameter. Server weigert product-brede aanroepen. |
|
||||||
|
| **P2 — `lib/user-settings.ts` expliciet** | Opgenomen in critical files. Zod-schema wijzigt: `activeSprints` nullable; `pendingSprintDraft` als optionele key. |
|
||||||
|
| **P2 — `clearActiveSprintInSettings`-semantiek** | Schrijft nu `null` i.p.v. key te `delete`. Onderscheid: key ontbreekt = fallback; key=null = bewust geen actieve sprint. |
|
||||||
|
| **P2 — Context-tekst stale** | Context-sectie herschreven: lazy-load-basis bestaat al; dit plan bouwt erop voort. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Volgende stap (na goedkeuring)
|
||||||
|
|
||||||
|
Per project-memory: PBI + stories + taken aanmaken via Scrum4Me-MCP, daarna implementatieplan koppelen, taken pas uitvoeren op verzoek.
|
||||||
|
|
||||||
|
Werk-splitsing (laag-voor-laag, met dataflow eerst maar zonder onnodige eager loads):
|
||||||
|
|
||||||
|
1. **Story 1 — Active-sprint null-contract** + `clearActiveSprintAction` + `resolveActiveSprint`-aanpassing + sprint-switcher uitbreiding ("— Geen actieve sprint —"-optie)
|
||||||
|
2. **Story 2 — User-settings draft-slot** + `setPendingSprintDraftAction` / `clearPendingSprintDraftAction` (compacte intent-shape)
|
||||||
|
3. **Story 3 — Sprint-membership-summary endpoint** + `crossSprintBlocks` endpoint + store-uitbreidingen (`pbiSummary`, `loadedStoryIdsByPbi`, `crossSprintBlocks`)
|
||||||
|
4. **Story 4 — State B pending-buffer-slice** (arrays) + selectors voor tri-state + `selectStoryEffectiveInSprint` / `selectStoryIsBlocked`
|
||||||
|
5. **Story 5 — A′ UI** (metadata-modal + sticky banner) + ombouw `selectionMode` in `PbiList` + persistente draft-restore
|
||||||
|
6. **Story 6 — State B vinkjes-UI** (PBI tri-state, story binair, disabled-bij-conflict) + "Sprint opslaan"-knop met teller
|
||||||
|
7. **Story 7 — `createSprintWithSelectionAction`** (uitbreiding van bestaande `createSprintWithPbisAction`) + server-side intent-resolve + cross-sprint guard + return-affected-IDs
|
||||||
|
8. **Story 8 — `commitSprintMembershipAction`** + cross-sprint guard + gerichte client-store patches + SSE-broadcast
|
||||||
|
9. **Story 9 — SprintEditDialog** (metadata) + `updateSprintAction` + link naar afrondings-flow
|
||||||
|
10. **Story 10 — Multi-OPEN sprints** (drop uniqueness-check in `createSprintAction`)
|
||||||
|
11. **Story 11 — Verificatie + tests** (Vitest + handmatige checklist)
|
||||||
|
|
@ -40,12 +40,20 @@ async function notifyUserSettings(
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getActiveSprintIdFromSettings(
|
type StoredActiveSprintState =
|
||||||
userId: string,
|
| { kind: 'unset' }
|
||||||
|
| { kind: 'cleared' }
|
||||||
|
| { kind: 'set'; sprintId: string }
|
||||||
|
|
||||||
|
export function readStoredActiveSprintState(
|
||||||
|
settings: UserSettings,
|
||||||
productId: string,
|
productId: string,
|
||||||
): Promise<string | null> {
|
): StoredActiveSprintState {
|
||||||
const settings = await readSettings(userId)
|
const map = settings.layout?.activeSprints
|
||||||
return settings.layout?.activeSprints?.[productId] ?? null
|
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(
|
export async function setActiveSprintInSettings(
|
||||||
|
|
@ -71,10 +79,10 @@ export async function clearActiveSprintInSettings(
|
||||||
productId: string,
|
productId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const current = await readSettings(userId)
|
const current = await readSettings(userId)
|
||||||
const existing = current.layout?.activeSprints
|
const nextActiveSprints: Record<string, string | null> = {
|
||||||
if (!existing || !(productId in existing)) return
|
...(current.layout?.activeSprints ?? {}),
|
||||||
const nextActiveSprints = { ...existing }
|
[productId]: null,
|
||||||
delete nextActiveSprints[productId]
|
}
|
||||||
const next: UserSettings = {
|
const next: UserSettings = {
|
||||||
...current,
|
...current,
|
||||||
layout: { ...current.layout, activeSprints: nextActiveSprints },
|
layout: { ...current.layout, activeSprints: nextActiveSprints },
|
||||||
|
|
@ -85,14 +93,69 @@ export async function clearActiveSprintInSettings(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(
|
export async function resolveActiveSprint(
|
||||||
productId: string,
|
productId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<ActiveSprint | null> {
|
): Promise<ActiveSprint | null> {
|
||||||
const stored = await getActiveSprintIdFromSettings(userId, productId)
|
const settings = await readSettings(userId)
|
||||||
if (stored) {
|
const state = readStoredActiveSprintState(settings, productId)
|
||||||
|
|
||||||
|
if (state.kind === 'cleared') return null
|
||||||
|
|
||||||
|
if (state.kind === 'set') {
|
||||||
const sprint = await prisma.sprint.findFirst({
|
const sprint = await prisma.sprint.findFirst({
|
||||||
where: { id: stored, product_id: productId },
|
where: { id: state.sprintId, product_id: productId },
|
||||||
select: { id: true, code: true, status: true },
|
select: { id: true, code: true, status: true },
|
||||||
})
|
})
|
||||||
if (sprint) return sprint
|
if (sprint) return sprint
|
||||||
|
|
|
||||||
116
lib/sprint-conflicts.ts
Normal file
116
lib/sprint-conflicts.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import type { Prisma, PrismaClient, StoryStatus } from '@prisma/client'
|
||||||
|
|
||||||
|
export type EligibilityReason = 'DONE' | 'IN_OTHER_SPRINT'
|
||||||
|
|
||||||
|
export type CrossSprintBlock = {
|
||||||
|
storyId: string
|
||||||
|
sprintId: string
|
||||||
|
sprintName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EligibilityPartition = {
|
||||||
|
eligible: string[]
|
||||||
|
notEligible: { storyId: string; reason: EligibilityReason }[]
|
||||||
|
crossSprint: CrossSprintBlock[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type StoryEligibilityInput = {
|
||||||
|
sprint_id: string | null
|
||||||
|
status: StoryStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEligibleForSprint(story: StoryEligibilityInput): boolean {
|
||||||
|
return story.sprint_id === null && story.status !== 'DONE'
|
||||||
|
}
|
||||||
|
|
||||||
|
type PrismaLike = Pick<PrismaClient, 'story'> | Prisma.TransactionClient
|
||||||
|
|
||||||
|
export async function partitionByEligibility(
|
||||||
|
prisma: PrismaLike,
|
||||||
|
storyIds: string[],
|
||||||
|
excludeSprintId?: string,
|
||||||
|
): Promise<EligibilityPartition> {
|
||||||
|
if (storyIds.length === 0) {
|
||||||
|
return { eligible: [], notEligible: [], crossSprint: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const stories = await prisma.story.findMany({
|
||||||
|
where: { id: { in: storyIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
sprint_id: true,
|
||||||
|
status: true,
|
||||||
|
sprint: { select: { id: true, code: true, status: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const eligible: string[] = []
|
||||||
|
const notEligible: { storyId: string; reason: EligibilityReason }[] = []
|
||||||
|
const crossSprint: CrossSprintBlock[] = []
|
||||||
|
|
||||||
|
for (const story of stories) {
|
||||||
|
const inOtherSprint = story.sprint_id !== null && story.sprint_id !== excludeSprintId
|
||||||
|
const inSameSprint = excludeSprintId !== undefined && story.sprint_id === excludeSprintId
|
||||||
|
|
||||||
|
if (inOtherSprint) {
|
||||||
|
if (story.sprint && story.sprint.status === 'OPEN') {
|
||||||
|
crossSprint.push({
|
||||||
|
storyId: story.id,
|
||||||
|
sprintId: story.sprint.id,
|
||||||
|
sprintName: story.sprint.code,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
notEligible.push({ storyId: story.id, reason: 'IN_OTHER_SPRINT' })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (story.status === 'DONE') {
|
||||||
|
notEligible.push({ storyId: story.id, reason: 'DONE' })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inSameSprint) {
|
||||||
|
eligible.push(story.id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
eligible.push(story.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { eligible, notEligible, crossSprint }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBlockingSprintMap(
|
||||||
|
prisma: PrismaLike,
|
||||||
|
productId: string,
|
||||||
|
storyIds: string[],
|
||||||
|
excludeSprintId?: string,
|
||||||
|
): Promise<Map<string, { sprintId: string; sprintName: string }>> {
|
||||||
|
const out = new Map<string, { sprintId: string; sprintName: string }>()
|
||||||
|
if (storyIds.length === 0) return out
|
||||||
|
|
||||||
|
const stories = await prisma.story.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: storyIds },
|
||||||
|
product_id: productId,
|
||||||
|
sprint_id: { not: null },
|
||||||
|
sprint: { status: 'OPEN' },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
sprint_id: true,
|
||||||
|
sprint: { select: { id: true, code: true, status: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const story of stories) {
|
||||||
|
if (!story.sprint) continue
|
||||||
|
if (excludeSprintId !== undefined && story.sprint.id === excludeSprintId) continue
|
||||||
|
out.set(story.id, {
|
||||||
|
sprintId: story.sprint.id,
|
||||||
|
sprintName: story.sprint.code,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
@ -45,16 +45,41 @@ const DevToolsPrefs = z.object({
|
||||||
|
|
||||||
const LayoutPrefs = z.object({
|
const LayoutPrefs = z.object({
|
||||||
splitPanePositions: z.record(z.string(), z.array(z.number())).optional(),
|
splitPanePositions: z.record(z.string(), z.array(z.number())).optional(),
|
||||||
activeSprints: z.record(z.string(), z.string()).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()
|
}).strict()
|
||||||
|
|
||||||
export const UserSettingsSchema = z.object({
|
export const UserSettingsSchema = z.object({
|
||||||
views: ViewsPrefs.optional(),
|
views: ViewsPrefs.optional(),
|
||||||
devTools: DevToolsPrefs.optional(),
|
devTools: DevToolsPrefs.optional(),
|
||||||
layout: LayoutPrefs.optional(),
|
layout: LayoutPrefs.optional(),
|
||||||
|
workflow: WorkflowPrefs.optional(),
|
||||||
}).strict()
|
}).strict()
|
||||||
|
|
||||||
export type UserSettings = z.infer<typeof UserSettingsSchema>
|
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 = {}
|
export const DEFAULT_USER_SETTINGS: UserSettings = {}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
import type { ProductWorkspaceStore } from './store'
|
import type { ProductWorkspaceStore } from './store'
|
||||||
import type { BacklogPbi, BacklogStory, BacklogTask, TaskDetail } from './types'
|
import type {
|
||||||
|
BacklogPbi,
|
||||||
|
BacklogStory,
|
||||||
|
BacklogTask,
|
||||||
|
CrossSprintBlock,
|
||||||
|
TaskDetail,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
export type PbiTriState = 'empty' | 'partial' | 'full'
|
||||||
|
|
||||||
// G1: stable EMPTY-references zodat selectors geen nieuwe array per call retourneren.
|
// G1: stable EMPTY-references zodat selectors geen nieuwe array per call retourneren.
|
||||||
const EMPTY_PBIS: BacklogPbi[] = []
|
const EMPTY_PBIS: BacklogPbi[] = []
|
||||||
|
|
@ -100,3 +108,72 @@ export function selectStoriesForPbi(
|
||||||
}
|
}
|
||||||
return out.length === 0 ? EMPTY_STORIES : out
|
return out.length === 0 ? EMPTY_STORIES : out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PBI-79 / ST-1336 — sprint-membership selectors.
|
||||||
|
//
|
||||||
|
// Tri-state PBI-vinkje. Werkt op counts uit het summary-endpoint zolang
|
||||||
|
// de PBI dichtgeklapt is (relations.storyIdsByPbi leeg). Wanneer stories
|
||||||
|
// geladen zijn rekenen we ook de pending-buffer mee per-story.
|
||||||
|
export function selectPbiTriState(
|
||||||
|
s: ProductWorkspaceStore,
|
||||||
|
pbiId: string,
|
||||||
|
): PbiTriState {
|
||||||
|
const summary = s.sprintMembership.pbiSummary[pbiId]
|
||||||
|
if (!summary || summary.totalStoryCount === 0) return 'empty'
|
||||||
|
|
||||||
|
const storyIds = s.relations.storyIdsByPbi[pbiId]
|
||||||
|
let inSprintAfterPending = summary.inActiveSprintStoryCount
|
||||||
|
|
||||||
|
if (storyIds && storyIds.length > 0) {
|
||||||
|
const idSet = new Set(storyIds)
|
||||||
|
const adds = s.sprintMembership.pending.adds
|
||||||
|
const removes = s.sprintMembership.pending.removes
|
||||||
|
for (const id of adds) if (idSet.has(id)) inSprintAfterPending++
|
||||||
|
for (const id of removes) if (idSet.has(id)) inSprintAfterPending--
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inSprintAfterPending <= 0) return 'empty'
|
||||||
|
if (inSprintAfterPending >= summary.totalStoryCount) return 'full'
|
||||||
|
return 'partial'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effectief membership van een story rekening houdend met de pending buffer.
|
||||||
|
* `activeSprintId` is de gekozen sprint (state B); zonder die context valt de
|
||||||
|
* selector terug op de DB-waarde.
|
||||||
|
*/
|
||||||
|
export function selectStoryEffectiveInSprint(
|
||||||
|
s: ProductWorkspaceStore,
|
||||||
|
storyId: string,
|
||||||
|
activeSprintId: string | null,
|
||||||
|
): boolean {
|
||||||
|
const story = s.entities.storiesById[storyId]
|
||||||
|
const inSprintDb = story?.sprint_id === activeSprintId && activeSprintId !== null
|
||||||
|
const inAdds = s.sprintMembership.pending.adds.includes(storyId)
|
||||||
|
const inRemoves = s.sprintMembership.pending.removes.includes(storyId)
|
||||||
|
if (inAdds) return true
|
||||||
|
if (inRemoves) return false
|
||||||
|
return inSprintDb
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectStoryIsBlocked(
|
||||||
|
s: ProductWorkspaceStore,
|
||||||
|
storyId: string,
|
||||||
|
): CrossSprintBlock | null {
|
||||||
|
return s.sprintMembership.crossSprintBlocks[storyId] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectIsDirty(s: ProductWorkspaceStore): boolean {
|
||||||
|
return (
|
||||||
|
s.sprintMembership.pending.adds.length +
|
||||||
|
s.sprintMembership.pending.removes.length >
|
||||||
|
0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectPendingCount(s: ProductWorkspaceStore): number {
|
||||||
|
return (
|
||||||
|
s.sprintMembership.pending.adds.length +
|
||||||
|
s.sprintMembership.pending.removes.length
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,15 @@ import {
|
||||||
type BacklogPbi,
|
type BacklogPbi,
|
||||||
type BacklogStory,
|
type BacklogStory,
|
||||||
type BacklogTask,
|
type BacklogTask,
|
||||||
|
type CrossSprintBlock,
|
||||||
type OptimisticMutation,
|
type OptimisticMutation,
|
||||||
|
type PbiSummaryEntry,
|
||||||
type PendingOptimisticMutation,
|
type PendingOptimisticMutation,
|
||||||
type ProductBacklogSnapshot,
|
type ProductBacklogSnapshot,
|
||||||
type ProductRealtimeEvent,
|
type ProductRealtimeEvent,
|
||||||
type RealtimeStatus,
|
type RealtimeStatus,
|
||||||
type ResyncReason,
|
type ResyncReason,
|
||||||
|
type SprintMembershipSlice,
|
||||||
type TaskDetail,
|
type TaskDetail,
|
||||||
} from './types'
|
} from './types'
|
||||||
import {
|
import {
|
||||||
|
|
@ -73,6 +76,7 @@ interface State {
|
||||||
loading: LoadingSlice
|
loading: LoadingSlice
|
||||||
sync: SyncSlice
|
sync: SyncSlice
|
||||||
pendingMutations: Record<string, PendingOptimisticMutation>
|
pendingMutations: Record<string, PendingOptimisticMutation>
|
||||||
|
sprintMembership: SprintMembershipSlice
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Actions {
|
interface Actions {
|
||||||
|
|
@ -100,6 +104,31 @@ interface Actions {
|
||||||
settleMutation(mutationId: string): void
|
settleMutation(mutationId: string): void
|
||||||
|
|
||||||
setRealtimeStatus(status: RealtimeStatus): void
|
setRealtimeStatus(status: RealtimeStatus): void
|
||||||
|
|
||||||
|
// PBI-79 / ST-1336: sprint-membership acties.
|
||||||
|
setPbiSummary(summary: Record<string, PbiSummaryEntry>): void
|
||||||
|
setCrossSprintBlocks(blocks: Record<string, CrossSprintBlock>): void
|
||||||
|
toggleStorySprintMembership(storyId: string, currentlyInSprint: boolean): void
|
||||||
|
resetSprintMembershipPending(): void
|
||||||
|
fetchSprintMembershipSummary(
|
||||||
|
productId: string,
|
||||||
|
sprintId: string,
|
||||||
|
pbiIds: string[],
|
||||||
|
): Promise<void>
|
||||||
|
fetchCrossSprintBlocks(
|
||||||
|
productId: string,
|
||||||
|
excludeSprintId: string | null,
|
||||||
|
pbiIds: string[],
|
||||||
|
): Promise<void>
|
||||||
|
|
||||||
|
// PBI-79 / ST-1340: gericht patchen na server-action commit. Tasks in
|
||||||
|
// de client-store hebben geen sprint_id-veld dus alleen story-records
|
||||||
|
// worden gemuteerd.
|
||||||
|
applyMembershipCommitResult(input: {
|
||||||
|
activeSprintId: string
|
||||||
|
addedStoryIds: string[]
|
||||||
|
removedStoryIds: string[]
|
||||||
|
}): void
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProductWorkspaceStore = State & Actions
|
export type ProductWorkspaceStore = State & Actions
|
||||||
|
|
@ -136,6 +165,12 @@ const initialState: State = {
|
||||||
resyncReason: null,
|
resyncReason: null,
|
||||||
},
|
},
|
||||||
pendingMutations: {},
|
pendingMutations: {},
|
||||||
|
sprintMembership: {
|
||||||
|
pbiSummary: {},
|
||||||
|
crossSprintBlocks: {},
|
||||||
|
pending: { adds: [], removes: [] },
|
||||||
|
loadedSummaryForSprintId: null,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
function comparePbi(a: BacklogPbi, b: BacklogPbi): number {
|
function comparePbi(a: BacklogPbi, b: BacklogPbi): number {
|
||||||
|
|
@ -194,6 +229,12 @@ export const useProductWorkspaceStore = create<ProductWorkspaceStore>()(
|
||||||
s.entities.storiesById = {}
|
s.entities.storiesById = {}
|
||||||
s.entities.tasksById = {}
|
s.entities.tasksById = {}
|
||||||
s.relations.pbiIds = []
|
s.relations.pbiIds = []
|
||||||
|
s.sprintMembership = {
|
||||||
|
pbiSummary: {},
|
||||||
|
crossSprintBlocks: {},
|
||||||
|
pending: { adds: [], removes: [] },
|
||||||
|
loadedSummaryForSprintId: null,
|
||||||
|
}
|
||||||
s.relations.storyIdsByPbi = {}
|
s.relations.storyIdsByPbi = {}
|
||||||
s.relations.taskIdsByStory = {}
|
s.relations.taskIdsByStory = {}
|
||||||
|
|
||||||
|
|
@ -293,11 +334,16 @@ export const useProductWorkspaceStore = create<ProductWorkspaceStore>()(
|
||||||
await get().ensurePbiLoaded(pbiId, requestId)
|
await get().ensurePbiLoaded(pbiId, requestId)
|
||||||
if (get().loading.activeRequestId !== requestId) return
|
if (get().loading.activeRequestId !== requestId) return
|
||||||
if (!productId) return
|
if (!productId) return
|
||||||
// T-857: cascade-restore
|
// T-857: cascade-restore. Alleen herstellen als de hint-story
|
||||||
|
// bij de nieuw-geselecteerde PBI hoort — anders blijft een task-
|
||||||
|
// selectie van een vorige PBI hangen (PBI-79 bugfix).
|
||||||
const hint = readHints().perProduct[productId]?.lastActiveStoryId
|
const hint = readHints().perProduct[productId]?.lastActiveStoryId
|
||||||
if (hint && get().entities.storiesById[hint]) {
|
if (hint) {
|
||||||
|
const hintStory = get().entities.storiesById[hint]
|
||||||
|
if (hintStory && hintStory.pbi_id === pbiId) {
|
||||||
get().setActiveStory(hint)
|
get().setActiveStory(hint)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -566,6 +612,102 @@ export const useProductWorkspaceStore = create<ProductWorkspaceStore>()(
|
||||||
s.sync.realtimeStatus = status
|
s.sync.realtimeStatus = status
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setPbiSummary(summary) {
|
||||||
|
set((s) => {
|
||||||
|
s.sprintMembership.pbiSummary = summary
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
setCrossSprintBlocks(blocks) {
|
||||||
|
set((s) => {
|
||||||
|
s.sprintMembership.crossSprintBlocks = blocks
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleStorySprintMembership(storyId, currentlyInSprint) {
|
||||||
|
set((s) => {
|
||||||
|
const pending = s.sprintMembership.pending
|
||||||
|
if (currentlyInSprint) {
|
||||||
|
const inRemoves = pending.removes.indexOf(storyId)
|
||||||
|
if (inRemoves >= 0) {
|
||||||
|
pending.removes.splice(inRemoves, 1)
|
||||||
|
} else {
|
||||||
|
const inAdds = pending.adds.indexOf(storyId)
|
||||||
|
if (inAdds >= 0) pending.adds.splice(inAdds, 1)
|
||||||
|
pending.removes.push(storyId)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const inAdds = pending.adds.indexOf(storyId)
|
||||||
|
if (inAdds >= 0) {
|
||||||
|
pending.adds.splice(inAdds, 1)
|
||||||
|
} else {
|
||||||
|
const inRemoves = pending.removes.indexOf(storyId)
|
||||||
|
if (inRemoves >= 0) pending.removes.splice(inRemoves, 1)
|
||||||
|
pending.adds.push(storyId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
resetSprintMembershipPending() {
|
||||||
|
set((s) => {
|
||||||
|
s.sprintMembership.pending = { adds: [], removes: [] }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchSprintMembershipSummary(productId, sprintId, pbiIds) {
|
||||||
|
if (pbiIds.length === 0) return
|
||||||
|
const url = `/api/products/${productId}/sprint-membership-summary?sprintId=${encodeURIComponent(sprintId)}&pbiIds=${pbiIds.map(encodeURIComponent).join(',')}`
|
||||||
|
const summary = await fetchJson<Record<string, PbiSummaryEntry>>(url)
|
||||||
|
set((s) => {
|
||||||
|
for (const [pbiId, entry] of Object.entries(summary)) {
|
||||||
|
s.sprintMembership.pbiSummary[pbiId] = entry
|
||||||
|
}
|
||||||
|
s.sprintMembership.loadedSummaryForSprintId = sprintId
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchCrossSprintBlocks(productId, excludeSprintId, pbiIds) {
|
||||||
|
if (pbiIds.length === 0) return
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (excludeSprintId) params.set('excludeSprintId', excludeSprintId)
|
||||||
|
params.set('pbiIds', pbiIds.join(','))
|
||||||
|
const url = `/api/products/${productId}/cross-sprint-blocks?${params.toString()}`
|
||||||
|
const blocks = await fetchJson<Record<string, CrossSprintBlock>>(url)
|
||||||
|
set((s) => {
|
||||||
|
for (const [storyId, info] of Object.entries(blocks)) {
|
||||||
|
s.sprintMembership.crossSprintBlocks[storyId] = info
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
applyMembershipCommitResult({
|
||||||
|
activeSprintId,
|
||||||
|
addedStoryIds,
|
||||||
|
removedStoryIds,
|
||||||
|
}) {
|
||||||
|
// Task-records in de client-store hebben geen sprint_id-veld (alleen
|
||||||
|
// story_id); de sprint-membership wordt afgeleid via story.sprint_id.
|
||||||
|
// Hier patchen we daarom alleen story-entities + de pending buffer.
|
||||||
|
set((s) => {
|
||||||
|
for (const id of addedStoryIds) {
|
||||||
|
const story = s.entities.storiesById[id]
|
||||||
|
if (story) {
|
||||||
|
story.sprint_id = activeSprintId
|
||||||
|
story.status = 'IN_SPRINT'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const id of removedStoryIds) {
|
||||||
|
const story = s.entities.storiesById[id]
|
||||||
|
if (story) {
|
||||||
|
story.sprint_id = null
|
||||||
|
story.status = 'OPEN'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.sprintMembership.pending = { adds: [], removes: [] }
|
||||||
|
})
|
||||||
|
},
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -138,3 +138,21 @@ export interface PendingOptimisticMutation {
|
||||||
mutation: OptimisticMutation
|
mutation: OptimisticMutation
|
||||||
createdAt: number
|
createdAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PBI-79 / ST-1336: sprint-membership state voor backlog-page.
|
||||||
|
export interface PbiSummaryEntry {
|
||||||
|
totalStoryCount: number
|
||||||
|
inActiveSprintStoryCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrossSprintBlock {
|
||||||
|
sprintId: string
|
||||||
|
sprintName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SprintMembershipSlice {
|
||||||
|
pbiSummary: Record<string, PbiSummaryEntry>
|
||||||
|
crossSprintBlocks: Record<string, CrossSprintBlock>
|
||||||
|
pending: { adds: string[]; removes: string[] }
|
||||||
|
loadedSummaryForSprintId: string | null
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import { immer } from 'zustand/middleware/immer'
|
||||||
import {
|
import {
|
||||||
DEFAULT_USER_SETTINGS,
|
DEFAULT_USER_SETTINGS,
|
||||||
mergeSettings,
|
mergeSettings,
|
||||||
|
type PbiIntent,
|
||||||
|
type PendingSprintDraft,
|
||||||
type UserSettings,
|
type UserSettings,
|
||||||
} from '@/lib/user-settings'
|
} from '@/lib/user-settings'
|
||||||
import { updateUserSettingsAction } from '@/actions/user-settings'
|
import { updateUserSettingsAction } from '@/actions/user-settings'
|
||||||
|
|
@ -28,6 +30,22 @@ interface UserSettingsActions {
|
||||||
hydrate: (initial: UserSettings, isDemo: boolean) => void
|
hydrate: (initial: UserSettings, isDemo: boolean) => void
|
||||||
setPref: (path: SettingsPath, value: unknown) => Promise<void>
|
setPref: (path: SettingsPath, value: unknown) => Promise<void>
|
||||||
applyServerPatch: (patch: Partial<UserSettings>) => void
|
applyServerPatch: (patch: Partial<UserSettings>) => void
|
||||||
|
setPendingSprintDraft: (
|
||||||
|
productId: string,
|
||||||
|
draft: PendingSprintDraft,
|
||||||
|
) => Promise<void>
|
||||||
|
clearPendingSprintDraft: (productId: string) => Promise<void>
|
||||||
|
upsertPbiIntent: (
|
||||||
|
productId: string,
|
||||||
|
pbiId: string,
|
||||||
|
intent: PbiIntent,
|
||||||
|
) => Promise<void>
|
||||||
|
upsertStoryOverride: (
|
||||||
|
productId: string,
|
||||||
|
pbiId: string,
|
||||||
|
storyId: string,
|
||||||
|
kind: 'add' | 'remove' | 'clear',
|
||||||
|
) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextMutationId = 1
|
let nextMutationId = 1
|
||||||
|
|
@ -58,7 +76,15 @@ export const useUserSettingsStore = create<UserSettingsState & UserSettingsActio
|
||||||
|
|
||||||
hydrate: (initial, isDemo) => {
|
hydrate: (initial, isDemo) => {
|
||||||
set((draft) => {
|
set((draft) => {
|
||||||
draft.entities.settings = initial as UserSettings
|
// PBI-79 scope-aanpassing: pendingSprintDraft is session-only;
|
||||||
|
// eventuele legacy DB-entries van vóór deze aanpassing worden bij
|
||||||
|
// hydratatie weggegooid zodat de draft niet 'spookt'.
|
||||||
|
const stripped: UserSettings = { ...initial }
|
||||||
|
if (stripped.workflow?.pendingSprintDraft) {
|
||||||
|
stripped.workflow = { ...stripped.workflow }
|
||||||
|
delete stripped.workflow.pendingSprintDraft
|
||||||
|
}
|
||||||
|
draft.entities.settings = stripped
|
||||||
draft.context.hydrated = true
|
draft.context.hydrated = true
|
||||||
draft.context.isDemo = isDemo
|
draft.context.isDemo = isDemo
|
||||||
})
|
})
|
||||||
|
|
@ -73,6 +99,79 @@ export const useUserSettingsStore = create<UserSettingsState & UserSettingsActio
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setPendingSprintDraft: async (productId, draft) => {
|
||||||
|
// PBI-79 scope-aanpassing: session-only. Geen server-roundtrip;
|
||||||
|
// de draft leeft uitsluitend in deze store-instantie en is bij
|
||||||
|
// page-refresh/leave weg (zie SprintDraftLeaveGuard voor de
|
||||||
|
// beforeunload-warning).
|
||||||
|
set((s) => {
|
||||||
|
if (!s.entities.settings.workflow) s.entities.settings.workflow = {}
|
||||||
|
if (!s.entities.settings.workflow.pendingSprintDraft) {
|
||||||
|
s.entities.settings.workflow.pendingSprintDraft = {}
|
||||||
|
}
|
||||||
|
s.entities.settings.workflow.pendingSprintDraft[productId] = draft
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
clearPendingSprintDraft: async (productId) => {
|
||||||
|
// PBI-79 scope-aanpassing: session-only — lokale delete is voldoende.
|
||||||
|
set((s) => {
|
||||||
|
const map = s.entities.settings.workflow?.pendingSprintDraft
|
||||||
|
if (map) delete map[productId]
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
upsertPbiIntent: async (productId, pbiId, intent) => {
|
||||||
|
const current =
|
||||||
|
get().entities.settings.workflow?.pendingSprintDraft?.[productId]
|
||||||
|
if (!current) return
|
||||||
|
const nextOverrides = { ...current.storyOverrides }
|
||||||
|
delete nextOverrides[pbiId]
|
||||||
|
const next: PendingSprintDraft = {
|
||||||
|
...current,
|
||||||
|
pbiIntent: { ...current.pbiIntent, [pbiId]: intent },
|
||||||
|
storyOverrides: nextOverrides,
|
||||||
|
}
|
||||||
|
await get().setPendingSprintDraft(productId, next)
|
||||||
|
},
|
||||||
|
|
||||||
|
upsertStoryOverride: async (productId, pbiId, storyId, kind) => {
|
||||||
|
const current =
|
||||||
|
get().entities.settings.workflow?.pendingSprintDraft?.[productId]
|
||||||
|
if (!current) return
|
||||||
|
const existing = current.storyOverrides[pbiId] ?? { add: [], remove: [] }
|
||||||
|
const dropFrom = (arr: string[]) => arr.filter((id) => id !== storyId)
|
||||||
|
let nextEntry: { add: string[]; remove: string[] }
|
||||||
|
switch (kind) {
|
||||||
|
case 'add':
|
||||||
|
nextEntry = {
|
||||||
|
add: existing.add.includes(storyId) ? existing.add : [...existing.add, storyId],
|
||||||
|
remove: dropFrom(existing.remove),
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'remove':
|
||||||
|
nextEntry = {
|
||||||
|
add: dropFrom(existing.add),
|
||||||
|
remove: existing.remove.includes(storyId)
|
||||||
|
? existing.remove
|
||||||
|
: [...existing.remove, storyId],
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'clear':
|
||||||
|
default:
|
||||||
|
nextEntry = { add: dropFrom(existing.add), remove: dropFrom(existing.remove) }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const nextOverrides = { ...current.storyOverrides }
|
||||||
|
if (nextEntry.add.length === 0 && nextEntry.remove.length === 0) {
|
||||||
|
delete nextOverrides[pbiId]
|
||||||
|
} else {
|
||||||
|
nextOverrides[pbiId] = nextEntry
|
||||||
|
}
|
||||||
|
const next: PendingSprintDraft = { ...current, storyOverrides: nextOverrides }
|
||||||
|
await get().setPendingSprintDraft(productId, next)
|
||||||
|
},
|
||||||
|
|
||||||
setPref: async (path, value) => {
|
setPref: async (path, value) => {
|
||||||
const patch = patchFromPath(path, value)
|
const patch = patchFromPath(path, value)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue