* feat(PBI-74): sprint-workspace-store skelet (Story 9 / T-879)
- stores/sprint-workspace/{types,store,selectors,restore}.ts conform
product-workspace blueprint
- ContextSlice: activeProduct, activeSprintId, activeStoryId, activeTaskId
- EntitiesSlice: sprintsById, storiesById, tasksById
- RelationsSlice: sprintIdsByProduct, storyIdsBySprint, taskIdsByStory
- LoadingSlice met activeRequestId voor race-safe ensure*Loaded
- SyncSlice: realtimeStatus, lastResyncAt, resyncReason
- Realtime applyRealtimeEvent voor sprint/story/task entities + unknown-event
fallback, parent-move handling, child-cleanup bij D op sprint/story
- Optimistic mutations: sprint-story-order, sprint-task-order, entity-patch
- LocalStorage hints (storage key sprint-workspace-hints) per product/sprint
- 45 unit-tests groen — verplicht 13 cases uit workspace-store.md §Tests
* feat(PBI-74): sprint hydratie + realtime SSE (Story 9 / T-880)
- app/api/realtime/sprint/route.ts: SSE-stream LISTEN/NOTIFY op
scrum4me_changes, filter entity ∈ {sprint, story, task} per product_id;
ready-event, heartbeat 25s, hard-close 240s
- lib/realtime/use-sprint-realtime.ts: client-hook met backoff-reconnect;
ready-cycle telt; geen close op hidden; setRealtimeStatus
- lib/realtime/use-sprint-workspace-resync.ts: visibility + online triggers
resyncActiveScopes('visible' | 'reconnect')
- components/sprint/sprint-hydration-wrapper.tsx: hydrateSnapshot via
useEffect met fingerprint-check; mount realtime + resync
- app/(app)/products/[id]/sprint/[sprintId]/page.tsx: wrap SprintBoardClient
in SprintHydrationWrapper; bouw SprintWorkspaceTask-shape voor
tasksByStoryWorkspace en SprintHydrationData voor de wrapper
Schaduw-fase: useSprintStore blijft parallel werken in board components
totdat T-881 die migreert en T-883 de oude store opruimt.
* feat(PBI-74): migreer sprint-board componenten naar workspace-store (Story 9 / T-881)
- TaskList: leest tasks via selectTasksForStory met useShallow; DnD via
applyOptimisticMutation('sprint-task-order') + settle/rollback
- SprintBacklogLeft: leest stories via selectStoriesForActiveSprint met
useShallow; props 'stories' verwijderd
- SprintBoardClient: leest sprintStories uit selector i.p.v. lokale state;
add/remove via direct setState met manuele snapshot-rollback;
reorder via applyOptimisticMutation('sprint-story-order'); assignee-
change via store entity-mutation; tasksByStory en sprintStoryIdList
props weg
- app/(app)/.../sprint/[sprintId]/page.tsx: bouwt SprintHydrationData voor
wrapper; geeft alleen non-store props door aan SprintBoardClient
useSprintStore wordt nergens meer geïmporteerd — alleen comment-referentie
in SprintHydrationWrapper. Cleanup van het bestand zelf in T-883.
Verify groen (671 tests, typecheck, lint clean).
* feat(PBI-74): read-routes voor sprint-workspace + cache-headers (Story 9 / T-882)
- GET /api/products/[id]/sprints — lijst sprints per product
(ensureProductSprintsLoaded). force-dynamic, productAccessFilter,
start_date/end_date naar ISO-date string.
- GET /api/sprints/[id]/workspace — sprint snapshot met sprint-meta,
stories (incl. taskCount/doneCount/assignee), tasks gegroepeerd per
story (ensureSprintLoaded). force-dynamic, productAccessFilter via
product, status-vertaling via taskStatusToApi/storyStatusToApi.
Race-safe loaders (activeRequestId-guard), restore-flow (cascade-restore
via writeProductHint/writeSprintHint/writeStoryHint/writeTaskHint),
resync-laag (useSprintWorkspaceResync visibility + online), unknown-event
filter (isUnknownEntityEvent → resyncActiveScopes('unknown-event')) zijn
allemaal in T-879/T-880 al ingebouwd; T-882 sluit het loop met de
ontbrekende API-endpoints + cache-headers (cache: 'no-store' op fetches,
force-dynamic op routes).
* feat(PBI-74): cleanup oude sprint-store (Story 9 / T-883)
- rm stores/sprint-store.ts — alle componenten lezen nu via
useSprintWorkspaceStore (T-881 voltooide imports-migratie)
- update SprintHydrationWrapper-comment: schaduw-fase referenties
verwijderd
Verify: 671 tests groen, typecheck clean, build groen.
Grep useSprintStore = 0.
* docs(PBI-74): update Story 9 status in implementatieplan (T-884)
- Frontmatter: ready-to-execute → in-progress; revision 1 → 2;
last_updated 2026-05-09 → 2026-05-10
- Stories-tabel: kolom Status toegevoegd (Stories 1-8 DONE via PR #180,
Story 9 met T-884 op review)
- §Story 9: per-taak status + acceptatie-checklist voor T-884 manuele
staging-checks
- Aanbeveling-blokje: noteert dat Story 9 vroeger gestart is dan het
ontwerpdoc adviseerde
110 lines
3.3 KiB
TypeScript
110 lines
3.3 KiB
TypeScript
// PBI-74 / Story 9 / T-882: GET /api/sprints/:id/workspace
|
|
//
|
|
// Levert een SprintWorkspaceSnapshot (sprint + stories + tasksByStory) voor
|
|
// de sprint-workspace-store (ensureSprintLoaded). Auth + access-control via
|
|
// product-membership.
|
|
|
|
import { authenticateApiRequest } from '@/lib/api-auth'
|
|
import { prisma } from '@/lib/prisma'
|
|
import { productAccessFilter } from '@/lib/product-access'
|
|
import { storyStatusToApi, taskStatusToApi } from '@/lib/task-status'
|
|
|
|
export const dynamic = 'force-dynamic'
|
|
|
|
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 } = await params
|
|
|
|
const sprint = await prisma.sprint.findFirst({
|
|
where: { id, product: productAccessFilter(auth.userId) },
|
|
select: {
|
|
id: true,
|
|
product_id: true,
|
|
code: true,
|
|
sprint_goal: true,
|
|
status: true,
|
|
start_date: true,
|
|
end_date: true,
|
|
created_at: true,
|
|
completed_at: true,
|
|
product: { select: { id: true, name: true } },
|
|
},
|
|
})
|
|
if (!sprint) {
|
|
return Response.json({ error: 'Sprint niet gevonden' }, { status: 404 })
|
|
}
|
|
|
|
const [stories, tasks] = await Promise.all([
|
|
prisma.story.findMany({
|
|
where: { sprint_id: id },
|
|
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }, { created_at: 'asc' }],
|
|
include: {
|
|
tasks: { select: { id: true, status: true } },
|
|
assignee: { select: { id: true, username: true } },
|
|
},
|
|
}),
|
|
prisma.task.findMany({
|
|
where: { sprint_id: id },
|
|
orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }],
|
|
select: {
|
|
id: true,
|
|
code: true,
|
|
title: true,
|
|
description: true,
|
|
priority: true,
|
|
sort_order: true,
|
|
status: true,
|
|
story_id: true,
|
|
sprint_id: true,
|
|
created_at: true,
|
|
},
|
|
}),
|
|
])
|
|
|
|
const tasksByStory: Record<string, unknown[]> = {}
|
|
for (const task of tasks) {
|
|
const apiTask = { ...task, status: taskStatusToApi(task.status) }
|
|
if (!tasksByStory[task.story_id]) tasksByStory[task.story_id] = []
|
|
tasksByStory[task.story_id].push(apiTask)
|
|
}
|
|
|
|
return Response.json({
|
|
product: sprint.product,
|
|
sprint: {
|
|
id: sprint.id,
|
|
product_id: sprint.product_id,
|
|
code: sprint.code,
|
|
sprint_goal: sprint.sprint_goal,
|
|
status: sprint.status,
|
|
start_date: sprint.start_date ? sprint.start_date.toISOString().slice(0, 10) : null,
|
|
end_date: sprint.end_date ? sprint.end_date.toISOString().slice(0, 10) : null,
|
|
created_at: sprint.created_at,
|
|
completed_at: sprint.completed_at,
|
|
},
|
|
stories: stories.map((s) => ({
|
|
id: s.id,
|
|
code: s.code,
|
|
title: s.title,
|
|
description: s.description,
|
|
acceptance_criteria: s.acceptance_criteria,
|
|
priority: s.priority,
|
|
sort_order: s.sort_order,
|
|
status: storyStatusToApi(s.status),
|
|
pbi_id: s.pbi_id,
|
|
sprint_id: s.sprint_id,
|
|
created_at: s.created_at,
|
|
taskCount: s.tasks.length,
|
|
doneCount: s.tasks.filter((t) => t.status === 'DONE').length,
|
|
assignee_id: s.assignee_id,
|
|
assignee_username: s.assignee?.username ?? null,
|
|
})),
|
|
tasksByStory,
|
|
})
|
|
}
|