From 4bd990d185786e746749a6741f289398c2c13bd6 Mon Sep 17 00:00:00 2001 From: Dennis Stolmeijer Date: Wed, 3 Dec 2025 14:02:59 +0100 Subject: [PATCH 1/3] Add new agent configurations for code review, meta-agent creation, and PR submission; update Scrumkit PRD with detailed features and requirements --- .claude/agents/code-reviewer.md | 54 +++ .claude/agents/meta-agent.md | 59 +++ .claude/commands/pr-submit.md | 103 +++++ ...03-scrumkit-initial-setup-retrospective.md | 387 +++++++++++++----- 4 files changed, 499 insertions(+), 104 deletions(-) create mode 100644 .claude/agents/code-reviewer.md create mode 100644 .claude/agents/meta-agent.md create mode 100644 .claude/commands/pr-submit.md 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/specs/new/20251203-scrumkit-initial-setup-retrospective.md b/specs/new/20251203-scrumkit-initial-setup-retrospective.md index a90a669..c32ad68 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,50 @@ 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,13 +88,13 @@ 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 -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 Liveblocks voor real-time functionaliteit. Liveblocks is Vercel's aanbevolen partner voor real-time collaboration. ```bash bun add @liveblocks/client @liveblocks/react @@ -93,13 +108,11 @@ Liveblocks voordelen: - 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 -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 +121,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 +136,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,18 +152,18 @@ 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 --- @@ -162,9 +175,10 @@ Real-time events: - Nieuwe input toegevoegd - Input gewijzigd/verwijderd -- Vote toegevoegd/verwijderd +- Stem toegevoegd/verwijderd - Discussie notities bijgewerkt - Deelnemer joined/left +- **Rapport gegenereerd/bijgewerkt** Presence awareness (Liveblocks native): @@ -174,33 +188,33 @@ Presence awareness (Liveblocks native): --- -**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 +224,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 +241,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: + +``` +[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: + +- Bij rapport generatie: broadcast naar alle deelnemers via Liveblocks +- Rapport tab toont automatisch het nieuwste rapport +- Indicator wanneer rapport wordt gegenereerd ("Rapport wordt gegenereerd...") +- Notificatie aan deelnemers wanneer rapport beschikbaar is --- -**FV4.1:** Automatische Rapport Generatie +**FV4.3:** Automatische Rapport Generatie Na afloop van de retrospective moet een AI-gegenereerd rapport beschikbaar zijn. @@ -253,26 +307,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 +336,7 @@ Configureerbare aspecten: - Toon (formeel/informeel) - Taal (Nederlands/Engels) - Focus gebieden -- Custom instructies per team +- Aangepaste instructies per team --- @@ -295,21 +350,32 @@ 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/ # 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 +β”‚ └── liveblocks/ # Liveblocks client setup +β”œβ”€β”€ hooks/ # Custom React hooks +β”œβ”€β”€ types/ # TypeScript type definities +└── utils/ # Utility functies ``` --- @@ -367,13 +433,22 @@ export const actionItems = pgTable('action_items', { dueDate: timestamp('due_date'), createdAt: timestamp('created_at').defaultNow(), }); + +// NIEUW: 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(), // Markdown inhoud + generatedAt: timestamp('generated_at').defaultNow(), + generatedBy: varchar('generated_by', { length: 255 }), // Gebruiker die generatie triggerde +}); ``` --- ### TO3: Liveblocks Room Structuur -Aanbevolen room structuur voor real-time communicatie: +Aanbevolen room structuur voor real-time communicatie inclusief rapport: ```typescript // src/lib/liveblocks/config.ts @@ -384,17 +459,22 @@ 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; + currentTab: "board" | "voting" | "discussion" | "report"; }; type Storage = { items: LiveList; - votes: LiveMap; // itemId -> userIds + votes: LiveMap; phase: "input" | "voting" | "discussion" | "completed"; + report: { + content: string | null; + isGenerating: boolean; + generatedAt: string | null; + }; }; export const { @@ -403,40 +483,100 @@ export const { useUpdateMyPresence, useStorage, useMutation, + useBroadcastEvent, + useEventListener, } = createRoomContext(client); ``` --- -### TO4: API Route Structuur +### TO4: Rapport Tab Component (v0.dev Gegenereerd) + +Voorbeeld structuur voor rapport tab component: + +```typescript +// src/components/features/retrospective/report-tab.tsx +"use client"; + +import { useStorage } from "@/lib/liveblocks/config"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Download, Copy, RefreshCw, FileText } from "lucide-react"; +import ReactMarkdown from "react-markdown"; + +export function ReportTab({ sessionId }: { sessionId: string }) { + const report = useStorage((root) => root.report); + + if (report.isGenerating) { + return ; + } + + if (!report.content) { + return ; + } + + return ( +
+
+

Retrospective Rapport

+
+ + +
+
+ + + + {report.content} + + + +

+ Gegenereerd op: {new Date(report.generatedAt).toLocaleString('nl-NL')} +

+
+ ); +} +``` + +--- + +### TO5: API Route Structuur ```text /api/retrospective POST / - Nieuwe sessie aanmaken GET /:id - Sessie ophalen - PATCH /:id - Sessie updaten (status, settings) + PATCH /:id - Sessie bijwerken (status, instellingen) DELETE /:id - Sessie verwijderen /api/retrospective/:id/items POST / - Item toevoegen - PATCH /:itemId - Item updaten + PATCH /:itemId - Item bijwerken DELETE /:itemId - Item verwijderen /api/retrospective/:id/votes - POST / - Vote toevoegen - DELETE /:itemId - Vote verwijderen + POST / - Stem toevoegen + DELETE /:itemId - Stem verwijderen /api/retrospective/:id/report - POST / - AI rapport genereren + POST / - AI rapport genereren & opslaan GET / - Rapport ophalen /api/liveblocks-auth - POST / - Liveblocks authentication + POST / - Liveblocks authenticatie ``` --- -### TO5: Environment Variables +### TO6: Environment Variables Vereiste environment variables: @@ -459,28 +599,62 @@ 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:** +``` +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:** +``` +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:** +``` +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 +- Liveblocks authenticatie 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. +- **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. --- @@ -491,28 +665,32 @@ 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 +- [ ] 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 in de sessie** +- [ ] 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 +- [ ] Liveblocks real-time synchronisatie werkt (inclusief rapport updates) - [ ] 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 +- [ ] InitiΓ«le pagina laadtijd < 3 seconden - [ ] Real-time updates binnen 500ms (Liveblocks) - [ ] AI rapport generatie < 30 seconden - [ ] Ondersteunt minimaal 10 gelijktijdige gebruikers per sessie @@ -523,9 +701,10 @@ 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) +- [v0.dev - Vercel AI UI Generator](https://v0.dev) +- [v0.dev Review 2025](https://skywork.ai/blog/vercel-v0-review-2025-ai-ui-code-generation-nextjs/) - [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) +- [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) From 96102c994bda8814b0fb0dc51f6a79b0c5c4b239 Mon Sep 17 00:00:00 2001 From: Dennis Stolmeijer Date: Wed, 3 Dec 2025 14:09:25 +0100 Subject: [PATCH 2/3] feat(retrospective): Add complete retrospective board application MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Initial setup of ScrumKit - a real-time retrospective tool for scrum teams. Features: - Retrospective board with Mad/Sad/Glad categories - Real-time collaboration via Server-Sent Events - Voting system with configurable vote limits - Action items tracking with assignees - AI-powered report generation using OpenAI - PostgreSQL database with Drizzle ORM Tech stack: - Next.js 16 with React 19 and Turbopack - Tailwind CSS 4 with Radix UI components - TypeScript with strict mode πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .claude/settings.local.json | 6 +- .gitignore | 42 +- Makefile | 61 ++ README.md | 36 ++ bun.lockb | Bin 0 -> 232447 bytes components.json | 22 + drizzle.config.ts | 10 + eslint.config.mjs | 18 + next.config.ts | 7 + package.json | 44 ++ postcss.config.mjs | 7 + public/file.svg | 1 + public/globe.svg | 1 + public/next.svg | 1 + public/vercel.svg | 1 + public/window.svg | 1 + ...03-scrumkit-initial-setup-retrospective.md | 531 ++++++++++++++++++ ...03-scrumkit-initial-setup-retrospective.md | 434 +++++++++----- .../[id]/actions/[actionId]/route.ts | 90 +++ .../api/retrospective/[id]/actions/route.ts | 63 +++ .../api/retrospective/[id]/events/route.ts | 64 +++ .../[id]/items/[itemId]/route.ts | 105 ++++ src/app/api/retrospective/[id]/items/route.ts | 92 +++ .../api/retrospective/[id]/report/route.ts | 84 +++ src/app/api/retrospective/[id]/route.ts | 104 ++++ src/app/api/retrospective/[id]/votes/route.ts | 175 ++++++ src/app/api/retrospective/route.ts | 55 ++ src/app/favicon.ico | Bin 0 -> 25931 bytes src/app/globals.css | 122 ++++ src/app/layout.tsx | 34 ++ src/app/page.tsx | 205 +++++++ src/app/retrospective/[id]/page.tsx | 296 ++++++++++ .../features/action-items-panel.tsx | 313 +++++++++++ src/components/features/index.ts | 3 + src/components/features/report-generator.tsx | 215 +++++++ src/components/features/retro-board.tsx | 462 +++++++++++++++ src/components/ui/avatar.tsx | 53 ++ src/components/ui/badge.tsx | 46 ++ src/components/ui/button.tsx | 60 ++ src/components/ui/card.tsx | 92 +++ src/components/ui/dialog.tsx | 143 +++++ src/components/ui/dropdown-menu.tsx | 257 +++++++++ src/components/ui/input.tsx | 21 + src/components/ui/sheet.tsx | 139 +++++ src/components/ui/skeleton.tsx | 13 + src/components/ui/textarea.tsx | 18 + src/components/ui/tooltip.tsx | 61 ++ src/hooks/index.ts | 1 + src/hooks/use-retrospective-events.ts | 73 +++ src/lib/ai/index.ts | 2 + src/lib/ai/openai.ts | 113 ++++ src/lib/db/index.ts | 13 + src/lib/db/schema.ts | 69 +++ src/lib/sse/event-emitter.ts | 41 ++ src/lib/sse/index.ts | 2 + src/lib/sse/types.ts | 46 ++ src/lib/utils.ts | 6 + src/types/index.ts | 63 +++ tsconfig.json | 34 ++ 59 files changed, 4922 insertions(+), 149 deletions(-) create mode 100644 Makefile create mode 100644 README.md create mode 100755 bun.lockb create mode 100644 components.json create mode 100644 drizzle.config.ts create mode 100644 eslint.config.mjs create mode 100644 next.config.ts create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 public/file.svg create mode 100644 public/globe.svg create mode 100644 public/next.svg create mode 100644 public/vercel.svg create mode 100644 public/window.svg create mode 100644 specs/completed/20251203-scrumkit-initial-setup-retrospective.md create mode 100644 src/app/api/retrospective/[id]/actions/[actionId]/route.ts create mode 100644 src/app/api/retrospective/[id]/actions/route.ts create mode 100644 src/app/api/retrospective/[id]/events/route.ts create mode 100644 src/app/api/retrospective/[id]/items/[itemId]/route.ts create mode 100644 src/app/api/retrospective/[id]/items/route.ts create mode 100644 src/app/api/retrospective/[id]/report/route.ts create mode 100644 src/app/api/retrospective/[id]/route.ts create mode 100644 src/app/api/retrospective/[id]/votes/route.ts create mode 100644 src/app/api/retrospective/route.ts create mode 100644 src/app/favicon.ico create mode 100644 src/app/globals.css create mode 100644 src/app/layout.tsx create mode 100644 src/app/page.tsx create mode 100644 src/app/retrospective/[id]/page.tsx create mode 100644 src/components/features/action-items-panel.tsx create mode 100644 src/components/features/index.ts create mode 100644 src/components/features/report-generator.tsx create mode 100644 src/components/features/retro-board.tsx create mode 100644 src/components/ui/avatar.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/button.tsx create mode 100644 src/components/ui/card.tsx create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/components/ui/dropdown-menu.tsx create mode 100644 src/components/ui/input.tsx create mode 100644 src/components/ui/sheet.tsx create mode 100644 src/components/ui/skeleton.tsx create mode 100644 src/components/ui/textarea.tsx create mode 100644 src/components/ui/tooltip.tsx create mode 100644 src/hooks/index.ts create mode 100644 src/hooks/use-retrospective-events.ts create mode 100644 src/lib/ai/index.ts create mode 100644 src/lib/ai/openai.ts create mode 100644 src/lib/db/index.ts create mode 100644 src/lib/db/schema.ts create mode 100644 src/lib/sse/event-emitter.ts create mode 100644 src/lib/sse/index.ts create mode 100644 src/lib/sse/types.ts create mode 100644 src/lib/utils.ts create mode 100644 src/types/index.ts create mode 100644 tsconfig.json 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 0000000000000000000000000000000000000000..c41e41ff836fc1bae2848d69f0047a0944adbf87 GIT binary patch literal 232447 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 CGV)RY literal 0 HcmV?d00001 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/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 c32ad68..593f13e 100644 --- a/specs/new/20251203-scrumkit-initial-setup-retrospective.md +++ b/specs/new/20251203-scrumkit-initial-setup-retrospective.md @@ -66,6 +66,7 @@ v0.dev is Vercel's AI UI generator die perfect integreert met Next.js + Tailwind - 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 @@ -92,21 +93,26 @@ 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. +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. + +SSE voordelen: + +- 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 -Liveblocks voordelen: +SSE architectuur: -- 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 +- **Server β†’ Client:** SSE stream voor real-time updates +- **Client β†’ Server:** Reguliere POST/PATCH requests voor mutaties +- Database als "source of truth" voor synchronisatie --- @@ -167,24 +173,28 @@ Per input item: --- -**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 -- Stem toegevoegd/verwijderd -- Discussie notities bijgewerkt -- Deelnemer joined/left -- **Rapport gegenereerd/bijgewerkt** +- `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 --- @@ -265,7 +275,7 @@ Het gegenereerde rapport moet worden gepresenteerd in een dedicated tab binnen d Tab structuur: -``` +```text [Bord] [Stemmen] [Discussie] [Rapport] ``` @@ -290,12 +300,12 @@ Opslag: - Bevat versie geschiedenis (optioneel voor v2) - Tijdstempel van generatie -Real-time synchronisatie: +Real-time synchronisatie (via SSE): -- Bij rapport generatie: broadcast naar alle deelnemers via Liveblocks +- Bij rapport generatie: `report:generated` event naar alle deelnemers - Rapport tab toont automatisch het nieuwste rapport - Indicator wanneer rapport wordt gegenereerd ("Rapport wordt gegenereerd...") -- Notificatie aan deelnemers wanneer rapport beschikbaar is +- Automatische refresh van rapport tab bij ontvangst van event --- @@ -354,6 +364,18 @@ src/ β”‚ β”œβ”€β”€ (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 @@ -361,19 +383,23 @@ src/ β”‚ β”œβ”€β”€ 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) +β”‚ β”‚ β”œβ”€β”€ 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) +β”‚ β”‚ └── 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 +β”‚ └── 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 ``` @@ -434,115 +460,222 @@ export const actionItems = pgTable('action_items', { createdAt: timestamp('created_at').defaultNow(), }); -// NIEUW: Rapport opslag +// 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(), // Markdown inhoud + content: text('content').notNull(), generatedAt: timestamp('generated_at').defaultNow(), - generatedBy: varchar('generated_by', { length: 255 }), // Gebruiker die generatie triggerde + 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 inclusief rapport: +**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', + }, + }); +} -const client = createClient({ - publicApiKey: process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY!, -}); +// 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 Presence = { - cursor: { x: number; y: number } | null; - name: string; - isTyping: boolean; - currentTab: "board" | "voting" | "discussion" | "report"; +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; }; +``` -type Storage = { - items: LiveList; - votes: LiveMap; - phase: "input" | "voting" | "discussion" | "completed"; - report: { - content: string | null; - isGenerating: boolean; - generatedAt: string | null; - }; +**Client-side SSE Hook:** + +```typescript +// src/hooks/use-sse.ts +'use client'; + +import { useEffect, useCallback, useRef } from 'react'; + +type SSEEvent = { + type: string; + payload: unknown; }; -export const { - RoomProvider, - useOthers, - useUpdateMyPresence, - useStorage, - useMutation, - useBroadcastEvent, - useEventListener, -} = createRoomContext(client); +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: Rapport Tab Component (v0.dev Gegenereerd) - -Voorbeeld structuur voor rapport tab component: +### TO4: Retrospective State Hook ```typescript -// src/components/features/retrospective/report-tab.tsx -"use client"; - -import { useStorage } from "@/lib/liveblocks/config"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Download, Copy, RefreshCw, FileText } from "lucide-react"; -import ReactMarkdown from "react-markdown"; - -export function ReportTab({ sessionId }: { sessionId: string }) { - const report = useStorage((root) => root.report); - - if (report.isGenerating) { - return ; - } - - if (!report.content) { - return ; - } - - return ( -
-
-

Retrospective Rapport

-
- - -
-
- - - - {report.content} - - - -

- Gegenereerd op: {new Date(report.generatedAt).toLocaleString('nl-NL')} -

-
- ); +// 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 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; } ``` @@ -552,26 +685,33 @@ export function ReportTab({ sessionId }: { sessionId: string }) { ```text /api/retrospective - POST / - Nieuwe sessie aanmaken - GET /:id - Sessie ophalen - PATCH /:id - Sessie bijwerken (status, instellingen) - 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 bijwerken - 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 / - Stem toevoegen - DELETE /:itemId - Stem verwijderen + POST / - Stem toevoegen (broadcast: vote:added) + DELETE /:itemId - Stem verwijderen (broadcast: vote:removed) /api/retrospective/:id/report - POST / - AI rapport genereren & opslaan - GET / - Rapport ophalen + POST / - AI rapport genereren (broadcast: report:generated) + GET / - Rapport ophalen -/api/liveblocks-auth - POST / - Liveblocks authenticatie +/api/retrospective/:id/participants + POST / - Deelnemer registreren (broadcast: participant:joined) + DELETE /:userId - Deelnemer verwijderen (broadcast: participant:left) + PATCH /heartbeat - Heartbeat update ``` --- @@ -586,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-... @@ -604,14 +740,16 @@ NEXT_PUBLIC_APP_URL=https://scrumkit.vercel.app 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 @@ -622,7 +760,8 @@ 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) @@ -638,8 +777,9 @@ Gebruik Shadcn/UI Avatar component. - API keys alleen server-side gebruiken - Input sanitization voor alle gebruikerscontent - Rate limiting op API endpoints -- Liveblocks authenticatie 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 --- @@ -664,7 +804,7 @@ Gebruik Shadcn/UI Avatar component. - [ ] 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 +- [ ] 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 @@ -672,7 +812,7 @@ Gebruik Shadcn/UI Avatar component. - [ ] 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 in de sessie** +- [ ] **Rapport is real-time zichtbaar voor alle deelnemers via SSE** - [ ] Rapport is downloadbaar in Markdown en PDF formaat ### Technische Acceptatiecriteria @@ -681,7 +821,8 @@ Gebruik Shadcn/UI Avatar component. - [ ] Turbopack wordt gebruikt als bundler - [ ] Deployment naar Vercel werkt zonder fouten - [ ] Vercel Postgres connectie werkt correct -- [ ] Liveblocks real-time synchronisatie werkt (inclusief rapport updates) +- [ ] 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 fouten @@ -691,7 +832,7 @@ Gebruik Shadcn/UI Avatar component. ### Performance Criteria - [ ] InitiΓ«le pagina laadtijd < 3 seconden -- [ ] Real-time updates binnen 500ms (Liveblocks) +- [ ] SSE event delivery < 500ms - [ ] AI rapport generatie < 30 seconden - [ ] Ondersteunt minimaal 10 gelijktijdige gebruikers per sessie @@ -701,10 +842,9 @@ Gebruik Shadcn/UI Avatar component. - [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) +- [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) -- [v0.dev Review 2025](https://skywork.ai/blog/vercel-v0-review-2025-ai-ui-code-generation-nextjs/) -- [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 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 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 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 LrJugUA?W`A8`#=m literal 0 HcmV?d00001 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 ? ( + + +