From 6cd98129f245d79317e9293c7cea74fe25274263 Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Thu, 30 Apr 2026 16:55:20 +0200 Subject: [PATCH] M14: TaskDialog (create/edit) + story auto-promotion (#21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(ST-1112): add deps for task dialog Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1112): add shared zod schema for task dialog Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1112): add missing MD3 tokens for task dialog outline-variant, on-error-container, status-review (light + dark) Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1112): add saveTask and deleteTask server actions for TaskDialog Unified create/edit action (saveTask) replaces separate formData-based actions for the new TaskDialog. Uses shared zod schema, structured SaveTaskResult union type, and context-aware revalidatePath for both sprint and backlog routes. Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1112): add TaskDialog component (create & edit mode) Builds the full TaskDialog on top of the existing @base-ui/react Dialog primitive. Covers create mode, edit mode (status field + created_at metadata + delete), dirty-check AlertDialog, delete confirm AlertDialog, Cmd+Enter submit, and per-field char counters. Uses react-hook-form + zodResolver against the shared taskSchema. Priority and status are extracted to PrioritySegmented and StatusSelect sub-components using MD3 tokens throughout. Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1112): refactor task-list to open TaskDialog via URL params Replaces inline create/edit forms with router.push navigation: - Clicking a task row → ?editTask= - "+ Taak" button → ?newTask=1&storyId= Removes CreateTaskForm, EditSubmitButton, updateTaskAction, and createTaskAction from the component. Status toggle and DnD remain unchanged. Rows now have cursor-pointer and keyboard a11y. Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1112): wire TaskDialog into sprint page via searchParams Sprint page now reads ?newTask, ?storyId, and ?editTask query params. For edit mode: fetches the task server-side with productAccessFilter scope (invalid/foreign IDs redirect to closePath). Renders TaskDialog when either param is present. closePath is the sprint route without query params. Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1112): add Suspense skeleton for edit-mode task loading Extracts task fetch into EditTaskLoader (async server component) so the sprint board renders immediately while the task loads. TaskDialogSkeleton shows 3 grey bars during the fetch. Invalid or out-of-scope task IDs redirect to closePath. Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1112): render description as markdown in task-detail-dialog Solo task detail now renders description via react-markdown + remark-gfm with prose styling. Sanitizes script/iframe elements. Co-Authored-By: Claude Sonnet 4.6 * test(ST-1112): add saveTask/deleteTask server action tests Covers all three demo-policy layers and cross-tenant scope: demo blocked (403), unauthenticated blocked, validation 422, edit cross-tenant forbidden, create cross-tenant forbidden, and happy-path for both edit and create. Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1112): add updateTaskStatusWithStoryPromotion helper Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1112): wire story-promotion into saveTask and PATCH /api/tasks/:id Co-Authored-By: Claude Sonnet 4.6 * docs(ST-1112): add task-dialog doc and architecture note Co-Authored-By: Claude Sonnet 4.6 * chore: extend allowed tools in settings.local.json Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1113): add 200ms animation-delay to TaskDialogSkeleton to prevent flicker Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1114): add DirtyCloseGuard reusable component for dirty-form close confirmation Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1114): add shared Markdown wrapper, apply to task-detail and story-dialog Co-Authored-By: Claude Sonnet 4.6 * chore: allow grep -E pattern in settings.local.json Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .claude/settings.local.json | 19 +- CLAUDE.md | 4 + __tests__/actions/tasks-dialog.test.ts | 225 +++ __tests__/api/security.test.ts | 30 +- __tests__/api/tasks.test.ts | 64 +- __tests__/lib/tasks-status-update.test.ts | 153 ++ actions/tasks.ts | 137 +- app/(app)/products/[id]/sprint/page.tsx | 35 +- app/_components/tasks/edit-task-loader.tsx | 47 + app/_components/tasks/priority-segmented.tsx | 56 + app/_components/tasks/status-select.tsx | 55 + .../tasks/task-dialog-skeleton.tsx | 42 + app/_components/tasks/task-dialog.tsx | 424 +++++ app/api/tasks/[id]/route.ts | 31 +- app/globals.css | 1 + app/styles/theme.css | 17 + components/backlog/story-dialog.tsx | 3 +- .../entity-dialog/dirty-close-guard.tsx | 58 + components/markdown.tsx | 21 + components/solo/task-detail-dialog.tsx | 3 +- components/sprint/task-list.tsx | 187 +- docs/scrum4me-architecture.md | 2 + docs/scrum4me-task-dialog.md | 506 ++++++ lib/schemas/task.ts | 12 + lib/tasks-status-update.ts | 72 + package-lock.json | 1585 ++++++++++++++++- package.json | 6 + 27 files changed, 3665 insertions(+), 130 deletions(-) create mode 100644 __tests__/actions/tasks-dialog.test.ts create mode 100644 __tests__/lib/tasks-status-update.test.ts create mode 100644 app/_components/tasks/edit-task-loader.tsx create mode 100644 app/_components/tasks/priority-segmented.tsx create mode 100644 app/_components/tasks/status-select.tsx create mode 100644 app/_components/tasks/task-dialog-skeleton.tsx create mode 100644 app/_components/tasks/task-dialog.tsx create mode 100644 components/entity-dialog/dirty-close-guard.tsx create mode 100644 components/markdown.tsx create mode 100644 docs/scrum4me-task-dialog.md create mode 100644 lib/schemas/task.ts create mode 100644 lib/tasks-status-update.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 30d7187..dd2c1d5 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -38,7 +38,24 @@ "Bash(git -C /Users/janpetervisser/Development/scrum4me-mcp/vendor/scrum4me log --oneline -3)", "Bash(git -C /Users/janpetervisser/Development/scrum4me-mcp/vendor/scrum4me branch -a)", "Bash(git fetch *)", - "Bash(git reset *)" + "Bash(git reset *)", + "mcp__scrum4me__update_task_plan", + "mcp__scrum4me__create_task", + "mcp__scrum4me__ask_user_question", + "Bash(git *)", + "mcp__scrum4me__create_pbi", + "mcp__scrum4me__create_story", + "mcp__scrum4me__health", + "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d.get\\('mcpServers', {}\\), indent=2\\)\\)\")", + "Read(//Users/janpetervisser/.claude/**)", + "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d, indent=2\\)\\)\")", + "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d.get\\('mcpServers',{}\\), indent=2\\)\\)\")", + "Bash(python3 -m json.tool)", + "mcp__scrum4me__wait_for_job", + "Bash(npx ctx7@latest docs /websites/github_en_rest \"How to fetch Copilot bot pull request reviews and identify them by author\")", + "Bash(npm i *)", + "Bash(curl *)", + "Bash(grep -E \"\\\\.\\(tsx|ts\\)$\")" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 6693080..f13941a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -289,6 +289,10 @@ Scrum4Me heeft een eigen MCP-server in repo [`madhura68/scrum4me-mcp`](https://g - `mcp__scrum4me__wait_for_job` — blokkeert ≤600s, claimt atomisch een QUEUED-job via FOR UPDATE SKIP LOCKED; retourneert volledige task-context (implementation_plan, story, pbi, sprint, repo_url). Zet stale CLAIMED-jobs (>30min) eerst terug naar QUEUED. - `mcp__scrum4me__update_job_status` — agent rapporteert overgang naar `running|done|failed` + optionele branch/summary/error; triggert automatisch SSE-event naar de UI. Auth: Bearer-token moet matchen `claimed_by_token_id`. +**Code koppellen aan app** +- 'Pak de volgende job uit de Scrum4Me-queue' - geeft in claude_workers een record toe, tool wait_for_job + + ### Prompt - `implement_next_story` (arg: `product_id`) — end-to-end workflow diff --git a/__tests__/actions/tasks-dialog.test.ts b/__tests__/actions/tasks-dialog.test.ts new file mode 100644 index 0000000..877aac5 --- /dev/null +++ b/__tests__/actions/tasks-dialog.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) +vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) })) +vi.mock('iron-session', () => ({ + getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), +})) +vi.mock('@/lib/session', () => ({ + sessionOptions: { cookieName: 'test', password: 'test' }, +})) +vi.mock('@/lib/product-access', () => ({ + productAccessFilter: vi.fn().mockReturnValue({}), +})) +vi.mock('@/lib/prisma', () => ({ + prisma: { + task: { + findFirst: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + findMany: vi.fn(), + }, + story: { + findFirst: vi.fn(), + findUniqueOrThrow: vi.fn(), + update: vi.fn(), + }, + $transaction: vi.fn(), + }, +})) + +import { prisma } from '@/lib/prisma' +import { getIronSession } from 'iron-session' +import { saveTask, deleteTask } from '@/actions/tasks' + +const mockPrisma = prisma as unknown as { + task: { + findFirst: ReturnType + create: ReturnType + update: ReturnType + delete: ReturnType + findMany: ReturnType + } + story: { + findFirst: ReturnType + findUniqueOrThrow: ReturnType + update: ReturnType + } + $transaction: ReturnType +} +const mockSession = getIronSession as ReturnType + +const VALID_INPUT = { + title: 'Test taak', + description: 'Beschrijving', + implementation_plan: 'Plan', + priority: 3, +} + +const TASK = { + id: 'task-1', + title: 'Test taak', + status: 'TO_DO', +} + +const STORY = { sprint_id: 'sprint-1' } + +beforeEach(() => { + vi.clearAllMocks() + mockSession.mockResolvedValue({ userId: 'user-1', isDemo: false }) + // Pass-through transaction so saveTask's $transaction wrapper executes its callback inline. + mockPrisma.$transaction.mockImplementation(async (run: (tx: typeof prisma) => Promise) => { + return run(prisma) + }) +}) + +// ─── saveTask ──────────────────────────────────────────────────────────────── + +describe('saveTask — demo-readonly (laag 2)', () => { + it('blokkeert demo-sessie', async () => { + mockSession.mockResolvedValue({ userId: 'user-1', isDemo: true }) + const result = await saveTask(VALID_INPUT, { productId: 'p-1' }) + expect(result).toEqual({ ok: false, code: 403, error: 'demo_readonly' }) + }) +}) + +describe('saveTask — unauthenticated', () => { + it('blokkeert niet-ingelogde gebruiker', async () => { + mockSession.mockResolvedValue({ userId: undefined, isDemo: false }) + const result = await saveTask(VALID_INPUT, { productId: 'p-1' }) + expect(result).toEqual({ ok: false, code: 403, error: 'forbidden' }) + }) +}) + +describe('saveTask — validatie', () => { + it('retourneert 422 bij lege titel', async () => { + const result = await saveTask({ ...VALID_INPUT, title: '' }, { productId: 'p-1', storyId: 's-1' }) + expect(result).toMatchObject({ ok: false, code: 422, error: 'validation' }) + }) + + it('retourneert 422 bij te lange titel (>120 tekens)', async () => { + const result = await saveTask( + { ...VALID_INPUT, title: 'a'.repeat(121) }, + { productId: 'p-1', storyId: 's-1' }, + ) + expect(result).toMatchObject({ ok: false, code: 422, error: 'validation' }) + }) +}) + +describe('saveTask — edit (cross-tenant scope)', () => { + it('retourneert forbidden als task buiten scope valt', async () => { + mockPrisma.task.findFirst.mockResolvedValue(null) // out-of-scope + const result = await saveTask(VALID_INPUT, { taskId: 'task-1', productId: 'p-1' }) + expect(result).toEqual({ ok: false, code: 403, error: 'forbidden' }) + }) + + it('update slaagt voor een geautoriseerde task', async () => { + mockPrisma.task.findFirst.mockResolvedValue(TASK) + mockPrisma.task.update.mockResolvedValue(TASK) + const result = await saveTask(VALID_INPUT, { taskId: 'task-1', productId: 'p-1' }) + expect(result).toMatchObject({ ok: true }) + // scope-filter is toegepast: findFirst bevat `story.product` + expect(mockPrisma.task.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ id: 'task-1', story: expect.anything() }), + }), + ) + }) +}) + +describe('saveTask — edit met status-promotie', () => { + it('promotes story naar DONE wanneer status flip naar DONE alle siblings DONE maakt', async () => { + mockPrisma.task.findFirst.mockResolvedValue({ id: 'task-1', status: 'IN_PROGRESS' }) + mockPrisma.task.update.mockResolvedValue({ + id: 'task-1', + title: 'Test taak', + status: 'IN_PROGRESS', + story_id: 'story-1', + implementation_plan: null, + }) + // Wanneer de helper draait, gebruikt-ie tx.task.update voor de status-flip. + // Dezelfde mock vangt beide updates op; tweede return-value voor de status-update. + mockPrisma.task.update.mockResolvedValueOnce({ + id: 'task-1', + title: 'Test taak', + status: 'IN_PROGRESS', + story_id: 'story-1', + implementation_plan: null, + }).mockResolvedValueOnce({ + id: 'task-1', + title: 'Test taak', + status: 'DONE', + story_id: 'story-1', + implementation_plan: null, + }) + mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }, { status: 'DONE' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + + const result = await saveTask( + { ...VALID_INPUT, status: 'DONE' }, + { taskId: 'task-1', productId: 'p-1' }, + ) + + expect(result).toMatchObject({ ok: true }) + expect(mockPrisma.story.update).toHaveBeenCalledWith({ + where: { id: 'story-1' }, + data: { status: 'DONE' }, + }) + }) +}) + +describe('saveTask — create (cross-tenant scope)', () => { + it('retourneert forbidden als story buiten scope valt', async () => { + mockPrisma.story.findFirst.mockResolvedValue(null) + const result = await saveTask(VALID_INPUT, { storyId: 's-1', productId: 'p-1' }) + expect(result).toEqual({ ok: false, code: 403, error: 'forbidden' }) + }) + + it('aanmaken slaagt voor een geautoriseerde story', async () => { + mockPrisma.story.findFirst.mockResolvedValue(STORY) + mockPrisma.task.findFirst.mockResolvedValue(null) // geen vorige taak + mockPrisma.task.create.mockResolvedValue(TASK) + const result = await saveTask(VALID_INPUT, { storyId: 's-1', productId: 'p-1' }) + expect(result).toMatchObject({ ok: true }) + }) +}) + +// ─── deleteTask ────────────────────────────────────────────────────────────── + +describe('deleteTask — demo-readonly (laag 2)', () => { + it('blokkeert demo-sessie', async () => { + mockSession.mockResolvedValue({ userId: 'user-1', isDemo: true }) + const result = await deleteTask('task-1', { productId: 'p-1' }) + expect(result).toEqual({ ok: false, code: 403, error: 'demo_readonly' }) + }) +}) + +describe('deleteTask — unauthenticated', () => { + it('blokkeert niet-ingelogde gebruiker', async () => { + mockSession.mockResolvedValue({ userId: undefined, isDemo: false }) + const result = await deleteTask('task-1', { productId: 'p-1' }) + expect(result).toEqual({ ok: false, code: 403, error: 'forbidden' }) + }) +}) + +describe('deleteTask — cross-tenant scope', () => { + it('retourneert forbidden als task buiten scope valt', async () => { + mockPrisma.task.findFirst.mockResolvedValue(null) + const result = await deleteTask('task-1', { productId: 'p-1' }) + expect(result).toEqual({ ok: false, code: 403, error: 'forbidden' }) + }) + + it('verwijderen slaagt voor een geautoriseerde task', async () => { + mockPrisma.task.findFirst.mockResolvedValue(TASK) + mockPrisma.task.delete.mockResolvedValue(TASK) + const result = await deleteTask('task-1', { productId: 'p-1' }) + expect(result).toEqual({ ok: true }) + // scope-filter toegepast + expect(mockPrisma.task.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ id: 'task-1', story: expect.anything() }), + }), + ) + }) +}) diff --git a/__tests__/api/security.test.ts b/__tests__/api/security.test.ts index 3df9d88..4d37fdd 100644 --- a/__tests__/api/security.test.ts +++ b/__tests__/api/security.test.ts @@ -11,10 +11,13 @@ vi.mock('@/lib/prisma', () => ({ }, story: { findFirst: vi.fn(), + findUniqueOrThrow: vi.fn(), + update: vi.fn(), }, task: { findFirst: vi.fn(), update: vi.fn(), + findMany: vi.fn(), }, storyLog: { create: vi.fn(), @@ -43,8 +46,16 @@ import { POST as postTodo } from '@/app/api/todos/route' const mockPrisma = prisma as unknown as { product: { findMany: ReturnType; findFirst: ReturnType } sprint: { findFirst: ReturnType } - story: { findFirst: ReturnType } - task: { findFirst: ReturnType; update: ReturnType } + story: { + findFirst: ReturnType + findUniqueOrThrow: ReturnType + update: ReturnType + } + task: { + findFirst: ReturnType + update: ReturnType + findMany: ReturnType + } storyLog: { create: ReturnType } todo: { create: ReturnType } $transaction: ReturnType @@ -85,6 +96,11 @@ function routeCtx(id: string) { beforeEach(() => { vi.clearAllMocks() + // Pass-through transaction so callers can `prisma.$transaction(async tx => ...)` in routes. + mockPrisma.$transaction.mockImplementation(async (run: unknown) => { + if (typeof run === 'function') return (run as (tx: typeof prisma) => Promise)(prisma) + return run + }) }) // ─── GET /api/products ──────────────────────────────────────────────────────── @@ -386,7 +402,15 @@ describe('PATCH /api/tasks/:id', () => { id: 'task-1', story: { product: { user_id: 'user-1' } }, }) - mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'DONE' }) + mockPrisma.task.update.mockResolvedValue({ + id: 'task-1', + title: 'Task', + status: 'DONE', + story_id: 'story-1', + implementation_plan: null, + }) + mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' }) const res = await patchTask( makePatch('http://localhost/api/tasks/task-1', { status: 'done' }), diff --git a/__tests__/api/tasks.test.ts b/__tests__/api/tasks.test.ts index b51f55b..ed0616e 100644 --- a/__tests__/api/tasks.test.ts +++ b/__tests__/api/tasks.test.ts @@ -5,7 +5,13 @@ vi.mock('@/lib/prisma', () => ({ task: { findFirst: vi.fn(), update: vi.fn(), + findMany: vi.fn(), }, + story: { + findUniqueOrThrow: vi.fn(), + update: vi.fn(), + }, + $transaction: vi.fn(), }, })) @@ -18,7 +24,16 @@ import { authenticateApiRequest } from '@/lib/api-auth' import { PATCH as patchTask } from '@/app/api/tasks/[id]/route' const mockPrisma = prisma as unknown as { - task: { findFirst: ReturnType; update: ReturnType } + task: { + findFirst: ReturnType + update: ReturnType + findMany: ReturnType + } + story: { + findUniqueOrThrow: ReturnType + update: ReturnType + } + $transaction: ReturnType } const mockAuth = authenticateApiRequest as ReturnType @@ -55,6 +70,15 @@ describe('PATCH /api/tasks/:id', () => { id: 'task-1', status: 'DONE', implementation_plan: null, + title: 'Task', + story_id: 'story-1', + }) + // Default sibling state: only this task, already DONE → no story-promotion + mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' }) + // Pass-through for $transaction so tests behave as if Prisma ran the run-fn directly. + mockPrisma.$transaction.mockImplementation(async (run: (tx: typeof prisma) => Promise) => { + return run(prisma) }) }) @@ -111,17 +135,28 @@ describe('PATCH /api/tasks/:id', () => { // TC-T-10 it('updates both status and implementation_plan and returns 200', async () => { const plan = 'Full plan here.' - mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'DONE', implementation_plan: plan }) + // First update writes the implementation_plan; second is the helper's status write. + mockPrisma.task.update + .mockResolvedValueOnce({ id: 'task-1', status: 'TO_DO', implementation_plan: plan }) + .mockResolvedValueOnce({ + id: 'task-1', + title: 'Task', + status: 'DONE', + story_id: 'story-1', + implementation_plan: plan, + }) const res = await patchTask(...makeRequest({ status: 'done', implementation_plan: plan })) const data = await res.json() expect(res.status).toBe(200) expect(data).toMatchObject({ status: 'done', implementation_plan: plan }) + // implementation_plan written via direct update; status written via helper update. expect(mockPrisma.task.update).toHaveBeenCalledWith( - expect.objectContaining({ - data: { status: 'DONE', implementation_plan: plan }, - }) + expect.objectContaining({ data: { implementation_plan: plan } }), + ) + expect(mockPrisma.task.update).toHaveBeenCalledWith( + expect.objectContaining({ data: { status: 'DONE' } }), ) }) @@ -146,6 +181,25 @@ describe('PATCH /api/tasks/:id', () => { expect(reviewRes.status).toBe(422) }) + it('promotes story to DONE when last sibling task transitions to DONE', async () => { + mockPrisma.task.update.mockResolvedValue({ + id: 'task-1', + status: 'DONE', + implementation_plan: null, + title: 'Task', + story_id: 'story-1', + }) + mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }, { status: 'DONE' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + + const res = await patchTask(...makeRequest({ status: 'done' })) + expect(res.status).toBe(200) + expect(mockPrisma.story.update).toHaveBeenCalledWith({ + where: { id: 'story-1' }, + data: { status: 'DONE' }, + }) + }) + it('returns 400 for malformed JSON', async () => { const req = new Request('http://localhost/api/tasks/task-1', { method: 'PATCH', diff --git a/__tests__/lib/tasks-status-update.test.ts b/__tests__/lib/tasks-status-update.test.ts new file mode 100644 index 0000000..418caa7 --- /dev/null +++ b/__tests__/lib/tasks-status-update.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/prisma', () => ({ + prisma: { + task: { + update: vi.fn(), + findMany: vi.fn(), + }, + story: { + findUniqueOrThrow: vi.fn(), + update: vi.fn(), + }, + $transaction: vi.fn(), + }, +})) + +import { prisma } from '@/lib/prisma' +import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update' + +const mockPrisma = prisma as unknown as { + task: { + update: ReturnType + findMany: ReturnType + } + story: { + findUniqueOrThrow: ReturnType + update: ReturnType + } + $transaction: ReturnType +} + +beforeEach(() => { + vi.clearAllMocks() + // Pass-through: $transaction(run) just calls run with the mocked prisma client. + mockPrisma.$transaction.mockImplementation(async (run: (tx: typeof prisma) => Promise) => { + return run(prisma) + }) +}) + +const TASK_BASE = { + id: 'task-1', + title: 'Task', + story_id: 'story-1', + implementation_plan: null, +} + +describe('updateTaskStatusWithStoryPromotion', () => { + it('promotes story to DONE when last sibling task transitions to DONE', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) + mockPrisma.task.findMany.mockResolvedValue([ + { status: 'DONE' }, + { status: 'DONE' }, + ]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + + const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE') + + expect(result.storyStatusChange).toBe('promoted') + expect(result.storyId).toBe('story-1') + expect(mockPrisma.story.update).toHaveBeenCalledWith({ + where: { id: 'story-1' }, + data: { status: 'DONE' }, + }) + }) + + it('does not promote when story is already DONE (idempotent)', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) + mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' }) + + const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE') + + expect(result.storyStatusChange).toBe(null) + expect(mockPrisma.story.update).not.toHaveBeenCalled() + }) + + it('does not promote when not all siblings are DONE', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) + mockPrisma.task.findMany.mockResolvedValue([ + { status: 'DONE' }, + { status: 'IN_PROGRESS' }, + ]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + + const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE') + + expect(result.storyStatusChange).toBe(null) + expect(mockPrisma.story.update).not.toHaveBeenCalled() + }) + + it('demotes story to IN_SPRINT when a task moves out of DONE on a DONE story', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' }) + mockPrisma.task.findMany.mockResolvedValue([ + { status: 'IN_PROGRESS' }, + { status: 'DONE' }, + ]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' }) + + const result = await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS') + + expect(result.storyStatusChange).toBe('demoted') + expect(mockPrisma.story.update).toHaveBeenCalledWith({ + where: { id: 'story-1' }, + data: { status: 'IN_SPRINT' }, + }) + }) + + it('does not demote when story is not DONE', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' }) + mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + + const result = await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS') + + expect(result.storyStatusChange).toBe(null) + expect(mockPrisma.story.update).not.toHaveBeenCalled() + }) + + it('updates the task regardless of story-status change', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' }) + mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + + await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS') + + expect(mockPrisma.task.update).toHaveBeenCalledWith({ + where: { id: 'task-1' }, + data: { status: 'IN_PROGRESS' }, + select: expect.any(Object), + }) + }) + + it('uses the provided transaction client when passed', async () => { + const tx = { + task: { update: vi.fn(), findMany: vi.fn() }, + story: { findUniqueOrThrow: vi.fn(), update: vi.fn() }, + } + tx.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) + tx.task.findMany.mockResolvedValue([{ status: 'DONE' }]) + tx.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE', tx as any) + + expect(result.storyStatusChange).toBe('promoted') + // $transaction should NOT be called when caller already provides a tx. + expect(mockPrisma.$transaction).not.toHaveBeenCalled() + expect(tx.story.update).toHaveBeenCalledWith({ + where: { id: 'story-1' }, + data: { status: 'DONE' }, + }) + }) +}) diff --git a/actions/tasks.ts b/actions/tasks.ts index f70c452..d3cc5c6 100644 --- a/actions/tasks.ts +++ b/actions/tasks.ts @@ -8,11 +8,146 @@ import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' import { productAccessFilter } from '@/lib/product-access' import { requireProductWriter } from '@/lib/auth' +import { taskSchema as sharedTaskSchema, type TaskInput } from '@/lib/schemas/task' +import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update' async function getSession() { return getIronSession(await cookies(), sessionOptions) } +// Return types for TaskDialog actions +export type SaveTaskResult = + | { ok: true; task: { id: string; title: string; status: string } } + | { ok: false; code: 422; error: 'validation'; fieldErrors: Record } + | { ok: false; code: 403; error: 'demo_readonly' | 'forbidden' } + | { ok: false; code: 500; error: 'server_error' } + +export type DeleteTaskResult = + | { ok: true } + | { ok: false; code: 403; error: 'demo_readonly' | 'forbidden' } + | { ok: false; code: 500; error: 'server_error' } + +// Unified create/edit action used by TaskDialog. +// context.taskId present → edit; context.storyId present → create. +export async function saveTask( + input: TaskInput, + context: { taskId?: string; storyId?: string; productId: string }, +): Promise { + const session = await getSession() + if (!session.userId) return { ok: false, code: 403, error: 'forbidden' } + if (session.isDemo) return { ok: false, code: 403, error: 'demo_readonly' } + + const parsed = sharedTaskSchema.safeParse(input) + if (!parsed.success) { + return { + ok: false, + code: 422, + error: 'validation', + fieldErrors: parsed.error.flatten().fieldErrors as Record, + } + } + + const { title, description, implementation_plan, priority, status } = parsed.data + const scope = productAccessFilter(session.userId) + + try { + if (context.taskId) { + const existing = await prisma.task.findFirst({ + where: { id: context.taskId, story: { product: scope } }, + select: { id: true, status: true }, + }) + if (!existing) return { ok: false, code: 403, error: 'forbidden' } + + const taskId = context.taskId + const statusChanged = status !== undefined && status !== existing.status + + const task = await prisma.$transaction(async (tx) => { + const updated = await tx.task.update({ + where: { id: taskId }, + data: { + title, + description: description ?? null, + implementation_plan: implementation_plan ?? null, + priority, + }, + select: { id: true, title: true, status: true }, + }) + + if (statusChanged) { + const result = await updateTaskStatusWithStoryPromotion(taskId, status, tx) + return { id: result.task.id, title: result.task.title, status: result.task.status } + } + return updated + }) + + revalidatePath(`/products/${context.productId}/sprint`) + revalidatePath(`/products/${context.productId}`) + return { ok: true, task: { ...task, status: task.status.toString() } } + } + + if (!context.storyId) { + return { ok: false, code: 422, error: 'validation', fieldErrors: { storyId: ['Verplicht'] } } + } + + const story = await prisma.story.findFirst({ + where: { id: context.storyId, product: scope }, + select: { sprint_id: true }, + }) + if (!story) return { ok: false, code: 403, error: 'forbidden' } + + const last = await prisma.task.findFirst({ + where: { story_id: context.storyId }, + orderBy: { sort_order: 'desc' }, + select: { sort_order: true }, + }) + + const task = await prisma.task.create({ + data: { + story_id: context.storyId, + sprint_id: story.sprint_id ?? null, + title, + description: description ?? null, + implementation_plan: implementation_plan ?? null, + priority, + sort_order: (last?.sort_order ?? 0) + 1.0, + status: 'TO_DO', + }, + select: { id: true, title: true, status: true }, + }) + + revalidatePath(`/products/${context.productId}/sprint`) + revalidatePath(`/products/${context.productId}`) + return { ok: true, task: { ...task, status: task.status.toString() } } + } catch { + return { ok: false, code: 500, error: 'server_error' } + } +} + +// Delete action used by TaskDialog (context-aware revalidation). +export async function deleteTask( + taskId: string, + context: { productId: string }, +): Promise { + const session = await getSession() + if (!session.userId) return { ok: false, code: 403, error: 'forbidden' } + if (session.isDemo) return { ok: false, code: 403, error: 'demo_readonly' } + + try { + const task = await prisma.task.findFirst({ + where: { id: taskId, story: { product: productAccessFilter(session.userId) } }, + }) + if (!task) return { ok: false, code: 403, error: 'forbidden' } + + await prisma.task.delete({ where: { id: taskId } }) + + revalidatePath(`/products/${context.productId}/sprint`) + revalidatePath(`/products/${context.productId}`) + return { ok: true } + } catch { + return { ok: false, code: 500, error: 'server_error' } + } +} + const taskSchema = z.object({ title: z.string().min(1, 'Titel is verplicht').max(200), description: z.string().max(1000).optional(), @@ -99,7 +234,7 @@ export async function updateTaskStatusAction(id: string, status: 'TO_DO' | 'IN_P }) if (!task) return { error: 'Taak niet gevonden' } - await prisma.task.update({ where: { id }, data: { status } }) + await updateTaskStatusWithStoryPromotion(id, status) // /solo bewust niet revalideren: dat zou de page soft-navigaten en de // open SSE-stream sluiten. De Solo Paneel-flow leunt op optimistic diff --git a/app/(app)/products/[id]/sprint/page.tsx b/app/(app)/products/[id]/sprint/page.tsx index 0365842..3b16d5f 100644 --- a/app/(app)/products/[id]/sprint/page.tsx +++ b/app/(app)/products/[id]/sprint/page.tsx @@ -1,3 +1,4 @@ +import { Suspense } from 'react' import { notFound, redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { getAccessibleProduct } from '@/lib/product-access' @@ -6,14 +7,24 @@ import { SprintBoardClient } from '@/components/sprint/sprint-board-client' import { SprintHeader } from '@/components/sprint/sprint-header' import type { SprintStory, PbiWithStories, ProductMember } from '@/components/sprint/sprint-backlog' import type { Task } from '@/components/sprint/task-list' +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 Link from 'next/link' interface Props { params: Promise<{ id: string }> + searchParams: Promise<{ + newTask?: string + storyId?: string + editTask?: string + }> } -export default async function SprintBoardPage({ params }: Props) { +export default async function SprintBoardPage({ params, searchParams }: Props) { const { id } = await params + const { newTask, storyId: storyIdParam, editTask } = await searchParams + const session = await getSession() if (!session.userId) redirect('/login') @@ -104,6 +115,7 @@ export default async function SprintBoardPage({ params }: Props) { const sprintStoryIdList = sprintStories.map(s => s.id) const isDemo = session.isDemo ?? false + const closePath = `/products/${id}/sprint` return (
@@ -134,6 +146,27 @@ export default async function SprintBoardPage({ params }: Props) { ← Product Backlog
+ + {newTask && ( + + )} + + {editTask && !newTask && ( + }> + + + )} ) } diff --git a/app/_components/tasks/edit-task-loader.tsx b/app/_components/tasks/edit-task-loader.tsx new file mode 100644 index 0000000..f66cce9 --- /dev/null +++ b/app/_components/tasks/edit-task-loader.tsx @@ -0,0 +1,47 @@ +import { redirect } from 'next/navigation' +import { prisma } from '@/lib/prisma' +import { productAccessFilter } from '@/lib/product-access' +import { TaskDialog } from './task-dialog' + +interface EditTaskLoaderProps { + taskId: string + userId: string + productId: string + closePath: string + isDemo: boolean +} + +export async function EditTaskLoader({ + taskId, + userId, + productId, + closePath, + isDemo, +}: EditTaskLoaderProps) { + const task = await prisma.task.findFirst({ + where: { + id: taskId, + story: { product: productAccessFilter(userId) }, + }, + select: { + id: true, + title: true, + description: true, + implementation_plan: true, + priority: true, + status: true, + created_at: true, + }, + }) + + if (!task) redirect(closePath) + + return ( + + ) +} diff --git a/app/_components/tasks/priority-segmented.tsx b/app/_components/tasks/priority-segmented.tsx new file mode 100644 index 0000000..4888e59 --- /dev/null +++ b/app/_components/tasks/priority-segmented.tsx @@ -0,0 +1,56 @@ +'use client' + +import { cn } from '@/lib/utils' + +const PRIORITIES = [ + { + value: 1, + label: 'P1 Critical', + selected: 'bg-error-container text-on-error-container border-transparent', + }, + { + value: 2, + label: 'P2 High', + selected: 'bg-priority-high/15 text-priority-high border-priority-high/30', + }, + { + value: 3, + label: 'P3 Medium', + selected: 'bg-primary text-primary-foreground border-transparent', + }, + { + value: 4, + label: 'P4 Low', + selected: 'bg-muted text-foreground border-border', + }, +] + +interface PrioritySegmentedProps { + value: number + onChange: (value: number) => void + disabled?: boolean +} + +export function PrioritySegmented({ value, onChange, disabled }: PrioritySegmentedProps) { + return ( +
+ {PRIORITIES.map(p => ( + + ))} +
+ ) +} diff --git a/app/_components/tasks/status-select.tsx b/app/_components/tasks/status-select.tsx new file mode 100644 index 0000000..5ba794d --- /dev/null +++ b/app/_components/tasks/status-select.tsx @@ -0,0 +1,55 @@ +'use client' + +import type { TaskStatus } from '@prisma/client' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, +} from '@/components/ui/select' +import { cn } from '@/lib/utils' + +const STATUS_CONFIG: Record = { + TO_DO: { label: 'To Do', dot: 'bg-muted-foreground' }, + IN_PROGRESS: { label: 'Bezig', dot: 'bg-status-in-progress' }, + REVIEW: { label: 'Review', dot: 'bg-status-review' }, + DONE: { label: 'Klaar', dot: 'bg-status-done' }, +} + +const STATUS_ORDER: TaskStatus[] = ['TO_DO', 'IN_PROGRESS', 'REVIEW', 'DONE'] + +function StatusIndicator({ status }: { status: TaskStatus }) { + return ( + + + {STATUS_CONFIG[status].label} + + ) +} + +interface StatusSelectProps { + value?: TaskStatus + onChange: (value: TaskStatus) => void + disabled?: boolean +} + +export function StatusSelect({ value = 'TO_DO', onChange, disabled }: StatusSelectProps) { + return ( + + ) +} diff --git a/app/_components/tasks/task-dialog-skeleton.tsx b/app/_components/tasks/task-dialog-skeleton.tsx new file mode 100644 index 0000000..bb6a66b --- /dev/null +++ b/app/_components/tasks/task-dialog-skeleton.tsx @@ -0,0 +1,42 @@ +import { Skeleton } from '@/components/ui/skeleton' +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' +import { cn } from '@/lib/utils' + +export function TaskDialogSkeleton() { + return ( + + + Taak laden… + + {/* Header */} +
+ +
+ + {/* Body — 3 bars mimicking title + description + plan */} +
+ + + +
+ + {/* Footer */} +
+
+ + +
+
+
+
+ ) +} diff --git a/app/_components/tasks/task-dialog.tsx b/app/_components/tasks/task-dialog.tsx new file mode 100644 index 0000000..2426dc1 --- /dev/null +++ b/app/_components/tasks/task-dialog.tsx @@ -0,0 +1,424 @@ +'use client' + +import { useState, useTransition } from 'react' +import { useRouter } from 'next/navigation' +import { useForm, Controller } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import TextareaAutosize from 'react-textarea-autosize' +import { toast } from 'sonner' +import { Loader2 } from 'lucide-react' +import type { TaskStatus } from '@prisma/client' +import { taskSchema, type TaskInput } from '@/lib/schemas/task' +import { saveTask, deleteTask } from '@/actions/tasks' +import { + Dialog, + DialogContent, + DialogTitle, +} from '@/components/ui/dialog' +import { + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogAction, + AlertDialogCancel, +} from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { DemoTooltip } from '@/components/shared/demo-tooltip' +import { PrioritySegmented } from './priority-segmented' +import { StatusSelect } from './status-select' +import { cn } from '@/lib/utils' + +export interface TaskDialogTask { + id: string + title: string + description: string | null + implementation_plan: string | null + priority: number + status: TaskStatus + created_at: Date +} + +interface TaskDialogProps { + task?: TaskDialogTask + storyId?: string + productId: string + closePath: string + isDemo?: boolean +} + +function CharCount({ value, max }: { value: string; max: number }) { + const len = (value ?? '').length + if (len < Math.floor(max * 0.75)) return null + return ( + + {len} / {max} + + ) +} + +const textareaClass = cn( + 'flex w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm', + 'transition-colors outline-none placeholder:text-muted-foreground resize-none', + 'focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50', + 'overflow-y-auto', +) + +export function TaskDialog({ task, storyId, productId, closePath, isDemo = false }: TaskDialogProps) { + const router = useRouter() + const [isPending, startTransition] = useTransition() + const [confirmClose, setConfirmClose] = useState(false) + const [confirmDelete, setConfirmDelete] = useState(false) + const isEdit = !!task + + const form = useForm({ + resolver: zodResolver(taskSchema), + mode: 'onTouched', + defaultValues: { + title: task?.title ?? '', + description: task?.description ?? '', + implementation_plan: task?.implementation_plan ?? '', + priority: task?.priority ?? 3, + status: task?.status, + }, + }) + + function handleClose() { + router.push(closePath) + } + + function handleAttemptClose() { + if (form.formState.isDirty) { + setConfirmClose(true) + } else { + handleClose() + } + } + + function handleKeyDown(e: React.KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault() + form.handleSubmit(onSubmit)() + } + } + + function onSubmit(data: TaskInput) { + startTransition(async () => { + const result = await saveTask(data, { + taskId: task?.id, + storyId, + productId, + }) + + if (result.ok) { + toast.success(isEdit ? 'Taak opgeslagen' : 'Taak aangemaakt') + router.push(closePath) + return + } + + if (result.code === 422 && result.error === 'validation') { + for (const [field, errors] of Object.entries(result.fieldErrors)) { + form.setError(field as keyof TaskInput, { message: errors[0] }) + } + const firstError = Object.keys(result.fieldErrors)[0] as keyof TaskInput + form.setFocus(firstError) + return + } + + if (result.code === 403) { + toast.error( + result.error === 'demo_readonly' + ? 'Demo-modus: opslaan uitgeschakeld' + : 'Geen toegang', + ) + return + } + + toast.error('Er ging iets mis. Probeer het opnieuw.', { + action: { label: 'Opnieuw', onClick: () => form.handleSubmit(onSubmit)() }, + }) + }) + } + + function handleDelete() { + if (!task) return + setConfirmDelete(false) + startTransition(async () => { + const result = await deleteTask(task.id, { productId }) + if (result.ok) { + toast.success('Taak verwijderd') + router.push(closePath) + return + } + if (result.code === 403) { + toast.error( + result.error === 'demo_readonly' + ? 'Demo-modus: verwijderen uitgeschakeld' + : 'Geen toegang', + ) + return + } + toast.error('Verwijderen mislukt') + }) + } + + return ( + <> + { if (!open) handleAttemptClose() }}> + + {/* Sticky header */} +
+ + {isEdit ? 'Taak bewerken' : 'Nieuwe taak'} + + {isEdit && ( + + Aangemaakt:{' '} + {new Intl.DateTimeFormat('nl-NL', { + day: 'numeric', + month: 'short', + year: 'numeric', + }).format(new Date(task.created_at))} + + )} +
+ + {/* Scrollable form body */} +
+ {/* Title */} +
+ + { if (e.key === 'Enter') e.preventDefault() }} + /> + {form.formState.errors.title && ( +

+ {form.formState.errors.title.message} +

+ )} +
+ + {/* Description */} +
+ + ( + <> + + +

+ Markdown ondersteund (lijstjes, **vet**, `code`) +

+ + )} + /> + {form.formState.errors.description && ( +

+ {form.formState.errors.description.message} +

+ )} +
+ + {/* Implementation plan */} +
+ + ( + <> + + +

+ Markdown ondersteund (lijstjes, **vet**, `code`) +

+ + )} + /> + {form.formState.errors.implementation_plan && ( +

+ {form.formState.errors.implementation_plan.message} +

+ )} +
+ + {/* Priority */} +
+ + ( + + )} + /> +
+ + {/* Status — edit only */} + {isEdit && ( +
+ + ( + + )} + /> +
+ )} +
+ + {/* Sticky footer */} +
+
+ {isEdit ? ( + + + + ) : ( +
+ )} + +
+ + + + +
+
+
+ +
+ + {/* Dirty-check confirm */} + + + + Wijzigingen niet opgeslagen + + Wil je de wijzigingen weggooien? + + + + setConfirmClose(false)}> + Terug + + { setConfirmClose(false); handleClose() }} + > + Weggooien + + + + + + {/* Delete confirm */} + + + + Taak verwijderen + + Weet je zeker dat je deze taak wilt verwijderen? Dit kan niet ongedaan worden gemaakt. + + + + setConfirmDelete(false)}> + Annuleren + + + {isPending ? : 'Verwijderen'} + + + + + + ) +} diff --git a/app/api/tasks/[id]/route.ts b/app/api/tasks/[id]/route.ts index c183ed2..ef17ccc 100644 --- a/app/api/tasks/[id]/route.ts +++ b/app/api/tasks/[id]/route.ts @@ -2,6 +2,7 @@ import { authenticateApiRequest } from '@/lib/api-auth' import { prisma } from '@/lib/prisma' import { z } from 'zod' import { TASK_STATUS_API_VALUES, taskStatusFromApi, taskStatusToApi } from '@/lib/task-status' +import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update' // `review` is a valid TaskStatus in the DB and the kanban-board UI, but the // sprint task list (components/sprint/task-list.tsx) does not yet render it. @@ -82,14 +83,28 @@ export async function PATCH( } } - const updated = await prisma.task.update({ - where: { id }, - data: { - ...(dbStatus !== undefined && dbStatus !== null && { status: dbStatus }), - ...(parsed.data.implementation_plan !== undefined && { - implementation_plan: parsed.data.implementation_plan, - }), - }, + const updated = await prisma.$transaction(async (tx) => { + const planUpdate = parsed.data.implementation_plan !== undefined + ? await tx.task.update({ + where: { id }, + data: { implementation_plan: parsed.data.implementation_plan }, + select: { id: true, status: true, implementation_plan: true }, + }) + : null + + if (dbStatus !== undefined && dbStatus !== null) { + const result = await updateTaskStatusWithStoryPromotion(id, dbStatus, tx) + return { + id: result.task.id, + status: result.task.status, + implementation_plan: result.task.implementation_plan, + } + } + + if (planUpdate) return planUpdate + + // Should not reach here — patchSchema rejects bodies without status or implementation_plan. + throw new Error('Geen wijzigingen') }) return Response.json({ diff --git a/app/globals.css b/app/globals.css index 965ae89..9e6d7af 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,4 +1,5 @@ @import "tailwindcss"; @import "tw-animate-css"; +@plugin "@tailwindcss/typography"; @import "./styles/theme.css"; diff --git a/app/styles/theme.css b/app/styles/theme.css index 6b14556..071598a 100644 --- a/app/styles/theme.css +++ b/app/styles/theme.css @@ -73,9 +73,16 @@ --switch-background: #79767d; --ring: var(--primary); + /* MD3 Outline Variant */ + --outline-variant: #c5c6d0; + + /* MD3 On-Error-Container */ + --on-error-container: #410002; + /* Project Management Specific Colors */ --status-todo: #6750a4; --status-in-progress: #0061a4; + --status-review: #7b5ea7; --status-done: #006e1c; --status-blocked: #ba1a1a; @@ -177,9 +184,16 @@ --switch-background: #898790; --ring: var(--primary); + /* MD3 Outline Variant */ + --outline-variant: #45464f; + + /* MD3 On-Error-Container */ + --on-error-container: #ffdad6; + /* Project Management Specific Colors */ --status-todo: #cfbdfe; --status-in-progress: #9fcbfa; + --status-review: #c9b6ef; --status-done: #77db77; --status-blocked: #ffb4ab; @@ -256,6 +270,7 @@ --color-error-foreground: var(--error-foreground); --color-error-container: var(--error-container); --color-error-container-foreground: var(--error-container-foreground); + --color-on-error-container: var(--on-error-container); --color-info: var(--info); --color-info-foreground: var(--info-foreground); @@ -273,6 +288,7 @@ --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); --color-destructive-foreground: var(--destructive-foreground); + --color-outline-variant: var(--outline-variant); --color-border: var(--border); --color-input: var(--input); --color-input-background: var(--input-background); @@ -282,6 +298,7 @@ /* Project management colors */ --color-status-todo: var(--status-todo); --color-status-in-progress: var(--status-in-progress); + --color-status-review: var(--status-review); --color-status-done: var(--status-done); --color-status-blocked: var(--status-blocked); diff --git a/components/backlog/story-dialog.tsx b/components/backlog/story-dialog.tsx index dbaac20..724f430 100644 --- a/components/backlog/story-dialog.tsx +++ b/components/backlog/story-dialog.tsx @@ -1,6 +1,7 @@ 'use client' import { useEffect, useRef, useState, useTransition } from 'react' +import { Markdown } from '@/components/markdown' import { useActionState } from 'react' import { useFormStatus } from 'react-dom' import { toast } from 'sonner' @@ -231,7 +232,7 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps {story?.description && (

Omschrijving

-

{story.description}

+ {story.description}
)} {story?.acceptance_criteria && ( diff --git a/components/entity-dialog/dirty-close-guard.tsx b/components/entity-dialog/dirty-close-guard.tsx new file mode 100644 index 0000000..eedd362 --- /dev/null +++ b/components/entity-dialog/dirty-close-guard.tsx @@ -0,0 +1,58 @@ +'use client' + +import { useState } from 'react' +import { + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogAction, + AlertDialogCancel, +} from '@/components/ui/alert-dialog' + +interface DirtyCloseGuardProps { + isDirty: boolean + onConfirm: () => void + children: (attemptClose: () => void) => React.ReactNode +} + +export function DirtyCloseGuard({ isDirty, onConfirm, children }: DirtyCloseGuardProps) { + const [open, setOpen] = useState(false) + + function attemptClose() { + if (isDirty) { + setOpen(true) + } else { + onConfirm() + } + } + + return ( + <> + {children(attemptClose)} + + + + Wijzigingen niet opgeslagen + + Wil je de wijzigingen weggooien? + + + + setOpen(false)}> + Blijven + + { setOpen(false); onConfirm() }} + > + Weggooien + + + + + + ) +} diff --git a/components/markdown.tsx b/components/markdown.tsx new file mode 100644 index 0000000..2b07fa8 --- /dev/null +++ b/components/markdown.tsx @@ -0,0 +1,21 @@ +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import { cn } from '@/lib/utils' + +interface MarkdownProps { + children: string + className?: string +} + +export function Markdown({ children, className }: MarkdownProps) { + return ( +
+ + {children} + +
+ ) +} diff --git a/components/solo/task-detail-dialog.tsx b/components/solo/task-detail-dialog.tsx index d9b6db1..5757393 100644 --- a/components/solo/task-detail-dialog.tsx +++ b/components/solo/task-detail-dialog.tsx @@ -3,6 +3,7 @@ import { useRef, useState, useTransition } from 'react' import Link from 'next/link' import { toast } from 'sonner' +import { Markdown } from '@/components/markdown' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' @@ -132,7 +133,7 @@ function TaskDetailContent({ task, productId, isDemo, onClose }: TaskDetailConte {task.description && (

Beschrijving

-

{task.description}

+ {task.description}
)} diff --git a/components/sprint/task-list.tsx b/components/sprint/task-list.tsx index 99650c3..028e449 100644 --- a/components/sprint/task-list.tsx +++ b/components/sprint/task-list.tsx @@ -1,7 +1,7 @@ 'use client' -import { useState, useTransition, useEffect, useActionState } from 'react' -import { useFormStatus } from 'react-dom' +import { useState, useTransition, useEffect } from 'react' +import { useRouter, usePathname } from 'next/navigation' import { DndContext, DragEndEvent, DragOverlay, KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter, @@ -13,17 +13,13 @@ import { import { CSS } from '@dnd-kit/utilities' import { toast } from 'sonner' import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' import { Badge } from '@/components/ui/badge' import { CodeBadge } from '@/components/shared/code-badge' import { PanelNavBar } from '@/components/shared/panel-nav-bar' import { PRIORITY_BORDER } from '@/components/backlog/backlog-card' import { deriveTaskCode } from '@/lib/code' import { useSprintStore } from '@/stores/sprint-store' -import { - createTaskAction, updateTaskStatusAction, updateTaskAction, - deleteTaskAction, reorderTasksAction, -} from '@/actions/tasks' +import { updateTaskStatusAction, reorderTasksAction } from '@/actions/tasks' import { DemoTooltip } from '@/components/shared/demo-tooltip' import { cn } from '@/lib/utils' @@ -60,69 +56,69 @@ interface TaskListProps { } function SortableTaskRow({ - task, code, isDemo, onStatusToggle, onDelete, -}: { task: Task; code: string | null; isDemo: boolean; onStatusToggle: () => void; onDelete: () => void }) { - const [editing, setEditing] = useState(false) + task, code, isDemo, onStatusToggle, onEdit, +}: { + task: Task + code: string | null + isDemo: boolean + onStatusToggle: () => void + onEdit: () => void +}) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id }) const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1 } - const [, formAction] = useActionState( - async (_prev: unknown, fd: FormData) => { - const result = await updateTaskAction(_prev, fd) - if (result?.success) setEditing(false) - return result - }, - undefined - ) - - if (editing) { - return ( -
-
-
- - - -
- - -
-
-
-
- ) - } - return (
-
+
onEdit()} + role="button" + tabIndex={0} + aria-label={`Bewerk taak: ${task.title}`} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onEdit() + } + }} + > {!isDemo && ( - + e.stopPropagation()} + className="text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 text-sm select-none mt-0.5" + aria-hidden="true" + > + ⠿ + )}
-

+

{task.title}

{code && }
-
- -
- - - - - - -
@@ -130,48 +126,12 @@ function SortableTaskRow({ ) } -function EditSubmitButton() { - const { pending } = useFormStatus() - return -} - -function CreateTaskForm({ storyId, sprintId, onDone }: { storyId: string; sprintId: string; onDone: () => void }) { - const [state, formAction] = useActionState( - async (_prev: unknown, fd: FormData) => { - const result = await createTaskAction(_prev, fd) - if (result?.success) { onDone(); return result } - if (result?.error) toast.error(typeof result.error === 'string' ? result.error : 'Aanmaken mislukt') - return result - }, - undefined - ) - return ( -
- - - -
- - - -
- {state && 'error' in state && typeof state.error === 'string' && ( -

{state.error}

- )} -
- ) -} - -function CreateSubmitButton() { - const { pending } = useFormStatus() - return -} - -export function TaskList({ storyId, storyCode, sprintId, productId: _productId, tasks, isDemo }: TaskListProps) { +export function TaskList({ storyId, storyCode, sprintId: _sprintId, productId: _productId, tasks, isDemo }: TaskListProps) { const { taskOrder, initTasks, reorderTasks, rollbackTasks } = useSprintStore() - const [creating, setCreating] = useState(false) const [activeDragId, setActiveDragId] = useState(null) const [, startTransition] = useTransition() + const router = useRouter() + const pathname = usePathname() const idKey = tasks.map(t => t.id).join(',') useEffect(() => { @@ -187,7 +147,7 @@ export function TaskList({ storyId, storyCode, sprintId, productId: _productId, const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), - useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ) function handleDragEnd(event: DragEndEvent) { @@ -209,11 +169,12 @@ export function TaskList({ storyId, storyCode, sprintId, productId: _productId, }) } - function handleDelete(id: string) { - startTransition(async () => { - const result = await deleteTaskAction(id) - if (result && 'error' in result) toast.error(result.error ?? 'Verwijderen mislukt') - }) + function openCreateDialog() { + router.push(`${pathname}?newTask=1&storyId=${storyId}`) + } + + function openEditDialog(taskId: string) { + router.push(`${pathname}?editTask=${taskId}`) } return ( @@ -224,22 +185,32 @@ export function TaskList({ storyId, storyCode, sprintId, productId: _productId, <> {doneCount}/{orderedTasks.length} klaar - + } />
- {creating && ( - setCreating(false)} /> - )} - - {orderedTasks.length === 0 && !creating ? ( + {orderedTasks.length === 0 ? (

Geen taken voor deze story.

- +
) : ( @@ -258,7 +229,7 @@ export function TaskList({ storyId, storyCode, sprintId, productId: _productId, code={deriveTaskCode(storyCode, idx + 1)} isDemo={isDemo} onStatusToggle={() => handleStatusToggle(task)} - onDelete={() => handleDelete(task.id)} + onEdit={() => openEditDialog(task.id)} /> ))} @@ -266,7 +237,7 @@ export function TaskList({ storyId, storyCode, sprintId, productId: _productId, {activeDragId && taskMap[activeDragId] && (
{taskMap[activeDragId].title}
diff --git a/docs/scrum4me-architecture.md b/docs/scrum4me-architecture.md index bc244f5..5197f99 100644 --- a/docs/scrum4me-architecture.md +++ b/docs/scrum4me-architecture.md @@ -157,6 +157,8 @@ Scrum4Me is een desktop-first Next.js 16 webapplicatie die server-side wordt ger **Indexes:** `(pbi_id, priority, sort_order)`, `(sprint_id, sort_order)`, `(product_id, status)` +**Auto-promotie/demotie via task-status:** zodra alle tasks van een story op `DONE` staan en de huidige story-status nog niet `DONE` is, promoot dezelfde transactie de story naar `DONE`. Wordt een task van een `DONE`-story uit `DONE` getrokken (heropening), dan demoot de story terug naar `IN_SPRINT` — niet naar `OPEN`, want `OPEN` betekent "terug in productbacklog" en is een sprint-management-actie. De logica zit in [lib/tasks-status-update.ts](../lib/tasks-status-update.ts) en wordt aangeroepen door alle drie de task-status-write-paden (`updateTaskStatusAction`, `saveTask` edit-mode, REST `PATCH /api/tasks/[id]`). + --- ### `story_logs` diff --git a/docs/scrum4me-task-dialog.md b/docs/scrum4me-task-dialog.md new file mode 100644 index 0000000..fa676bd --- /dev/null +++ b/docs/scrum4me-task-dialog.md @@ -0,0 +1,506 @@ +# Scrum4Me — TaskDialog Spec + +> Volledige design-spec voor de add/update task dialog van de inspannings monitor app. +> Resultaat van een grill-me sessie (15 vragen, alle beslissingen vastgelegd). + +--- + +## Stack + +- **Framework:** Next.js (App Router) +- **ORM:** Prisma +- **UI components:** shadcn/ui — wrappers rond `@base-ui/react` (zoals expliciet vastgelegd in `CLAUDE.md`) +- **Styling:** Tailwind CSS +- **Form:** react-hook-form + @hookform/resolvers/zod +- **Design language:** Material Design 3 als theming-laag (geen MUI components) +- **Theming:** `material-color-utilities` voor dynamic color, `next-themes` voor dark mode +- **Icons:** Lucide +- **Markdown rendering:** `react-markdown` + `remark-gfm` +- **Toasts:** sonner (shadcn default) + +> **Composition-regel:** dit project gebruikt `@base-ui/react`, niet Radix. Composition gebeurt via de **`render`-prop**, niet via `asChild`. Zie ook `CLAUDE.md` "UI Library Conventions". +> +> ```tsx +> // ✅ goed +> }>... +> // ❌ fout — geeft TS-errors +> +> ``` + +> **Dialog-primitive:** bouw de TaskDialog op de bestaande wrapper in `components/ui/dialog.tsx` (shadcn rond `@base-ui/react`). **Geen** directe imports uit `@base-ui/react` voor dialog-primitives in deze feature — anders krijg je twee parallelle dialog-implementaties die uit de pas gaan lopen qua animatie, focus-trap en theming. + +--- + +## Dependency-impact + +De volgende packages staan **nog niet** in `package.json` en moeten direct als runtime-`dependencies` worden toegevoegd voordat de eerste commit van deze feature gemerged wordt (CLAUDE.md "Dependencies"-regel). Voeg ze in dezelfde change toe waarin ze geïmporteerd worden, en vermeld ze in de docs-sync. + +| Package | Doel | Scope | +|---|---|---| +| `react-hook-form` | form-state management voor TaskDialog | runtime | +| `@hookform/resolvers` | zod-resolver voor `react-hook-form` | runtime | +| `react-textarea-autosize` | auto-grow textareas voor `description` / `implementation_plan` | runtime | +| `react-markdown` | markdown rendering elders in de app (taakdetail, hover-card) | runtime | +| `remark-gfm` | GFM-extensies (tabellen, taken, strikethrough) | runtime | +| `@tailwindcss/typography` | `prose`-classes voor markdown-styling | runtime (Tailwind v4 plugin) | + +**Bewust niet meegenomen:** + +- `material-color-utilities` — dynamic color valt buiten v1 (zie Theming hieronder). +- `nuqs` — start met **native `searchParams`**; als de URL-state-handling te omslachtig wordt, dan pas `nuqs` als losse refactor-task introduceren. Niet in deze feature mengen. + +Reeds aanwezig en gebruikt: `@base-ui/react`, `next-themes`, `lucide-react`, `sonner`, `zod`, `prisma`. + +--- + +## Component-API + +Eén component `TaskDialog`, mode afgeleid uit `task?: Task` prop: + +```tsx + // task undefined = create mode, task aanwezig = edit mode +``` + +Open/close-state komt uit de URL via `nuqs` of `searchParams`. Taken leven binnen de context van een sprint of een PBI/story — er is **geen** zelfstandige `/tasks`-route: + +``` +/sprint/?newTask=1 → create-dialog open binnen sprint-context +/sprint/?editTask= → edit-dialog open binnen sprint-context +/products//backlog?newTask=1 → create-dialog open binnen backlog-context +/products//backlog?editTask= +``` + +Dialog sluit door dezelfde route opnieuw te pushen zonder de `newTask` / `editTask` query-params (bv. `router.push(\`/sprint/\${sprintId}\`)`). + +--- + +## Velden die de dialog gebruikt + +De dialog leest en schrijft uitsluitend deze velden van het `Task`-record. Het volledige datamodel valt buiten scope van deze spec. + +| Veld | Type | Mode | +|---|---|---| +| `title` | `string` (required) | beide | +| `description` | `string \| null` | beide | +| `implementation_plan` | `string \| null` | beide | +| `priority` | `int` (1-4, P1 = hoogste) | beide | +| `status` | `TaskStatus` enum | alleen edit (default `TO_DO` op create, niet getoond) | +| `created_at` | `Date` | alleen edit, read-only metadata in header | + +`TaskStatus` enum-waarden: `TO_DO | IN_PROGRESS | REVIEW | DONE`. + +--- + +## Layout & responsive gedrag + +| Breakpoint | Breedte | Hoogte | +|---|---|---| +| Mobiel (<640px) | full-screen | full-screen | +| Tablet (640-1024px) | `90vw` | `max-h-[85vh]` | +| Desktop (≥1024px) | `max-w-[50vw]`, `min-w-[480px]` | `max-h-[85vh]` | + +- Padding: `p-6` rondom +- Veld-spacing binnen blok: `space-y-6` (24px) +- Sticky header (titel + close) en sticky footer (knoppen) +- Body scrollt als content de `max-h` overschrijdt +- Footer heeft top-border in `outline-variant` kleur + +--- + +## Velden + +In volgorde van boven naar beneden: + +| Veld | Control | Mode | Validatie | +|---|---|---|---| +| `title` | `Input` (single-line) | beide | required, trim, 1-120 chars | +| `description` | `Textarea` (auto-grow, 3-6 regels) | beide | optional, max 2.000 chars, markdown | +| `implementation_plan` | `Textarea` (auto-grow, 5-12 regels) | beide | optional, max 10.000 chars, markdown | +| `priority` | Segmented buttons (P1/P2/P3/P4) | beide | int 1-4, default 3 | +| `status` | `Select` met gekleurde dot | alleen edit | enum, default TO_DO | + +Verberg `status` in create-mode (default = TO_DO is genoeg). + +### Auto-grow textareas +Gebruik `react-textarea-autosize`. Bereikt het veld zijn max-regels, dan `overflow-y-auto` (interne scroll). De **dialog-body** scrollt onafhankelijk; je krijgt zelden geneste scrolls. + +### Karakter-counter +Alleen tonen vanaf 75% van de limiet. Klein, rechtsonder in het veld, `muted-foreground` kleur. Bv. `1547 / 2000`. + +### Markdown hint +Onder elk textarea: `Markdown ondersteund (lijstjes, **vet**, \`code\`)` — klein, muted. + +### Priority segmented buttons +``` +[ P1 Critical ] [ P2 High ] [ P3 Medium ] [ P4 Low ] + error tertiary primary outline +``` +- Lager getal = hoger prio (industriestandaard, Linear/Jira-conform) +- Default geselecteerd: P3 Medium +- Geen 0-waarde toestaan + +### Status select (alleen edit) +- TO_DO — grijze dot +- IN_PROGRESS — blauwe dot +- REVIEW — paarse dot +- DONE — groene dot + +### `created_at` als header-metadata +In edit-mode tonen in de dialog-header naast de titel: + +``` +Taak bewerken Aangemaakt: 23 apr 2026 +``` + +Klein, `muted-foreground`, niet als form-veld. + +--- + +## Validatie + +- **Gedeeld zod-schema** in `lib/schemas/task.ts`, geïmporteerd door zowel form als server action +- react-hook-form mode: `onTouched` (eerste validatie bij blur, daarna onChange) +- Errors onder het veld, in error-color, met label en outline van het veld in dezelfde kleur +- Geen toasts voor field-level errors +- Submit-button blijft enabled bij errors — klik scrollt naar eerste error-veld + focus + +```ts +// lib/schemas/task.ts (richtlijn) +export const taskSchema = z.object({ + title: z.string().trim().min(1, "Verplicht").max(120), + description: z.string().max(2000).optional(), + implementation_plan: z.string().max(10000).optional(), + priority: z.number().int().min(1).max(4), + status: z.nativeEnum(TaskStatus).optional(), // alleen in edit +}); +``` + +--- + +## Submission + +### Auth-scoping (verplicht) + +Elke server action — zowel `saveTask` als `deleteTask` — moet de operatie scope-en op de huidige user. Cross-tenant writes voorkomen via `productAccessFilter(userId)` (of het project-equivalent), zodat een user geen task kan schrijven of verwijderen die niet onder zijn product-scope valt. + +> Concreet: de Prisma-mutatie staat nóóit alleen op `where: { id: taskId }`. De scope wordt verplicht gecombineerd in elke `update`/`delete`/`create`-call. + +### Demo read-only enforcement (drie lagen — ST-1110) + +Elke write-flow moet door deze drie lagen: + +1. **Middleware-guard in `proxy.ts`** — blokkeert demo-sessies op write-routes vóór de server action überhaupt loopt. Returnt **403**. +2. **`session.isDemo`-check in de server action zelf** — defense-in-depth voor het geval een write-flow buiten een proxy-route loopt (bv. directe action-invocation). Returnt **403**. +3. **`` op de save- en delete-knoppen** — UI-laag: knoppen zijn zichtbaar disabled met tooltip "Demo-modus: opslaan uitgeschakeld". Vermijdt onnodige round-trips. + +### Server Action + +```ts +// app/actions/tasks.ts +"use server" + +export async function saveTask( + input: TaskInput, + context: { sprintId?: string; productId?: string }, // voor revalidatePath en scope +): Promise { + const session = await getSession(); + if (session.isDemo) return { ok: false, code: 403, error: "demo_readonly" }; + + const scope = await productAccessFilter(session.userId); // verplicht + // ... validate met taskSchema → Prisma write binnen `scope` +} + +type SaveTaskResult = + | { ok: true; task: Task } + | { ok: false; code: 422; error: "validation"; fieldErrors: Record } + | { ok: false; code: 403; error: "demo_readonly" | "forbidden" } + | { ok: false; code: 500; error: "server_error" } +``` + +### Foutcodes (volgens `CLAUDE.md` "Foutcodes API") + +| Code | Wanneer | UI-respons | +|---|---|---| +| **422** | zod-validatiefout (server-side dubbelcheck) | `fieldErrors` mappen naar `form.setError()`, geen toast | +| **403** | demo-sessie probeert te schrijven, of cross-tenant write geblokkeerd | toast "Niet toegestaan in demo-modus" / "Geen toegang", form blijft open | +| **500** | onverwachte serverfout | toast met "Opnieuw proberen"-knop, form-state behouden | + +> Field-level errors zijn **alleen** geldig bij `code: 422`. Bij andere codes is `fieldErrors` ongedefinieerd. + +### Revalidation + +`revalidatePath` op de **context-route** waarin de dialog werd geopend, niet op een statische `/tasks`-path: + +```ts +if (context.sprintId) revalidatePath(`/sprint/${context.sprintId}`); +if (context.productId) revalidatePath(`/products/${context.productId}/backlog`); +``` + +De aanroepende client geeft de relevante `sprintId` of `productId` mee als argument bij elke save/delete. Geen hard-coded paths in de action zelf. + +### Flow + +- Synchroon (geen optimistic update in v1) +- Tijdens submit: cancel- en save-knop disabled, spinner in save-knop met "Opslaan...", velden blijven enabled +- Server saniteert en valideert opnieuw met hetzelfde zod-schema +- Field-level server errors (bv. unique constraint op title binnen scope) → `code: 422` met `fieldErrors`, terugmappen naar `form.setError()` + +### Error handling + +- **422** → field errors inline tonen, geen toast +- **403** → toast met passende boodschap, form blijft open, ingevulde waarden behouden +- **500 / netwerk** → toast met "Opnieuw proberen"-knop, form-state behouden, knoppen weer enabled + +--- + +## Dialog-gedrag + +### Sluiten met dirty state +- Form niet aangeraakt → Esc / backdrop-klik / Cancel sluiten direct +- Form `isDirty` → Esc / backdrop-klik / Cancel triggeren `AlertDialog`: *"Wijzigingen niet opgeslagen — weggooien?"* + +### Keyboard shortcuts +- **Esc** — sluit (met dirty-check) +- **Cmd/Ctrl+Enter** — submit vanuit elk veld +- **Enter in title-input** — submit niet (alleen Cmd/Ctrl+Enter) +- **Enter in textarea** — newline (default browser behavior, niet overriden) +- **Tab** — title → description → implementation_plan → priority → (status) → cancel → save + +### Focus management +- Bij openen: focus op `title`-input +- Edit-mode: cursor aan einde van bestaande titel, **geen auto-select** (anders typt user per ongeluk de titel weg) +- Bij sluiten: focus terug naar het element dat de dialog opende (`@base-ui/react` doet dit by default — niet breken) +- Bij submit-error: focus naar eerste error-veld + +### Motion +MD3-conform: +- Open: 250ms, easing `cubic-bezier(0.2, 0, 0, 1)`, scale 0.95→1 + opacity 0→1 +- Close: 200ms, easing `cubic-bezier(0.4, 0, 1, 1)` + +### Backdrop +Scrim `rgba(0,0,0,0.4)` (iets sterker dan MD3-default 0.32 voor betere contrast op licht/donker). + +--- + +## Footer + +### Edit-mode +``` +[ Verwijderen ] [ Annuleren ] [ Opslaan ] + tonal (error-container) text filled (primary) +``` + +### Create-mode +``` + [ Annuleren ] [ Aanmaken ] + text filled (primary) +``` + +### Delete-flow +- Klik op "Verwijderen" → `AlertDialog`: *"Weet je zeker? Dit kan niet ongedaan worden."* +- Bevestigen → `deleteTask` server action (zelfde auth-scoping en demo-checks als `saveTask`) → `revalidatePath` op de context-route (`/sprint/` of `/products//backlog`) → dialog sluit → toast "Taak verwijderd" +- Geen undo in v1 + +--- + +## Triggers (hoe komt de user erbij?) + +De dialog wordt vanuit twee context-pagina's geopend: een sprint-detail (`/sprint/`) of een product-backlog (`/products//backlog`). + +> **Vervangt bestaande create/edit-flows.** Deze TaskDialog is de **enige** flow voor het aanmaken en bewerken van taken in beide contexten. Bestaande inline-edit-paden in `components/sprint/task-list.tsx` (en eventueel in de backlog) worden door deze dialog vervangen — niet er naast geplaatst. De huidige task-row-rendering wordt aangepast om bij klik de dialog te openen via `?editTask=`; geen aparte edit-icon, geen inline form. Een eventuele "+ Nieuwe taak"-knop in de bestaande tasklist-header wordt eveneens omgeleid naar `?newTask=1` op dezelfde route. + +- **Create:** filled button `+ Nieuwe taak` rechtsboven in de tasklist-header van de huidige context (FAB op mobiel optioneel later). Klik zet de juiste query-param (`?newTask=1`) op de huidige route. +- **Edit:** klik op de hele rij in de tasklist (geen apart edit-icoon). Klik zet `?editTask=` op de huidige route. +- **Loading edit-mode:** Suspense met minimale skeleton (3 grijze balken voor inputs), `200ms` delay zodat snelle fetches geen flicker tonen + +### Server-fetch +Bij `?editTask=`: server component fetcht de taak vóór render — **inclusief auth-scoping** via `productAccessFilter(userId)` zodat een user nooit een task uit een ander product kan openen via een geraden ID. Bestaat de taak niet of valt 'm buiten scope → toast + redirect naar de context-route zonder query-param (bv. `/sprint/`). + +--- + +## Theming (Material Design 3 tokens) + +> **Bron-of-truth in v1:** de bestaande **statische** tokens in `app/styles/theme.css` zijn canoniek. De TaskDialog **consumeert** deze tokens en voegt er geen nieuwe aan toe. Dynamic color (`material-color-utilities`) valt **buiten v1** — niet introduceren in deze feature. + +### Color +- TaskDialog gebruikt de bestaande MD3-tokens uit `app/styles/theme.css`: `--primary`, `--on-primary`, `--surface-container`, `--surface-container-high`, `--surface-container-low`, `--error-container`, `--on-error-container`, `--outline-variant`, plus de project-specifieke `--status-*` en `--priority-*` tokens +- Eventueel ontbrekende tokens (bv. een specifieke `surface-container-high` als die er nog niet is) worden in **dezelfde commit** als de feature aan `theme.css` toegevoegd, niet ad-hoc per component gehard-codeerd +- **Verboden:** willekeurige Tailwind-kleuren (`bg-blue-500`, etc.). Altijd semantische tokens — zie `docs/scrum4me-styling.md` + +### Dark mode +- `next-themes` is al in de stack; TaskDialog erft automatisch de actieve kleurmodus via de bestaande tokens +- Geen extra setup nodig in deze feature + +### Surface elevation +Hybrid (tonal surface + zachte shadow): +- Dialog: `surface-container-high` background + `shadow-2xl` met getemperde opacity +- Form inputs: `surface-container-low` background, geen shadow +- Geen pure tonal-only (voelt te plat op desktop) + +### Buttons +- **Filled** (Save/Aanmaken): `primary` background, `on-primary` tekst +- **Text** (Cancel): geen background, `primary` tekst +- **Tonal error** (Delete): `error-container` background, `on-error-container` tekst + +### Density +Comfortable (geen compact): +- Single-line input-hoogte: 56px (MD3 outlined text field default) +- Veld-spacing: 24px (`space-y-6`) +- Dialog-padding: 24px alle kanten (`p-6`) + +### Typography +- **Font:** Inter via `next/font/google` (geen Roboto-dwang) +- **Schaal (beperkt):** + - `headline-small` (24px) — dialog-titel + - `body-large` (16px) — form-input tekst + - `body-medium` (14px) — helptext, counter +- Geen Material-specifieke letter-spacing tweaks; Inter-defaults voldoen + +### Iconen +Lucide (shadcn default). Geen Material Symbols importeren — ~150kb winst en visueel neutraal genoeg om in MD3-themed app te passen. + +--- + +## Markdown rendering (buiten de dialog) + +Voor weergave van `description` en `implementation_plan` elders in de app (taakdetail, hover-card, etc.): + +```tsx +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + + + {content} + +``` + +- Tailwind Typography (`prose prose-sm`) voor styling +- `remark-gfm` voor tabellen, taken, strikethrough +- `react-markdown` saniteert by default; `disallowedElements` als extra defense-in-depth + +--- + +## Hergebruik & generalisatie + +De TaskDialog is de eerste van naar verwachting meerdere entity-dialogs (PBI, Story, Todo volgen logisch). Bouw daarom **vanaf dag 1** een dunne scheiding tussen generic shell+primitives en entity-specifieke form-body. Dit is geen speculatieve abstractie: de breuklijn tussen "dialog-mechanica" en "welke velden horen bij deze entiteit" is natuurlijk en levert per nieuwe entiteit ~70% codebesparing op. + +### Wat generic wordt (`components/entity-dialog/`) + +| Component | Waarom generic | +|---|---| +| `entity-dialog.tsx` | Shell: sticky header/footer, responsive layout, motion, backdrop, dirty-close-guard, keyboard-shortcuts, focus-management. Slot-props voor body en footer-actions. | +| `priority-segmented.tsx` | P1-P4 segmented buttons; `priority: int 1-4` is identiek over Task / PBI / Story / Todo. | +| `auto-grow-textarea.tsx` | Wrapper rond `react-textarea-autosize` met char-counter (vanaf 75%) en markdown-hint. Generic — neemt min/max regels en max-chars als props. | +| `dirty-close-guard.tsx` | AlertDialog "Wijzigingen niet opgeslagen — weggooien?" — entity-agnostisch. | + +Deze primitives importeren **alleen** uit `components/ui/*` en hebben geen kennis van Task / Story / PBI. + +### Wat entity-specifiek blijft (`components/tasks/`) + +| Component | Waarom niet generic | +|---|---| +| `task-dialog.tsx` | Dunne wrapper: kiest body, koppelt `saveTask`/`deleteTask`, levert label-strings ("Taak bewerken" / "Aangemaakt: …"). Geen mechanica meer in dit bestand. | +| `task-form.tsx` | Velden zijn task-specifiek (`title`, `description`, `implementation_plan`, `priority`, `status`). Andere entiteiten (Story heeft `acceptance_criteria`, PBI heeft alleen `description`) krijgen elk hun eigen `*-form.tsx`. | +| `task-status-select.tsx` | `TaskStatus` enum met 4 specifieke waarden + dot-kleurmapping. `StoryStatus` (`OPEN | IN_SPRINT | DONE`) en `PbiStatus` (`OPEN | IN_SPRINT | DONE` + `BLOCKED`) hebben andere enums en horen bij eigen select-componenten. | + +### Wat **niet** abstraheren in v1 + +- **URL-state pattern** — `?newTask=1` / `?editTask=` per route. Een toekomstige PBI-dialog krijgt `?newPbi=1` / `?editPbi=` op zijn eigen routes. Copy-paste tussen 2-3 pages is goedkoper dan een generic helper die je later toch moet generaliseren. +- **Save/delete-flows** — auth-scoping, demo-checks en revalidatePath verschillen subtiel per entiteit (verschillende productAccessFilter-paden, verschillende context-routes). Per entiteit een eigen actions-file in `app/actions/.ts`. + +### Per-entiteit kostenplaatje + +Wanneer er straks een PbiDialog of StoryDialog gebouwd wordt, kost dat alleen: + +1. `components//-form.tsx` — de velden + zod-schema +2. `components//-status-select.tsx` — als de entiteit een status-veld heeft +3. `components//-dialog.tsx` — dunne wrapper rond `EntityDialog` met de juiste form en save/delete-handler +4. `app/actions/.ts` — server actions +5. URL-state uitbreiding op de relevante page(s) + +Geen herhaling van layout, motion, dirty-check, keyboard-shortcuts, of segmented/textarea-primitives. + +--- + +## Bewust NIET in v1 + +Om scope te bewaken: + +- ❌ Bulk-edit (meerdere taken tegelijk) +- ❌ Drag-and-drop herorderen +- ❌ Sub-tasks / parent-child relaties +- ❌ Tags / labels / categorieën +- ❌ Due dates / reminders +- ❌ Attachments / file uploads +- ❌ Comments / activity log +- ❌ Sharing / collaboration +- ❌ Undo na delete (toast met undo-actie) +- ❌ Cmd+K keyboard-driven creation zonder dialog +- ❌ Templates voor terugkerende taken +- ❌ Time tracking (uren-registratie) — wel relevant voor inspannings-monitor, maar apart feature +- ❌ Telemetrie / analytics +- ❌ Optimistic locking — niet geïmplementeerd in v1 (last-write-wins binnen scope) +- ❌ Tabs voor secties — alleen spacing-gebaseerde groepering +- ❌ Section-headers — implicit via spacing, geen labels + +> Heroverweeg deze keuzes pas als de app groeit. Niet om je te beperken, maar om elke "ja maar moeten we niet ook…"-impuls een bewuste afweging te maken. + +--- + +## File structuur (richtlijn) + +``` +app/ +├── sprint/ +│ └── [id]/ +│ └── page.tsx # leest searchParams, rendert TaskDialog +├── products/ +│ └── [id]/ +│ └── backlog/ +│ └── page.tsx # leest searchParams, rendert TaskDialog +├── actions/ +│ └── tasks.ts # saveTask, deleteTask server actions (auth-scoped) +components/ +├── ui/ +│ ├── dialog.tsx # bestaande @base-ui/react-wrapper +│ └── demo-tooltip.tsx # wrapper voor save/delete-knoppen in demo-mode +├── entity-dialog/ # GENERIC — geen kennis van Task/Story/PBI +│ ├── entity-dialog.tsx # shell: header/footer/motion/dirty-check/keyboard +│ ├── priority-segmented.tsx # P1-P4 segmented buttons +│ ├── auto-grow-textarea.tsx # textarea met counter + markdown-hint +│ └── dirty-close-guard.tsx # AlertDialog bij dirty close +├── tasks/ # ENTITY-SPECIFIEK +│ ├── task-dialog.tsx # dunne wrapper rond EntityDialog +│ ├── task-form.tsx # task-velden + react-hook-form binding +│ └── task-status-select.tsx # TaskStatus enum + dot-kleuren +lib/ +├── schemas/ +│ └── task.ts # gedeeld zod-schema (form + server action) +├── auth/ +│ └── product-access-filter.ts # scope-helper, gedeeld door page-fetches en actions +proxy.ts # demo-readonly middleware-guard (laag 1 van 3) +``` + +--- + +## Implementatie-volgorde (suggestie) + +1. Dependencies toevoegen aan `package.json` (zie "Dependency-impact"); commit als `chore(ST-XXX): add deps for task dialog` +2. zod-schema in `lib/schemas/task.ts` +3. `productAccessFilter` helper checken/uitbreiden in `lib/auth/` +4. Server actions (`saveTask`, `deleteTask`) met **auth-scoping én demo-check** (laag 2) — testen via thunk +5. `proxy.ts` middleware-guard voor demo-routes (laag 1) — alleen als nog niet aanwezig voor deze routes +6. Eventueel ontbrekende MD3-tokens aanvullen in `app/styles/theme.css` (geen dynamic color in v1) +7. ``-wrapper component (laag 3) +8. TaskDialog — create-mode eerst (minder edge cases), bovenop bestaande `components/ui/dialog.tsx`-wrapper +9. Edit-mode toevoegen (status field, delete-knop, `created_at`-metadata) +10. URL-state via native `searchParams` binnen sprint en backlog routes (geen `nuqs` in v1) +11. **Bestaande task-row / tasklist-trigger refactoren** — `components/sprint/task-list.tsx` (en backlog-equivalent) klikbaar maken zodat ze de dialog openen via query-param; oude inline-edit-paden verwijderen +12. Suspense + skeleton voor edit-mode loading + scope-check op fetch +13. Dirty-check + AlertDialog +14. Keyboard shortcuts (Cmd+Enter) +15. Markdown rendering elders (out-of-scope voor dialog zelf, maar related) diff --git a/lib/schemas/task.ts b/lib/schemas/task.ts new file mode 100644 index 0000000..b4c0c3e --- /dev/null +++ b/lib/schemas/task.ts @@ -0,0 +1,12 @@ +import { z } from 'zod' +import { TaskStatus } from '@prisma/client' + +export const taskSchema = z.object({ + title: z.string().trim().min(1, 'Verplicht').max(120), + description: z.string().max(2000).optional(), + implementation_plan: z.string().max(10000).optional(), + priority: z.number().int().min(1).max(4), + status: z.nativeEnum(TaskStatus).optional(), +}) + +export type TaskInput = z.infer diff --git a/lib/tasks-status-update.ts b/lib/tasks-status-update.ts new file mode 100644 index 0000000..ca273ca --- /dev/null +++ b/lib/tasks-status-update.ts @@ -0,0 +1,72 @@ +import type { Prisma, TaskStatus } from '@prisma/client' +import { prisma } from '@/lib/prisma' + +export type StoryStatusChange = 'promoted' | 'demoted' | null + +export interface UpdateTaskStatusResult { + task: { + id: string + title: string + status: TaskStatus + story_id: string + implementation_plan: string | null + } + storyStatusChange: StoryStatusChange + storyId: string +} + +// Update task.status atomically and auto-promote/demote the parent story: +// - All sibling tasks DONE → story.status = DONE +// - Story was DONE and a task moves out of DONE → story.status = IN_SPRINT +// Demote target is IN_SPRINT (not OPEN): OPEN means "back in product backlog", +// which is a sprint-management action, not a status side-effect. +export async function updateTaskStatusWithStoryPromotion( + taskId: string, + newStatus: TaskStatus, + client?: Prisma.TransactionClient, +): Promise { + const run = async (tx: Prisma.TransactionClient): Promise => { + const task = await tx.task.update({ + where: { id: taskId }, + data: { status: newStatus }, + select: { + id: true, + title: true, + status: true, + story_id: true, + implementation_plan: true, + }, + }) + + const siblings = await tx.task.findMany({ + where: { story_id: task.story_id }, + select: { status: true }, + }) + const allDone = siblings.every((s) => s.status === 'DONE') + + const story = await tx.story.findUniqueOrThrow({ + where: { id: task.story_id }, + select: { status: true }, + }) + + let storyStatusChange: StoryStatusChange = null + if (newStatus === 'DONE' && allDone && story.status !== 'DONE') { + await tx.story.update({ + where: { id: task.story_id }, + data: { status: 'DONE' }, + }) + storyStatusChange = 'promoted' + } else if (newStatus !== 'DONE' && story.status === 'DONE') { + await tx.story.update({ + where: { id: task.story_id }, + data: { status: 'IN_SPRINT' }, + }) + storyStatusChange = 'demoted' + } + + return { task, storyStatusChange, storyId: task.story_id } + } + + if (client) return run(client) + return prisma.$transaction(run) +} diff --git a/package-lock.json b/package-lock.json index ccbe18b..3a6f70f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", + "@hookform/resolvers": "^5.2.2", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "@tanstack/react-table": "^8.21.3", @@ -30,6 +31,10 @@ "qrcode.react": "^4.2.0", "react": "19.2.4", "react-dom": "19.2.4", + "react-hook-form": "^7.74.0", + "react-markdown": "^10.1.0", + "react-textarea-autosize": "^8.5.9", + "remark-gfm": "^4.0.1", "shadcn": "^4.4.0", "sharp": "^0.34.5", "sonner": "^1.7.4", @@ -41,6 +46,7 @@ "devDependencies": { "@mermaid-js/mermaid-cli": "^11.12.0", "@tailwindcss/postcss": "^4", + "@tailwindcss/typography": "^0.5.19", "@types/bcryptjs": "^2.4.6", "@types/node": "^20", "@types/pg": "^8.20.0", @@ -1633,6 +1639,18 @@ "hono": "^4" } }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -3619,6 +3637,12 @@ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -3911,6 +3935,33 @@ "tailwindcss": "4.2.4" } }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@tanstack/react-table": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", @@ -4369,6 +4420,15 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -4380,9 +4440,17 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/@types/geojson": { "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", @@ -4390,6 +4458,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4404,6 +4481,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.39", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", @@ -4466,6 +4558,12 @@ "license": "MIT", "optional": true }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/validate-npm-package-name": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", @@ -4779,6 +4877,12 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", @@ -5986,6 +6090,16 @@ } } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -6430,6 +6544,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -6457,6 +6581,46 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chart.js": { "version": "4.5.1", "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", @@ -7031,6 +7195,16 @@ "dev": true, "license": "MIT" }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", @@ -7902,6 +8076,19 @@ "node": ">=0.10.0" } }, + "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", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -8081,6 +8268,15 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/destr": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", @@ -8096,6 +8292,19 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/devtools-protocol": { "version": "0.0.1367902", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1367902.tgz", @@ -8982,6 +9191,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -9173,6 +9392,12 @@ "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", "license": "MIT" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -9989,6 +10214,46 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/headers-polyfill": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz", @@ -10049,6 +10314,16 @@ "dev": true, "license": "MIT" }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -10228,6 +10503,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -10295,6 +10576,30 @@ "url": "https://github.com/sponsors/brc-dd" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -10472,6 +10777,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -10553,6 +10868,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-in-ssh": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", @@ -11777,6 +12102,16 @@ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -11874,6 +12209,16 @@ "node": ">=10" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/marked": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", @@ -11896,6 +12241,288 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -11975,6 +12602,569 @@ "node": ">= 20" } }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -12869,6 +14059,31 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -13630,6 +14845,16 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -13892,6 +15117,22 @@ "react": "^19.2.4" } }, + "node_modules/react-hook-form": { + "version": "7.74.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.74.0.tgz", + "integrity": "sha512-yR6wHr99p9wFv686jhRWVSFhUvDvNbdUf2dKlbno8/VKOCuoNobDGC6S+M2dua9A9Yo8vpcrp8assIYbsZCQ9g==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -13899,6 +15140,33 @@ "dev": true, "license": "MIT" }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-stately": { "version": "3.46.0", "resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.46.0.tgz", @@ -13917,6 +15185,23 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/react-textarea-autosize": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz", + "integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "use-composed-ref": "^1.3.0", + "use-latest": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -14027,6 +15312,72 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remeda": { "version": "2.33.4", "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", @@ -14917,6 +16268,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -15165,6 +16526,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stringify-object": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-5.0.0.tgz", @@ -15231,6 +16606,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -15605,6 +16998,26 @@ "tree-kill": "cli.js" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -15960,6 +17373,93 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -16062,6 +17562,51 @@ "punycode": "^2.1.0" } }, + "node_modules/use-composed-ref": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz", + "integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", + "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-latest": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz", + "integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==", + "license": "MIT", + "dependencies": { + "use-isomorphic-layout-effect": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", @@ -16123,6 +17668,34 @@ "node": ">= 0.8" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "8.0.10", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", @@ -16858,6 +18431,16 @@ "optional": true } } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index a8f39df..bf99e24 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", + "@hookform/resolvers": "^5.2.2", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "@tanstack/react-table": "^8.21.3", @@ -39,6 +40,10 @@ "qrcode.react": "^4.2.0", "react": "19.2.4", "react-dom": "19.2.4", + "react-hook-form": "^7.74.0", + "react-markdown": "^10.1.0", + "react-textarea-autosize": "^8.5.9", + "remark-gfm": "^4.0.1", "shadcn": "^4.4.0", "sharp": "^0.34.5", "sonner": "^1.7.4", @@ -56,6 +61,7 @@ "devDependencies": { "@mermaid-js/mermaid-cli": "^11.12.0", "@tailwindcss/postcss": "^4", + "@tailwindcss/typography": "^0.5.19", "@types/bcryptjs": "^2.4.6", "@types/node": "^20", "@types/pg": "^8.20.0",