diff --git a/__tests__/components/backlog/new-sprint-trigger.test.tsx b/__tests__/components/backlog/new-sprint-trigger.test.tsx new file mode 100644 index 0000000..72c669e --- /dev/null +++ b/__tests__/components/backlog/new-sprint-trigger.test.tsx @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import type { ReactNode } from 'react' + +const workflowMock: { + value: { pendingSprintDraft?: Record } | undefined +} = { value: undefined } + +vi.mock('@/stores/user-settings/store', () => ({ + useUserSettingsStore: ( + selector: (s: { + entities: { + settings: { + workflow: { pendingSprintDraft?: Record } | undefined + } + } + }) => unknown, + ) => selector({ entities: { settings: { workflow: workflowMock.value } } }), +})) + +vi.mock('./new-sprint-metadata-dialog', () => ({ + NewSprintMetadataDialog: () => null, +})) + +vi.mock('@/components/shared/demo-tooltip', () => ({ + DemoTooltip: ({ children }: { children: ReactNode }) => children, +})) + +import { NewSprintTrigger } from '@/components/backlog/new-sprint-trigger' + +beforeEach(() => { + workflowMock.value = undefined +}) + +describe('NewSprintTrigger', () => { + it('renders the button on an active product without a draft', () => { + render() + expect(screen.getByText('Nieuwe sprint')).toBeInTheDocument() + }) + + it('renders nothing on a non-active product (G6)', () => { + const { container } = render( + , + ) + expect(container).toBeEmptyDOMElement() + }) + + it('renders nothing when a sprint draft is pending', () => { + workflowMock.value = { pendingSprintDraft: { p1: { goal: 'X' } } } + const { container } = render( + , + ) + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/__tests__/components/shared/sprint-switcher.test.tsx b/__tests__/components/shared/sprint-switcher.test.tsx index 29c29c0..8af2df1 100644 --- a/__tests__/components/shared/sprint-switcher.test.tsx +++ b/__tests__/components/shared/sprint-switcher.test.tsx @@ -24,6 +24,11 @@ vi.mock('sonner', () => ({ })) const isDemoMock = { value: false } +const workflowMock: { + value: + | { pendingSprintDraft?: Record } + | undefined +} = { value: undefined } // Mock-state shape moet alle paden dekken die SprintSwitcher selecteert: // - s.context.isDemo (oude code) // - s.entities.settings.workflow?.pendingSprintDraft?.[productId]?.goal (PBI-79) @@ -38,8 +43,11 @@ type MockStoreState = { } } vi.mock('@/stores/user-settings/store', () => ({ - useUserSettingsStore: (selector: (s: { context: { isDemo: boolean }; entities: { settings: { workflow: null } } }) => unknown) => - selector({ context: { isDemo: isDemoMock.value }, entities: { settings: { workflow: null } } }), + useUserSettingsStore: (selector: (s: MockStoreState) => unknown) => + selector({ + context: { isDemo: isDemoMock.value }, + entities: { settings: { workflow: workflowMock.value } }, + }), })) vi.mock('@/components/ui/dropdown-menu', () => { @@ -85,6 +93,7 @@ const sprints = [ beforeEach(() => { vi.clearAllMocks() isDemoMock.value = false + workflowMock.value = undefined actionMock.mockResolvedValue({ success: true }) pathnameMock.mockReturnValue('/products/p1/sprint') }) @@ -137,4 +146,29 @@ describe('SprintSwitcher', () => { expect(pushMock).not.toHaveBeenCalled() expect(actionMock).not.toHaveBeenCalled() }) + + it('shows the concept-sprint on the trigger when a draft is pending (G5)', () => { + workflowMock.value = { pendingSprintDraft: { p1: { goal: 'Test goal' } } } + render( + , + ) + expect(screen.getByText('⚙ Concept — Test goal')).toBeInTheDocument() + }) + + it('shows no concept label on the trigger when no draft is pending', () => { + render( + , + ) + expect(screen.queryByText(/⚙ Concept/)).not.toBeInTheDocument() + }) }) diff --git a/__tests__/stores/product-workspace/screen-state.test.ts b/__tests__/stores/product-workspace/screen-state.test.ts new file mode 100644 index 0000000..7463fff --- /dev/null +++ b/__tests__/stores/product-workspace/screen-state.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest' +import { + deriveScreenState, + type ScreenStateInput, +} from '@/stores/product-workspace/screen-state' + +const base: ScreenStateInput = { + activeSprintItem: null, + buildingSprintIds: [], + hasPendingDraft: false, + pendingAdds: [], + pendingRemoves: [], +} + +describe('deriveScreenState', () => { + it('returns NO_SPRINT without draft or active sprint', () => { + expect(deriveScreenState(base)).toEqual({ kind: 'NO_SPRINT' }) + }) + + it('returns DRAFT when a pending draft exists', () => { + expect(deriveScreenState({ ...base, hasPendingDraft: true })).toEqual({ + kind: 'DRAFT', + }) + }) + + it('lets a draft win over an active sprint with pending changes', () => { + expect( + deriveScreenState({ + ...base, + hasPendingDraft: true, + activeSprintItem: { id: 's1' }, + pendingAdds: ['x'], + }), + ).toEqual({ kind: 'DRAFT' }) + }) + + it('returns ACTIVE for an active sprint with no pending changes', () => { + expect( + deriveScreenState({ ...base, activeSprintItem: { id: 's1' } }), + ).toEqual({ kind: 'ACTIVE', building: false }) + }) + + it('flags building when the active sprint is in buildingSprintIds', () => { + expect( + deriveScreenState({ + ...base, + activeSprintItem: { id: 's1' }, + buildingSprintIds: ['s1'], + }), + ).toEqual({ kind: 'ACTIVE', building: true }) + }) + + it('returns EDITING when there are pending adds', () => { + expect( + deriveScreenState({ + ...base, + activeSprintItem: { id: 's1' }, + pendingAdds: ['x'], + }), + ).toEqual({ kind: 'EDITING', building: false }) + }) + + it('returns EDITING when there are pending removes', () => { + expect( + deriveScreenState({ + ...base, + activeSprintItem: { id: 's1' }, + pendingRemoves: ['y'], + }), + ).toEqual({ kind: 'EDITING', building: false }) + }) + + it('flags building on EDITING when the active sprint is building', () => { + expect( + deriveScreenState({ + ...base, + activeSprintItem: { id: 's1' }, + pendingAdds: ['x'], + buildingSprintIds: ['s1'], + }), + ).toEqual({ kind: 'EDITING', building: true }) + }) +}) diff --git a/app/(app)/products/[id]/page.tsx b/app/(app)/products/[id]/page.tsx index 5157b73..161b4dc 100644 --- a/app/(app)/products/[id]/page.tsx +++ b/app/(app)/products/[id]/page.tsx @@ -131,7 +131,13 @@ export default async function ProductBacklogPage({ params, searchParams }: Props {activeSprintItem && !isDemo && ( )} - {!isDemo && } + {!isDemo && ( + + )} {!isDemo && product.user_id === session.userId && ( !!s.entities.settings.workflow?.pendingSprintDraft?.[productId], ) if (hasDraft) return null + if (!isActiveProduct) return null return ( <> diff --git a/components/shared/sprint-switcher.tsx b/components/shared/sprint-switcher.tsx index 11fa596..e718a42 100644 --- a/components/shared/sprint-switcher.tsx +++ b/components/shared/sprint-switcher.tsx @@ -18,6 +18,7 @@ import { switchActiveSprintAction, } from '@/actions/active-sprint' import { useProductWorkspaceStore } from '@/stores/product-workspace/store' +import { deriveScreenState } from '@/stores/product-workspace/screen-state' import { useUserSettingsStore } from '@/stores/user-settings/store' import type { SprintStatusApi } from '@/lib/task-status' import { debugProps } from '@/lib/debug' @@ -57,6 +58,20 @@ export function SprintSwitcher({ const draftGoal = useUserSettingsStore( (s) => s.entities.settings.workflow?.pendingSprintDraft?.[productId]?.goal ?? null, ) + const pendingAdds = useProductWorkspaceStore( + (s) => s.sprintMembership.pending.adds, + ) + const pendingRemoves = useProductWorkspaceStore( + (s) => s.sprintMembership.pending.removes, + ) + + const screenState = deriveScreenState({ + activeSprintItem: activeSprint, + buildingSprintIds, + hasPendingDraft: draftGoal !== null, + pendingAdds, + pendingRemoves, + }) const visibleSprints = sprints.filter(s => { if (showClosed) return true @@ -139,10 +154,19 @@ export function SprintSwitcher({ disabled={isPending} className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors px-2 py-1 rounded-md hover:bg-surface-container focus:outline-none" > - - {activeSprint ? activeSprint.code : 'Selecteer sprint'} + + {screenState.kind === 'DRAFT' + ? `⚙ Concept — ${draftGoal}` + : activeSprint + ? activeSprint.code + : 'Selecteer sprint'} - {activeSprint && ( + {screenState.kind !== 'DRAFT' && activeSprint && ( **Status:** goedgekeurd 2026-05-15 · gematerialiseerd via Scrum4Me-MCP. +> **PBI-91** · Story **ST-1369** (OPEN — in de productbacklog, geen sprint) · Taken **T-1033 t/m T-1037** (uitvoervolgorde = sort_order 1–5). +> +> Vervolg op **PBI-88** ("Product Backlog page workflow & states", PR #208) — implementeert 3 van de 4 niet-bindende aanbevelingen uit `docs/architecture/product-backlog-workflow.md`. G3 uitgesteld. + +## Context + +PBI-88 leverde een as-is/to-be analyse op: [docs/architecture/product-backlog-workflow.md](../architecture/product-backlog-workflow.md). Dat doc sluit af met vier **niet-bindende** aanbevelingen (G1, G3, G5, G6) als "input voor latere PBI's". + +Deze PBI implementeert er **drie** van. **G3** (expliciete ERROR-schermstaat) wordt **uitgesteld** — het doc zegt zelf "alleen oppakken als falende commits of SSE-verlies een echt UX-probleem blijken", en het is meer een ontwerpkeuze dan een heldere implementatie. + +De directe aanleiding voor PBI-88 was een **bug**: de concept-sprint (`pendingSprintDraft`) is niet zichtbaar op de SprintSwitcher-trigger-knop — alleen in de (disabled) dropdown. Dat is **G5** en wordt hier opgelost. + +**Scope:** +- **G1** — `deriveScreenState()`: één pure functie die de vandaag verspreide schermstaat-afleiding consolideert. +- **G5** — draft-status zichtbaar op de SprintSwitcher-trigger (de oorspronkelijke bug). +- **G6** — `NewSprintTrigger` achter een `isActiveProduct`-gate. +- **G3** — *uitgesteld*, vastgelegd als follow-up. + +## Aanpak + +### Nieuwe bestanden + +**`stores/product-workspace/screen-state.ts`** — pure module, géén React, spiegelt `selectors.ts`: + +```ts +export type ScreenState = + | { kind: 'NO_SPRINT' } + | { kind: 'DRAFT' } + | { kind: 'ACTIVE'; building: boolean } + | { kind: 'EDITING'; building: boolean } + +export interface ScreenStateInput { + activeSprintItem: { id: string } | null // SSR-prop uit page.tsx + buildingSprintIds: string[] // SSR-prop uit page.tsx + hasPendingDraft: boolean // user-settings store + pendingAdds: string[] // product-workspace store + pendingRemoves: string[] // product-workspace store +} + +export function deriveScreenState(i: ScreenStateInput): ScreenState { + if (i.hasPendingDraft) return { kind: 'DRAFT' } // draft wint van alles + if (i.activeSprintItem) { + const building = i.buildingSprintIds.includes(i.activeSprintItem.id) + const dirty = i.pendingAdds.length > 0 || i.pendingRemoves.length > 0 + return dirty ? { kind: 'EDITING', building } : { kind: 'ACTIVE', building } + } + return { kind: 'NO_SPRINT' } +} +``` + +Bewust **geen** `useScreenState`-hook: consumers roepen `deriveScreenState()` inline aan met store-slices + SSR-props. `ScreenStateInput` is daar precies voor ontworpen. Hook-extractie is "straks" als meer componenten meedoen — niet nu. `PRODUCT_NOT_ACTIVE` en `DEMO_MODE` blijven **buiten** `ScreenState` (gates, geen knopen — conform het doc). + +**`__tests__/stores/product-workspace/screen-state.test.ts`** — pure input→output tests, patroon van [__tests__/lib/product-switch-path.test.ts](../../__tests__/lib/product-switch-path.test.ts) (vitest, geen mocks). + +### Te wijzigen bestanden + +| Bestand | Wijziging | +|---|---| +| [components/shared/sprint-switcher.tsx](../../components/shared/sprint-switcher.tsx) | Leest `pendingAdds`/`pendingRemoves` uit `useProductWorkspaceStore`; `hasPendingDraft` = bestaand `draftGoal !== null` (regel 57). Roept `deriveScreenState()` aan. Trigger-label (regel 142-156) vertakt op `screenState.kind`: bij `DRAFT` toont de **trigger** `⚙ Concept — {draftGoal}` i.p.v. "Selecteer sprint"; status/BUILDING-badge verborgen in `DRAFT`. **(G1 + G5)** | +| [components/backlog/new-sprint-trigger.tsx](../../components/backlog/new-sprint-trigger.tsx) | `isActiveProduct: boolean` toevoegen aan props; `if (!isActiveProduct) return null` — spiegelt het bestaande `if (hasDraft) return null` patroon (regel 25). **(G6)** | +| [app/(app)/products/[id]/page.tsx](../../app/(app)/products/%5Bid%5D/page.tsx) | Regel 134: `isActiveProduct={isActiveProduct}` doorgeven aan `NewSprintTrigger` (`isActiveProduct` bestaat al op regel 49). **(G6)** | +| [__tests__/components/shared/sprint-switcher.test.tsx](../../__tests__/components/shared/sprint-switcher.test.tsx) | Uitbreiden: trigger toont "⚙ Concept" als er een draft is. **(G5)** | +| `__tests__/components/backlog/new-sprint-trigger.test.tsx` | Toevoegen of uitbreiden: component returnt `null` bij `isActiveProduct={false}`. **(G6)** | + +### Hergebruik (niets nieuws bouwen) + +- Pure-selector-patroon: `selectIsDirty` / `selectPendingCount` — [stores/product-workspace/selectors.ts:166](../../stores/product-workspace/selectors.ts:166) +- Bestaande `draftGoal`-selector — [components/shared/sprint-switcher.tsx:57](../../components/shared/sprint-switcher.tsx:57) +- Bestaande `buildingSet`-logica voor dropdown-items — [components/shared/sprint-switcher.tsx:51](../../components/shared/sprint-switcher.tsx:51) +- `sprintMembership.pending` shape `{ adds, removes }` — bestaande store-slice +- Test-patroon pure functie — [__tests__/lib/product-switch-path.test.ts](../../__tests__/lib/product-switch-path.test.ts) + +## Taken (Story ST-1369) + +| Code | Taak | Kern | +|---|---|---| +| T-1033 | `screen-state.ts` — `ScreenState` type + pure `deriveScreenState()` | Nieuw bestand, pure module | +| T-1034 | Unit tests `screen-state.test.ts` | 4 kinds + `building`-flag + precedence (draft wint) | +| T-1035 | `SprintSwitcher` op `deriveScreenState()` + G5: draft op de trigger-knop | G1-wiring + G5 | +| T-1036 | `NewSprintTrigger` achter `isActiveProduct`-gate (component + `page.tsx`) | G6 | +| T-1037 | Component-tests: sprint-switcher (G5) + new-sprint-trigger (G6) | Regressie-dekking | + +## Verificatie + +- `npm run verify` — lint + typecheck + test (de lokale gate; `npm run build` kan in een worktree falen op ontbrekende `DATABASE_URL`) +- `npm test -- screen-state` — de nieuwe pure-functie-tests geïsoleerd +- `npm run dev` + browser: + - **G5**: start een nieuwe sprint-draft → de SprintSwitcher-**trigger** toont "⚙ Concept — [goal]" (niet alleen de dropdown) + - **G5**: annuleer de draft → trigger valt terug op sprint-code / "Selecteer sprint" + - **G6**: open een **niet-actief** product → de "Nieuwe sprint"-knop is afwezig; activeer het product → knop verschijnt + - regressie: actieve sprint zonder draft toont gewoon code + status/BUILDING-badge + +## Uitgesteld (follow-up) + +**G3 — expliciete ERROR-schermstaat.** Vandaag: server-action-fout → `toast.error`, scherm blijft in huidige state. Reden uitstel: het doc adviseert dit alleen op te pakken als falende commits of SSE-verlies een aantoonbaar UX-probleem blijken; ERROR past bovendien niet natuurlijk in de pure `deriveScreenState()` (een fout is geen afgeleide van de input-flags). Vereist eerst een aparte ontwerpkeuze. diff --git a/stores/product-workspace/screen-state.ts b/stores/product-workspace/screen-state.ts new file mode 100644 index 0000000..c32e58b --- /dev/null +++ b/stores/product-workspace/screen-state.ts @@ -0,0 +1,33 @@ +// Expliciete schermstaat voor de Product Backlog page. +// +// Consolideert de vandaag verspreide schermstaat-afleiding (page.tsx, +// sprint-switcher.tsx, new-sprint-trigger.tsx, save-sprint-button.tsx) tot één +// pure, testbare functie. Zie docs/architecture/product-backlog-workflow.md, +// sectie "To-be: expliciete state machine". +// +// PRODUCT_NOT_ACTIVE en DEMO_MODE blijven bewust BUITEN ScreenState — het zijn +// cross-cutting gates, geen knopen in de state machine. + +export type ScreenState = + | { kind: 'NO_SPRINT' } + | { kind: 'DRAFT' } + | { kind: 'ACTIVE'; building: boolean } + | { kind: 'EDITING'; building: boolean } + +export interface ScreenStateInput { + activeSprintItem: { id: string } | null // SSR-prop uit page.tsx + buildingSprintIds: string[] // SSR-prop uit page.tsx + hasPendingDraft: boolean // user-settings store + pendingAdds: string[] // product-workspace store: sprintMembership.pending.adds + pendingRemoves: string[] // product-workspace store: sprintMembership.pending.removes +} + +export function deriveScreenState(i: ScreenStateInput): ScreenState { + if (i.hasPendingDraft) return { kind: 'DRAFT' } // draft wint van alles + if (i.activeSprintItem) { + const building = i.buildingSprintIds.includes(i.activeSprintItem.id) + const dirty = i.pendingAdds.length > 0 || i.pendingRemoves.length > 0 + return dirty ? { kind: 'EDITING', building } : { kind: 'ACTIVE', building } + } + return { kind: 'NO_SPRINT' } +}