diff --git a/__tests__/api/backlog-realtime.test.ts b/__tests__/api/backlog-realtime.test.ts new file mode 100644 index 0000000..4898cda --- /dev/null +++ b/__tests__/api/backlog-realtime.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockGetSession } = vi.hoisted(() => ({ mockGetSession: vi.fn() })) + +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) +vi.mock('@/lib/product-access', () => ({ + getAccessibleProduct: vi.fn(), +})) + +import { getAccessibleProduct } from '@/lib/product-access' +import type { NextRequest } from 'next/server' +import { GET } from '@/app/api/realtime/backlog/route' +import { useBacklogStore } from '@/stores/backlog-store' + +const mockGetAccessibleProduct = getAccessibleProduct as ReturnType + +function makeReq(productId?: string): NextRequest { + const url = productId + ? `http://localhost/api/realtime/backlog?product_id=${productId}` + : 'http://localhost/api/realtime/backlog' + return { + signal: new AbortController().signal, + nextUrl: new URL(url), + } as unknown as NextRequest +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('GET /api/realtime/backlog', () => { + it('401 when not authenticated', async () => { + mockGetSession.mockResolvedValue({ userId: undefined, isDemo: false }) + const res = await GET(makeReq('prod-1')) + expect(res.status).toBe(401) + expect(mockGetAccessibleProduct).not.toHaveBeenCalled() + }) + + it('400 when product_id is missing', async () => { + mockGetSession.mockResolvedValue({ userId: 'user-1', isDemo: false }) + const res = await GET(makeReq()) + expect(res.status).toBe(400) + }) + + it('403 when user has no access to the product', async () => { + mockGetSession.mockResolvedValue({ userId: 'user-1', isDemo: false }) + mockGetAccessibleProduct.mockResolvedValue(null) + const res = await GET(makeReq('prod-1')) + expect(res.status).toBe(403) + expect(mockGetAccessibleProduct).toHaveBeenCalledWith('prod-1', 'user-1') + }) + + it('500 when DIRECT_URL and DATABASE_URL are absent', async () => { + mockGetSession.mockResolvedValue({ userId: 'user-1', isDemo: false }) + mockGetAccessibleProduct.mockResolvedValue({ id: 'prod-1' }) + + const before = { DIRECT_URL: process.env.DIRECT_URL, DATABASE_URL: process.env.DATABASE_URL } + delete process.env.DIRECT_URL + delete process.env.DATABASE_URL + try { + const res = await GET(makeReq('prod-1')) + expect(res.status).toBe(500) + } finally { + if (before.DIRECT_URL !== undefined) process.env.DIRECT_URL = before.DIRECT_URL + if (before.DATABASE_URL !== undefined) process.env.DATABASE_URL = before.DATABASE_URL + } + }) + + it('demo user is allowed (no 403) when product is accessible', async () => { + mockGetSession.mockResolvedValue({ userId: 'demo-user', isDemo: true }) + mockGetAccessibleProduct.mockResolvedValue({ id: 'prod-1' }) + + const before = { DIRECT_URL: process.env.DIRECT_URL, DATABASE_URL: process.env.DATABASE_URL } + delete process.env.DIRECT_URL + delete process.env.DATABASE_URL + try { + const res = await GET(makeReq('prod-1')) + // Fails at 500 (no DB URL) — not 403, confirming demo user is not blocked + expect(res.status).toBe(500) + } finally { + if (before.DIRECT_URL !== undefined) process.env.DIRECT_URL = before.DIRECT_URL + if (before.DATABASE_URL !== undefined) process.env.DATABASE_URL = before.DATABASE_URL + } + }) +}) + +// shouldEmit scope filter — white-box unit tests +describe('shouldEmit scope filter (via backlog-store reducer)', () => { + it('applyChange: pbi INSERT adds to pbis array', () => { + useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: {} }) + const pbi = { id: 'pbi-1', code: 'PBI-1', title: 'Test', priority: 2, created_at: new Date(), status: 'ready' as const } + useBacklogStore.getState().applyChange('pbi', 'I', pbi) + expect(useBacklogStore.getState().pbis).toHaveLength(1) + expect(useBacklogStore.getState().pbis[0].id).toBe('pbi-1') + }) + + it('applyChange: pbi UPDATE patches existing pbi', () => { + const pbi = { id: 'pbi-1', code: 'PBI-1', title: 'Old', priority: 2, created_at: new Date(), status: 'ready' as const } + useBacklogStore.setState({ pbis: [pbi], storiesByPbi: {}, tasksByStory: {} }) + useBacklogStore.getState().applyChange('pbi', 'U', { id: 'pbi-1', title: 'New' }) + expect(useBacklogStore.getState().pbis[0].title).toBe('New') + }) + + it('applyChange: pbi DELETE removes pbi', () => { + const pbi = { id: 'pbi-1', code: 'PBI-1', title: 'Test', priority: 2, created_at: new Date(), status: 'ready' as const } + useBacklogStore.setState({ pbis: [pbi], storiesByPbi: {}, tasksByStory: {} }) + useBacklogStore.getState().applyChange('pbi', 'D', { id: 'pbi-1' }) + expect(useBacklogStore.getState().pbis).toHaveLength(0) + }) + + it('applyChange: story INSERT adds to storiesByPbi', () => { + useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [] }, tasksByStory: {} }) + const story = { id: 'story-1', code: 'ST-1', title: 'S', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: 'pbi-1', created_at: new Date() } + useBacklogStore.getState().applyChange('story', 'I', story) + expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(1) + }) + + it('applyChange: story DELETE removes from correct pbi bucket', () => { + const story = { id: 'story-1', code: 'ST-1', title: 'S', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: 'pbi-1', created_at: new Date() } + useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [story] }, tasksByStory: {} }) + useBacklogStore.getState().applyChange('story', 'D', { id: 'story-1' }) + expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(0) + }) + + it('applyChange: task UPDATE patches task across story buckets', () => { + const task = { id: 'task-1', title: 'Old', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: 'story-1', created_at: new Date() } + useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: { 'story-1': [task] } }) + useBacklogStore.getState().applyChange('task', 'U', { id: 'task-1', status: 'IN_PROGRESS' }) + expect(useBacklogStore.getState().tasksByStory['story-1'][0].status).toBe('IN_PROGRESS') + }) +}) diff --git a/__tests__/components/backlog/backlog-split-pane.test.tsx b/__tests__/components/backlog/backlog-split-pane.test.tsx new file mode 100644 index 0000000..f57e53f --- /dev/null +++ b/__tests__/components/backlog/backlog-split-pane.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import { useSelectionStore } from '@/stores/selection-store' +import { BacklogSplitPane } from '@/components/backlog/backlog-split-pane' + +const PANES = [ +
PBI pane
, +
Stories pane
, +
Tasks pane
, +] + +function renderPane() { + return render( + + ) +} + +beforeEach(() => { + useSelectionStore.setState({ selectedPbiId: null, selectedStoryId: null }) + // Force mobile viewport + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 }) + window.dispatchEvent(new Event('resize')) +}) + +describe('BacklogSplitPane auto-switch', () => { + it('starts on tab 0 with no selection', () => { + renderPane() + expect(screen.getByText('PBI pane')).toBeTruthy() + expect(screen.queryByText('Stories pane')).toBeNull() + }) + + it('auto-switches to tab 1 when PBI is selected', () => { + const { rerender } = renderPane() + useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: null }) + rerender( + + ) + expect(screen.getByText('Stories pane')).toBeTruthy() + expect(screen.queryByText('PBI pane')).toBeNull() + }) + + it('auto-switches to tab 2 when story is selected', () => { + const { rerender } = renderPane() + useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: 'story-1' }) + rerender( + + ) + expect(screen.getByText('Tasks pane')).toBeTruthy() + expect(screen.queryByText('PBI pane')).toBeNull() + }) + + it('switches to tab 1 on cascade-reset (story cleared when new PBI selected)', () => { + // Start with story selected (tab 2) + useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: 'story-1' }) + const { rerender } = renderPane() + + // Cascade-reset: new PBI → story clears + useSelectionStore.setState({ selectedPbiId: 'pbi-2', selectedStoryId: null }) + rerender( + + ) + expect(screen.getByText('Stories pane')).toBeTruthy() + }) +}) diff --git a/__tests__/components/backlog/integration.test.tsx b/__tests__/components/backlog/integration.test.tsx new file mode 100644 index 0000000..928ccce --- /dev/null +++ b/__tests__/components/backlog/integration.test.tsx @@ -0,0 +1,133 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { useSelectionStore } from '@/stores/selection-store' +import { useBacklogStore } from '@/stores/backlog-store' + +// Mock next/navigation +const mockPush = vi.fn() +vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mockPush }) })) + +// localStorage mock for StoryPanel sort mode persistence +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: (k: string) => store[k] ?? null, + setItem: (k: string, v: string) => { store[k] = v }, + removeItem: (k: string) => { delete store[k] }, + clear: () => { store = {} }, + } +})() +Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }) + +// Mock server actions +vi.mock('@/actions/stories', () => ({ + reorderStoriesAction: vi.fn().mockResolvedValue({ success: true }), + reorderPbisAction: vi.fn().mockResolvedValue({ success: true }), + updatePbiPriorityAction: vi.fn().mockResolvedValue({ success: true }), +})) +vi.mock('@/actions/pbis', () => ({ deletePbiAction: vi.fn().mockResolvedValue({ success: true }) })) +vi.mock('@/actions/tasks', () => ({ reorderTasksAction: vi.fn().mockResolvedValue({ success: true }) })) +vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) + +// Mock dnd-kit +vi.mock('@dnd-kit/core', () => ({ + DndContext: ({ children }: { children: React.ReactNode }) => <>{children}, + PointerSensor: class {}, + KeyboardSensor: class {}, + useSensor: vi.fn(), + useSensors: vi.fn(() => []), + closestCenter: vi.fn(), + DragOverlay: () => null, +})) +vi.mock('@dnd-kit/sortable', () => ({ + SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}, + useSortable: () => ({ + attributes: {}, listeners: {}, setNodeRef: vi.fn(), + transform: null, transition: undefined, isDragging: false, + }), + verticalListSortingStrategy: {}, + rectSortingStrategy: {}, + sortableKeyboardCoordinates: {}, + arrayMove: (arr: unknown[]) => arr, +})) +vi.mock('@dnd-kit/utilities', () => ({ CSS: { Transform: { toString: () => '' } } })) + +import { StoryPanel } from '@/components/backlog/story-panel' +import { TaskPanel } from '@/components/backlog/task-panel' + +const PRODUCT_ID = 'prod-1' +const PBI_ID = 'pbi-1' +const ALT_PBI_ID = 'pbi-2' +const STORY_ID = 'story-1' + +const STORIES = [ + { id: STORY_ID, code: 'ST-1', title: 'Eerste story', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: PBI_ID, created_at: new Date() }, +] +const TASKS = [ + { id: 'task-1', title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_ID, created_at: new Date() }, +] + +function resetStores() { + useSelectionStore.setState({ selectedPbiId: null, selectedStoryId: null }) + useBacklogStore.setState({ + pbis: [], + storiesByPbi: { [PBI_ID]: STORIES }, + tasksByStory: { [STORY_ID]: TASKS }, + }) +} + +describe('Backlog 3-pane integration', () => { + beforeEach(() => { + mockPush.mockClear() + resetStores() + }) + + it('StoryPanel shows empty state when no PBI selected', () => { + render() + expect(screen.getByText('Selecteer een PBI om de stories te bekijken.')).toBeTruthy() + }) + + it('StoryPanel shows stories when PBI is selected', () => { + useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: null }) + render() + expect(screen.getByText('Eerste story')).toBeTruthy() + }) + + it('clicking a story dispatches selectStory to the store', () => { + useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: null }) + render() + fireEvent.click(screen.getByText('Eerste story')) + expect(useSelectionStore.getState().selectedStoryId).toBe(STORY_ID) + }) + + it('cascade-reset: selecting different PBI clears selectedStoryId', () => { + useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: STORY_ID }) + useSelectionStore.getState().selectPbi(ALT_PBI_ID) + expect(useSelectionStore.getState().selectedStoryId).toBeNull() + }) + + it('TaskPanel shows tasks after story is selected', () => { + useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: STORY_ID }) + render() + expect(screen.getByText('Eerste taak')).toBeTruthy() + }) + + it('TaskPanel shows empty state after cascade-reset', () => { + useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: STORY_ID }) + render() + // Reset via selectPbi + useSelectionStore.getState().selectPbi(ALT_PBI_ID) + // Re-render reflects new store state + render() + expect(screen.getAllByText('Selecteer een story om de taken te bekijken.').length).toBeGreaterThan(0) + }) + + it('selected story card has isSelected highlight class applied', () => { + useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: STORY_ID }) + const { container } = render() + // bg-primary-container is applied when isSelected + const selected = container.querySelector('.bg-primary-container') + expect(selected).toBeTruthy() + }) +}) diff --git a/__tests__/components/backlog/task-panel.test.tsx b/__tests__/components/backlog/task-panel.test.tsx new file mode 100644 index 0000000..97a5894 --- /dev/null +++ b/__tests__/components/backlog/task-panel.test.tsx @@ -0,0 +1,136 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { useSelectionStore } from '@/stores/selection-store' +import { useBacklogStore } from '@/stores/backlog-store' + +// Mock next/navigation +const mockPush = vi.fn() +vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mockPush }) })) + +// Mock reorderTasksAction +vi.mock('@/actions/tasks', () => ({ reorderTasksAction: vi.fn().mockResolvedValue({ success: true }) })) +vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) + +// Mock dnd-kit to avoid jsdom drag complexity +vi.mock('@dnd-kit/core', () => ({ + DndContext: ({ children }: { children: React.ReactNode }) => <>{children}, + PointerSensor: class {}, + KeyboardSensor: class {}, + useSensor: vi.fn(), + useSensors: vi.fn(() => []), + closestCenter: vi.fn(), + DragOverlay: () => null, +})) +vi.mock('@dnd-kit/sortable', () => ({ + SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}, + useSortable: () => ({ + attributes: {}, listeners: {}, setNodeRef: vi.fn(), + transform: null, transition: undefined, isDragging: false, + }), + rectSortingStrategy: {}, + sortableKeyboardCoordinates: {}, + arrayMove: (arr: unknown[], from: number, to: number) => { + const next = [...arr] + next.splice(from, 1) + next.splice(to, 0, arr[from]) + return next + }, +})) +vi.mock('@dnd-kit/utilities', () => ({ CSS: { Transform: { toString: () => '' } } })) + +import { TaskPanel } from '@/components/backlog/task-panel' + +const PRODUCT_ID = 'prod-1' +const STORY_ID = 'story-1' +const CLOSE_PATH = `/products/${PRODUCT_ID}` + +const TASKS = [ + { id: 'task-1', title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_ID, created_at: new Date() }, + { id: 'task-2', title: 'Tweede taak', description: null, priority: 3, status: 'IN_PROGRESS', sort_order: 2, story_id: STORY_ID, created_at: new Date() }, +] + +function renderPanel(isDemo = false) { + return render() +} + +describe('TaskPanel', () => { + beforeEach(() => { + mockPush.mockClear() + useSelectionStore.setState({ selectedStoryId: null, selectedPbiId: null }) + useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: {} }) + }) + + it('shows empty state when no story is selected', () => { + renderPanel() + expect(screen.getByText('Selecteer een story om de taken te bekijken.')).toBeTruthy() + }) + + it('shows empty state with action when story selected but no tasks', () => { + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: [] } }) + renderPanel() + expect(screen.getByText('Nog geen taken voor deze story.')).toBeTruthy() + expect(screen.getAllByText('+ Nieuwe taak').length).toBeGreaterThanOrEqual(1) + }) + + it('renders task cards when tasks are present', () => { + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } }) + renderPanel() + expect(screen.getByText('Eerste taak')).toBeTruthy() + expect(screen.getByText('Tweede taak')).toBeTruthy() + }) + + it('renders status badges on task cards', () => { + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } }) + renderPanel() + expect(screen.getByText('To Do')).toBeTruthy() + expect(screen.getByText('Bezig')).toBeTruthy() + }) + + it('task cards are rendered inside a grid container', () => { + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } }) + const { container } = renderPanel() + const grid = container.querySelector('.grid') + expect(grid).toBeTruthy() + }) + + it('clicking + button calls router.push with newTask params', () => { + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: [] } }) + renderPanel() + const buttons = screen.getAllByText('+ Nieuwe taak') + fireEvent.click(buttons[0]) + expect(mockPush).toHaveBeenCalledWith(`${CLOSE_PATH}?newTask=1&storyId=${STORY_ID}`) + }) + + it('clicking task card calls router.push with editTask param', () => { + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } }) + renderPanel() + fireEvent.click(screen.getByText('Eerste taak')) + expect(mockPush).toHaveBeenCalledWith(`${CLOSE_PATH}?editTask=task-1`) + }) + + it('+ button is disabled in demo mode', () => { + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: [] } }) + renderPanel(true) + const btn = screen.getAllByText('+ Nieuwe taak')[0].closest('button') + expect(btn).toBeTruthy() + expect((btn as HTMLButtonElement).disabled).toBe(true) + }) + + it('cards have no drag listeners in demo mode (whole-card drag disabled)', () => { + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } }) + // In demo mode, listeners ({} from useSortable mock) are not spread onto the card. + // The mock always returns empty listeners, so we just verify the cards render without error. + renderPanel(true) + expect(screen.getByText('Eerste taak')).toBeTruthy() + expect(screen.getByText('Tweede taak')).toBeTruthy() + }) +}) diff --git a/__tests__/components/split-pane.test.tsx b/__tests__/components/split-pane.test.tsx new file mode 100644 index 0000000..cd166c0 --- /dev/null +++ b/__tests__/components/split-pane.test.tsx @@ -0,0 +1,227 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { SplitPane } from '@/components/split-pane/split-pane' + +// Helper to set a cookie +function setCookie(key: string, value: string) { + Object.defineProperty(document, 'cookie', { + writable: true, + configurable: true, + value: `sp:${key}=${encodeURIComponent(value)}`, + }) +} + +function clearCookies() { + Object.defineProperty(document, 'cookie', { + writable: true, + configurable: true, + value: '', + }) +} + +describe('SplitPane', () => { + beforeEach(() => { + clearCookies() + // Default: desktop viewport + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1440 }) + window.dispatchEvent(new Event('resize')) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('renders 2 panes', () => { + render( + Pane A,
Pane B
]} + defaultSplit={[30, 70]} + cookieKey="test-2pane" + /> + ) + expect(screen.getByText('Pane A')).toBeTruthy() + expect(screen.getByText('Pane B')).toBeTruthy() + }) + + it('renders 3 panes with 2 dividers', () => { + const { container } = render( + Left, +
Middle
, +
Right
, + ]} + defaultSplit={[28, 35, 37]} + cookieKey="test-3pane" + /> + ) + expect(screen.getByText('Left')).toBeTruthy() + expect(screen.getByText('Middle')).toBeTruthy() + expect(screen.getByText('Right')).toBeTruthy() + // 2 dividers: cursor-col-resize elements + const dividers = container.querySelectorAll('.cursor-col-resize') + expect(dividers).toHaveLength(2) + }) + + it('restores splits from cookie on mount', () => { + const stored = JSON.stringify([40, 60]) + setCookie('test-restore', stored) + + const { container } = render( + A,
B
]} + defaultSplit={[20, 80]} + cookieKey="test-restore" + /> + ) + + // Left pane should have width 40%, not the default 20% + const paneDiv = container.querySelector('[style*="40%"]') + expect(paneDiv).toBeTruthy() + }) + + it('falls back to defaultSplit when cookie is invalid', () => { + setCookie('test-invalid', 'not-valid-json') + + const { container } = render( + A,
B
]} + defaultSplit={[25, 75]} + cookieKey="test-invalid" + /> + ) + + const paneDiv = container.querySelector('[style*="25%"]') + expect(paneDiv).toBeTruthy() + }) + + it('renders tabs on mobile viewport', () => { + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 768 }) + window.dispatchEvent(new Event('resize')) + + render( + Content A,
Content B
]} + defaultSplit={[50, 50]} + cookieKey="test-mobile" + tabLabels={['Tab A', 'Tab B']} + /> + ) + + expect(screen.getByText('Tab A')).toBeTruthy() + expect(screen.getByText('Tab B')).toBeTruthy() + // Only first tab content visible by default + expect(screen.getByText('Content A')).toBeTruthy() + expect(screen.queryByText('Content B')).toBeNull() + }) + + it('switches tab content on mobile', () => { + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 }) + window.dispatchEvent(new Event('resize')) + + render( + Content A,
Content B
]} + defaultSplit={[50, 50]} + cookieKey="test-mobile-switch" + tabLabels={['Tab A', 'Tab B']} + /> + ) + + // Click second tab + fireEvent.click(screen.getByText('Tab B')) + expect(screen.queryByText('Content A')).toBeNull() + expect(screen.getByText('Content B')).toBeTruthy() + }) + + it('back button not visible on tab 0 in mobile', () => { + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 }) + window.dispatchEvent(new Event('resize')) + + render( + A,
B
,
C
]} + defaultSplit={[33, 33, 34]} + cookieKey="test-back-hidden" + tabLabels={['T1', 'T2', 'T3']} + /> + ) + + // On tab 0, no back button + expect(screen.queryByLabelText('Terug')).toBeNull() + }) + + it('back button visible on tab > 0 and navigates back', () => { + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 }) + window.dispatchEvent(new Event('resize')) + + render( + A,
B
,
C
]} + defaultSplit={[33, 33, 34]} + cookieKey="test-back-nav" + tabLabels={['T1', 'T2', 'T3']} + /> + ) + + // Switch to tab 2 + fireEvent.click(screen.getByText('T3')) + expect(screen.getByText('C')).toBeTruthy() + expect(screen.getByLabelText('Terug')).toBeTruthy() + + // Click back → tab 1 + fireEvent.click(screen.getByLabelText('Terug')) + expect(screen.getByText('B')).toBeTruthy() + + // Click back again → tab 0, no back button + fireEvent.click(screen.getByLabelText('Terug')) + expect(screen.getByText('A')).toBeTruthy() + expect(screen.queryByLabelText('Terug')).toBeNull() + }) + + it('controlled activeTab prop switches the active pane', () => { + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 }) + window.dispatchEvent(new Event('resize')) + + const { rerender } = render( + A,
B
,
C
]} + defaultSplit={[33, 33, 34]} + cookieKey="test-controlled" + tabLabels={['T1', 'T2', 'T3']} + activeTab={0} + onActiveTabChange={vi.fn()} + /> + ) + expect(screen.getByText('A')).toBeTruthy() + + rerender( + A,
B
,
C
]} + defaultSplit={[33, 33, 34]} + cookieKey="test-controlled" + tabLabels={['T1', 'T2', 'T3']} + activeTab={2} + onActiveTabChange={vi.fn()} + /> + ) + expect(screen.getByText('C')).toBeTruthy() + }) + + it('does not render dividers on mobile', () => { + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 }) + window.dispatchEvent(new Event('resize')) + + const { container } = render( + A,
B
]} + defaultSplit={[50, 50]} + cookieKey="test-no-dividers" + /> + ) + + const dividers = container.querySelectorAll('.cursor-col-resize') + expect(dividers).toHaveLength(0) + }) +}) diff --git a/app/(app)/products/[id]/page.tsx b/app/(app)/products/[id]/page.tsx index 426cd03..647d8c6 100644 --- a/app/(app)/products/[id]/page.tsx +++ b/app/(app)/products/[id]/page.tsx @@ -1,22 +1,31 @@ +import { Suspense } from 'react' import { notFound, redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { getAccessibleProduct } from '@/lib/product-access' import { prisma } from '@/lib/prisma' import { pbiStatusToApi } from '@/lib/task-status' -import { SplitPane } from '@/components/split-pane/split-pane' +import { BacklogSplitPane } from '@/components/backlog/backlog-split-pane' import { PbiList } from '@/components/backlog/pbi-list' import { StoryPanel } from '@/components/backlog/story-panel' import type { Story } from '@/components/backlog/story-panel' +import { TaskPanel } from '@/components/backlog/task-panel' +import { BacklogHydrationWrapper } from '@/components/backlog/backlog-hydration-wrapper' +import { TaskDialog } from '@/app/_components/tasks/task-dialog' +import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader' +import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton' import { StartSprintButton } from '@/components/sprint/start-sprint-button' import { ActivateProductButton } from '@/components/shared/activate-product-button' import Link from 'next/link' interface Props { params: Promise<{ id: string }> + searchParams: Promise<{ newTask?: string; storyId?: string; editTask?: string }> } -export default async function ProductBacklogPage({ params }: Props) { +export default async function ProductBacklogPage({ params, searchParams }: Props) { const { id } = await params + const { newTask, storyId: storyIdParam, editTask } = await searchParams + const closePath = `/products/${id}` const session = await getSession() if (!session.userId) redirect('/login') @@ -33,21 +42,37 @@ export default async function ProductBacklogPage({ params }: Props) { orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], }) - const stories = await prisma.story.findMany({ - where: { product_id: id }, - orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], - select: { - id: true, - code: true, - title: true, - description: true, - acceptance_criteria: true, - priority: true, - status: true, - pbi_id: true, - created_at: true, - }, - }) + const [stories, tasks] = await Promise.all([ + prisma.story.findMany({ + where: { product_id: id }, + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + select: { + id: true, + code: true, + title: true, + description: true, + acceptance_criteria: true, + priority: true, + status: true, + pbi_id: true, + created_at: true, + }, + }), + prisma.task.findMany({ + where: { story: { pbi: { product_id: id } } }, + select: { + id: true, + title: true, + description: true, + priority: true, + status: true, + sort_order: true, + story_id: true, + created_at: true, + }, + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + }), + ]) // Group stories by PBI id const storiesByPbi: Record = {} @@ -56,6 +81,13 @@ export default async function ProductBacklogPage({ params }: Props) { storiesByPbi[story.pbi_id].push(story) } + // Group tasks by story id + const tasksByStory: Record = {} + for (const task of tasks) { + if (!tasksByStory[task.story_id]) tasksByStory[task.story_id] = [] + tasksByStory[task.story_id].push(task) + } + const isDemo = session.isDemo ?? false return ( @@ -90,24 +122,60 @@ export default async function ProductBacklogPage({ params }: Props) { {/* Split pane */}
- ({ id: p.id, code: p.code, title: p.title, priority: p.priority, description: p.description, created_at: p.created_at, status: pbiStatusToApi(p.status) }))} - isDemo={isDemo} - /> - } - right={ - - } - /> + ({ id: p.id, code: p.code, title: p.title, priority: p.priority, description: p.description, created_at: p.created_at, status: pbiStatusToApi(p.status) })), + storiesByPbi, + tasksByStory, + }} + > + , + , + , + ]} + /> +
+ + {newTask && ( + + )} + + {editTask && !newTask && ( + }> + + + )} ) } diff --git a/app/api/realtime/backlog/route.ts b/app/api/realtime/backlog/route.ts new file mode 100644 index 0000000..1736710 --- /dev/null +++ b/app/api/realtime/backlog/route.ts @@ -0,0 +1,129 @@ +// SSE endpoint for the backlog 3-pane (PBI / story / task changes). +// Simpler than /api/realtime/solo — no sprint or user scoping, just product_id filter. +// Auth: iron-session cookie. Demo users may read (no 403 for demo). + +import { NextRequest } from 'next/server' +import { Client } from 'pg' +import { getSession } from '@/lib/auth' +import { getAccessibleProduct } from '@/lib/product-access' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 300 + +const CHANNEL = 'scrum4me_changes' +const HEARTBEAT_MS = 25_000 +const HARD_CLOSE_MS = 240_000 + +type NotifyPayload = Record + +function shouldEmit(payload: NotifyPayload, productId: string): boolean { + if ('type' in payload) return false // job / worker events — not relevant here + const entity = payload.entity as string | undefined + if (!entity || !['pbi', 'story', 'task'].includes(entity)) return false + return payload.product_id === productId +} + +export async function GET(request: NextRequest) { + const session = await getSession() + if (!session.userId) { + return Response.json({ error: 'Niet ingelogd' }, { status: 401 }) + } + + const productId = request.nextUrl.searchParams.get('product_id') + if (!productId) { + return Response.json({ error: 'product_id is verplicht' }, { status: 400 }) + } + + const product = await getAccessibleProduct(productId, session.userId) + if (!product) { + return Response.json({ error: 'Geen toegang tot dit product' }, { status: 403 }) + } + + const directUrl = process.env.DIRECT_URL ?? process.env.DATABASE_URL + if (!directUrl) { + return Response.json({ error: 'DIRECT_URL/DATABASE_URL niet geconfigureerd' }, { status: 500 }) + } + + const encoder = new TextEncoder() + const pgClient = new Client({ connectionString: directUrl }) + + let heartbeatTimer: ReturnType | null = null + let hardCloseTimer: ReturnType | null = null + let closed = false + + const stream = new ReadableStream({ + async start(controller) { + const enqueue = (chunk: string) => { + if (closed) return + try { + controller.enqueue(encoder.encode(chunk)) + } catch { + // stream already closed + } + } + + const cleanup = async (reason: string) => { + if (closed) return + closed = true + if (heartbeatTimer) clearInterval(heartbeatTimer) + if (hardCloseTimer) clearTimeout(hardCloseTimer) + try { await pgClient.end() } catch { /* ignore */ } + try { controller.close() } catch { /* already closed */ } + if (process.env.NODE_ENV !== 'production') { + console.log(`[realtime/backlog] closed: ${reason}`) + } + } + + try { + await pgClient.connect() + await pgClient.query(`LISTEN ${CHANNEL}`) + } catch (err) { + console.error('[realtime/backlog] pg connect/listen failed:', err) + enqueue(`event: error\ndata: ${JSON.stringify({ message: 'pg connect failed' })}\n\n`) + await cleanup('pg connect failed') + return + } + + pgClient.on('notification', (msg) => { + if (!msg.payload) return + let payload: NotifyPayload + try { + payload = JSON.parse(msg.payload) as NotifyPayload + } catch { + return + } + if (!shouldEmit(payload, productId)) return + enqueue(`data: ${msg.payload}\n\n`) + }) + + pgClient.on('error', async (err) => { + console.error('[realtime/backlog] pg client error:', err) + await cleanup('pg error') + }) + + enqueue(`event: ready\ndata: ${JSON.stringify({ product_id: productId })}\n\n`) + + heartbeatTimer = setInterval(() => { + enqueue(`: heartbeat\n\n`) + }, HEARTBEAT_MS) + + hardCloseTimer = setTimeout(() => { + cleanup('hard close 240s') + }, HARD_CLOSE_MS) + + request.signal.addEventListener('abort', () => { + cleanup('client aborted') + }) + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }) +} diff --git a/components/backlog/backlog-hydration-wrapper.tsx b/components/backlog/backlog-hydration-wrapper.tsx new file mode 100644 index 0000000..4bc5731 --- /dev/null +++ b/components/backlog/backlog-hydration-wrapper.tsx @@ -0,0 +1,30 @@ +'use client' + +import { useEffect } from 'react' +import { useBacklogStore, type BacklogPbi, type BacklogStory, type BacklogTask } from '@/stores/backlog-store' +import { useBacklogRealtime } from '@/lib/realtime/use-backlog-realtime' + +interface InitialData { + pbis: BacklogPbi[] + storiesByPbi: Record + tasksByStory: Record +} + +interface BacklogHydrationWrapperProps { + initialData: InitialData + productId: string + children: React.ReactNode +} + +export function BacklogHydrationWrapper({ initialData, productId, children }: BacklogHydrationWrapperProps) { + const setInitialData = useBacklogStore((s) => s.setInitialData) + + useEffect(() => { + setInitialData(initialData) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useBacklogRealtime(productId) + + return <>{children} +} diff --git a/components/backlog/backlog-split-pane.tsx b/components/backlog/backlog-split-pane.tsx new file mode 100644 index 0000000..882f13b --- /dev/null +++ b/components/backlog/backlog-split-pane.tsx @@ -0,0 +1,34 @@ +'use client' + +import { useState } from 'react' +import { useSelectionStore } from '@/stores/selection-store' +import { SplitPane, type SplitPaneProps } from '@/components/split-pane/split-pane' + +type Props = Omit + +export function BacklogSplitPane(props: Props) { + const { selectedPbiId, selectedStoryId } = useSelectionStore() + const [activeTab, setActiveTab] = useState(0) + + // React-recommended "derived state from props" pattern: update state during render + // instead of useEffect to avoid cascading renders. + const [prevPbiId, setPrevPbiId] = useState(selectedPbiId) + const [prevStoryId, setPrevStoryId] = useState(selectedStoryId) + + if (selectedStoryId !== prevStoryId) { + setPrevStoryId(selectedStoryId) + if (selectedStoryId) setActiveTab(2) + } + if (selectedPbiId !== prevPbiId) { + setPrevPbiId(selectedPbiId) + if (selectedPbiId && !selectedStoryId) setActiveTab(1) + } + + return ( + + ) +} diff --git a/components/backlog/empty-panel.tsx b/components/backlog/empty-panel.tsx new file mode 100644 index 0000000..e48f688 --- /dev/null +++ b/components/backlog/empty-panel.tsx @@ -0,0 +1,35 @@ +'use client' + +import { Button } from '@/components/ui/button' +import { DemoTooltip } from '@/components/shared/demo-tooltip' + +interface EmptyPanelProps { + title?: string + message: string + action?: { + label: string + onClick: () => void + disabled?: boolean + } +} + +export function EmptyPanel({ title, message, action }: EmptyPanelProps) { + return ( +
+ {title &&

{title}

} +

{message}

+ {action && ( + + + + )} +
+ ) +} diff --git a/components/backlog/pbi-list.tsx b/components/backlog/pbi-list.tsx index cb6a721..8f79138 100644 --- a/components/backlog/pbi-list.tsx +++ b/components/backlog/pbi-list.tsx @@ -27,11 +27,13 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { PanelNavBar } from '@/components/shared/panel-nav-bar' import { useSelectionStore } from '@/stores/selection-store' import { usePlannerStore } from '@/stores/planner-store' +import { useBacklogStore } from '@/stores/backlog-store' import { deletePbiAction } from '@/actions/pbis' import { reorderPbisAction, updatePbiPriorityAction } from '@/actions/stories' import { cn } from '@/lib/utils' import { PbiDialog, type PbiDialogState } from './pbi-dialog' import { BacklogCard } from './backlog-card' +import { EmptyPanel } from './empty-panel' import { DemoTooltip } from '@/components/shared/demo-tooltip' import { PRIORITY_COLORS } from '@/components/shared/priority-select' import { PBI_STATUS_LABELS, PBI_STATUS_COLORS } from '@/components/shared/pbi-status-select' @@ -115,7 +117,6 @@ interface Pbi { interface PbiListProps { productId: string - pbis: Pbi[] isDemo: boolean } @@ -194,7 +195,8 @@ function SortablePbiRow({ } // --- Main component --- -export function PbiList({ productId, pbis, isDemo }: PbiListProps) { +export function PbiList({ productId, isDemo }: PbiListProps) { + const pbis = useBacklogStore((s) => s.pbis) const { selectedPbiId, selectPbi } = useSelectionStore() const { pbiOrder, pbiPriority, initPbis, reorderPbis, rollbackPbis, updatePbiPriority } = usePlannerStore() // Defaults match SSR; persisted values applied post-mount in the loader effect below. @@ -417,14 +419,10 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
{pbis.length === 0 ? ( -
-

Nog geen PBI's aangemaakt.

- - - -
+ setDialogState({ mode: 'create', productId, defaultPriority: 2 }), disabled: isDemo }} + /> ) : ( isDemo: boolean } // --- Sortable story block --- function SortableStoryBlock({ story, - onClick, + isSelected, + onSelect, + onEdit, }: { story: Story - onClick: () => void + isSelected: boolean + onSelect: () => void + onEdit: () => void }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: story.id, @@ -91,19 +96,33 @@ function SortableStoryBlock({ code={story.code} priority={story.priority} isDragging={isDragging} - onClick={onClick} + isSelected={isSelected} + onClick={onSelect} badge={ {STATUS_LABELS[story.status] ?? story.status} } + actions={ + + } /> ) } // --- Main component --- -export function StoryPanel({ productId, storiesByPbi, isDemo }: StoryPanelProps) { - const { selectedPbiId } = useSelectionStore() +export function StoryPanel({ productId, isDemo }: StoryPanelProps) { + const { selectedPbiId, selectedStoryId, selectStory } = useSelectionStore() + const storiesByPbi = useBacklogStore((s) => s.storiesByPbi) const { storyOrder, initStories, reorderStories, rollbackStories } = usePlannerStore() const [filterStatus, setFilterStatus] = useState(null) const [filterPriority, setFilterPriority] = useState(null) @@ -242,20 +261,12 @@ export function StoryPanel({ productId, storiesByPbi, isDemo }: StoryPanelProps)
{selectedPbiId === null ? ( -

- Selecteer een PBI om de stories te bekijken. -

+ ) : rawStories.length === 0 ? ( -
-

Nog geen stories voor dit PBI.

- {selectedPbiId && ( - - - - )} -
+ setStoryDialogState({ mode: 'create', pbiId: selectedPbiId, productId, defaultPriority: 2 }), disabled: isDemo }} + /> ) : ( setStoryDialogState({ mode: 'edit', story, productId })} + isSelected={selectedStoryId === story.id} + onSelect={() => selectStory(story.id)} + onEdit={() => setStoryDialogState({ mode: 'edit', story, productId })} /> ))}
diff --git a/components/backlog/task-panel.tsx b/components/backlog/task-panel.tsx new file mode 100644 index 0000000..c3d7526 --- /dev/null +++ b/components/backlog/task-panel.tsx @@ -0,0 +1,225 @@ +'use client' + +import { useState, useTransition } from 'react' +import { useRouter } from 'next/navigation' +import { + DndContext, + DragEndEvent, + DragOverlay, + DragStartEvent, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + closestCenter, +} from '@dnd-kit/core' +import { + SortableContext, + useSortable, + rectSortingStrategy, + arrayMove, + sortableKeyboardCoordinates, +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { toast } from 'sonner' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { PanelNavBar } from '@/components/shared/panel-nav-bar' +import { DemoTooltip } from '@/components/shared/demo-tooltip' +import { useSelectionStore } from '@/stores/selection-store' +import { useBacklogStore, type BacklogTask } from '@/stores/backlog-store' +import { reorderTasksAction } from '@/actions/tasks' +import { BacklogCard } from './backlog-card' +import { EmptyPanel } from './empty-panel' +import { cn } from '@/lib/utils' + +const STATUS_COLORS: Record = { + TO_DO: 'bg-status-todo/15 text-status-todo border-status-todo/30', + IN_PROGRESS: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30', + REVIEW: 'bg-status-review/15 text-status-review border-status-review/30', + DONE: 'bg-status-done/15 text-status-done border-status-done/30', +} +const STATUS_LABELS: Record = { + TO_DO: 'To Do', + IN_PROGRESS: 'Bezig', + REVIEW: 'Review', + DONE: 'Klaar', +} + +function SortableTaskCard({ + task, + isDemo, + onClick, +}: { + task: BacklogTask + isDemo: boolean + onClick: () => void +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = + useSortable({ id: task.id }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + } + + return ( + + {STATUS_LABELS[task.status] ?? task.status} + + } + /> + ) +} + +interface TaskPanelProps { + productId: string + isDemo: boolean + closePath: string +} + +export function TaskPanel({ isDemo, closePath }: TaskPanelProps) { + const router = useRouter() + const [, startTransition] = useTransition() + const selectedStoryId = useSelectionStore((s) => s.selectedStoryId) + const tasksByStory = useBacklogStore((s) => s.tasksByStory) + const [activeDragId, setActiveDragId] = useState(null) + const [localOrder, setLocalOrder] = useState(null) + + const rawTasks = selectedStoryId ? (tasksByStory[selectedStoryId] ?? []) : null + + // Merge local order with rawTasks for optimistic reorder + const tasks: BacklogTask[] | null = rawTasks === null + ? null + : localOrder + ? localOrder.map((id) => rawTasks.find((t) => t.id === id)).filter(Boolean) as BacklogTask[] + : rawTasks + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ) + + function handleDragStart(event: DragStartEvent) { + setActiveDragId(event.active.id as string) + } + + function handleDragEnd(event: DragEndEvent) { + setActiveDragId(null) + if (!selectedStoryId || !tasks) return + const { active, over } = event + if (!over || active.id === over.id) return + + const ids = tasks.map((t) => t.id) + const oldIndex = ids.indexOf(active.id as string) + const newIndex = ids.indexOf(over.id as string) + if (oldIndex === -1 || newIndex === -1) return + + const newOrder = arrayMove(ids, oldIndex, newIndex) + setLocalOrder(newOrder) + + startTransition(async () => { + const result = await reorderTasksAction(selectedStoryId, newOrder) + if (result?.error) { + setLocalOrder(null) + toast.error(result.error) + } + }) + } + + const navActions = ( + + + + ) + + if (tasks === null) { + return ( +
+ + +
+ ) + } + + if (tasks.length === 0) { + return ( +
+ + router.push(`${closePath}?newTask=1&storyId=${selectedStoryId}`), + disabled: isDemo, + }} + /> +
+ ) + } + + const activeTask = activeDragId ? tasks.find((t) => t.id === activeDragId) : null + + return ( +
+ +
+ + t.id)} strategy={rectSortingStrategy}> +
+ {tasks.map((task) => ( + router.push(`${closePath}?editTask=${task.id}`)} + /> + ))} +
+
+ + + {activeTask && ( + + )} + +
+
+
+ ) +} diff --git a/components/split-pane/split-pane.tsx b/components/split-pane/split-pane.tsx index 58cc8d9..13dac6f 100644 --- a/components/split-pane/split-pane.tsx +++ b/components/split-pane/split-pane.tsx @@ -1,70 +1,117 @@ 'use client' -import { useRef, useState, useEffect, useCallback } from 'react' +import { Fragment, useRef, useState, useEffect, useCallback } from 'react' import { cn } from '@/lib/utils' -const COOKIE_PREFIX = 'split-pane:' +const COOKIE_PREFIX = 'sp:' const COOKIE_MAX_AGE = 60 * 60 * 24 * 365 -function readSplitCookie(key: string): number | null { +function readSplits(cookieKey: string, n: number): number[] | null { if (typeof document === 'undefined') return null - const match = document.cookie.match(new RegExp(`(?:^|; )${COOKIE_PREFIX}${key}=([^;]+)`)) + const match = document.cookie.match( + new RegExp(`(?:^|; )${COOKIE_PREFIX}${cookieKey}=([^;]+)`) + ) if (!match) return null - const val = parseFloat(decodeURIComponent(match[1])) - return !isNaN(val) && val > 0 && val < 100 ? val : null + try { + const parsed: unknown = JSON.parse(decodeURIComponent(match[1])) + if ( + !Array.isArray(parsed) || + parsed.length !== n || + parsed.some((v) => typeof v !== 'number') || + Math.abs((parsed as number[]).reduce((a, b) => a + b, 0) - 100) > 1 + ) return null + return parsed as number[] + } catch { + return null + } } -function writeSplitCookie(key: string, value: number) { - document.cookie = `${COOKIE_PREFIX}${key}=${value}; max-age=${COOKIE_MAX_AGE}; path=/; samesite=lax` +function writeSplits(cookieKey: string, splits: number[]) { + document.cookie = `${COOKIE_PREFIX}${cookieKey}=${encodeURIComponent( + JSON.stringify(splits) + )}; max-age=${COOKIE_MAX_AGE}; path=/; samesite=lax` } -interface SplitPaneProps { - left: React.ReactNode - right: React.ReactNode - storageKey: string - defaultSplit?: number // percentage for left pane - minSize?: number // minimum px per pane, default 200 +export interface SplitPaneProps { + panes: React.ReactNode[] + defaultSplit: number[] // length n, values sum to 100 + cookieKey: string + tabLabels?: string[] // mobile tab labels, defaults to "Pane N" + minSize?: number // minimum px per pane, default 200 + mobileBreakpoint?: number // default 1024 + activeTab?: number // controlled: parent manages which tab is active + onActiveTabChange?: (index: number) => void } export function SplitPane({ - left, - right, - storageKey, - defaultSplit = 20, + panes, + defaultSplit, + cookieKey, + tabLabels, minSize = 200, + mobileBreakpoint = 1024, + activeTab: activeTabProp, + onActiveTabChange, }: SplitPaneProps) { + const isControlled = activeTabProp !== undefined + const n = panes.length const containerRef = useRef(null) - const [split, setSplit] = useState(() => { - return readSplitCookie(storageKey) ?? defaultSplit - }) - const [isDragging, setIsDragging] = useState(false) - const [isMobile, setIsMobile] = useState(false) - const [activeTab, setActiveTab] = useState<'left' | 'right'>('left') + const splitsRef = useRef(defaultSplit) + + const [splits, setSplits] = useState(() => { + return readSplits(cookieKey, n) ?? defaultSplit + }) + const [dragging, setDragging] = useState(null) // divider index (0..n-2) + const [isMobile, setIsMobile] = useState(false) + const [internalTab, setInternalTab] = useState(0) + const activeTab = isControlled ? activeTabProp : internalTab + + const handleTabChange = (i: number) => { + if (!isControlled) setInternalTab(i) + onActiveTabChange?.(i) + } + + useEffect(() => { splitsRef.current = splits }, [splits]) - // Detect mobile useEffect(() => { - const check = () => setIsMobile(window.innerWidth < 1024) + const check = () => setIsMobile(window.innerWidth < mobileBreakpoint) check() window.addEventListener('resize', check) return () => window.removeEventListener('resize', check) - }, []) + }, [mobileBreakpoint]) const onMouseMove = useCallback((e: MouseEvent) => { - if (!isDragging || !containerRef.current) return + if (dragging === null || !containerRef.current) return const rect = containerRef.current.getBoundingClientRect() const containerWidth = rect.width - const offsetX = e.clientX - rect.left const minPct = (minSize / containerWidth) * 100 - const maxPct = 100 - minPct - const newSplit = Math.min(maxPct, Math.max(minPct, (offsetX / containerWidth) * 100)) - setSplit(newSplit) - writeSplitCookie(storageKey, newSplit) - }, [isDragging, minSize, storageKey]) - const onMouseUp = useCallback(() => setIsDragging(false), []) + const cursorPct = ((e.clientX - rect.left) / containerWidth) * 100 + const current = splitsRef.current + // Left edge of pane[dragging] in percentage + const leftEdge = current.slice(0, dragging).reduce((a, b) => a + b, 0) + const combinedWidth = current[dragging] + current[dragging + 1] + + const newLeft = Math.min(Math.max(cursorPct - leftEdge, minPct), combinedWidth - minPct) + const newRight = combinedWidth - newLeft + + setSplits((prev) => { + const next = [...prev] + next[dragging] = newLeft + next[dragging + 1] = newRight + return next + }) + }, [dragging, minSize]) + + const onMouseUp = useCallback(() => { + if (dragging !== null) { + writeSplits(cookieKey, splitsRef.current) + setDragging(null) + } + }, [dragging, cookieKey]) useEffect(() => { - if (isDragging) { + if (dragging !== null) { window.addEventListener('mousemove', onMouseMove) window.addEventListener('mouseup', onMouseUp) } @@ -72,37 +119,38 @@ export function SplitPane({ window.removeEventListener('mousemove', onMouseMove) window.removeEventListener('mouseup', onMouseUp) } - }, [isDragging, onMouseMove, onMouseUp]) + }, [dragging, onMouseMove, onMouseUp]) if (isMobile) { return (
-
- - +
+ {activeTab > 0 && ( + + )} + {panes.map((_, i) => ( + + ))}
- {activeTab === 'left' ? left : right} + {panes[activeTab]}
) @@ -110,24 +158,25 @@ export function SplitPane({ return (
- {/* Left pane */} -
- {left} -
- - {/* Divider */} -
setIsDragging(true)} - className={cn( - 'w-1 shrink-0 bg-border hover:bg-primary transition-colors cursor-col-resize', - isDragging && 'bg-primary' - )} - /> - - {/* Right pane */} -
- {right} -
+ {panes.map((pane, i) => ( + + {i > 0 && ( +
setDragging(i - 1)} + className={cn( + 'w-1 shrink-0 bg-border hover:bg-primary transition-colors cursor-col-resize', + dragging === i - 1 && 'bg-primary' + )} + /> + )} +
+ {pane} +
+ + ))}
) } diff --git a/components/split-pane/triple-pane.tsx b/components/split-pane/triple-pane.tsx deleted file mode 100644 index ee4a728..0000000 --- a/components/split-pane/triple-pane.tsx +++ /dev/null @@ -1,137 +0,0 @@ -'use client' - -import { useRef, useState, useEffect, useCallback } from 'react' -import { cn } from '@/lib/utils' - -interface TriplePaneProps { - left: React.ReactNode - middle: React.ReactNode - right: React.ReactNode - storageKey: string - defaultLeft?: number // % width for left pane - defaultMiddle?: number // % width for middle pane, right gets the rest - minSize?: number // minimum px per pane -} - -export function TriplePane({ - left, middle, right, storageKey, - defaultLeft = 28, defaultMiddle = 35, minSize = 180, -}: TriplePaneProps) { - const containerRef = useRef(null) - - const load = (key: string, def: number) => { - if (typeof window === 'undefined') return def - const stored = localStorage.getItem(`triple-pane:${storageKey}:${key}`) - if (stored) { - const val = parseFloat(stored) - if (!isNaN(val) && val > 0 && val < 100) return val - } - return def - } - - const [leftPct, setLeftPct] = useState(() => load('left', defaultLeft)) - const [midPct, setMidPct] = useState(() => load('mid', defaultMiddle)) - const [dragging, setDragging] = useState<'left' | 'right' | null>(null) - const [isMobile, setIsMobile] = useState(false) - const [activeTab, setActiveTab] = useState<'left' | 'middle' | 'right'>('left') - - useEffect(() => { - const check = () => setIsMobile(window.innerWidth < 1024) - check() - window.addEventListener('resize', check) - return () => window.removeEventListener('resize', check) - }, []) - - const onMouseMove = useCallback((e: MouseEvent) => { - if (!dragging || !containerRef.current) return - const rect = containerRef.current.getBoundingClientRect() - const width = rect.width - const minPct = (minSize / width) * 100 - const offsetPct = ((e.clientX - rect.left) / width) * 100 - - if (dragging === 'left') { - const clamped = Math.min(Math.max(offsetPct, minPct), 100 - midPct - minPct) - setLeftPct(clamped) - localStorage.setItem(`triple-pane:${storageKey}:left`, String(clamped)) - } else { - const clamped = Math.min(Math.max(offsetPct - leftPct, minPct), 100 - leftPct - minPct) - setMidPct(clamped) - localStorage.setItem(`triple-pane:${storageKey}:mid`, String(clamped)) - } - }, [dragging, leftPct, midPct, minSize, storageKey]) - - const onMouseUp = useCallback(() => setDragging(null), []) - - useEffect(() => { - if (dragging) { - window.addEventListener('mousemove', onMouseMove) - window.addEventListener('mouseup', onMouseUp) - } - return () => { - window.removeEventListener('mousemove', onMouseMove) - window.removeEventListener('mouseup', onMouseUp) - } - }, [dragging, onMouseMove, onMouseUp]) - - if (isMobile) { - const tabs = ['left', 'middle', 'right'] as const - const labels = ['Backlog', 'Sprint', 'Taken'] - return ( -
-
- {tabs.map((tab, i) => ( - - ))} -
-
- {activeTab === 'left' ? left : activeTab === 'middle' ? middle : right} -
-
- ) - } - - const rightPct = 100 - leftPct - midPct - - return ( -
-
- {left} -
- -
setDragging('left')} - className={cn( - 'w-1 shrink-0 bg-border hover:bg-primary transition-colors cursor-col-resize', - dragging === 'left' && 'bg-primary' - )} - /> - -
- {middle} -
- -
setDragging('right')} - className={cn( - 'w-1 shrink-0 bg-border hover:bg-primary transition-colors cursor-col-resize', - dragging === 'right' && 'bg-primary' - )} - /> - -
- {right} -
-
- ) -} diff --git a/components/sprint/sprint-board-client.tsx b/components/sprint/sprint-board-client.tsx index ce3e18d..d499767 100644 --- a/components/sprint/sprint-board-client.tsx +++ b/components/sprint/sprint-board-client.tsx @@ -7,7 +7,7 @@ import { } from '@dnd-kit/core' import { sortableKeyboardCoordinates, arrayMove } from '@dnd-kit/sortable' import { toast } from 'sonner' -import { TriplePane } from '@/components/split-pane/triple-pane' +import { SplitPane } from '@/components/split-pane/split-pane' import { SprintBacklogLeft, SprintBacklogRight } from './sprint-backlog' import type { SprintStory, PbiWithStories, ProductMember } from './sprint-backlog' import { TaskList } from './task-list' @@ -200,18 +200,20 @@ export function SprintBoardClient({ onDragStart={handleDragStart} onDragEnd={handleDragEnd} > - - } - middle={ + />, - } - right={ + />, selectedStoryId ? ( s.id === selectedStoryId)?.code ?? null} sprintId={sprintId} @@ -235,11 +236,11 @@ export function SprintBoardClient({ isDemo={isDemo} /> ) : ( -
+

Selecteer een story om de taken te bekijken.

- ) - } + ), + ]} /> {activeDragStory && ( diff --git a/docs/scrum4me-architecture.md b/docs/scrum4me-architecture.md index 5197f99..6439d5f 100644 --- a/docs/scrum4me-architecture.md +++ b/docs/scrum4me-architecture.md @@ -733,8 +733,9 @@ scrum4me/ │ ├── product-access.ts # productAccessFilter helper (eigenaar of teamlid) │ └── env.ts # Zod-gevalideerde env vars ├── stores/ # Zustand stores +│ ├── backlog-store.ts # PBI/story/task hydration + applyChange (SSE) │ ├── planner-store.ts # Optimistische drag-and-drop volgorde -│ ├── selection-store.ts # Geselecteerd PBI / story +│ ├── selection-store.ts # Geselecteerd PBI / story (cascade-reset) │ ├── sprint-store.ts # Sprint Backlog taakvolgordes │ ├── solo-store.ts # Solo board optimistische taakstatus │ └── product-store.ts # Actief product (naam + id) voor navbar @@ -1003,6 +1004,67 @@ Iron-session cookie of Bearer-token (demo). De auth-check loopt éénmalig bij d --- +## Realtime — Backlog SSE (ST-1115) + +De Product Backlog-pagina (`/products/[id]`) update live als PBI's, stories of taken worden gemuteerd. De pijplijn is gelijk aan de Solo-SSE (M8), maar met een eenvoudiger server-side filter: alleen `product_id`-scope, geen sprint- of user-scope. + +``` +┌─────────────────────────┐ +│ Mutatie (Prisma write) │ Server Action, MCP, etc. +└────────────┬────────────┘ + ▼ +┌─────────────────────────┐ +│ Postgres row trigger │ AFTER INSERT/UPDATE/DELETE +│ scrum4me_notify_change()│ entity: 'pbi' | 'story' | 'task' +└────────────┬────────────┘ + ▼ pg_notify('scrum4me_changes', json) +┌─────────────────────────┐ +│ /api/realtime/backlog │ Node runtime, dedicated pg.Client +│ LISTEN scrum4me_changes │ filtert op entity ∈ {pbi,story,task} +│ │ én product_id matcht query-param +└────────────┬────────────┘ + ▼ text/event-stream +┌─────────────────────────┐ +│ EventSource (browser) │ beheerd door useBacklogRealtime +│ → backlog-store.apply │ via applyChange(entity, op, data) +│ Change(entity,op,data)│ +└────────────┬────────────┘ + ▼ +┌─────────────────────────┐ +│ PbiList / StoryPanel / │ re-render op basis van Zustand state +│ TaskPanel re-render │ +└─────────────────────────┘ +``` + +### Hydration en SSE-mount + +De pagina is een Server Component die alle data parallel fetcht. Resultaten worden doorgegeven aan `BacklogHydrationWrapper` (client component), die: +1. `useBacklogStore.setInitialData(...)` aanroept op mount (eenmalig). +2. `useBacklogRealtime(productId)` mount — opent de SSE-stream. + +Alle client-componenten (PbiList, StoryPanel, TaskPanel) lezen uitsluitend uit de Zustand store; ze accepteren geen data-props meer. + +### backlog-store en applyChange + +```ts +// stores/backlog-store.ts +applyChange(entity: 'pbi' | 'story' | 'task', op: 'I' | 'U' | 'D', data: Record) +``` + +- **I (Insert):** voegt het nieuwe object toe aan de juiste sub-array +- **U (Update):** patcht de bestaande entry in-place via spread (`{ ...existing, ...data }`) +- **D (Delete):** filtert de entry weg op `id`; doorzoekt alle sub-arrays omdat de parent-ID afwezig kan zijn in het delete-payload + +### Server-side filter (backlog) + +`/api/realtime/backlog?product_id=...` filtert op: +- `entity ∈ {pbi, story, task}` — job/worker-events en questions worden genegeerd +- `product_id` matcht de query-param + +Demo-gebruikers mogen lezen (geen 403). Overige lifecycle-kenmerken (heartbeat, hard-close, backoff, visibility-pause) zijn identiek aan de Solo SSE. + +--- + ## Demo-user policy (ST-1110) Demo-gebruikers (`is_demo = true` in de database, `isDemo: true` in de iron-session) hebben volledig read-only toegang. Bescherming is drielaags: diff --git a/docs/scrum4me-functional-spec.md b/docs/scrum4me-functional-spec.md index 9b5712e..69dc93a 100644 --- a/docs/scrum4me-functional-spec.md +++ b/docs/scrum4me-functional-spec.md @@ -194,27 +194,37 @@ Gebruikers kunnen producten aanmaken, bewerken en archiveren. Een product is het --- -### F-04: Product Backlog — gesplitst scherm +### F-04: Product Backlog — 3-paneels gesplitst scherm **Prioriteit:** v1 — Kritiek **Persona:** Lars, Dina, Remi **Omschrijving:** -De Product Backlog wordt weergegeven als een gesplitst scherm: links de PBI's gerangschikt op prioriteit en volgorde, rechts de stories van het geselecteerde PBI. De splitter is horizontaal versleepbaar. Elk paneel heeft een eigen navigatiebar met acties (aanmaken, filteren, verwijderen). +De Product Backlog wordt weergegeven als een 3-paneels gesplitst scherm: PBI's (links) | Stories (midden) | Taken (rechts). De splitters zijn versleepbaar. Selectie cascadeert: klikken op een PBI toont de bijbehorende stories; klikken op een story toont de bijbehorende taken. Elk paneel heeft een eigen navigatiebar met acties. **Acceptatiecriteria:** -- [ ] Standaard splitverhouding is 40/60 (PBI's / stories) -- [ ] Splitter is versleepbaar; positie wordt lokaal opgeslagen (localStorage) -- [ ] Selecteren van een PBI links toont de bijbehorende stories rechts +- [ ] Standaard splitverhouding is 20/45/35 (PBI's / Stories / Taken) +- [ ] Splitters zijn versleepbaar; positie wordt opgeslagen in een cookie (`sp:backlog-{id}`) +- [ ] Selecteren van een PBI toont de bijbehorende stories in het middenpaneel - [ ] Geselecteerd PBI is visueel gemarkeerd (achtergrondkleur of rand) -- [ ] Linkerpaneel navigatiebar bevat: [+ PBI aanmaken], [filter], [verwijderen] -- [ ] Rechterpaneel navigatiebar bevat: [+ Story aanmaken], [filter], [verwijderen] -- [ ] Lege staat links: prompt om eerste PBI aan te maken -- [ ] Lege staat rechts (geen PBI geselecteerd): instructie om een PBI te selecteren -- [ ] Lege staat rechts (PBI geselecteerd, geen stories): prompt om eerste story aan te maken +- [ ] Selecteren van een story toont de bijbehorende taken in het rechterpaneel +- [ ] Geselecteerde story is visueel gemarkeerd +- [ ] Cascade-reset: selecteren van een ander PBI wist de geselecteerde story en taken +- [ ] PBI-paneel navigatiebar bevat: [+ PBI aanmaken] +- [ ] Stories-paneel navigatiebar bevat: [+ Story aanmaken], [sorteer], [filter status] +- [ ] Taken-paneel navigatiebar bevat: [+ Nieuwe taak] +- [ ] Lege staat PBI-paneel: prompt om eerste PBI aan te maken +- [ ] Lege staat Stories-paneel (geen PBI geselecteerd): instructie om een PBI te selecteren +- [ ] Lege staat Stories-paneel (PBI geselecteerd, geen stories): prompt om eerste story aan te maken +- [ ] Lege staat Taken-paneel (geen story geselecteerd): instructie om een story te selecteren +- [ ] Lege staat Taken-paneel (story geselecteerd, geen taken): prompt om eerste taak aan te maken +- [ ] Taak aanmaken opent TaskDialog via `?newTask=1&storyId={id}` +- [ ] Taak bewerken opent TaskDialog via `?editTask={id}` **Randgevallen:** -- Scherm smaller dan 768px → gesplitst scherm schakelt over naar tabbladen (PBI's / Stories) +- Scherm smaller dan 1024px → 3-paneels scherm schakelt over naar 3 tabbladen (PBI's | Stories | Taken) +- Mobile tab-navigatie: klikken op PBI schakelt automatisch naar Stories-tab; klikken op story schakelt naar Taken-tab +- Mobile ← terug-knop in tab-header op tabs 2 en 3 navigeert naar het vorige tabblad --- diff --git a/lib/realtime/use-backlog-realtime.ts b/lib/realtime/use-backlog-realtime.ts new file mode 100644 index 0000000..272adac --- /dev/null +++ b/lib/realtime/use-backlog-realtime.ts @@ -0,0 +1,92 @@ +'use client' + +// ST-1115: Client hook for the backlog 3-pane SSE stream. +// Mounts in BacklogHydrationWrapper so it survives Server Action refreshes. +// Dispatches pbi/story/task change events into useBacklogStore.applyChange. + +import { useEffect, useRef } from 'react' +import { useBacklogStore } from '@/stores/backlog-store' + +const BACKOFF_START_MS = 1_000 +const BACKOFF_MAX_MS = 30_000 + +type EntityPayload = { + op: 'I' | 'U' | 'D' + entity: 'pbi' | 'story' | 'task' + [key: string]: unknown +} + +export function useBacklogRealtime(productId: string | null) { + const sourceRef = useRef(null) + const backoffRef = useRef(BACKOFF_START_MS) + const reconnectTimerRef = useRef | null>(null) + + useEffect(() => { + if (!productId) return + + const close = () => { + if (sourceRef.current) { + sourceRef.current.close() + sourceRef.current = null + } + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current) + reconnectTimerRef.current = null + } + } + + const connect = () => { + close() + const source = new EventSource( + `/api/realtime/backlog?product_id=${encodeURIComponent(productId)}`, + ) + sourceRef.current = source + + source.addEventListener('ready', () => { + backoffRef.current = BACKOFF_START_MS + }) + + source.onmessage = (e) => { + if (!e.data) return + try { + const payload = JSON.parse(e.data) as EntityPayload + useBacklogStore + .getState() + .applyChange(payload.entity, payload.op, payload as Record) + } catch (err) { + if (process.env.NODE_ENV !== 'production') { + console.error('[realtime/backlog] failed to parse event', err, e.data) + } + } + } + + source.onerror = () => { + if (sourceRef.current !== source) return + close() + if (document.visibilityState === 'hidden') return + const delay = backoffRef.current + backoffRef.current = Math.min(backoffRef.current * 2, BACKOFF_MAX_MS) + reconnectTimerRef.current = setTimeout(connect, delay) + } + } + + const onVisibility = () => { + if (document.visibilityState === 'hidden') { + close() + } else if (sourceRef.current === null) { + backoffRef.current = BACKOFF_START_MS + connect() + } + } + + if (document.visibilityState === 'visible') { + connect() + } + document.addEventListener('visibilitychange', onVisibility) + + return () => { + document.removeEventListener('visibilitychange', onVisibility) + close() + } + }, [productId]) +} diff --git a/package-lock.json b/package-lock.json index 3a6f70f..50921e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,8 @@ "@mermaid-js/mermaid-cli": "^11.12.0", "@tailwindcss/postcss": "^4", "@tailwindcss/typography": "^0.5.19", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/bcryptjs": "^2.4.6", "@types/node": "^20", "@types/pg": "^8.20.0", @@ -58,6 +60,7 @@ "eslint": "^9", "eslint-config-next": "16.2.4", "husky": "^9.1.7", + "jsdom": "^29.1.1", "lint-staged": "^16.4.0", "prisma-erd-generator": "^2.4.2", "tailwindcss": "^4", @@ -66,6 +69,13 @@ "vitest": "^4.1.5" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -93,6 +103,57 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -584,6 +645,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@chevrotain/cst-dts-gen": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz", @@ -626,6 +700,146 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", @@ -1523,6 +1737,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -4024,6 +4256,93 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tootallnate/quickjs-emscripten": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", @@ -4118,6 +4437,14 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/bcryptjs": { "version": "2.4.6", "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", @@ -6266,6 +6593,16 @@ "integrity": "sha512-YOf0VSj5nUPI27doTtXF+BBnsiRq3qY7avHqfIWnppxTLGyvkLq1QV2RTxkwoZwJ60ywLfZ0raFF4J/G886i7A==", "license": "MIT" }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -7390,6 +7727,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -7988,6 +8346,20 @@ "node": ">= 12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -8076,6 +8448,13 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -8349,6 +8728,14 @@ "node": ">=0.10.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dompurify": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.1.tgz", @@ -8474,6 +8861,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", @@ -10300,6 +10700,19 @@ "node": ">=16.9.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -10497,6 +10910,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -11002,6 +11425,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -11346,6 +11776,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -12158,6 +12639,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -12523,6 +13015,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -13224,6 +13723,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -14114,6 +14623,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -14684,6 +15206,55 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/pretty-ms": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", @@ -15268,6 +15839,20 @@ "node": ">= 4" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -15733,6 +16318,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -16593,6 +17191,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -16713,6 +17324,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tabbable": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", @@ -16988,6 +17606,19 @@ "node": ">=16" } }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -17355,6 +17986,16 @@ "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", "license": "MIT" }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -17952,6 +18593,19 @@ "dev": true, "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -17961,6 +18615,41 @@ "node": ">= 8" } }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -18202,6 +18891,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index bf99e24..2d3b7df 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,8 @@ "@mermaid-js/mermaid-cli": "^11.12.0", "@tailwindcss/postcss": "^4", "@tailwindcss/typography": "^0.5.19", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/bcryptjs": "^2.4.6", "@types/node": "^20", "@types/pg": "^8.20.0", @@ -73,6 +75,7 @@ "eslint": "^9", "eslint-config-next": "16.2.4", "husky": "^9.1.7", + "jsdom": "^29.1.1", "lint-staged": "^16.4.0", "prisma-erd-generator": "^2.4.2", "tailwindcss": "^4", diff --git a/stores/backlog-store.ts b/stores/backlog-store.ts new file mode 100644 index 0000000..67daa62 --- /dev/null +++ b/stores/backlog-store.ts @@ -0,0 +1,139 @@ +import { create } from 'zustand' +import type { PbiStatusApi } from '@/lib/task-status' + +export interface BacklogPbi { + id: string + code: string | null + title: string + priority: number + description?: string | null + created_at: Date + status: PbiStatusApi +} + +export interface BacklogStory { + id: string + code: string | null + title: string + description: string | null + acceptance_criteria: string | null + priority: number + status: string + pbi_id: string + created_at: Date +} + +export interface BacklogTask { + id: string + title: string + description: string | null + priority: number + status: string + sort_order: number + story_id: string + created_at: Date +} + +type Entity = 'pbi' | 'story' | 'task' +type Op = 'I' | 'U' | 'D' + +interface InitialData { + pbis: BacklogPbi[] + storiesByPbi: Record + tasksByStory: Record +} + +interface BacklogStore extends InitialData { + setInitialData: (data: InitialData) => void + applyChange: (entity: Entity, op: Op, data: Record) => void +} + +export const useBacklogStore = create((set) => ({ + pbis: [], + storiesByPbi: {}, + tasksByStory: {}, + + setInitialData: (data) => set(data), + + applyChange: (entity, op, data) => + set((state) => { + if (entity === 'pbi') { + const id = data.id as string + if (op === 'D') { + return { pbis: state.pbis.filter((p) => p.id !== id) } + } + if (op === 'U') { + return { + pbis: state.pbis.map((p) => + p.id === id ? { ...p, ...(data as Partial) } : p + ), + } + } + // I + return { pbis: [...state.pbis, data as unknown as BacklogPbi] } + } + + if (entity === 'story') { + const id = data.id as string + if (op === 'D') { + const storiesByPbi = { ...state.storiesByPbi } + for (const pbiId of Object.keys(storiesByPbi)) { + storiesByPbi[pbiId] = storiesByPbi[pbiId].filter((s) => s.id !== id) + } + return { storiesByPbi } + } + if (op === 'U') { + const storiesByPbi = { ...state.storiesByPbi } + for (const pbiId of Object.keys(storiesByPbi)) { + const idx = storiesByPbi[pbiId].findIndex((s) => s.id === id) + if (idx !== -1) { + storiesByPbi[pbiId] = storiesByPbi[pbiId].map((s) => + s.id === id ? { ...s, ...(data as Partial) } : s + ) + break + } + } + return { storiesByPbi } + } + // I + const pbiId = data.pbi_id as string + return { + storiesByPbi: { + ...state.storiesByPbi, + [pbiId]: [...(state.storiesByPbi[pbiId] ?? []), data as unknown as BacklogStory], + }, + } + } + + // task + const id = data.id as string + if (op === 'D') { + const tasksByStory = { ...state.tasksByStory } + for (const storyId of Object.keys(tasksByStory)) { + tasksByStory[storyId] = tasksByStory[storyId].filter((t) => t.id !== id) + } + return { tasksByStory } + } + if (op === 'U') { + const tasksByStory = { ...state.tasksByStory } + for (const storyId of Object.keys(tasksByStory)) { + const idx = tasksByStory[storyId].findIndex((t) => t.id === id) + if (idx !== -1) { + tasksByStory[storyId] = tasksByStory[storyId].map((t) => + t.id === id ? { ...t, ...(data as Partial) } : t + ) + break + } + } + return { tasksByStory } + } + // I + const storyId = data.story_id as string + return { + tasksByStory: { + ...state.tasksByStory, + [storyId]: [...(state.tasksByStory[storyId] ?? []), data as unknown as BacklogTask], + }, + } + }), +}))