feat(PBI-91): expliciete schermstaat + draft-zichtbaarheid PB-page (#210)
* docs(ST-1369): plan PBI-91 — expliciete schermstaat + draft-zichtbaarheid Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ST-1369): screen-state module — ScreenState + deriveScreenState() Pure afleidingslaag die de verspreide schermstaat-derivatie van de Product Backlog page consolideert tot één testbaar ScreenState-model. Nog geen consumers — die volgen in T-1035/T-1036. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(ST-1369): unit-tests voor deriveScreenState() Dekt alle vier de kinds (NO_SPRINT, DRAFT, ACTIVE, EDITING), de building-flag en de draft-voorrang boven een actieve sprint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ST-1369): SprintSwitcher op deriveScreenState + draft op trigger (G5) De trigger-knop toont nu de concept-sprint zodra er een sprint-draft loopt, niet langer alleen de (disabled) dropdown-regel. Schermstaat-afleiding loopt via de pure deriveScreenState() i.p.v. losse flags. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ST-1369): NewSprintTrigger achter isActiveProduct-gate (G6) De "Nieuwe sprint"-knop rendert niet langer op een niet-actief product — een sprint-draft starten daar was verwarrend. page.tsx geeft de bestaande isActiveProduct-flag door. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(ST-1369): component-tests voor draft-op-trigger (G5) en isActiveProduct-gate (G6) sprint-switcher: trigger toont concept-sprint bij een pending draft, en geen concept-label zonder draft. new-sprint-trigger: nieuw testbestand — rendert niet op een niet-actief product, wel op een actief product zonder draft. 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
2a6386163c
commit
3d5c22382c
9 changed files with 351 additions and 8 deletions
57
__tests__/components/backlog/new-sprint-trigger.test.tsx
Normal file
57
__tests__/components/backlog/new-sprint-trigger.test.tsx
Normal file
|
|
@ -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<string, unknown> } | undefined
|
||||||
|
} = { value: undefined }
|
||||||
|
|
||||||
|
vi.mock('@/stores/user-settings/store', () => ({
|
||||||
|
useUserSettingsStore: (
|
||||||
|
selector: (s: {
|
||||||
|
entities: {
|
||||||
|
settings: {
|
||||||
|
workflow: { pendingSprintDraft?: Record<string, unknown> } | 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(<NewSprintTrigger productId="p1" isDemo={false} isActiveProduct={true} />)
|
||||||
|
expect(screen.getByText('Nieuwe sprint')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders nothing on a non-active product (G6)', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<NewSprintTrigger productId="p1" isDemo={false} isActiveProduct={false} />,
|
||||||
|
)
|
||||||
|
expect(container).toBeEmptyDOMElement()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders nothing when a sprint draft is pending', () => {
|
||||||
|
workflowMock.value = { pendingSprintDraft: { p1: { goal: 'X' } } }
|
||||||
|
const { container } = render(
|
||||||
|
<NewSprintTrigger productId="p1" isDemo={false} isActiveProduct={true} />,
|
||||||
|
)
|
||||||
|
expect(container).toBeEmptyDOMElement()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -24,6 +24,11 @@ vi.mock('sonner', () => ({
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const isDemoMock = { value: false }
|
const isDemoMock = { value: false }
|
||||||
|
const workflowMock: {
|
||||||
|
value:
|
||||||
|
| { pendingSprintDraft?: Record<string, { goal: string } | undefined> }
|
||||||
|
| undefined
|
||||||
|
} = { value: undefined }
|
||||||
// Mock-state shape moet alle paden dekken die SprintSwitcher selecteert:
|
// Mock-state shape moet alle paden dekken die SprintSwitcher selecteert:
|
||||||
// - s.context.isDemo (oude code)
|
// - s.context.isDemo (oude code)
|
||||||
// - s.entities.settings.workflow?.pendingSprintDraft?.[productId]?.goal (PBI-79)
|
// - s.entities.settings.workflow?.pendingSprintDraft?.[productId]?.goal (PBI-79)
|
||||||
|
|
@ -38,8 +43,11 @@ type MockStoreState = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
vi.mock('@/stores/user-settings/store', () => ({
|
vi.mock('@/stores/user-settings/store', () => ({
|
||||||
useUserSettingsStore: (selector: (s: { context: { isDemo: boolean }; entities: { settings: { workflow: null } } }) => unknown) =>
|
useUserSettingsStore: (selector: (s: MockStoreState) => unknown) =>
|
||||||
selector({ context: { isDemo: isDemoMock.value }, entities: { settings: { workflow: null } } }),
|
selector({
|
||||||
|
context: { isDemo: isDemoMock.value },
|
||||||
|
entities: { settings: { workflow: workflowMock.value } },
|
||||||
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/components/ui/dropdown-menu', () => {
|
vi.mock('@/components/ui/dropdown-menu', () => {
|
||||||
|
|
@ -85,6 +93,7 @@ const sprints = [
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
isDemoMock.value = false
|
isDemoMock.value = false
|
||||||
|
workflowMock.value = undefined
|
||||||
actionMock.mockResolvedValue({ success: true })
|
actionMock.mockResolvedValue({ success: true })
|
||||||
pathnameMock.mockReturnValue('/products/p1/sprint')
|
pathnameMock.mockReturnValue('/products/p1/sprint')
|
||||||
})
|
})
|
||||||
|
|
@ -137,4 +146,29 @@ describe('SprintSwitcher', () => {
|
||||||
expect(pushMock).not.toHaveBeenCalled()
|
expect(pushMock).not.toHaveBeenCalled()
|
||||||
expect(actionMock).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(
|
||||||
|
<SprintSwitcher
|
||||||
|
productId="p1"
|
||||||
|
sprints={sprints}
|
||||||
|
activeSprint={null}
|
||||||
|
buildingSprintIds={[]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('⚙ Concept — Test goal')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows no concept label on the trigger when no draft is pending', () => {
|
||||||
|
render(
|
||||||
|
<SprintSwitcher
|
||||||
|
productId="p1"
|
||||||
|
sprints={sprints}
|
||||||
|
activeSprint={sprints[0]}
|
||||||
|
buildingSprintIds={[]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.queryByText(/⚙ Concept/)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
83
__tests__/stores/product-workspace/screen-state.test.ts
Normal file
83
__tests__/stores/product-workspace/screen-state.test.ts
Normal file
|
|
@ -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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -131,7 +131,13 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
|
||||||
{activeSprintItem && !isDemo && (
|
{activeSprintItem && !isDemo && (
|
||||||
<SaveSprintButton activeSprintId={activeSprintItem.id} />
|
<SaveSprintButton activeSprintId={activeSprintItem.id} />
|
||||||
)}
|
)}
|
||||||
{!isDemo && <NewSprintTrigger productId={id} isDemo={isDemo} />}
|
{!isDemo && (
|
||||||
|
<NewSprintTrigger
|
||||||
|
productId={id}
|
||||||
|
isDemo={isDemo}
|
||||||
|
isActiveProduct={isActiveProduct}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{!isDemo && product.user_id === session.userId && (
|
{!isDemo && product.user_id === session.userId && (
|
||||||
<EditProductButton
|
<EditProductButton
|
||||||
product={{
|
product={{
|
||||||
|
|
|
||||||
|
|
@ -9,20 +9,27 @@ import { NewSprintMetadataDialog } from './new-sprint-metadata-dialog'
|
||||||
interface NewSprintTriggerProps {
|
interface NewSprintTriggerProps {
|
||||||
productId: string
|
productId: string
|
||||||
isDemo: boolean
|
isDemo: boolean
|
||||||
|
isActiveProduct: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PBI-79 / ST-1337: trigger-knop voor de nieuwe sprint-flow.
|
* PBI-79 / ST-1337: trigger-knop voor de nieuwe sprint-flow.
|
||||||
* Verbergt zichzelf wanneer er al een pendingSprintDraft loopt — dan
|
* Verbergt zichzelf wanneer er al een pendingSprintDraft loopt — dan
|
||||||
* staat de SprintDefinitionBanner zelf de afronding te regelen.
|
* staat de SprintDefinitionBanner zelf de afronding te regelen — en
|
||||||
|
* wanneer het product niet het actieve product is (ST-1369 / G6).
|
||||||
*/
|
*/
|
||||||
export function NewSprintTrigger({ productId, isDemo }: NewSprintTriggerProps) {
|
export function NewSprintTrigger({
|
||||||
|
productId,
|
||||||
|
isDemo,
|
||||||
|
isActiveProduct,
|
||||||
|
}: NewSprintTriggerProps) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const hasDraft = useUserSettingsStore(
|
const hasDraft = useUserSettingsStore(
|
||||||
(s) => !!s.entities.settings.workflow?.pendingSprintDraft?.[productId],
|
(s) => !!s.entities.settings.workflow?.pendingSprintDraft?.[productId],
|
||||||
)
|
)
|
||||||
|
|
||||||
if (hasDraft) return null
|
if (hasDraft) return null
|
||||||
|
if (!isActiveProduct) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
switchActiveSprintAction,
|
switchActiveSprintAction,
|
||||||
} from '@/actions/active-sprint'
|
} from '@/actions/active-sprint'
|
||||||
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
||||||
|
import { deriveScreenState } from '@/stores/product-workspace/screen-state'
|
||||||
import { useUserSettingsStore } from '@/stores/user-settings/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'
|
||||||
|
|
@ -57,6 +58,20 @@ export function SprintSwitcher({
|
||||||
const draftGoal = useUserSettingsStore(
|
const draftGoal = useUserSettingsStore(
|
||||||
(s) => s.entities.settings.workflow?.pendingSprintDraft?.[productId]?.goal ?? null,
|
(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 => {
|
const visibleSprints = sprints.filter(s => {
|
||||||
if (showClosed) return true
|
if (showClosed) return true
|
||||||
|
|
@ -139,10 +154,19 @@ export function SprintSwitcher({
|
||||||
disabled={isPending}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
<span className="truncate max-w-[160px]">
|
<span
|
||||||
{activeSprint ? activeSprint.code : 'Selecteer sprint'}
|
className={cn(
|
||||||
|
'truncate max-w-[160px]',
|
||||||
|
screenState.kind === 'DRAFT' && 'italic text-tertiary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{screenState.kind === 'DRAFT'
|
||||||
|
? `⚙ Concept — ${draftGoal}`
|
||||||
|
: activeSprint
|
||||||
|
? activeSprint.code
|
||||||
|
: 'Selecteer sprint'}
|
||||||
</span>
|
</span>
|
||||||
{activeSprint && (
|
{screenState.kind !== 'DRAFT' && activeSprint && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-sm',
|
'text-sm',
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ Auto-generated on 2026-05-14 from front-matter and headings.
|
||||||
| [Plan v3.5 — Bootstrap-wizard voor nieuwe Product-repo (Scrum4Me feature)](./plans/M8-bootstrap-wizard.md) | reviewed | — |
|
| [Plan v3.5 — Bootstrap-wizard voor nieuwe Product-repo (Scrum4Me feature)](./plans/M8-bootstrap-wizard.md) | reviewed | — |
|
||||||
| [PBI-80 — Demo-gebruiker mag eigen UI-voorkeuren wijzigen](./plans/PBI-80-demo-prefs.md) | — | — |
|
| [PBI-80 — Demo-gebruiker mag eigen UI-voorkeuren wijzigen](./plans/PBI-80-demo-prefs.md) | — | — |
|
||||||
| [Plan — `code` wordt bindende volgorde voor stories & taken; drag-and-drop eruit](./plans/PBI-84-code-binding-order.md) | — | — |
|
| [Plan — `code` wordt bindende volgorde voor stories & taken; drag-and-drop eruit](./plans/PBI-84-code-binding-order.md) | — | — |
|
||||||
|
| [Plan — Expliciete schermstaat + draft-zichtbaarheid op de Product Backlog page](./plans/PBI-91-pb-screen-state.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) | — | — |
|
||||||
| [Sprint MCP-tools — create_sprint & update_sprint](./plans/sprint-mcp-tools.md) | draft | 2026-05-11 |
|
| [Sprint MCP-tools — create_sprint & update_sprint](./plans/sprint-mcp-tools.md) | draft | 2026-05-11 |
|
||||||
| [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 |
|
||||||
|
|
|
||||||
98
docs/plans/PBI-91-pb-screen-state.md
Normal file
98
docs/plans/PBI-91-pb-screen-state.md
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
# Plan — Expliciete schermstaat + draft-zichtbaarheid op de Product Backlog page
|
||||||
|
|
||||||
|
> **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.
|
||||||
33
stores/product-workspace/screen-state.ts
Normal file
33
stores/product-workspace/screen-state.ts
Normal file
|
|
@ -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' }
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue