diff --git a/.claude/agents/code-reviewer.md b/.claude/agents/code-reviewer.md new file mode 100644 index 0000000..6187a47 --- /dev/null +++ b/.claude/agents/code-reviewer.md @@ -0,0 +1,54 @@ +--- +name: code-reviewer +description: Use this agent when you need comprehensive code review and quality assurance for custom application code. Examples: Context: The user has just written a new service class for handling application status transitions. user: 'I just created a new ApplicationStatusService class that handles status transitions for job applications. Can you review it?' assistant: 'I'll use the code-reviewer agent to perform a thorough review of your ApplicationStatusService class.' Since the user is requesting code review for custom business logic, use the code-reviewer agent to analyze the code quality, conventions, and testing requirements. Context: The user has implemented a new feature for persona approval workflow. user: 'Here's my implementation of the persona approval workflow with custom validation rules' assistant: 'Let me use the code-reviewer agent to review your persona approval implementation for code quality and testing coverage.' The user has implemented custom business logic that needs review for Laravel conventions, testing requirements, and code quality. +tools: Bash, Glob, Grep, LS, Read, NotebookRead, WebFetch, TodoWrite, WebSearch, mcp__ide__getDiagnostics, mcp__ide__executeCode +model: sonnet +color: pink +--- + +You are a senior Laravel code reviewer with deep expertise in Laravel 12, PHP 8.3+, Pest testing, and modern development practices. You specialize in maintaining high code quality standards while understanding the practical balance between comprehensive testing and framework-specific code. + +When reviewing code, you will: + +**Code Quality Analysis:** +- Enforce strict adherence to Laravel conventions and PSR standards +- Verify proper use of Laravel features (Eloquent, validation, middleware, etc.) +- Check for security vulnerabilities and best practices +- Ensure proper error handling and edge case coverage +- Validate code organization and separation of concerns +- Review for performance implications and optimization opportunities + +**Testing Strategy:** +- Identify custom business logic that requires testing coverage +- Distinguish between framework code (Laravel/Filament) that doesn't need tests and custom application logic that does +- Recognize that Filament resources, form components, and basic CRUD operations typically don't require custom tests +- Focus testing requirements on: custom services, business logic, validation rules, custom middleware, API endpoints, complex calculations, and domain-specific workflows +- Automatically run existing tests to validate current functionality +- Suggest specific test cases for custom code including edge cases and error conditions + +**Laravel/NextHire Specific Considerations:** +- Understand the NextHire recruitment domain (positions, personas, applications, vacancies) +- Recognize the enum implementation strategy (PHP enums with VARCHAR columns, not database enums) +- Validate proper use of Spatie Laravel Permission for role-based access +- Check UUID implementation alongside auto-increment IDs +- Ensure proper polymorphic relationships for conversations and AI integration +- Verify status workflow implementations follow project patterns + +**Review Process:** +1. Analyze the code structure and adherence to Laravel conventions +2. Identify security concerns and potential bugs +3. Run existing tests to ensure no regressions +4. Determine which parts need testing (custom logic only) +5. Provide specific, actionable improvement recommendations +6. Suggest test cases for custom business logic +7. Highlight any missing error handling or validation + +**Output Format:** +Provide a structured review with: +- **Code Quality**: Specific issues and improvements +- **Security & Best Practices**: Vulnerabilities and recommendations +- **Testing Requirements**: What needs tests and why (excluding framework code) +- **Test Results**: Output from running existing tests +- **Action Items**: Prioritized list of changes to implement + +Be direct and specific in your feedback. Focus on maintainability, security, and proper testing of custom business logic while respecting that framework-specific code doesn't always need custom tests. diff --git a/.claude/agents/meta-agent.md b/.claude/agents/meta-agent.md new file mode 100644 index 0000000..392518c --- /dev/null +++ b/.claude/agents/meta-agent.md @@ -0,0 +1,59 @@ +--- +name: meta-agent +description: Generates a new, complete Claude Code sub-agent configuration file from a user's description. Use this to create new agents. Use this Proactively when the user asks you to create a new sub agent. +tools: Write, WebFetch, MultiEdit, Read +color: cyan +model: opus +--- + +# Purpose + +Your sole purpose is to act as an expert agent architect. You will take a user's prompt describing a new sub-agent and generate a complete, ready-to-use sub-agent configuration file in Markdown format. You will create and write this new file. Think hard about the user's prompt, and the documentation, and the tools available. + +## Instructions + +**0. Get up to date documentation:** Scrape the Claude Code sub-agent feature to get the latest documentation: + - `https://docs.anthropic.com/en/docs/claude-code/sub-agents` - Sub-agent feature + - `https://docs.anthropic.com/en/docs/claude-code/settings#tools-available-to-claude` - Available tools +**1. Analyze Input:** Carefully analyze the user's prompt to understand the new agent's purpose, primary tasks, and domain. +**2. Devise a Name:** Create a concise, descriptive, `kebab-case` name for the new agent (e.g., `dependency-manager`, `api-tester`). +**3. Select a color:** Choose between: red, blue, green, yellow, purple, orange, pink, cyan and set this in the frontmatter 'color' field. +**4. Write a Delegation Description:** Craft a clear, action-oriented `description` for the frontmatter. This is critical for Claude's automatic delegation. It should state *when* to use the agent. Use phrases like "Use proactively for..." or "Specialist for reviewing...". +**5. Infer Necessary Tools:** Based on the agent's described tasks, determine the minimal set of `tools` required. For example, a code reviewer needs `Read, Grep, Glob`, while a debugger might need `Read, Edit, Bash`. If it writes new files, it needs `Write`. +**6. Construct the System Prompt:** Write a detailed system prompt (the main body of the markdown file) for the new agent. +**7. Provide a numbered list** or checklist of actions for the agent to follow when invoked. +**8. Incorporate best practices** relevant to its specific domain. +**9. Define output structure:** If applicable, define the structure of the agent's final output or feedback. +**10. Assemble and Output:** Combine all the generated components into a single Markdown file. Adhere strictly to the `Output Format` below. Your final response should ONLY be the content of the new agent file. Write the file to the `.claude/agents/.md` directory. + +## Output Format + +You must generate a single Markdown code block containing the complete agent definition. The structure must be exactly as follows: + +```md +--- +name: +description: +tools: , +model: haiku | sonnet | opus +--- + +# Purpose + +You are a . + +## Instructions + +When invoked, you must follow these steps: +1. +2. <...> +3. <...> + +**Best Practices:** +- +- <...> + +## Report / Response + +Provide your final response in a clear and organized manner. +``` \ No newline at end of file diff --git a/.claude/commands/pr-submit.md b/.claude/commands/pr-submit.md new file mode 100644 index 0000000..7dae3fb --- /dev/null +++ b/.claude/commands/pr-submit.md @@ -0,0 +1,103 @@ +--- +description: Maak branch, commit wijzigingen, push en maak PR volgens het PR template +allowed-tools: [Bash, Read, Edit, Write, Grep, Glob] +argument-hint: [pr-nummer] (optioneel - detecteert automatisch van huidige branch) +--- + +Je bent een ervaren tech lead die pull requests voorbereidt voor het NextHire development team. Je analyseert code wijzigingen grondig, schrijft heldere technische beschrijvingen en communiceert op een directe, professionele manier zonder poespas. Je vermijdt overdreven AI-taal, clichΓ©s en dramatische woorden. Je schrijft zoals een senior developer die zijn collega's informeert: zakelijk, betrokken en to the point. + +## Proces + +1. **Analyseer huidige staat** + - Controleer git status voor uncommitted changes + - Controleer huidige branch naam met `git branch --show-current` + - Controleer of er al een PR bestaat voor deze branch met `gh pr view` + - Bepaal de Favro card URL op basis van branch naam of argument + +2. **Valideer branch naam tegen wijzigingen** + - Analyseer alle gewijzigde bestanden om de feature/wijziging te bepalen + - Vergelijk de huidige branch naam met de aard van de wijzigingen + - **Als branch naam NIET gerelateerd is aan de wijzigingen:** + - Stel een passende nieuwe branch naam voor op basis van de wijzigingen + - Formaat: `feature/`, `fix/`, `refactor/`, etc. gevolgd door korte beschrijving + - Voorbeeld: `feature/facebook-lead-forms`, `fix/authentication-bug` + - Vraag gebruiker om bevestiging voor de voorgestelde branch naam + - Maak de nieuwe branch aan met `git checkout -b ` + - **Als branch naam WEL gerelateerd is:** + - Ga door met de huidige branch + - **Als je op main/master zit:** + - ALTIJD een nieuwe branch maken, nooit direct op main/master werken + +3. **Maak een beschrijvende commit** + - Analyseer alle gewijzigde bestanden + - Maak een commit message volgens Conventional Commits format + - Type(scope): Beschrijving in het Nederlands + - Voeg co-authored-by Claude toe + +4. **Push naar remote** + - Push de huidige branch naar origin + - Zorg dat upstream tracking is geconfigureerd + +5. **Maak of update Pull Request** + + **Als er GEEN bestaande PR is:** + - Gebruik het PR template uit `.github/PULL_REQUEST_TEMPLATE.md` + - Vul alle secties in op basis van de wijzigingen: + - Korte beschrijving van de wijzigingen + - **Slack Channel update**: Stakeholder-vriendelijke samenvatting + - **Hoe test ik dit?**: Concrete teststappen + - **Wanneer kan dit live?**: Timing en afhankelijkheden + - **Bijzonderheden**: Eventuele speciale aandachtspunten + - Maak de PR met `gh pr create` + + **Als er WEL een bestaande PR is:** + - Analyseer de nieuwe commits sinds de laatste push + - Maak een comment met `gh pr comment` die: + - Een korte samenvatting geeft van de nieuwe wijzigingen + - Uitlegt wat er is toegevoegd/gewijzigd/gerepareerd + - Vermeldt welke commits zijn toegevoegd + - Duidelijk aangeeft dat dit een update is van de PR + - Formaat voor de comment: + ```markdown + ## πŸ”„ Update + + [Korte beschrijving van wat er is toegevoegd] + + **Nieuwe commits:** + - commit hash: commit message + - commit hash: commit message + + **Wijzigingen:** + - [Beschrijving van belangrijkste wijzigingen] + ``` + +6. **Toon resultaat** + - Geef de gebruiker de URL van de (nieuwe of bestaande) PR + - Bij een update: toon de toegevoegde comment + - Vraag of er nog aanpassingen nodig zijn + +## Belangrijke opmerkingen + +- Gebruik `gh pr create` voor het maken van de PR +- Follow het PULL_REQUEST_TEMPLATE.md formaat exact +- Schrijf duidelijke, zakelijke tekst zonder overdreven AI-taal +- Analyseer de code wijzigingen grondig voor accurate beschrijvingen +- Vraag om bevestiging voordat je pushed en de PR maakt + +## Bekende issues + +### PR beschrijving updaten +Het commando `gh pr edit --body` kan falen met een error over "Projects Classic deprecation". +Gebruik in dat geval de GitHub API direct: + +```bash +# Schrijf body naar tijdelijk bestand +cat > /tmp/pr_body.md <<'EOF' +[PR beschrijving hier] +EOF + +# Update via API +gh api repos/{owner}/{repo}/pulls/{pr_number} -X PATCH -f body="$(cat /tmp/pr_body.md)" +``` + +Dit omzeilt de Projects Classic GraphQL error en werkt betrouwbaar. \ No newline at end of file diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c632575..b5d5b7a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,11 @@ { "permissions": { "allow": [ - "WebSearch" + "WebSearch", + "Bash(bunx shadcn@latest add:*)", + "Bash(bun add:*)", + "Bash(bunx tsc:*)", + "Bash(bun remove:*)" ], "deny": [], "ask": [] diff --git a/.gitignore b/.gitignore index 2eea525..5ef6a52 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,41 @@ -.env \ No newline at end of file +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e26772f --- /dev/null +++ b/Makefile @@ -0,0 +1,61 @@ +.PHONY: setup dev db-create db-push db-studio build clean help share + +# Default postgres config (override with environment variables) +DB_HOST ?= 127.0.0.1 +DB_PORT ?= 5432 +DB_USER ?= dennisstolmeijer +DB_NAME ?= scrumkit + +# Construct DATABASE_URL (no password for local dev) +export POSTGRES_URL = postgres://$(DB_USER)@$(DB_HOST):$(DB_PORT)/$(DB_NAME) + +help: ## Show this help + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}' + +setup: db-create db-push ## Full setup: create database and run migrations + @echo "βœ… Setup complete! Run 'make dev' to start the server." + +dev: ## Start development server + bun run dev + +db-create: ## Create the database (requires psql) + @echo "Creating database '$(DB_NAME)'..." + @createdb $(DB_NAME) 2>/dev/null || echo "Database already exists or couldn't be created" + +db-drop: ## Drop the database (DESTRUCTIVE!) + @echo "⚠️ Dropping database '$(DB_NAME)'..." + @dropdb $(DB_NAME) 2>/dev/null || echo "Database doesn't exist" + +db-push: ## Push schema to database + @echo "Pushing schema to database..." + POSTGRES_URL="$(POSTGRES_URL)" bun run db:push + +db-generate: ## Generate migration files + POSTGRES_URL="$(POSTGRES_URL)" bun run db:generate + +db-studio: ## Open Drizzle Studio (database GUI) + POSTGRES_URL="$(POSTGRES_URL)" bun run db:studio + +build: ## Build for production + bun run build + +lint: ## Run linter + bun run lint + +clean: ## Clean build artifacts + rm -rf .next node_modules + +install: ## Install dependencies + bun install + +# Quick test command +test-api: ## Test if API is working (requires running server) + @echo "Testing API..." + @curl -s http://localhost:3000/api/retrospective | head -c 100 || echo "Server not running?" + +# Sharing +share: ## Share local server via ngrok (requires ngrok installed) + @echo "Starting ngrok tunnel to localhost:3000..." + @echo "Install ngrok: brew install ngrok" + @echo "" + ngrok http 3000 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..c41e41f Binary files /dev/null and b/bun.lockb differ diff --git a/components.json b/components.json new file mode 100644 index 0000000..edcaef2 --- /dev/null +++ b/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..32947fa --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/lib/db/schema.ts', + out: './drizzle', + dialect: 'postgresql', + dbCredentials: { + url: process.env.POSTGRES_URL!, + }, +}); diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..05e726d --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/knowledge/codebase/01-index.md b/knowledge/codebase/01-index.md index bfa2c9b..c8e22f2 100644 --- a/knowledge/codebase/01-index.md +++ b/knowledge/codebase/01-index.md @@ -4,11 +4,10 @@ This folder contains foundational project knowledge including architecture, data ## Available Documentation -No documentation has been added yet. Consider adding: -- Architecture documentation -- Core business processes (sequence diagrams) -- Data models (ERD diagrams) -- Architecture Decision Records (ADRs) +### Architecture Overview +**File:** @knowledge/codebase/02-architecture.md +**Keywords:** architecture, tech stack, directory structure, data model, SSE, real-time, API routes +**When to use:** When you need to understand the overall application structure, tech stack, data models, or how real-time updates work. ## Usage diff --git a/knowledge/codebase/02-architecture.md b/knowledge/codebase/02-architecture.md new file mode 100644 index 0000000..9903f54 --- /dev/null +++ b/knowledge/codebase/02-architecture.md @@ -0,0 +1,148 @@ +# ScrumKit Architecture + +## Overview + +ScrumKit is een real-time retrospective tool gebouwd met Next.js 16 (App Router) en React 19. De applicatie faciliteert team retrospectives met een Mad/Sad/Glad board structuur, voting systeem, en AI-gegenereerde samenvattingen. + +## Tech Stack + +| Component | Technologie | +|-----------|------------| +| Framework | Next.js 16 (App Router, Turbopack) | +| Frontend | React 19, Tailwind CSS 4 | +| UI Components | Radix UI + shadcn/ui | +| Database | PostgreSQL | +| ORM | Drizzle ORM | +| Real-time | Server-Sent Events (SSE) | +| AI | OpenAI API | + +## Directory Structure + +``` +src/ +β”œβ”€β”€ app/ +β”‚ β”œβ”€β”€ api/retrospective/ # REST API routes +β”‚ β”œβ”€β”€ retrospective/[id]/ # Retrospective detail page +β”‚ └── page.tsx # Home page (create retro) +β”œβ”€β”€ components/ +β”‚ β”œβ”€β”€ features/ # Feature components (retro board, action items) +β”‚ └── ui/ # shadcn/ui components +β”œβ”€β”€ hooks/ # React hooks (SSE subscription) +β”œβ”€β”€ lib/ +β”‚ β”œβ”€β”€ ai/ # OpenAI integration +β”‚ β”œβ”€β”€ db/ # Database schema & connection +β”‚ └── sse/ # Server-Sent Events infrastructure +└── types/ # TypeScript type definitions +``` + +## Data Model + +### Core Entities + +``` +RetrospectiveSession +β”œβ”€β”€ id (uuid) +β”œβ”€β”€ name +β”œβ”€β”€ sprintName +β”œβ”€β”€ status: input | voting | discussion | completed +β”œβ”€β”€ votesPerUser (default: 5) +└── hideVotesUntilComplete + +RetrospectiveItem +β”œβ”€β”€ id (uuid) +β”œβ”€β”€ sessionId β†’ RetrospectiveSession +β”œβ”€β”€ category: went_well | to_improve | action_item +β”œβ”€β”€ content +β”œβ”€β”€ authorId, authorName +β”œβ”€β”€ isAnonymous +└── voteCount (computed) + +Vote +β”œβ”€β”€ id (uuid) +β”œβ”€β”€ itemId β†’ RetrospectiveItem +└── userId + +ActionItem +β”œβ”€β”€ id (uuid) +β”œβ”€β”€ sessionId β†’ RetrospectiveSession +β”œβ”€β”€ sourceItemId β†’ RetrospectiveItem (optional) +β”œβ”€β”€ description +β”œβ”€β”€ assigneeId, assigneeName +β”œβ”€β”€ priority: low | medium | high +└── status: open | in_progress | done +``` + +### Session Status Flow + +``` +input β†’ voting β†’ discussion β†’ completed +``` + +- **input**: Deelnemers voegen items toe +- **voting**: Deelnemers stemmen op items +- **discussion**: Team bespreekt top-voted items +- **completed**: Sessie afgerond + +## Real-time Architecture + +### Server-Sent Events (SSE) + +De applicatie gebruikt SSE voor real-time updates. Dit is een simpelere oplossing dan WebSockets voor unidirectionele serverβ†’client communicatie. + +**Event Types:** +- `item:added`, `item:updated`, `item:deleted` +- `vote:added`, `vote:removed` +- `session:updated` +- `action:added`, `action:updated`, `action:deleted` +- `connected` + +**Server-side (event-emitter.ts):** +```typescript +// In-memory pub/sub per session +sessionEmitter.emit(sessionId, { type: 'item:added', data: item }); +``` + +**Client-side (use-retrospective-events.ts):** +```typescript +useRetrospectiveEvents(sessionId, { + 'item:added': (data) => handleNewItem(data), + 'vote:added': (data) => handleVote(data), +}); +``` + +**Productie opmerking:** De huidige implementatie gebruikt in-memory pub/sub. Voor multi-instance deployments moet dit vervangen worden door Redis pub/sub. + +## API Routes + +| Method | Route | Beschrijving | +|--------|-------|--------------| +| POST | `/api/retrospective` | Create session | +| GET | `/api/retrospective/[id]` | Get session with items | +| PATCH | `/api/retrospective/[id]` | Update session status | +| GET | `/api/retrospective/[id]/events` | SSE endpoint | +| POST | `/api/retrospective/[id]/items` | Add item | +| DELETE | `/api/retrospective/[id]/items/[itemId]` | Delete item | +| POST | `/api/retrospective/[id]/votes` | Add vote | +| DELETE | `/api/retrospective/[id]/votes` | Remove vote | +| POST | `/api/retrospective/[id]/actions` | Create action item | +| PATCH | `/api/retrospective/[id]/actions/[actionId]` | Update action item | +| POST | `/api/retrospective/[id]/report` | Generate AI report | + +## AI Integration + +De report generator gebruikt OpenAI om samenvattingen te genereren van retrospective sessies: + +- Analyseert items per categorie +- Identificeert patronen en thema's +- Genereert aanbevelingen voor verbetering +- Vat actiepunten samen + +## Environment Variables + +```bash +# Database +DATABASE_URL="postgresql://..." + +# OpenAI +OPENAI_API_KEY="sk-..." +``` diff --git a/knowledge/implementation-guidelines/01-index.md b/knowledge/implementation-guidelines/01-index.md index 834b721..2b7f643 100644 --- a/knowledge/implementation-guidelines/01-index.md +++ b/knowledge/implementation-guidelines/01-index.md @@ -4,7 +4,10 @@ This index helps determine which implementation guidelines to load based on the ## Available Guidelines -No guidelines have been added yet. Add guidelines as needed based on your project requirements. +### SSE Real-time Patterns +**File:** @knowledge/implementation-guidelines/02-sse-real-time-patterns.md +**Keywords:** SSE, Server-Sent Events, real-time, websocket, events, broadcast, pub/sub +**When to use:** When implementing real-time features, adding new event types, or scaling the SSE infrastructure. ## Loading Logic diff --git a/knowledge/implementation-guidelines/02-sse-real-time-patterns.md b/knowledge/implementation-guidelines/02-sse-real-time-patterns.md new file mode 100644 index 0000000..7f575f8 --- /dev/null +++ b/knowledge/implementation-guidelines/02-sse-real-time-patterns.md @@ -0,0 +1,221 @@ +# SSE Real-time Patterns + +Server-Sent Events (SSE) implementatie patronen voor real-time functionaliteit in ScrumKit. + +## Wanneer SSE vs WebSockets + +**Gebruik SSE wanneer:** +- Data alleen van server β†’ client stroomt +- Je simpele reconnect logica wilt (browsers doen dit automatisch) +- Je HTTP/2 multiplexing wilt benutten +- Geen bidirectionele communicatie nodig is + +**Gebruik WebSockets wanneer:** +- Bidirectionele communicatie nodig is (client β†’ server β†’ client) +- Zeer lage latency vereist is +- Binaire data gestreamd moet worden + +In ScrumKit is SSE voldoende omdat alle user actions via REST API gaan en alleen de broadcasts serverβ†’client zijn. + +## Server-side Implementatie + +### Event Emitter Pattern + +```typescript +// src/lib/sse/event-emitter.ts +class SessionEventEmitter { + private listeners: Map> = new Map(); + + subscribe(sessionId: string, listener: Listener): () => void { + // Return unsubscribe function voor cleanup + } + + emit(sessionId: string, event: { type: string; data: unknown }): void { + const eventString = `data: ${JSON.stringify({ ...event, timestamp: Date.now() })}\n\n`; + // Broadcast naar alle listeners voor deze session + } +} + +export const sessionEmitter = new SessionEventEmitter(); +``` + +### SSE API Route + +```typescript +// src/app/api/retrospective/[id]/events/route.ts +export async function GET(request: Request, { params }: { params: { id: string } }) { + const sessionId = params.id; + + const stream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + + // Stuur connected event + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'connected' })}\n\n`)); + + // Subscribe op session events + const unsubscribe = sessionEmitter.subscribe(sessionId, (data) => { + controller.enqueue(encoder.encode(data)); + }); + + // Cleanup bij disconnect + request.signal.addEventListener('abort', () => { + unsubscribe(); + }); + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }, + }); +} +``` + +### Event Emitting vanuit API Routes + +```typescript +// In een POST/PATCH/DELETE route +import { sessionEmitter } from '@/lib/sse'; + +// Na database operatie +sessionEmitter.emit(sessionId, { + type: 'item:added', + data: { id: item.id, content: item.content, ... } +}); +``` + +## Client-side Implementatie + +### React Hook Pattern + +```typescript +// src/hooks/use-retrospective-events.ts +export function useRetrospectiveEvents( + sessionId: string, + handlers: Partial> +) { + const eventSourceRef = useRef(null); + const handlersRef = useRef(handlers); + + // βœ… Handlers in ref om stale closure te voorkomen + useEffect(() => { + handlersRef.current = handlers; + }, [handlers]); + + const connect = useCallback(() => { + const eventSource = new EventSource(`/api/retrospective/${sessionId}/events`); + + eventSource.onmessage = (event) => { + const parsed = JSON.parse(event.data); + const handler = handlersRef.current[parsed.type]; + if (handler) handler(parsed.data); + }; + + // βœ… Auto-reconnect bij error + eventSource.onerror = () => { + eventSource.close(); + setTimeout(connect, 3000); + }; + + return eventSource; + }, [sessionId]); + + useEffect(() => { + const eventSource = connect(); + return () => eventSource.close(); + }, [connect]); +} +``` + +### Gebruik in Components + +```typescript +// In een page component +useRetrospectiveEvents(sessionId, { + 'item:added': (data) => { + setItems(prev => [...prev, data as RetrospectiveItem]); + }, + 'vote:added': (data) => { + const { itemId } = data as VoteEvent; + setItems(prev => prev.map(item => + item.id === itemId + ? { ...item, voteCount: (item.voteCount || 0) + 1 } + : item + )); + }, +}); +``` + +## Event Type Definities + +```typescript +// src/lib/sse/types.ts +export type SSEEventType = + | 'item:added' + | 'item:updated' + | 'item:deleted' + | 'vote:added' + | 'vote:removed' + | 'session:updated' + | 'action:added' + | 'action:updated' + | 'action:deleted' + | 'connected'; + +export interface SSEEvent { + type: SSEEventType; + data: T; + timestamp: number; +} +``` + +## Productie Overwegingen + +### Multi-instance Deployment + +De huidige in-memory EventEmitter werkt alleen voor single-instance deployments. Voor productie met meerdere instances: + +```typescript +// ❌ Werkt niet met meerdere instances +class SessionEventEmitter { + private listeners: Map> = new Map(); +} + +// βœ… Gebruik Redis pub/sub +import Redis from 'ioredis'; + +const publisher = new Redis(); +const subscriber = new Redis(); + +subscriber.subscribe('retrospective-events'); +subscriber.on('message', (channel, message) => { + const { sessionId, event } = JSON.parse(message); + localEmitter.emit(sessionId, event); +}); + +export function broadcastEvent(sessionId: string, event: SSEEvent) { + publisher.publish('retrospective-events', JSON.stringify({ sessionId, event })); +} +``` + +### Connection Limits + +Browsers limiteren het aantal SSE connections per domein (meestal 6). Overweeg: +- Één connection per tab, niet per component +- Connection pooling voor meerdere sessions +- HTTP/2 (verhoogt limit significant) + +### Heartbeat + +Voor long-running connections, stuur periodiek een heartbeat: + +```typescript +// Server-side +setInterval(() => { + controller.enqueue(encoder.encode(': heartbeat\n\n')); +}, 30000); +``` diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..e9ffa30 --- /dev/null +++ b/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + /* config options here */ +}; + +export default nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..635875b --- /dev/null +++ b/package.json @@ -0,0 +1,44 @@ +{ + "name": "scrumkit", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack", + "build": "next build", + "start": "next start", + "lint": "eslint .", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:push": "drizzle-kit push", + "db:studio": "drizzle-kit studio" + }, + "dependencies": { + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tooltip": "^1.2.8", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "drizzle-orm": "^0.44.7", + "lucide-react": "^0.555.0", + "next": "16.0.6", + "openai": "^6.9.1", + "postgres": "^3.4.7", + "react": "19.2.0", + "react-dom": "19.2.0", + "tailwind-merge": "^3.4.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "drizzle-kit": "^0.31.7", + "eslint": "^9", + "eslint-config-next": "16.0.6", + "tailwindcss": "^4", + "tw-animate-css": "^1.4.0", + "typescript": "^5" + } +} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/public/file.svg b/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/globe.svg b/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/next.svg b/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/window.svg b/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/specs/completed/20251203-scrumkit-initial-setup-retrospective.md b/specs/completed/20251203-scrumkit-initial-setup-retrospective.md new file mode 100644 index 0000000..d72480d --- /dev/null +++ b/specs/completed/20251203-scrumkit-initial-setup-retrospective.md @@ -0,0 +1,531 @@ +# Product Requirements Document: Scrumkit - Initial Setup & Sprint Retrospective + +## 1. Feature Overzicht + +**Feature:** Scrumkit Initial Project Setup met Sprint Retrospective Tool + +**Doel:** Een AI-first toolbox voor scrum teams opzetten met als eerste feature een collaboratieve Sprint Retrospective tool die real-time samenwerking, voting, en AI-gegenereerde rapporten ondersteunt. + +**Doelgroep:** Scrum teams die hun retrospectives willen verbeteren door middel van digitale samenwerking, gestructureerde feedback, en AI-ondersteunde rapportage. + +--- + +## 2. Functionele Vereisten + +### FV1: Project Initialisatie & Architectuur + +--- + +**FV1.1:** Next.js 16 Project Setup met Bun [DONE - FV1.1] + +Het project moet worden opgezet met Next.js 16 (App Router), TypeScript, en Bun als runtime en package manager. + +```bash +bun create next-app scrumkit --typescript --tailwind --eslint --app --src-dir --import-alias "@/*" +``` + +Next.js 16 features die worden benut: + +- **Turbopack** als default bundler (stabiel voor dev Γ©n production) +- **React 19.2** met View Transitions, `useEffectEvent()`, en Activity +- **React Compiler 1.0** voor automatische memoization +- **proxy.ts** als vervanging voor middleware (nieuwe network boundary) + +Vereiste configuraties: + +- Node.js 20.9+ (minimum vereiste voor Next.js 16) +- TypeScript 5.1+ met strict mode +- App Router (geen Pages Router) +- `src/` directory structuur +- Import alias `@/*` voor cleane imports + +--- + +**FV1.2:** Shadcn/UI Integratie [DONE - FV1.2] + +Het project moet Shadcn/UI gebruiken als component library met Tailwind CSS voor styling. + +```bash +bunx shadcn@latest init +``` + +Vereiste componenten voor retrospective feature (installeren via CLI): + +```bash +bunx shadcn@latest add button card input textarea dialog sheet dropdown-menu avatar badge tooltip skeleton +``` + +--- + +**FV1.3:** Database Setup met Vercel Postgres [DONE - FV1.3] + +Het project gebruikt Vercel Postgres als database met Drizzle ORM voor type-safe queries. + +```bash +bun add @vercel/postgres drizzle-orm +bun add -D drizzle-kit +``` + +Vercel Postgres voordelen: + +- Native Vercel integratie +- Serverless-ready +- Automatische connection pooling +- Zero-config met Vercel deployment + +Drizzle ORM versie: **0.44.7** (stable) + +--- + +**FV1.4:** Real-time Infrastructuur met Liveblocks [DONE - FV1.4] + +Het project gebruikt Liveblocks voor real-time functionaliteit. Liveblocks is Vercel's aanbevolen partner voor real-time collaboration (Vercel gebruikt dit zelf voor hun Ship livestream). + +```bash +bun add @liveblocks/client @liveblocks/react +``` + +Liveblocks voordelen: + +- OfficiΓ«le Vercel templates en documentatie +- Native Vercel Postgres synchronisatie support +- Presence awareness out-of-the-box +- Conflict-free data store (Storage) +- Gebouwd voor collaboration use-cases + +Documentatie: [Liveblocks + Vercel Postgres sync](https://liveblocks.io/docs/guides/how-to-synchronize-your-liveblocks-storage-document-data-to-a-vercel-postgres-database) + +--- + +**FV1.5:** OpenAI Integratie [DONE - FV1.5] + +Het project moet OpenAI SDK integreren voor AI-gegenereerde retrospective rapporten. + +```bash +bun add openai +``` + +Configuratie: + +- Environment variable `OPENAI_API_KEY` +- Server-side only API calls (geen client-side exposure van API key) +- Rate limiting en error handling + +--- + +### FV2: Sprint Retrospective - Core Functionaliteit + +--- + +**FV2.1:** Retrospective Sessie Aanmaken [DONE - FV2.1] + +Een gebruiker moet een nieuwe retrospective sessie kunnen aanmaken voor een sprint. + +Vereiste velden: + +- Sessie naam (verplicht) +- Sprint nummer/naam (optioneel) +- Team identifier (voor toekomstige multi-team support) + +Output: + +- Unieke sessie ID (UUID) +- Deelbare link voor teamleden +- QR code voor makkelijke toegang (optioneel) + +--- + +**FV2.2:** Input Toevoegen door Teamleden [DONE - FV2.2] + +Elk teamlid moet input kunnen toevoegen aan de retrospective in categorieΓ«n. + +Standaard categorieΓ«n (configureerbaar): + +- **Went Well** (Wat ging goed) +- **To Improve** (Wat kan beter) +- **Action Items** (Actiepunten) + +Per input item: + +- Tekst content (max 500 karakters) +- Categorie selectie +- Auteur (anoniem optie beschikbaar) +- Timestamp + +--- + +**FV2.3:** Real-time Synchronisatie met Liveblocks [DONE - FV2.3] + +Alle wijzigingen moeten real-time gesynchroniseerd worden tussen alle deelnemers via Liveblocks. + +Real-time events: + +- Nieuwe input toegevoegd +- Input gewijzigd/verwijderd +- Vote toegevoegd/verwijderd +- Discussie notities bijgewerkt +- Deelnemer joined/left + +Presence awareness (Liveblocks native): + +- Toon welke teamleden online zijn +- Toon wie momenteel aan het typen is +- Cursors van andere gebruikers (optioneel) + +--- + +**FV2.4:** Voting Systeem [DONE - FV2.4] + +Teamleden moeten kunnen stemmen op input items om prioriteit te bepalen. + +Voting regels: + +- Elk teamlid heeft een configureerbaar aantal votes (default: 5) +- Meerdere votes op hetzelfde item toegestaan +- Eigen items mogen gevoted worden +- Votes zijn zichtbaar voor iedereen (of hidden tot voting fase eindigt - configureerbaar) + +--- + +**FV2.5:** Automatische Ordening op Votes [DONE - FV2.5] + +Items moeten automatisch geordend worden op basis van het aantal votes. + +Sorteerlogica: + +- Primair: Aantal votes (hoogste eerst) +- Secundair: Timestamp (oudste eerst bij gelijk aantal votes) + +Weergave: + +- Ranking nummer tonen +- Visuele indicatie van vote count +- Groupering per categorie behouden + +--- + +### FV3: Sprint Retrospective - Discussie & Vastlegging + +--- + +**FV3.1:** Discussie Modus per Item [DONE - FV3.1] + +Na de voting fase moet elk item besproken kunnen worden met vastlegging van de discussie. + +Per item discussie: + +- Rich text notities veld +- Gekoppelde action items +- Eigenaar toewijzen aan action items +- Status markering (besproken/niet besproken) + +Facilitator controls: + +- Item markeren als "nu bespreken" +- Timer per discussie (optioneel) +- Naar volgend item navigeren + +--- + +**FV3.2:** Action Items Registratie [DONE - FV3.2] + +Concrete actiepunten moeten vastgelegd kunnen worden tijdens de discussie. + +Per action item: + +- Beschrijving +- Toegewezen persoon +- Deadline (optioneel) +- Prioriteit (low/medium/high) +- Status (open/in progress/done) + +--- + +### FV4: AI Rapport Generatie + +--- + +**FV4.1:** Automatische Rapport Generatie [DONE - FV4.1] + +Na afloop van de retrospective moet een AI-gegenereerd rapport beschikbaar zijn. + +Rapport inhoud: + +- Samenvatting van de retrospective +- Overzicht van alle input per categorie +- Top items op basis van votes +- Discussie highlights en besluiten +- Lijst van action items met eigenaren +- Trends vergeleken met vorige retrospectives (toekomstige feature) + +--- + +**FV4.2:** Rapport Formaten [DONE - FV4.2] + +Het rapport moet in meerdere formaten beschikbaar zijn. + +Ondersteunde formaten: + +- Markdown (voor copy/paste naar tools) +- PDF (voor archivering) +- Slack/Teams message format (voor delen) + +--- + +**FV4.3:** AI Prompt Configuratie [DONE - FV4.3] + +De AI prompt voor rapport generatie moet configureerbaar zijn. + +Configureerbare aspecten: + +- Toon (formeel/informeel) +- Taal (Nederlands/Engels) +- Focus gebieden +- Custom instructies per team + +--- + +## 3. Technische Overwegingen + +--- + +### TO1: Project Structuur [DONE - TO1] + +Aanbevolen mappenstructuur voor schaalbaarheid: + +```text +src/ +β”œβ”€β”€ app/ # Next.js App Router +β”‚ β”œβ”€β”€ (auth)/ # Auth-gerelateerde routes +β”‚ β”œβ”€β”€ (dashboard)/ # Dashboard routes +β”‚ β”œβ”€β”€ api/ # API routes +β”‚ └── retrospective/ # Retrospective feature routes +β”œβ”€β”€ components/ +β”‚ β”œβ”€β”€ ui/ # Shadcn components +β”‚ └── features/ # Feature-specifieke components +β”œβ”€β”€ lib/ +β”‚ β”œβ”€β”€ db/ # Database schema & queries (Drizzle) +β”‚ β”œβ”€β”€ ai/ # OpenAI integratie +β”‚ └── liveblocks/ # Liveblocks client setup +β”œβ”€β”€ hooks/ # Custom React hooks +β”œβ”€β”€ types/ # TypeScript type definitions +└── utils/ # Utility functions +``` + +--- + +### TO2: Database Schema (Drizzle + Vercel Postgres) [DONE - TO2] + +Kern entiteiten voor de retrospective feature: + +```typescript +// src/lib/db/schema.ts +import { pgTable, uuid, varchar, text, boolean, timestamp, pgEnum } from 'drizzle-orm/pg-core'; + +export const sessionStatusEnum = pgEnum('session_status', ['input', 'voting', 'discussion', 'completed']); +export const categoryEnum = pgEnum('category', ['went_well', 'to_improve', 'action_item']); +export const priorityEnum = pgEnum('priority', ['low', 'medium', 'high']); +export const actionStatusEnum = pgEnum('action_status', ['open', 'in_progress', 'done']); + +export const retrospectiveSessions = pgTable('retrospective_sessions', { + id: uuid('id').primaryKey().defaultRandom(), + name: varchar('name', { length: 255 }).notNull(), + sprintName: varchar('sprint_name', { length: 255 }), + teamId: varchar('team_id', { length: 255 }), + status: sessionStatusEnum('status').default('input'), + createdAt: timestamp('created_at').defaultNow(), + completedAt: timestamp('completed_at'), +}); + +export const retrospectiveItems = pgTable('retrospective_items', { + id: uuid('id').primaryKey().defaultRandom(), + sessionId: uuid('session_id').references(() => retrospectiveSessions.id), + category: categoryEnum('category').notNull(), + content: text('content').notNull(), + authorId: varchar('author_id', { length: 255 }), + isAnonymous: boolean('is_anonymous').default(false), + discussionNotes: text('discussion_notes'), + isDiscussed: boolean('is_discussed').default(false), + createdAt: timestamp('created_at').defaultNow(), +}); + +export const votes = pgTable('votes', { + id: uuid('id').primaryKey().defaultRandom(), + itemId: uuid('item_id').references(() => retrospectiveItems.id), + oderId: varchar('user_id', { length: 255 }).notNull(), + createdAt: timestamp('created_at').defaultNow(), +}); + +export const actionItems = pgTable('action_items', { + id: uuid('id').primaryKey().defaultRandom(), + sessionId: uuid('session_id').references(() => retrospectiveSessions.id), + sourceItemId: uuid('source_item_id').references(() => retrospectiveItems.id), + description: text('description').notNull(), + assigneeId: varchar('assignee_id', { length: 255 }), + priority: priorityEnum('priority').default('medium'), + status: actionStatusEnum('status').default('open'), + dueDate: timestamp('due_date'), + createdAt: timestamp('created_at').defaultNow(), +}); +``` + +--- + +### TO3: Liveblocks Room Structuur [DONE - TO3] + +Aanbevolen room structuur voor real-time communicatie: + +```typescript +// src/lib/liveblocks/config.ts +import { createClient } from "@liveblocks/client"; +import { createRoomContext } from "@liveblocks/react"; + +const client = createClient({ + publicApiKey: process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY!, +}); + +// Room type per retrospective sessie +type Presence = { + cursor: { x: number; y: number } | null; + name: string; + isTyping: boolean; +}; + +type Storage = { + items: LiveList; + votes: LiveMap; // itemId -> userIds + phase: "input" | "voting" | "discussion" | "completed"; +}; + +export const { + RoomProvider, + useOthers, + useUpdateMyPresence, + useStorage, + useMutation, +} = createRoomContext(client); +``` + +--- + +### TO4: API Route Structuur [DONE - TO4] + +```text +/api/retrospective + POST / - Nieuwe sessie aanmaken + GET /:id - Sessie ophalen + PATCH /:id - Sessie updaten (status, settings) + DELETE /:id - Sessie verwijderen + +/api/retrospective/:id/items + POST / - Item toevoegen + PATCH /:itemId - Item updaten + DELETE /:itemId - Item verwijderen + +/api/retrospective/:id/votes + POST / - Vote toevoegen + DELETE /:itemId - Vote verwijderen + +/api/retrospective/:id/report + POST / - AI rapport genereren + GET / - Rapport ophalen + +/api/liveblocks-auth + POST / - Liveblocks authentication +``` + +--- + +### TO5: Environment Variables [DONE - TO5] + +Vereiste environment variables: + +```env +# Vercel Postgres (automatisch beschikbaar via Vercel) +POSTGRES_URL= +POSTGRES_PRISMA_URL= +POSTGRES_URL_NON_POOLING= + +# Liveblocks +LIVEBLOCKS_SECRET_KEY=sk_... +NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY=pk_... + +# OpenAI +OPENAI_API_KEY=sk-... + +# App +NEXT_PUBLIC_APP_URL=https://scrumkit.vercel.app +``` + +--- + +### TO6: Security Overwegingen + +- API keys alleen server-side gebruiken +- Input sanitization voor alle user content +- Rate limiting op API endpoints +- Liveblocks authentication voor room toegang +- Sessie toegang valideren (alleen teamleden met link) + +--- + +## 4. Buiten Scope (voor deze versie) + +- **User Authentication** - Eerste versie werkt met anonieme sessies of simpele naam-invoer. Volledige auth (NextAuth.js/Clerk) komt in v2. +- **Team Management** - Geen team aanmaak, leden beheer, of permissies. Teams worden later toegevoegd. +- **Historische Data & Trends** - Vergelijking met vorige retrospectives wordt niet geΓ―mplementeerd in v1. +- **Integraties** - Geen Jira, Slack, Teams, of andere tool integraties. +- **Mobile App** - Alleen responsive web, geen native apps. +- **Offline Support** - Vereist internetverbinding voor real-time functionaliteit. +- **Custom Retrospective Formats** - Alleen standaard 3-kolom format (Went Well, To Improve, Action Items). +- **Timer Functionaliteit** - Discussie timers worden later toegevoegd. +- **Export naar externe tools** - Alleen in-app rapport generatie, geen directe export naar Confluence/Notion etc. +- **Multi-language UI** - Interface alleen in Engels voor v1. + +--- + +## 5. Succescriteria + +### Functionele Acceptatiecriteria + +- [ ] Gebruiker kan een nieuwe retrospective sessie aanmaken en een deelbare link ontvangen +- [ ] Meerdere gebruikers kunnen tegelijkertijd items toevoegen die real-time zichtbaar zijn voor alle deelnemers +- [ ] Liveblocks presence toont welke teamleden online zijn +- [ ] Gebruikers kunnen votes uitbrengen met correct vote-limiet beheer +- [ ] Items worden automatisch gesorteerd op vote count +- [ ] Discussie notities kunnen worden toegevoegd en zijn real-time zichtbaar +- [ ] Action items kunnen worden aangemaakt met assignee en prioriteit +- [ ] AI genereert een coherent rapport na afloop van de sessie +- [ ] Rapport is downloadbaar in minimaal Markdown formaat + +### Technische Acceptatiecriteria + +- [ ] Project draait succesvol met `bun dev` +- [ ] Turbopack wordt gebruikt als bundler +- [ ] Deployment naar Vercel werkt zonder errors +- [ ] Vercel Postgres connectie werkt correct +- [ ] Liveblocks real-time synchronisatie werkt +- [ ] Database migraties draaien succesvol +- [ ] Alle API endpoints retourneren correcte HTTP status codes +- [ ] TypeScript compileert zonder errors +- [ ] ESLint toont geen errors + +### Performance Criteria + +- [ ] InitiΓ«le page load < 3 seconden +- [ ] Real-time updates binnen 500ms (Liveblocks) +- [ ] AI rapport generatie < 30 seconden +- [ ] Ondersteunt minimaal 10 gelijktijdige gebruikers per sessie + +--- + +## 6. Bronnen & Referenties + +- [Next.js 16 Release Notes](https://nextjs.org/blog/next-16) +- [Next.js 16 Upgrade Guide](https://nextjs.org/docs/app/guides/upgrading/version-16) +- [Liveblocks Starter Kit - Vercel](https://vercel.com/templates/next.js/liveblocks-starter-kit) +- [Liveblocks + Vercel Postgres Sync](https://liveblocks.io/docs/guides/how-to-synchronize-your-liveblocks-storage-document-data-to-a-vercel-postgres-database) +- [Vercel Postgres Documentation](https://vercel.com/docs/storage/vercel-postgres) +- [Drizzle ORM Documentation](https://orm.drizzle.team/) +- [Shadcn/UI Next.js Installation](https://ui.shadcn.com/docs/installation/next) +- [How Vercel Used Liveblocks for Ship](https://liveblocks.io/blog/how-vercel-used-live-reactions-to-improve-engagement-on-their-vercel-ship-livestream) diff --git a/specs/new/20251203-scrumkit-initial-setup-retrospective.md b/specs/new/20251203-scrumkit-initial-setup-retrospective.md index a90a669..593f13e 100644 --- a/specs/new/20251203-scrumkit-initial-setup-retrospective.md +++ b/specs/new/20251203-scrumkit-initial-setup-retrospective.md @@ -1,8 +1,8 @@ -# Product Requirements Document: Scrumkit - Initial Setup & Sprint Retrospective +# Product Requirements Document: Scrumkit - InitiΓ«le Setup & Sprint Retrospective ## 1. Feature Overzicht -**Feature:** Scrumkit Initial Project Setup met Sprint Retrospective Tool +**Feature:** Scrumkit InitiΓ«le Project Setup met Sprint Retrospective Tool **Doel:** Een AI-first toolbox voor scrum teams opzetten met als eerste feature een collaboratieve Sprint Retrospective tool die real-time samenwerking, voting, en AI-gegenereerde rapporten ondersteunt. @@ -26,35 +26,51 @@ bun create next-app scrumkit --typescript --tailwind --eslint --app --src-dir -- Next.js 16 features die worden benut: -- **Turbopack** als default bundler (stabiel voor dev Γ©n production) +- **Turbopack** als standaard bundler (stabiel voor dev Γ©n productie) - **React 19.2** met View Transitions, `useEffectEvent()`, en Activity - **React Compiler 1.0** voor automatische memoization -- **proxy.ts** als vervanging voor middleware (nieuwe network boundary) +- **proxy.ts** als vervanging voor middleware (nieuwe netwerk grens) Vereiste configuraties: -- Node.js 20.9+ (minimum vereiste voor Next.js 16) +- Node.js 20.9+ (minimumvereiste voor Next.js 16) - TypeScript 5.1+ met strict mode - App Router (geen Pages Router) -- `src/` directory structuur +- `src/` mappenstructuur - Import alias `@/*` voor cleane imports --- -**FV1.2:** Shadcn/UI Integratie +**FV1.2:** Shadcn/UI Integratie met v0.dev Component Generatie -Het project moet Shadcn/UI gebruiken als component library met Tailwind CSS voor styling. +Het project gebruikt Shadcn/UI als component library met Tailwind CSS voor styling. Voor complexere UI componenten wordt v0.dev gebruikt om professioneel gestylde componenten te genereren. ```bash bunx shadcn@latest init ``` -Vereiste componenten voor retrospective feature (installeren via CLI): +Basis componenten (installeren via CLI): ```bash -bunx shadcn@latest add button card input textarea dialog sheet dropdown-menu avatar badge tooltip skeleton +bunx shadcn@latest add button card input textarea dialog sheet dropdown-menu avatar badge tooltip skeleton tabs ``` +**v0.dev Workflow voor UI Componenten:** + +v0.dev is Vercel's AI UI generator die perfect integreert met Next.js + Tailwind + Shadcn/UI. Gebruik v0.dev voor: + +- Retrospective board layout met kolommen +- Voting cards met animaties +- Rapport weergave met tabs +- Presence indicators en avatars +- Dashboard layouts + +Werkwijze: + +1. Genereer component op [v0.dev](https://v0.dev) met beschrijving +2. Kopieer gegenereerde code naar `src/components/features/` +3. Pas aan indien nodig + --- **FV1.3:** Database Setup met Vercel Postgres @@ -73,33 +89,36 @@ Vercel Postgres voordelen: - Automatische connection pooling - Zero-config met Vercel deployment -Drizzle ORM versie: **0.44.7** (stable) +Drizzle ORM versie: **0.44.7** (stabiel) --- -**FV1.4:** Real-time Infrastructuur met Liveblocks +**FV1.4:** Real-time Infrastructuur met Server-Sent Events (SSE) -Het project gebruikt Liveblocks voor real-time functionaliteit. Liveblocks is Vercel's aanbevolen partner voor real-time collaboration (Vercel gebruikt dit zelf voor hun Ship livestream). +Het project gebruikt Server-Sent Events (SSE) voor real-time functionaliteit. SSE is een native browser API die eenrichtings real-time communicatie van server naar client mogelijk maakt zonder externe dependencies. -```bash -bun add @liveblocks/client @liveblocks/react -``` +**Geen extra packages nodig** - SSE werkt native met Next.js API routes. -Liveblocks voordelen: +SSE voordelen: -- OfficiΓ«le Vercel templates en documentatie -- Native Vercel Postgres synchronisatie support -- Presence awareness out-of-the-box -- Conflict-free data store (Storage) -- Gebouwd voor collaboration use-cases +- Geen externe services of kosten +- Native browser ondersteuning +- Eenvoudige implementatie met Next.js API routes +- Lichtgewicht en efficiΓ«nt +- Automatische reconnect bij verbindingsverlies +- Werkt perfect met Vercel serverless -Documentatie: [Liveblocks + Vercel Postgres sync](https://liveblocks.io/docs/guides/how-to-synchronize-your-liveblocks-storage-document-data-to-a-vercel-postgres-database) +SSE architectuur: + +- **Server β†’ Client:** SSE stream voor real-time updates +- **Client β†’ Server:** Reguliere POST/PATCH requests voor mutaties +- Database als "source of truth" voor synchronisatie --- **FV1.5:** OpenAI Integratie -Het project moet OpenAI SDK integreren voor AI-gegenereerde retrospective rapporten. +Het project integreert OpenAI SDK voor AI-gegenereerde retrospective rapporten. ```bash bun add openai @@ -108,12 +127,12 @@ bun add openai Configuratie: - Environment variable `OPENAI_API_KEY` -- Server-side only API calls (geen client-side exposure van API key) +- Server-side only API calls (geen client-side blootstelling van API key) - Rate limiting en error handling --- -### FV2: Sprint Retrospective - Core Functionaliteit +### FV2: Sprint Retrospective - Kernfunctionaliteit --- @@ -123,9 +142,9 @@ Een gebruiker moet een nieuwe retrospective sessie kunnen aanmaken voor een spri Vereiste velden: -- Sessie naam (verplicht) -- Sprint nummer/naam (optioneel) -- Team identifier (voor toekomstige multi-team support) +- Sessienaam (verplicht) +- Sprintnummer/naam (optioneel) +- Team identifier (voor toekomstige multi-team ondersteuning) Output: @@ -139,68 +158,73 @@ Output: Elk teamlid moet input kunnen toevoegen aan de retrospective in categorieΓ«n. -Standaard categorieΓ«n (configureerbaar): +Standaard categorieΓ«n: -- **Went Well** (Wat ging goed) -- **To Improve** (Wat kan beter) -- **Action Items** (Actiepunten) +- **Ging Goed** (Wat ging goed deze sprint) +- **Kan Beter** (Wat kan verbeterd worden) +- **Actiepunten** (Concrete acties voor volgende sprint) Per input item: -- Tekst content (max 500 karakters) +- Tekst inhoud (max 500 karakters) - Categorie selectie - Auteur (anoniem optie beschikbaar) -- Timestamp +- Tijdstempel --- -**FV2.3:** Real-time Synchronisatie met Liveblocks +**FV2.3:** Real-time Synchronisatie met SSE -Alle wijzigingen moeten real-time gesynchroniseerd worden tussen alle deelnemers via Liveblocks. +Alle wijzigingen moeten real-time gesynchroniseerd worden tussen alle deelnemers via Server-Sent Events. -Real-time events: +Real-time events (via SSE): -- Nieuwe input toegevoegd -- Input gewijzigd/verwijderd -- Vote toegevoegd/verwijderd -- Discussie notities bijgewerkt -- Deelnemer joined/left +- `item:created` - Nieuwe input toegevoegd +- `item:updated` - Input gewijzigd +- `item:deleted` - Input verwijderd +- `vote:added` - Stem toegevoegd +- `vote:removed` - Stem verwijderd +- `discussion:updated` - Discussie notities bijgewerkt +- `phase:changed` - Sessie fase gewijzigd +- `report:generated` - Rapport gegenereerd +- `participant:joined` - Deelnemer toegetreden +- `participant:left` - Deelnemer vertrokken -Presence awareness (Liveblocks native): +Presence tracking: +- Heartbeat mechanisme (elke 30 seconden) - Toon welke teamleden online zijn -- Toon wie momenteel aan het typen is -- Cursors van andere gebruikers (optioneel) +- Automatische cleanup bij disconnect --- -**FV2.4:** Voting Systeem +**FV2.4:** Stem Systeem Teamleden moeten kunnen stemmen op input items om prioriteit te bepalen. -Voting regels: +Stemregels: -- Elk teamlid heeft een configureerbaar aantal votes (default: 5) -- Meerdere votes op hetzelfde item toegestaan -- Eigen items mogen gevoted worden -- Votes zijn zichtbaar voor iedereen (of hidden tot voting fase eindigt - configureerbaar) +- Elk teamlid heeft een configureerbaar aantal stemmen (standaard: 5) +- Meerdere stemmen op hetzelfde item toegestaan +- Eigen items mogen gestemd worden +- Stemmen zijn zichtbaar voor iedereen (of verborgen tot stemfase eindigt - configureerbaar) --- -**FV2.5:** Automatische Ordening op Votes +**FV2.5:** Automatische Ordening op Stemmen -Items moeten automatisch geordend worden op basis van het aantal votes. +Items moeten automatisch geordend worden op basis van het aantal stemmen. Sorteerlogica: -- Primair: Aantal votes (hoogste eerst) -- Secundair: Timestamp (oudste eerst bij gelijk aantal votes) +- Primair: Aantal stemmen (hoogste eerst) +- Secundair: Tijdstempel (oudste eerst bij gelijk aantal stemmen) Weergave: -- Ranking nummer tonen -- Visuele indicatie van vote count -- Groupering per categorie behouden +- Rankingnummer tonen +- Visuele indicatie van stemtelling +- Groepering per categorie behouden --- @@ -210,13 +234,13 @@ Weergave: **FV3.1:** Discussie Modus per Item -Na de voting fase moet elk item besproken kunnen worden met vastlegging van de discussie. +Na de stemfase moet elk item besproken kunnen worden met vastlegging van de discussie. Per item discussie: - Rich text notities veld -- Gekoppelde action items -- Eigenaar toewijzen aan action items +- Gekoppelde actiepunten +- Eigenaar toewijzen aan actiepunten - Status markering (besproken/niet besproken) Facilitator controls: @@ -227,25 +251,65 @@ Facilitator controls: --- -**FV3.2:** Action Items Registratie +**FV3.2:** Actiepunten Registratie Concrete actiepunten moeten vastgelegd kunnen worden tijdens de discussie. -Per action item: +Per actiepunt: - Beschrijving - Toegewezen persoon - Deadline (optioneel) -- Prioriteit (low/medium/high) -- Status (open/in progress/done) +- Prioriteit (laag/gemiddeld/hoog) +- Status (open/in uitvoering/afgerond) + +--- + +### FV4: AI Rapport Generatie & Weergave + +--- + +**FV4.1:** Rapport Tab in Retrospective Interface + +Het gegenereerde rapport moet worden gepresenteerd in een dedicated tab binnen de retrospective interface. + +Tab structuur: + +```text +[Bord] [Stemmen] [Discussie] [Rapport] +``` + +Rapport tab features: + +- Volledige breedte weergave voor leesbaarheid +- Markdown rendering met syntax highlighting +- Print-vriendelijke layout +- Kopieer naar klembord functie +- Download opties (Markdown, PDF) --- -### FV4: AI Rapport Generatie +**FV4.2:** Rapport Opslag & Real-time Synchronisatie + +Na generatie moet het rapport worden opgeslagen en real-time zichtbaar zijn voor alle deelnemers in de sessie. + +Opslag: + +- Rapport wordt opgeslagen in database (`retrospective_reports` tabel) +- Gekoppeld aan sessie ID +- Bevat versie geschiedenis (optioneel voor v2) +- Tijdstempel van generatie + +Real-time synchronisatie (via SSE): + +- Bij rapport generatie: `report:generated` event naar alle deelnemers +- Rapport tab toont automatisch het nieuwste rapport +- Indicator wanneer rapport wordt gegenereerd ("Rapport wordt gegenereerd...") +- Automatische refresh van rapport tab bij ontvangst van event --- -**FV4.1:** Automatische Rapport Generatie +**FV4.3:** Automatische Rapport Generatie Na afloop van de retrospective moet een AI-gegenereerd rapport beschikbaar zijn. @@ -253,26 +317,27 @@ Rapport inhoud: - Samenvatting van de retrospective - Overzicht van alle input per categorie -- Top items op basis van votes +- Top items op basis van stemmen - Discussie highlights en besluiten -- Lijst van action items met eigenaren -- Trends vergeleken met vorige retrospectives (toekomstige feature) +- Lijst van actiepunten met eigenaren + +Rapport taal: **Nederlands** (standaard, configureerbaar) --- -**FV4.2:** Rapport Formaten +**FV4.4:** Rapport Formaten Het rapport moet in meerdere formaten beschikbaar zijn. Ondersteunde formaten: -- Markdown (voor copy/paste naar tools) +- Markdown (voor kopiΓ«ren/plakken naar tools) - PDF (voor archivering) -- Slack/Teams message format (voor delen) +- Slack/Teams bericht formaat (voor delen) --- -**FV4.3:** AI Prompt Configuratie +**FV4.5:** AI Prompt Configuratie De AI prompt voor rapport generatie moet configureerbaar zijn. @@ -281,7 +346,7 @@ Configureerbare aspecten: - Toon (formeel/informeel) - Taal (Nederlands/Engels) - Focus gebieden -- Custom instructies per team +- Aangepaste instructies per team --- @@ -295,21 +360,48 @@ Aanbevolen mappenstructuur voor schaalbaarheid: ```text src/ -β”œβ”€β”€ app/ # Next.js App Router -β”‚ β”œβ”€β”€ (auth)/ # Auth-gerelateerde routes -β”‚ β”œβ”€β”€ (dashboard)/ # Dashboard routes -β”‚ β”œβ”€β”€ api/ # API routes -β”‚ └── retrospective/ # Retrospective feature routes +β”œβ”€β”€ app/ # Next.js App Router +β”‚ β”œβ”€β”€ (auth)/ # Auth-gerelateerde routes +β”‚ β”œβ”€β”€ (dashboard)/ # Dashboard routes +β”‚ β”œβ”€β”€ api/ # API routes +β”‚ β”‚ └── retrospective/ +β”‚ β”‚ β”œβ”€β”€ route.ts # POST: nieuwe sessie +β”‚ β”‚ └── [id]/ +β”‚ β”‚ β”œβ”€β”€ route.ts # GET, PATCH, DELETE sessie +β”‚ β”‚ β”œβ”€β”€ stream/ +β”‚ β”‚ β”‚ └── route.ts # SSE endpoint +β”‚ β”‚ β”œβ”€β”€ items/ +β”‚ β”‚ β”‚ └── route.ts # Items CRUD +β”‚ β”‚ β”œβ”€β”€ votes/ +β”‚ β”‚ β”‚ └── route.ts # Votes CRUD +β”‚ β”‚ └── report/ +β”‚ β”‚ └── route.ts # Rapport generatie +β”‚ └── retrospective/ # Retrospective feature routes +β”‚ └── [id]/ +β”‚ └── page.tsx # Retrospective sessie pagina met tabs β”œβ”€β”€ components/ -β”‚ β”œβ”€β”€ ui/ # Shadcn components -β”‚ └── features/ # Feature-specifieke components +β”‚ β”œβ”€β”€ ui/ # Shadcn componenten +β”‚ └── features/ # Feature-specifieke componenten (v0.dev generated) +β”‚ β”œβ”€β”€ retrospective/ +β”‚ β”‚ β”œβ”€β”€ board.tsx # Retrospective bord (v0.dev) +β”‚ β”‚ β”œβ”€β”€ item-card.tsx # Item kaart met stemmen (v0.dev) +β”‚ β”‚ β”œβ”€β”€ voting-panel.tsx # Stem paneel (v0.dev) +β”‚ β”‚ β”œβ”€β”€ discussion-view.tsx +β”‚ β”‚ └── report-tab.tsx # Rapport tab weergave (v0.dev) +β”‚ └── shared/ +β”‚ β”œβ”€β”€ presence-avatars.tsx # Online gebruikers (v0.dev) +β”‚ └── phase-indicator.tsx β”œβ”€β”€ lib/ -β”‚ β”œβ”€β”€ db/ # Database schema & queries (Drizzle) -β”‚ β”œβ”€β”€ ai/ # OpenAI integratie -β”‚ └── liveblocks/ # Liveblocks client setup -β”œβ”€β”€ hooks/ # Custom React hooks -β”œβ”€β”€ types/ # TypeScript type definitions -└── utils/ # Utility functions +β”‚ β”œβ”€β”€ db/ # Database schema & queries (Drizzle) +β”‚ β”œβ”€β”€ ai/ # OpenAI integratie +β”‚ └── sse/ # SSE utilities +β”‚ β”œβ”€β”€ client.ts # Client-side SSE hook +β”‚ └── server.ts # Server-side SSE helpers +β”œβ”€β”€ hooks/ +β”‚ β”œβ”€β”€ use-sse.ts # SSE connection hook +β”‚ └── use-retrospective.ts # Retrospective state hook +β”œβ”€β”€ types/ # TypeScript type definities +└── utils/ # Utility functies ``` --- @@ -367,76 +459,264 @@ export const actionItems = pgTable('action_items', { dueDate: timestamp('due_date'), createdAt: timestamp('created_at').defaultNow(), }); + +// Rapport opslag +export const retrospectiveReports = pgTable('retrospective_reports', { + id: uuid('id').primaryKey().defaultRandom(), + sessionId: uuid('session_id').references(() => retrospectiveSessions.id).unique(), + content: text('content').notNull(), + generatedAt: timestamp('generated_at').defaultNow(), + generatedBy: varchar('generated_by', { length: 255 }), +}); + +// Participant tracking voor presence +export const sessionParticipants = pgTable('session_participants', { + id: uuid('id').primaryKey().defaultRandom(), + sessionId: uuid('session_id').references(() => retrospectiveSessions.id), + oderId: varchar('user_id', { length: 255 }).notNull(), + userName: varchar('user_name', { length: 255 }).notNull(), + lastSeen: timestamp('last_seen').defaultNow(), + isOnline: boolean('is_online').default(true), +}); ``` --- -### TO3: Liveblocks Room Structuur +### TO3: SSE Implementatie -Aanbevolen room structuur voor real-time communicatie: +**Server-side SSE Route:** ```typescript -// src/lib/liveblocks/config.ts -import { createClient } from "@liveblocks/client"; -import { createRoomContext } from "@liveblocks/react"; +// src/app/api/retrospective/[id]/stream/route.ts +import { NextRequest } from 'next/server'; + +// In-memory store voor SSE connections (per sessie) +const sessions = new Map>(); + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + const sessionId = params.id; + + const stream = new ReadableStream({ + start(controller) { + // Voeg controller toe aan sessie + if (!sessions.has(sessionId)) { + sessions.set(sessionId, new Set()); + } + sessions.get(sessionId)!.add(controller); + + // Stuur initiΓ«le connectie bevestiging + const encoder = new TextEncoder(); + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'connected' })}\n\n`)); + }, + cancel(controller) { + // Verwijder controller bij disconnect + sessions.get(sessionId)?.delete(controller); + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }, + }); +} + +// Helper functie om events te broadcasen +export function broadcastToSession(sessionId: string, event: SSEEvent) { + const controllers = sessions.get(sessionId); + if (!controllers) return; + + const encoder = new TextEncoder(); + const message = `data: ${JSON.stringify(event)}\n\n`; + + controllers.forEach((controller) => { + try { + controller.enqueue(encoder.encode(message)); + } catch { + // Controller is gesloten, verwijderen + controllers.delete(controller); + } + }); +} + +type SSEEvent = { + type: 'item:created' | 'item:updated' | 'item:deleted' | + 'vote:added' | 'vote:removed' | + 'discussion:updated' | 'phase:changed' | + 'report:generated' | 'participant:joined' | 'participant:left'; + payload: unknown; +}; +``` -const client = createClient({ - publicApiKey: process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY!, -}); +**Client-side SSE Hook:** -// Room type per retrospective sessie -type Presence = { - cursor: { x: number; y: number } | null; - name: string; - isTyping: boolean; +```typescript +// src/hooks/use-sse.ts +'use client'; + +import { useEffect, useCallback, useRef } from 'react'; + +type SSEEvent = { + type: string; + payload: unknown; }; -type Storage = { - items: LiveList; - votes: LiveMap; // itemId -> userIds - phase: "input" | "voting" | "discussion" | "completed"; +export function useSSE( + sessionId: string, + onEvent: (event: SSEEvent) => void +) { + const eventSourceRef = useRef(null); + + const connect = useCallback(() => { + const eventSource = new EventSource(`/api/retrospective/${sessionId}/stream`); + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data) as SSEEvent; + onEvent(data); + } catch (error) { + console.error('SSE parse error:', error); + } + }; + + eventSource.onerror = () => { + eventSource.close(); + // Reconnect na 3 seconden + setTimeout(connect, 3000); + }; + + eventSourceRef.current = eventSource; + }, [sessionId, onEvent]); + + useEffect(() => { + connect(); + + return () => { + eventSourceRef.current?.close(); + }; + }, [connect]); +} +``` + +--- + +### TO4: Retrospective State Hook + +```typescript +// src/hooks/use-retrospective.ts +'use client'; + +import { useState, useCallback } from 'react'; +import { useSSE } from './use-sse'; + +type RetrospectiveState = { + items: RetrospectiveItem[]; + votes: Vote[]; + participants: Participant[]; + report: Report | null; + phase: 'input' | 'voting' | 'discussion' | 'completed'; }; -export const { - RoomProvider, - useOthers, - useUpdateMyPresence, - useStorage, - useMutation, -} = createRoomContext(client); +export function useRetrospective(sessionId: string, initialData: RetrospectiveState) { + const [state, setState] = useState(initialData); + + const handleSSEEvent = useCallback((event: SSEEvent) => { + switch (event.type) { + case 'item:created': + setState((prev) => ({ + ...prev, + items: [...prev.items, event.payload as RetrospectiveItem], + })); + break; + + case 'item:deleted': + setState((prev) => ({ + ...prev, + items: prev.items.filter((item) => item.id !== event.payload.id), + })); + break; + + case 'vote:added': + setState((prev) => ({ + ...prev, + votes: [...prev.votes, event.payload as Vote], + })); + break; + + case 'report:generated': + setState((prev) => ({ + ...prev, + report: event.payload as Report, + })); + break; + + case 'phase:changed': + setState((prev) => ({ + ...prev, + phase: event.payload.phase, + })); + break; + + case 'participant:joined': + setState((prev) => ({ + ...prev, + participants: [...prev.participants, event.payload as Participant], + })); + break; + + // ... andere event handlers + } + }, []); + + useSSE(sessionId, handleSSEEvent); + + return state; +} ``` --- -### TO4: API Route Structuur +### TO5: API Route Structuur ```text /api/retrospective - POST / - Nieuwe sessie aanmaken - GET /:id - Sessie ophalen - PATCH /:id - Sessie updaten (status, settings) - DELETE /:id - Sessie verwijderen + POST / - Nieuwe sessie aanmaken + +/api/retrospective/:id + GET / - Sessie ophalen met alle data + PATCH / - Sessie bijwerken (status, instellingen) + DELETE / - Sessie verwijderen + +/api/retrospective/:id/stream + GET / - SSE endpoint voor real-time updates /api/retrospective/:id/items - POST / - Item toevoegen - PATCH /:itemId - Item updaten - DELETE /:itemId - Item verwijderen + POST / - Item toevoegen (broadcast: item:created) + PATCH /:itemId - Item bijwerken (broadcast: item:updated) + DELETE /:itemId - Item verwijderen (broadcast: item:deleted) /api/retrospective/:id/votes - POST / - Vote toevoegen - DELETE /:itemId - Vote verwijderen + POST / - Stem toevoegen (broadcast: vote:added) + DELETE /:itemId - Stem verwijderen (broadcast: vote:removed) /api/retrospective/:id/report - POST / - AI rapport genereren - GET / - Rapport ophalen + POST / - AI rapport genereren (broadcast: report:generated) + GET / - Rapport ophalen -/api/liveblocks-auth - POST / - Liveblocks authentication +/api/retrospective/:id/participants + POST / - Deelnemer registreren (broadcast: participant:joined) + DELETE /:userId - Deelnemer verwijderen (broadcast: participant:left) + PATCH /heartbeat - Heartbeat update ``` --- -### TO5: Environment Variables +### TO6: Environment Variables Vereiste environment variables: @@ -446,10 +726,6 @@ POSTGRES_URL= POSTGRES_PRISMA_URL= POSTGRES_URL_NON_POOLING= -# Liveblocks -LIVEBLOCKS_SECRET_KEY=sk_... -NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY=pk_... - # OpenAI OPENAI_API_KEY=sk-... @@ -459,28 +735,66 @@ NEXT_PUBLIC_APP_URL=https://scrumkit.vercel.app --- -### TO6: Security Overwegingen +### TO7: v0.dev Component Generatie Workflow + +Gebruik de volgende prompts op [v0.dev](https://v0.dev) voor het genereren van professionele componenten: + +**Retrospective Board:** + +```text +Maak een Kanban-achtig bord met 3 kolommen: "Ging Goed" (groen), "Kan Beter" (oranje), "Actiepunten" (blauw). +Elke kolom bevat kaarten met tekst en een stem-counter. Gebruik Shadcn/UI componenten en Tailwind. +Voeg een "+" knop toe per kolom om nieuwe items toe te voegen. +``` + +**Rapport Tab:** + +```text +Maak een rapport weergave component met: +- Header met titel "Retrospective Rapport" en actieknoppen (kopiΓ«ren, download PDF) +- Markdown content gebied met mooie typografie +- Footer met generatie timestamp +- Loading skeleton state +- Lege state met "Genereer Rapport" knop +Gebruik Shadcn/UI en Tailwind. Nederlandse labels. +``` + +**Presence Avatars:** + +```text +Maak een avatar stack component die online gebruikers toont. +- Circulaire avatars met initialen +- Overlappende layout (max 5 zichtbaar, "+3" indicator voor meer) +- Groene online indicator dot +- Tooltip met naam bij hover +Gebruik Shadcn/UI Avatar component. +``` + +--- + +### TO8: Security Overwegingen - API keys alleen server-side gebruiken -- Input sanitization voor alle user content +- Input sanitization voor alle gebruikerscontent - Rate limiting op API endpoints -- Liveblocks authentication voor room toegang -- Sessie toegang valideren (alleen teamleden met link) +- Sessie toegang valideren via sessie ID in URL (link-based access) +- SSE endpoints beveiligen tegen misbruik +- Heartbeat timeout voor automatische participant cleanup --- ## 4. Buiten Scope (voor deze versie) -- **User Authentication** - Eerste versie werkt met anonieme sessies of simpele naam-invoer. Volledige auth (NextAuth.js/Clerk) komt in v2. -- **Team Management** - Geen team aanmaak, leden beheer, of permissies. Teams worden later toegevoegd. +- **Gebruikersauthenticatie** - Eerste versie werkt met anonieme sessies of simpele naam-invoer. Volledige auth (NextAuth.js/Clerk) komt in v2. +- **Team Beheer** - Geen team aanmaak, ledenbeheer, of permissies. Teams worden later toegevoegd. - **Historische Data & Trends** - Vergelijking met vorige retrospectives wordt niet geΓ―mplementeerd in v1. - **Integraties** - Geen Jira, Slack, Teams, of andere tool integraties. -- **Mobile App** - Alleen responsive web, geen native apps. -- **Offline Support** - Vereist internetverbinding voor real-time functionaliteit. -- **Custom Retrospective Formats** - Alleen standaard 3-kolom format (Went Well, To Improve, Action Items). +- **Mobiele App** - Alleen responsive web, geen native apps. +- **Offline Ondersteuning** - Vereist internetverbinding voor real-time functionaliteit. +- **Aangepaste Retrospective Formats** - Alleen standaard 3-kolom format (Ging Goed, Kan Beter, Actiepunten). - **Timer Functionaliteit** - Discussie timers worden later toegevoegd. - **Export naar externe tools** - Alleen in-app rapport generatie, geen directe export naar Confluence/Notion etc. -- **Multi-language UI** - Interface alleen in Engels voor v1. +- **Meertalige UI** - Interface in Nederlands voor v1, Engels als toekomstige optie. --- @@ -490,30 +804,35 @@ NEXT_PUBLIC_APP_URL=https://scrumkit.vercel.app - [ ] Gebruiker kan een nieuwe retrospective sessie aanmaken en een deelbare link ontvangen - [ ] Meerdere gebruikers kunnen tegelijkertijd items toevoegen die real-time zichtbaar zijn voor alle deelnemers -- [ ] Liveblocks presence toont welke teamleden online zijn -- [ ] Gebruikers kunnen votes uitbrengen met correct vote-limiet beheer -- [ ] Items worden automatisch gesorteerd op vote count +- [ ] SSE-gebaseerde presence toont welke teamleden online zijn +- [ ] Gebruikers kunnen stemmen uitbrengen met correct stemlimiet beheer +- [ ] Items worden automatisch gesorteerd op stemtelling - [ ] Discussie notities kunnen worden toegevoegd en zijn real-time zichtbaar -- [ ] Action items kunnen worden aangemaakt met assignee en prioriteit -- [ ] AI genereert een coherent rapport na afloop van de sessie -- [ ] Rapport is downloadbaar in minimaal Markdown formaat +- [ ] Actiepunten kunnen worden aangemaakt met toegewezen persoon en prioriteit +- [ ] AI genereert een coherent Nederlandstalig rapport na afloop van de sessie +- [ ] **Rapport wordt getoond in dedicated tab** +- [ ] **Rapport wordt opgeslagen in database** +- [ ] **Rapport is real-time zichtbaar voor alle deelnemers via SSE** +- [ ] Rapport is downloadbaar in Markdown en PDF formaat ### Technische Acceptatiecriteria - [ ] Project draait succesvol met `bun dev` - [ ] Turbopack wordt gebruikt als bundler -- [ ] Deployment naar Vercel werkt zonder errors +- [ ] Deployment naar Vercel werkt zonder fouten - [ ] Vercel Postgres connectie werkt correct -- [ ] Liveblocks real-time synchronisatie werkt +- [ ] SSE real-time synchronisatie werkt correct +- [ ] SSE reconnect werkt bij verbindingsverlies - [ ] Database migraties draaien succesvol - [ ] Alle API endpoints retourneren correcte HTTP status codes -- [ ] TypeScript compileert zonder errors -- [ ] ESLint toont geen errors +- [ ] TypeScript compileert zonder fouten +- [ ] ESLint toont geen fouten +- [ ] v0.dev gegenereerde componenten zijn geΓ―ntegreerd ### Performance Criteria -- [ ] InitiΓ«le page load < 3 seconden -- [ ] Real-time updates binnen 500ms (Liveblocks) +- [ ] InitiΓ«le pagina laadtijd < 3 seconden +- [ ] SSE event delivery < 500ms - [ ] AI rapport generatie < 30 seconden - [ ] Ondersteunt minimaal 10 gelijktijdige gebruikers per sessie @@ -523,9 +842,9 @@ NEXT_PUBLIC_APP_URL=https://scrumkit.vercel.app - [Next.js 16 Release Notes](https://nextjs.org/blog/next-16) - [Next.js 16 Upgrade Guide](https://nextjs.org/docs/app/guides/upgrading/version-16) -- [Liveblocks Starter Kit - Vercel](https://vercel.com/templates/next.js/liveblocks-starter-kit) -- [Liveblocks + Vercel Postgres Sync](https://liveblocks.io/docs/guides/how-to-synchronize-your-liveblocks-storage-document-data-to-a-vercel-postgres-database) -- [Vercel Postgres Documentation](https://vercel.com/docs/storage/vercel-postgres) -- [Drizzle ORM Documentation](https://orm.drizzle.team/) -- [Shadcn/UI Next.js Installation](https://ui.shadcn.com/docs/installation/next) -- [How Vercel Used Liveblocks for Ship](https://liveblocks.io/blog/how-vercel-used-live-reactions-to-improve-engagement-on-their-vercel-ship-livestream) +- [MDN: Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) +- [Next.js Streaming with SSE](https://nextjs.org/docs/app/building-your-application/routing/route-handlers#streaming) +- [v0.dev - Vercel AI UI Generator](https://v0.dev) +- [Vercel Postgres Documentatie](https://vercel.com/docs/storage/vercel-postgres) +- [Drizzle ORM Documentatie](https://orm.drizzle.team/) +- [Shadcn/UI Next.js Installatie](https://ui.shadcn.com/docs/installation/next) diff --git a/src/app/api/retrospective/[id]/actions/[actionId]/route.ts b/src/app/api/retrospective/[id]/actions/[actionId]/route.ts new file mode 100644 index 0000000..93369d2 --- /dev/null +++ b/src/app/api/retrospective/[id]/actions/[actionId]/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, actionItems } from "@/lib/db"; +import { eq, and } from "drizzle-orm"; + +type RouteParams = { params: Promise<{ id: string; actionId: string }> }; + +// PATCH - Update action item +export async function PATCH(request: NextRequest, { params }: RouteParams) { + try { + const { id: sessionId, actionId } = await params; + const body = await request.json(); + const { description, assigneeId, assigneeName, priority, status, dueDate } = body; + + const updateData: Record = {}; + + if (description !== undefined) { + if (typeof description !== "string" || description.trim().length === 0) { + return NextResponse.json( + { error: "Description cannot be empty" }, + { status: 400 } + ); + } + updateData.description = description.trim(); + } + + if (assigneeId !== undefined) updateData.assigneeId = assigneeId || null; + if (assigneeName !== undefined) updateData.assigneeName = assigneeName || null; + if (priority !== undefined) updateData.priority = priority; + if (status !== undefined) updateData.status = status; + if (dueDate !== undefined) updateData.dueDate = dueDate ? new Date(dueDate) : null; + + const [action] = await db + .update(actionItems) + .set(updateData) + .where( + and( + eq(actionItems.id, actionId), + eq(actionItems.sessionId, sessionId) + ) + ) + .returning(); + + if (!action) { + return NextResponse.json( + { error: "Action item not found" }, + { status: 404 } + ); + } + + return NextResponse.json(action); + } catch (error) { + console.error("Failed to update action item:", error); + return NextResponse.json( + { error: "Failed to update action item" }, + { status: 500 } + ); + } +} + +// DELETE - Delete action item +export async function DELETE(request: NextRequest, { params }: RouteParams) { + try { + const { id: sessionId, actionId } = await params; + + const [deleted] = await db + .delete(actionItems) + .where( + and( + eq(actionItems.id, actionId), + eq(actionItems.sessionId, sessionId) + ) + ) + .returning(); + + if (!deleted) { + return NextResponse.json( + { error: "Action item not found" }, + { status: 404 } + ); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Failed to delete action item:", error); + return NextResponse.json( + { error: "Failed to delete action item" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/retrospective/[id]/actions/route.ts b/src/app/api/retrospective/[id]/actions/route.ts new file mode 100644 index 0000000..b0e3322 --- /dev/null +++ b/src/app/api/retrospective/[id]/actions/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, actionItems } from "@/lib/db"; +import { eq, and } from "drizzle-orm"; + +type RouteParams = { params: Promise<{ id: string }> }; + +// POST - Create new action item +export async function POST(request: NextRequest, { params }: RouteParams) { + try { + const { id: sessionId } = await params; + const body = await request.json(); + const { description, sourceItemId, assigneeId, assigneeName, priority, dueDate } = body; + + if (!description || typeof description !== "string" || description.trim().length === 0) { + return NextResponse.json( + { error: "Description is required" }, + { status: 400 } + ); + } + + const [action] = await db + .insert(actionItems) + .values({ + sessionId, + description: description.trim(), + sourceItemId: sourceItemId || null, + assigneeId: assigneeId || null, + assigneeName: assigneeName || null, + priority: priority || "medium", + dueDate: dueDate ? new Date(dueDate) : null, + }) + .returning(); + + return NextResponse.json(action, { status: 201 }); + } catch (error) { + console.error("Failed to create action item:", error); + return NextResponse.json( + { error: "Failed to create action item" }, + { status: 500 } + ); + } +} + +// GET - List all action items for a retrospective +export async function GET(request: NextRequest, { params }: RouteParams) { + try { + const { id: sessionId } = await params; + + const actions = await db + .select() + .from(actionItems) + .where(eq(actionItems.sessionId, sessionId)) + .orderBy(actionItems.createdAt); + + return NextResponse.json(actions); + } catch (error) { + console.error("Failed to fetch action items:", error); + return NextResponse.json( + { error: "Failed to fetch action items" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/retrospective/[id]/events/route.ts b/src/app/api/retrospective/[id]/events/route.ts new file mode 100644 index 0000000..309f890 --- /dev/null +++ b/src/app/api/retrospective/[id]/events/route.ts @@ -0,0 +1,64 @@ +import { NextRequest } from "next/server"; +import { sessionEmitter } from "@/lib/sse"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export async function GET(request: NextRequest, { params }: RouteParams) { + const { id: sessionId } = await params; + + // Create a readable stream for SSE + const stream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + + // Send initial connection event + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: "connected", data: { sessionId }, timestamp: Date.now() })}\n\n`) + ); + + // Subscribe to session events + const unsubscribe = sessionEmitter.subscribe(sessionId, (eventData) => { + try { + controller.enqueue(encoder.encode(eventData)); + } catch { + // Stream closed + unsubscribe(); + } + }); + + // Handle client disconnect + request.signal.addEventListener("abort", () => { + unsubscribe(); + try { + controller.close(); + } catch { + // Already closed + } + }); + + // Send periodic heartbeat to keep connection alive + const heartbeat = setInterval(() => { + try { + controller.enqueue(encoder.encode(`: heartbeat\n\n`)); + } catch { + clearInterval(heartbeat); + unsubscribe(); + } + }, 30000); + + // Cleanup on abort + request.signal.addEventListener("abort", () => { + clearInterval(heartbeat); + }); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", // Disable nginx buffering + }, + }); +} diff --git a/src/app/api/retrospective/[id]/items/[itemId]/route.ts b/src/app/api/retrospective/[id]/items/[itemId]/route.ts new file mode 100644 index 0000000..ed8cae2 --- /dev/null +++ b/src/app/api/retrospective/[id]/items/[itemId]/route.ts @@ -0,0 +1,105 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, retrospectiveItems } from "@/lib/db"; +import { eq, and } from "drizzle-orm"; +import { sessionEmitter } from "@/lib/sse"; + +type RouteParams = { params: Promise<{ id: string; itemId: string }> }; + +// PATCH - Update retrospective item +export async function PATCH(request: NextRequest, { params }: RouteParams) { + try { + const { id: sessionId, itemId } = await params; + const body = await request.json(); + const { content, discussionNotes, isDiscussed } = body; + + const updateData: Record = {}; + + if (content !== undefined) { + if (typeof content !== "string" || content.trim().length === 0) { + return NextResponse.json( + { error: "Content cannot be empty" }, + { status: 400 } + ); + } + if (content.length > 500) { + return NextResponse.json( + { error: "Content must be 500 characters or less" }, + { status: 400 } + ); + } + updateData.content = content.trim(); + } + + if (discussionNotes !== undefined) { + updateData.discussionNotes = discussionNotes?.trim() || null; + } + + if (isDiscussed !== undefined) { + updateData.isDiscussed = isDiscussed; + } + + const [item] = await db + .update(retrospectiveItems) + .set(updateData) + .where( + and( + eq(retrospectiveItems.id, itemId), + eq(retrospectiveItems.sessionId, sessionId) + ) + ) + .returning(); + + if (!item) { + return NextResponse.json( + { error: "Item not found" }, + { status: 404 } + ); + } + + // Emit SSE event + sessionEmitter.emit(sessionId, { type: "item:updated", data: item }); + + return NextResponse.json(item); + } catch (error) { + console.error("Failed to update retrospective item:", error); + return NextResponse.json( + { error: "Failed to update item" }, + { status: 500 } + ); + } +} + +// DELETE - Delete retrospective item +export async function DELETE(request: NextRequest, { params }: RouteParams) { + try { + const { id: sessionId, itemId } = await params; + + const [deleted] = await db + .delete(retrospectiveItems) + .where( + and( + eq(retrospectiveItems.id, itemId), + eq(retrospectiveItems.sessionId, sessionId) + ) + ) + .returning(); + + if (!deleted) { + return NextResponse.json( + { error: "Item not found" }, + { status: 404 } + ); + } + + // Emit SSE event + sessionEmitter.emit(sessionId, { type: "item:deleted", data: { id: itemId, sessionId } }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Failed to delete retrospective item:", error); + return NextResponse.json( + { error: "Failed to delete item" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/retrospective/[id]/items/route.ts b/src/app/api/retrospective/[id]/items/route.ts new file mode 100644 index 0000000..625241c --- /dev/null +++ b/src/app/api/retrospective/[id]/items/route.ts @@ -0,0 +1,92 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, retrospectiveItems } from "@/lib/db"; +import { eq, sql } from "drizzle-orm"; +import { sessionEmitter } from "@/lib/sse"; + +type RouteParams = { params: Promise<{ id: string }> }; + +// POST - Add new item to retrospective +export async function POST(request: NextRequest, { params }: RouteParams) { + try { + const { id: sessionId } = await params; + const body = await request.json(); + const { category, content, authorId, authorName, isAnonymous } = body; + + if (!category || !["went_well", "to_improve", "action_item"].includes(category)) { + return NextResponse.json( + { error: "Valid category is required (went_well, to_improve, action_item)" }, + { status: 400 } + ); + } + + if (!content || typeof content !== "string" || content.trim().length === 0) { + return NextResponse.json( + { error: "Content is required" }, + { status: 400 } + ); + } + + if (content.length > 500) { + return NextResponse.json( + { error: "Content must be 500 characters or less" }, + { status: 400 } + ); + } + + const [item] = await db + .insert(retrospectiveItems) + .values({ + sessionId, + category, + content: content.trim(), + authorId: authorId || null, + authorName: isAnonymous ? null : (authorName || null), + isAnonymous: isAnonymous || false, + }) + .returning(); + + // Emit SSE event + sessionEmitter.emit(sessionId, { type: "item:added", data: item }); + + return NextResponse.json(item, { status: 201 }); + } catch (error) { + console.error("Failed to create retrospective item:", error); + return NextResponse.json( + { error: "Failed to create item" }, + { status: 500 } + ); + } +} + +// GET - List all items for a retrospective with vote counts +export async function GET(request: NextRequest, { params }: RouteParams) { + try { + const { id: sessionId } = await params; + + const items = await db + .select({ + id: retrospectiveItems.id, + sessionId: retrospectiveItems.sessionId, + category: retrospectiveItems.category, + content: retrospectiveItems.content, + authorId: retrospectiveItems.authorId, + authorName: retrospectiveItems.authorName, + isAnonymous: retrospectiveItems.isAnonymous, + discussionNotes: retrospectiveItems.discussionNotes, + isDiscussed: retrospectiveItems.isDiscussed, + createdAt: retrospectiveItems.createdAt, + voteCount: sql`(SELECT COUNT(*) FROM votes WHERE votes.item_id = ${retrospectiveItems.id})`.as("vote_count"), + }) + .from(retrospectiveItems) + .where(eq(retrospectiveItems.sessionId, sessionId)) + .orderBy(retrospectiveItems.createdAt); + + return NextResponse.json(items); + } catch (error) { + console.error("Failed to fetch retrospective items:", error); + return NextResponse.json( + { error: "Failed to fetch items" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/retrospective/[id]/report/route.ts b/src/app/api/retrospective/[id]/report/route.ts new file mode 100644 index 0000000..0124d2f --- /dev/null +++ b/src/app/api/retrospective/[id]/report/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, retrospectiveSessions, retrospectiveItems, actionItems, votes } from "@/lib/db"; +import { eq, sql } from "drizzle-orm"; +import { generateRetrospectiveReport, type ReportConfig, type RetrospectiveData } from "@/lib/ai"; + +type RouteParams = { params: Promise<{ id: string }> }; + +// POST - Generate AI report +export async function POST(request: NextRequest, { params }: RouteParams) { + try { + const { id: sessionId } = await params; + const body = await request.json(); + const config: ReportConfig = { + tone: body.tone || "informal", + language: body.language || "en", + focusAreas: body.focusAreas, + customInstructions: body.customInstructions, + }; + + // Fetch session + const [session] = await db + .select() + .from(retrospectiveSessions) + .where(eq(retrospectiveSessions.id, sessionId)) + .limit(1); + + if (!session) { + return NextResponse.json( + { error: "Session not found" }, + { status: 404 } + ); + } + + // Fetch items with vote counts + const items = await db + .select({ + id: retrospectiveItems.id, + category: retrospectiveItems.category, + content: retrospectiveItems.content, + discussionNotes: retrospectiveItems.discussionNotes, + isDiscussed: retrospectiveItems.isDiscussed, + voteCount: sql`(SELECT COUNT(*) FROM votes WHERE votes.item_id = ${retrospectiveItems.id})`.as("vote_count"), + }) + .from(retrospectiveItems) + .where(eq(retrospectiveItems.sessionId, sessionId)); + + // Fetch action items + const actions = await db + .select() + .from(actionItems) + .where(eq(actionItems.sessionId, sessionId)); + + // Prepare data for report generation + const reportData: RetrospectiveData = { + sessionName: session.name, + sprintName: session.sprintName || undefined, + items: items.map((item) => ({ + category: item.category, + content: item.content, + voteCount: Number(item.voteCount), + discussionNotes: item.discussionNotes || undefined, + isDiscussed: item.isDiscussed, + })), + actionItems: actions.map((action) => ({ + description: action.description, + assigneeName: action.assigneeName || undefined, + priority: action.priority, + status: action.status, + dueDate: action.dueDate?.toISOString().split("T")[0], + })), + }; + + // Generate report + const report = await generateRetrospectiveReport(reportData, config); + + return NextResponse.json({ report, config }); + } catch (error) { + console.error("Failed to generate report:", error); + return NextResponse.json( + { error: "Failed to generate report" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/retrospective/[id]/route.ts b/src/app/api/retrospective/[id]/route.ts new file mode 100644 index 0000000..9c86599 --- /dev/null +++ b/src/app/api/retrospective/[id]/route.ts @@ -0,0 +1,104 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, retrospectiveSessions } from "@/lib/db"; +import { eq } from "drizzle-orm"; + +type RouteParams = { params: Promise<{ id: string }> }; + +// GET - Fetch single retrospective session +export async function GET(request: NextRequest, { params }: RouteParams) { + try { + const { id } = await params; + + const [session] = await db + .select() + .from(retrospectiveSessions) + .where(eq(retrospectiveSessions.id, id)) + .limit(1); + + if (!session) { + return NextResponse.json( + { error: "Session not found" }, + { status: 404 } + ); + } + + return NextResponse.json(session); + } catch (error) { + console.error("Failed to fetch retrospective session:", error); + return NextResponse.json( + { error: "Failed to fetch session" }, + { status: 500 } + ); + } +} + +// PATCH - Update retrospective session +export async function PATCH(request: NextRequest, { params }: RouteParams) { + try { + const { id } = await params; + const body = await request.json(); + const { name, sprintName, teamId, status, votesPerUser, hideVotesUntilComplete } = body; + + const updateData: Record = {}; + + if (name !== undefined) updateData.name = name.trim(); + if (sprintName !== undefined) updateData.sprintName = sprintName?.trim() || null; + if (teamId !== undefined) updateData.teamId = teamId?.trim() || null; + if (status !== undefined) { + updateData.status = status; + if (status === "completed") { + updateData.completedAt = new Date(); + } + } + if (votesPerUser !== undefined) updateData.votesPerUser = votesPerUser; + if (hideVotesUntilComplete !== undefined) updateData.hideVotesUntilComplete = hideVotesUntilComplete; + + const [session] = await db + .update(retrospectiveSessions) + .set(updateData) + .where(eq(retrospectiveSessions.id, id)) + .returning(); + + if (!session) { + return NextResponse.json( + { error: "Session not found" }, + { status: 404 } + ); + } + + return NextResponse.json(session); + } catch (error) { + console.error("Failed to update retrospective session:", error); + return NextResponse.json( + { error: "Failed to update session" }, + { status: 500 } + ); + } +} + +// DELETE - Delete retrospective session +export async function DELETE(request: NextRequest, { params }: RouteParams) { + try { + const { id } = await params; + + const [deleted] = await db + .delete(retrospectiveSessions) + .where(eq(retrospectiveSessions.id, id)) + .returning(); + + if (!deleted) { + return NextResponse.json( + { error: "Session not found" }, + { status: 404 } + ); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Failed to delete retrospective session:", error); + return NextResponse.json( + { error: "Failed to delete session" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/retrospective/[id]/votes/route.ts b/src/app/api/retrospective/[id]/votes/route.ts new file mode 100644 index 0000000..dfd04c1 --- /dev/null +++ b/src/app/api/retrospective/[id]/votes/route.ts @@ -0,0 +1,175 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, votes, retrospectiveSessions } from "@/lib/db"; +import { eq, and, sql } from "drizzle-orm"; +import { sessionEmitter } from "@/lib/sse"; + +type RouteParams = { params: Promise<{ id: string }> }; + +// POST - Add vote to an item +export async function POST(request: NextRequest, { params }: RouteParams) { + try { + const { id: sessionId } = await params; + const body = await request.json(); + const { itemId, oderId } = body; + + if (!itemId) { + return NextResponse.json( + { error: "Item ID is required" }, + { status: 400 } + ); + } + + if (!oderId) { + return NextResponse.json( + { error: "User ID is required" }, + { status: 400 } + ); + } + + // Get session to check vote limit + const [session] = await db + .select() + .from(retrospectiveSessions) + .where(eq(retrospectiveSessions.id, sessionId)) + .limit(1); + + if (!session) { + return NextResponse.json( + { error: "Session not found" }, + { status: 404 } + ); + } + + // Count user's existing votes in this session + const [voteCountResult] = await db + .select({ count: sql`count(*)` }) + .from(votes) + .innerJoin( + sql`retrospective_items`, + sql`retrospective_items.id = votes.item_id` + ) + .where( + and( + eq(votes.oderId, oderId), + sql`retrospective_items.session_id = ${sessionId}` + ) + ); + + const currentVoteCount = Number(voteCountResult?.count || 0); + + if (currentVoteCount >= session.votesPerUser) { + return NextResponse.json( + { error: `You have reached the maximum of ${session.votesPerUser} votes` }, + { status: 400 } + ); + } + + const [vote] = await db + .insert(votes) + .values({ + itemId, + oderId, + }) + .returning(); + + // Emit SSE event + sessionEmitter.emit(sessionId, { type: "vote:added", data: { itemId, oderId, sessionId } }); + + return NextResponse.json(vote, { status: 201 }); + } catch (error) { + console.error("Failed to add vote:", error); + return NextResponse.json( + { error: "Failed to add vote" }, + { status: 500 } + ); + } +} + +// GET - Get votes for session (for a specific user) +export async function GET(request: NextRequest, { params }: RouteParams) { + try { + const { id: sessionId } = await params; + const { searchParams } = new URL(request.url); + const oderId = searchParams.get("userId"); + + if (!oderId) { + return NextResponse.json( + { error: "User ID query parameter is required" }, + { status: 400 } + ); + } + + // Get user's votes for items in this session + const userVotes = await db + .select({ + id: votes.id, + itemId: votes.itemId, + oderId: votes.oderId, + createdAt: votes.createdAt, + }) + .from(votes) + .innerJoin( + sql`retrospective_items`, + sql`retrospective_items.id = votes.item_id` + ) + .where( + and( + eq(votes.oderId, oderId), + sql`retrospective_items.session_id = ${sessionId}` + ) + ); + + return NextResponse.json(userVotes); + } catch (error) { + console.error("Failed to fetch votes:", error); + return NextResponse.json( + { error: "Failed to fetch votes" }, + { status: 500 } + ); + } +} + +// DELETE - Remove vote from an item +export async function DELETE(request: NextRequest, { params }: RouteParams) { + try { + const { id: sessionId } = await params; + const body = await request.json(); + const { itemId, oderId } = body; + + if (!itemId || !oderId) { + return NextResponse.json( + { error: "Item ID and User ID are required" }, + { status: 400 } + ); + } + + // Find and delete ONE vote (in case of multiple votes on same item) + const [deleted] = await db + .delete(votes) + .where( + and( + eq(votes.itemId, itemId), + eq(votes.oderId, oderId) + ) + ) + .returning(); + + if (!deleted) { + return NextResponse.json( + { error: "Vote not found" }, + { status: 404 } + ); + } + + // Emit SSE event + sessionEmitter.emit(sessionId, { type: "vote:removed", data: { itemId, oderId, sessionId } }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Failed to remove vote:", error); + return NextResponse.json( + { error: "Failed to remove vote" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/retrospective/route.ts b/src/app/api/retrospective/route.ts new file mode 100644 index 0000000..0713ae3 --- /dev/null +++ b/src/app/api/retrospective/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, retrospectiveSessions } from "@/lib/db"; +import { eq } from "drizzle-orm"; + +// POST - Create new retrospective session +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { name, sprintName, teamId, votesPerUser, hideVotesUntilComplete } = body; + + if (!name || typeof name !== "string" || name.trim().length === 0) { + return NextResponse.json( + { error: "Session name is required" }, + { status: 400 } + ); + } + + const [session] = await db + .insert(retrospectiveSessions) + .values({ + name: name.trim(), + sprintName: sprintName?.trim() || null, + teamId: teamId?.trim() || null, + votesPerUser: votesPerUser || 5, + hideVotesUntilComplete: hideVotesUntilComplete || false, + }) + .returning(); + + return NextResponse.json(session, { status: 201 }); + } catch (error) { + console.error("Failed to create retrospective session:", error); + return NextResponse.json( + { error: "Failed to create session" }, + { status: 500 } + ); + } +} + +// GET - List all retrospective sessions +export async function GET() { + try { + const sessions = await db + .select() + .from(retrospectiveSessions) + .orderBy(retrospectiveSessions.createdAt); + + return NextResponse.json(sessions); + } catch (error) { + console.error("Failed to fetch retrospective sessions:", error); + return NextResponse.json( + { error: "Failed to fetch sessions" }, + { status: 500 } + ); + } +} diff --git a/src/app/favicon.ico b/src/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/src/app/favicon.ico differ diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..dc98be7 --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,122 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..f7fa87e --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,34 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "Create Next App", + description: "Generated by create next app", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..f9b477b --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Plus, Users, ArrowRight } from "lucide-react"; + +export default function Home() { + const router = useRouter(); + const [isCreating, setIsCreating] = useState(false); + const [sessionName, setSessionName] = useState(""); + const [sprintName, setSprintName] = useState(""); + const [joinSessionId, setJoinSessionId] = useState(""); + const [dialogOpen, setDialogOpen] = useState(false); + + const handleCreateSession = async () => { + if (!sessionName.trim()) return; + + setIsCreating(true); + try { + const response = await fetch("/api/retrospective", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: sessionName, + sprintName: sprintName || null, + }), + }); + + if (response.ok) { + const session = await response.json(); + router.push(`/retrospective/${session.id}`); + } + } catch (error) { + console.error("Failed to create session:", error); + } finally { + setIsCreating(false); + } + }; + + const handleJoinSession = () => { + if (!joinSessionId.trim()) return; + + // Extract ID from URL if pasted + const id = joinSessionId.includes("/") + ? joinSessionId.split("/").pop() + : joinSessionId; + + router.push(`/retrospective/${id}`); + }; + + return ( +
+
+
+

+ Scrumkit +

+

+ AI-powered retrospective tool for agile teams. + Collaborate in real-time, vote on items, and generate comprehensive reports. +

+
+ +
+ {/* Create New Session */} + + + + + Start New Retrospective + + + Create a new session and invite your team to participate + + + + + + + + + + Create New Retrospective + + Set up your retrospective session. You'll get a link to share with your team. + + +
+
+ + setSessionName(e.target.value)} + /> +
+
+ + setSprintName(e.target.value)} + /> +
+
+ + + +
+
+
+
+ + {/* Join Existing Session */} + + + + + Join Existing Session + + + Enter a session ID or paste the invite link + + + + setJoinSessionId(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleJoinSession()} + /> + + + +
+ + {/* Features */} +
+

+ Everything you need for effective retrospectives +

+
+
+
+ +
+

Real-time Collaboration

+

+ See changes instantly as your team adds items and votes +

+
+
+
+ + + +
+

Voting & Prioritization

+

+ Vote on items to surface what matters most to your team +

+
+
+
+ + + +
+

AI-Generated Reports

+

+ Get comprehensive summaries powered by AI +

+
+
+
+
+
+ ); +} diff --git a/src/app/retrospective/[id]/page.tsx b/src/app/retrospective/[id]/page.tsx new file mode 100644 index 0000000..9fc3129 --- /dev/null +++ b/src/app/retrospective/[id]/page.tsx @@ -0,0 +1,296 @@ +"use client"; + +import { use, useEffect, useState, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ArrowLeft, Copy, Check, Settings, FileText } from "lucide-react"; +import { RetroBoard } from "@/components/features/retro-board"; +import { ActionItemsPanel } from "@/components/features/action-items-panel"; +import { ReportGenerator } from "@/components/features/report-generator"; +import type { RetrospectiveSession } from "@/lib/db"; + +type PageParams = { id: string }; + +export default function RetrospectivePage({ params }: { params: Promise }) { + const { id } = use(params); + const router = useRouter(); + const [session, setSession] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + const [userName, setUserName] = useState(""); + const [userId, setUserId] = useState(""); + const [showNameDialog, setShowNameDialog] = useState(true); + + // Load session data + useEffect(() => { + async function loadSession() { + try { + const response = await fetch(`/api/retrospective/${id}`); + if (!response.ok) { + if (response.status === 404) { + setError("Session not found"); + } else { + setError("Failed to load session"); + } + return; + } + const data = await response.json(); + setSession(data); + } catch { + setError("Failed to load session"); + } finally { + setLoading(false); + } + } + + loadSession(); + }, [id]); + + // Load saved user info from localStorage + useEffect(() => { + const savedName = localStorage.getItem("scrumkit-user-name"); + const savedId = localStorage.getItem("scrumkit-user-id"); + if (savedName && savedId) { + setUserName(savedName); + setUserId(savedId); + setShowNameDialog(false); + } + }, []); + + const handleSetName = useCallback(() => { + if (!userName.trim()) return; + + const newUserId = userId || `user-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + setUserId(newUserId); + localStorage.setItem("scrumkit-user-name", userName); + localStorage.setItem("scrumkit-user-id", newUserId); + setShowNameDialog(false); + }, [userName, userId]); + + const copyLink = async () => { + const url = `${window.location.origin}/retrospective/${id}`; + await navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const updateSessionStatus = async (status: string) => { + try { + const response = await fetch(`/api/retrospective/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status }), + }); + if (response.ok) { + const updated = await response.json(); + setSession(updated); + } + } catch (error) { + console.error("Failed to update session status:", error); + } + }; + + if (loading) { + return ( +
+
+ + +
+ + + +
+
+
+ ); + } + + if (error || !session) { + return ( +
+
+

+ {error || "Session not found"} +

+

+ The retrospective session you're looking for doesn't exist or has been deleted. +

+ +
+
+ ); + } + + const statusColors = { + input: "bg-blue-500", + voting: "bg-yellow-500", + discussion: "bg-purple-500", + completed: "bg-green-500", + }; + + const statusLabels = { + input: "Input Phase", + voting: "Voting Phase", + discussion: "Discussion Phase", + completed: "Completed", + }; + + return ( + <> + {/* Name Dialog */} + + + + Join Retrospective + + Enter your name to join the session. This will be visible to other participants. + + +
+ setUserName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSetName()} + autoFocus + /> +
+ + + +
+
+ + {/* Main Content - only render when we have user info */} + {!showNameDialog && userId && ( +
+ {/* Header */} +
+
+
+
+ +
+

+ {session.name} +

+ {session.sprintName && ( +

+ {session.sprintName} +

+ )} +
+ + {statusLabels[session.status as keyof typeof statusLabels]} + +
+ +
+ + Joined as {userName} + + + + + {/* Phase Controls */} + {session.status === "input" && ( + + )} + {session.status === "voting" && ( + + )} + {session.status === "discussion" && ( + + )} + + {/* Action Items Panel */} + + + + + + + Action Items + + Track action items and assign owners + + + + + + + {/* Report Generator */} + {(session.status === "discussion" || session.status === "completed") && ( + + + + + + + Generate Report + + Create an AI-powered summary of the retrospective + + + + + + )} +
+
+
+
+ + {/* Retro Board */} +
+ +
+
+ )} + + ); +} diff --git a/src/components/features/action-items-panel.tsx b/src/components/features/action-items-panel.tsx new file mode 100644 index 0000000..818d548 --- /dev/null +++ b/src/components/features/action-items-panel.tsx @@ -0,0 +1,313 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Plus, MoreVertical, Trash2, CheckCircle, Circle, Clock } from "lucide-react"; +import type { ActionItem } from "@/lib/db"; + +interface ActionItemsPanelProps { + sessionId: string; + userId: string; + userName: string; +} + +const PRIORITY_COLORS = { + low: "bg-zinc-100 text-zinc-700", + medium: "bg-yellow-100 text-yellow-700", + high: "bg-red-100 text-red-700", +}; + +const STATUS_ICONS = { + open: Circle, + in_progress: Clock, + done: CheckCircle, +}; + +export function ActionItemsPanel({ sessionId, userId, userName }: ActionItemsPanelProps) { + const [actions, setActions] = useState([]); + const [loading, setLoading] = useState(true); + const [isAdding, setIsAdding] = useState(false); + const [newDescription, setNewDescription] = useState(""); + const [newAssignee, setNewAssignee] = useState(""); + const [newPriority, setNewPriority] = useState<"low" | "medium" | "high">("medium"); + + // Load action items + useEffect(() => { + async function loadActions() { + try { + const response = await fetch(`/api/retrospective/${sessionId}/actions`); + if (response.ok) { + const data = await response.json(); + setActions(data); + } + } catch (error) { + console.error("Failed to load action items:", error); + } finally { + setLoading(false); + } + } + loadActions(); + }, [sessionId]); + + const handleAddAction = async () => { + if (!newDescription.trim()) return; + + try { + const response = await fetch(`/api/retrospective/${sessionId}/actions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + description: newDescription, + assigneeName: newAssignee || userName, + assigneeId: userId, + priority: newPriority, + }), + }); + + if (response.ok) { + const action = await response.json(); + setActions((prev) => [...prev, action]); + setNewDescription(""); + setNewAssignee(""); + setNewPriority("medium"); + setIsAdding(false); + } + } catch (error) { + console.error("Failed to add action item:", error); + } + }; + + const handleUpdateStatus = async (actionId: string, status: "open" | "in_progress" | "done") => { + try { + const response = await fetch(`/api/retrospective/${sessionId}/actions/${actionId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status }), + }); + + if (response.ok) { + const updated = await response.json(); + setActions((prev) => prev.map((a) => (a.id === actionId ? updated : a))); + } + } catch (error) { + console.error("Failed to update action item:", error); + } + }; + + const handleDeleteAction = async (actionId: string) => { + try { + const response = await fetch(`/api/retrospective/${sessionId}/actions/${actionId}`, { + method: "DELETE", + }); + + if (response.ok) { + setActions((prev) => prev.filter((a) => a.id !== actionId)); + } + } catch (error) { + console.error("Failed to delete action item:", error); + } + }; + + if (loading) { + return ( +
+ Loading action items... +
+ ); + } + + const groupedByStatus = { + open: actions.filter((a) => a.status === "open"), + in_progress: actions.filter((a) => a.status === "in_progress"), + done: actions.filter((a) => a.status === "done"), + }; + + return ( +
+ {/* Add New Action */} + {isAdding ? ( + + +