[EPIC-003] Story 7: Statistics & Analytics#54
Conversation
- Add types for SessionStatistics, StoryStatistics, ParticipantStatistics, ExportData - Create statistics.ts with calculation functions: - calculateConsensusPercentage: % votes within 1 step of mode - calculateStoryVelocity: time from first vote to final estimate - calculateSessionStatistics: aggregate session-wide metrics - Add getSessionStatistics server action to fetch and compute statistics - Create csv-export.ts utility for exporting session data to CSV Relates to #23
- Import calculateConsensusPercentage from statistics utility - Add consensusPercentage to VoteAnalysis interface - Calculate consensus % (votes within 1 step of mode) - Display consensus % in statistics summary with 5-column grid - Highlight consensus >=70% with green background - Show percentage with 0 decimal places Relates to #23
- Create use-poker-statistics hook with React Query integration - Create ExportButton component: - Export session statistics to CSV - Disable when no estimated stories - Loading state with spinner - Toast notifications - Create SessionSummary component: - Display key metrics: stories completed, avg time, velocity, consensus rate - Show participant contributions with vote counts - Display estimate distribution with bar charts - Responsive grid layout - Loading and empty states Relates to #23
- Add SessionSummary component after Session Settings card - Add ExportButton to session header area - Import required components - Layout adjustments for better UX Relates to #23
- Add null coalescing for session.description in ExportButton - Add type assertion for estimation_sequence in getSessionStatistics - Add type assertion for custom_sequence JSON from database - Add null coalescing for story.final_estimate in statistics - Import EstimationSequenceType for type safety Relates to #23
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Note Other AI code review bot(s) detectedCodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review. WalkthroughAdds Planning Poker analytics: types and calculation utilities, a server action to compute per-session statistics, a react-query hook, UI components (SessionSummary, ExportButton), CSV export utilities, VoteResults consensus display, session page integration, and CHANGELOG entry. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor U as User
participant P as Session Page
participant SS as SessionSummary
participant H as useSessionStatistics
participant Q as react-query
participant A as getSessionStatistics
participant S as statistics.ts
participant DB as Data Store
U->>P: Open session page
P->>SS: Render with sessionId
SS->>H: useSessionStatistics(sessionId)
H->>Q: query(key, fetcher)
Q->>A: fetch statistics(sessionId)
A->>DB: load session, stories, votes
A->>S: calculateSessionStatistics(...)
S-->>A: SessionStatistics
A-->>Q: return SessionStatistics
Q-->>H: cache + return
H-->>SS: data/loading/error
SS-->>U: Render dashboard
sequenceDiagram
autonumber
actor U as User
participant EB as ExportButton
participant H as useSessionStatistics
participant E as csv-export.ts
participant B as Browser
U->>EB: Click "Export CSV"
EB->>H: read cached statistics
EB->>E: prepareExportData(session, statistics)
E-->>EB: ExportData
EB->>E: exportSessionToCSV(ExportData)
E-->>EB: csvContent
EB->>E: downloadCSV(csvContent, filename)
E->>B: trigger download
B-->>U: CSV file downloaded
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing touches
🧪 Generate unit tests
Comment |
There was a problem hiding this comment.
Pull Request Overview
This PR implements comprehensive statistics and analytics for Planning Poker sessions, adding detailed metrics, visualization, and export capabilities to help teams analyze their estimation performance.
- Comprehensive statistics dashboard with session-wide analytics
- CSV export functionality for detailed session data
- Real-time consensus tracking and velocity metrics
Reviewed Changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/lib/poker/types.ts | Adds new TypeScript interfaces for statistics data structures |
| src/lib/poker/statistics.ts | Core calculation functions for consensus, velocity, and session analytics |
| src/lib/poker/csv-export.ts | CSV generation and download utilities |
| src/lib/poker/actions.ts | Server action to fetch comprehensive session statistics |
| src/hooks/use-poker-statistics.ts | React Query hook for statistics data fetching |
| src/components/poker/VoteResults.tsx | Enhanced vote results with consensus percentage display |
| src/components/poker/SessionSummary.tsx | New statistics dashboard component |
| src/components/poker/ExportButton.tsx | CSV export functionality with loading states |
| src/app/poker/[sessionUrl]/page.tsx | Integration of statistics components into session page |
| CHANGELOG.md | Documentation of new features |
Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.
|
|
||
| exportData.stories.forEach((story) => { | ||
| const row = [ | ||
| escapeCsvField(story.title), |
There was a problem hiding this comment.
The CSV header includes 'Description' but the data row omits the description field. Either add the description field to the data row or remove it from the header.
| escapeCsvField(story.title), | |
| escapeCsvField(story.title), | |
| escapeCsvField(story.description || ""), |
| .slice(0, 5); // Top 5 | ||
|
|
||
| return { | ||
| sessionId: stories[0]?.session_id || "", |
There was a problem hiding this comment.
When no stories exist, accessing stories[0] returns undefined, making sessionId an empty string. The sessionId parameter should be used instead to ensure correct session identification.
| sessionId: stories[0]?.session_id || "", | |
| sessionId: sessionId, |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/components/poker/VoteResults.tsx (1)
200-246: Keep the consensus summary visible for all decks.Because the entire stats grid is guarded by
analysis.numericVotes.length > 0, any non-numeric deck (e.g., T-shirt sizes whereparseFloatreturnsNaN) hides the consensus card altogether—even though votes exist and we still need to highlight high consensus. That regresses the new consensus feature for those sessions. Render the grid unconditionally and guard only the numeric-only cards (average/median/range) so consensus is always shown.- {analysis.numericVotes.length > 0 && ( - <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-6"> + <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-6"> <div className="text-center p-3 bg-slate-50 dark:bg-slate-800 rounded-lg"> <p className="text-xs text-slate-600 dark:text-slate-400">Most Common</p> <p className="text-lg font-bold text-slate-900 dark:text-slate-100"> {analysis.mode.join(", ")} </p> </div> {analysis.average !== undefined && ( <div className="text-center p-3 bg-slate-50 dark:bg-slate-800 rounded-lg"> <p className="text-xs text-slate-600 dark:text-slate-400">Average</p> <p className="text-lg font-bold text-slate-900 dark:text-slate-100"> {analysis.average.toFixed(1)} </p> </div> )} {analysis.median !== undefined && ( <div className="text-center p-3 bg-slate-50 dark:bg-slate-800 rounded-lg"> <p className="text-xs text-slate-600 dark:text-slate-400">Median</p> <p className="text-lg font-bold text-slate-900 dark:text-slate-100"> {analysis.median} </p> </div> )} - <div className="text-center p-3 bg-slate-50 dark:bg-slate-800 rounded-lg"> - <p className="text-xs text-slate-600 dark:text-slate-400">Range</p> - <p className="text-lg font-bold text-slate-900 dark:text-slate-100"> - {Math.min(...analysis.numericVotes)} - {Math.max(...analysis.numericVotes)} - </p> - </div> + {analysis.numericVotes.length > 0 && ( + <div className="text-center p-3 bg-slate-50 dark:bg-slate-800 rounded-lg"> + <p className="text-xs text-slate-600 dark:text-slate-400">Range</p> + <p className="text-lg font-bold text-slate-900 dark:text-slate-100"> + {Math.min(...analysis.numericVotes)} - {Math.max(...analysis.numericVotes)} + </p> + </div> + )} <div className={cn( "text-center p-3 rounded-lg", analysis.consensusPercentage >= 70 ? "bg-green-50 dark:bg-green-900/20" : "bg-slate-50 dark:bg-slate-800" )}> <p className="text-xs text-slate-600 dark:text-slate-400">Consensus</p> <p className={cn( "text-lg font-bold", analysis.consensusPercentage >= 70 ? "text-green-700 dark:text-green-400" : "text-slate-900 dark:text-slate-100" )}> {analysis.consensusPercentage.toFixed(0)}% </p> </div> - </div> - )} + </div>
🧹 Nitpick comments (1)
src/components/poker/SessionSummary.tsx (1)
202-225: Avoid mutating cached statistics in render.Calling
.sortdirectly onstatistics.participantStatsmutates the React Query cache in place, which can leak mutations across subscribers and future re-renders. Clone before sorting so the cached data stays immutable.- {statistics.participantStats - .sort((a, b) => b.totalVotes - a.totalVotes) + {[...statistics.participantStats] + .sort((a, b) => b.totalVotes - a.totalVotes) .map((participant) => (
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (10)
CHANGELOG.md(1 hunks)src/app/poker/[sessionUrl]/page.tsx(3 hunks)src/components/poker/ExportButton.tsx(1 hunks)src/components/poker/SessionSummary.tsx(1 hunks)src/components/poker/VoteResults.tsx(5 hunks)src/hooks/use-poker-statistics.ts(1 hunks)src/lib/poker/actions.ts(2 hunks)src/lib/poker/csv-export.ts(1 hunks)src/lib/poker/statistics.ts(1 hunks)src/lib/poker/types.ts(1 hunks)
🧰 Additional context used
📓 Path-based instructions (3)
**/*.{ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
**/*.{ts,tsx}: Use the @/* path alias for imports from ./src/*
Use the provided Supabase clients (import from src/lib/supabase/client.ts on the browser and src/lib/supabase/server.ts on the server) and types from src/lib/supabase/types.ts; do not instantiate ad-hoc clients
Files:
src/lib/poker/types.tssrc/lib/poker/csv-export.tssrc/components/poker/ExportButton.tsxsrc/components/poker/SessionSummary.tsxsrc/app/poker/[sessionUrl]/page.tsxsrc/hooks/use-poker-statistics.tssrc/lib/poker/statistics.tssrc/lib/poker/actions.tssrc/components/poker/VoteResults.tsx
src/**/*.{tsx,jsx}
📄 CodeRabbit inference engine (CLAUDE.md)
In App Router components, add the "use client" directive only where client-side interactivity is required
Files:
src/components/poker/ExportButton.tsxsrc/components/poker/SessionSummary.tsxsrc/app/poker/[sessionUrl]/page.tsxsrc/components/poker/VoteResults.tsx
src/app/poker/**
📄 CodeRabbit inference engine (CLAUDE.md)
Keep Planning Poker feature routes and code under src/app/poker/
Files:
src/app/poker/[sessionUrl]/page.tsx
🧬 Code graph analysis (8)
src/lib/poker/csv-export.ts (1)
src/lib/poker/types.ts (2)
ExportData(194-207)SessionStatistics(171-184)
src/components/poker/ExportButton.tsx (3)
src/lib/poker/types.ts (1)
PokerSession(24-50)src/hooks/use-poker-statistics.ts (1)
useSessionStatistics(20-34)src/lib/poker/csv-export.ts (3)
prepareExportData(73-96)exportSessionToCSV(10-65)downloadCSV(122-137)
src/components/poker/SessionSummary.tsx (1)
src/hooks/use-poker-statistics.ts (1)
useSessionStatistics(20-34)
src/app/poker/[sessionUrl]/page.tsx (2)
src/components/poker/ExportButton.tsx (1)
ExportButton(17-89)src/components/poker/SessionSummary.tsx (1)
SessionSummary(20-264)
src/hooks/use-poker-statistics.ts (2)
src/lib/poker/types.ts (1)
SessionStatistics(171-184)src/lib/poker/actions.ts (1)
getSessionStatistics(1073-1158)
src/lib/poker/statistics.ts (1)
src/lib/poker/types.ts (6)
PokerStory(52-64)PokerVote(79-88)PokerParticipant(66-77)StoryStatistics(158-169)SessionStatistics(171-184)ParticipantStatistics(186-192)
src/lib/poker/actions.ts (3)
src/lib/poker/types.ts (5)
SessionStatistics(171-184)EstimationSequenceType(3-3)PokerVote(79-88)PokerParticipant(66-77)PokerStory(52-64)src/lib/poker/utils.ts (1)
getSequenceByType(38-46)src/lib/poker/statistics.ts (1)
calculateSessionStatistics(167-292)
src/components/poker/VoteResults.tsx (1)
src/lib/poker/statistics.ts (1)
calculateConsensusPercentage(20-73)
🔇 Additional comments (1)
src/app/poker/[sessionUrl]/page.tsx (1)
39-50: Nice integration of analytics controls.The revised header cleanly surfaces the description and wires in the export control without disrupting the existing layout.
| lines.push("Story Details"); | ||
| lines.push( | ||
| [ | ||
| "Title", | ||
| "Description", | ||
| "Final Estimate", | ||
| "Vote Count", | ||
| "Average Vote", | ||
| "Median Vote", | ||
| "Consensus %", | ||
| "Time (min)", | ||
| "Participants", | ||
| "Votes", | ||
| ].join(",") | ||
| ); | ||
|
|
||
| exportData.stories.forEach((story) => { | ||
| const row = [ | ||
| escapeCsvField(story.title), | ||
| escapeCsvField(story.finalEstimate || ""), | ||
| story.voteCount.toString(), | ||
| story.averageVote !== null ? story.averageVote.toFixed(1) : "N/A", | ||
| story.medianVote !== null ? story.medianVote.toString() : "N/A", | ||
| story.consensusPercentage.toFixed(1) + "%", | ||
| story.estimationTimeMinutes !== null | ||
| ? story.estimationTimeMinutes.toFixed(1) | ||
| : "N/A", | ||
| escapeCsvField(story.participants.join("; ")), | ||
| escapeCsvField(story.votes.map((v) => `${v.participantName}:${v.voteValue}`).join("; ")), | ||
| ]; | ||
| lines.push(row.join(",")); | ||
| }); |
There was a problem hiding this comment.
Fix CSV column misalignment before shipping.
The header advertises a “Description” column, but the row data never populates it—so every value after the title shifts one column to the left (final estimates show under “Description”, vote counts under “Final Estimate”, etc.). That makes the exported CSV misleading for any consumer. Drop the unused column (or populate it) so the column order and data line up.
lines.push("Story Details");
lines.push(
[
"Title",
- "Description",
"Final Estimate",
"Vote Count",
"Average Vote",
"Median Vote",
"Consensus %",
"Time (min)",
"Participants",
"Votes",
].join(",")
);
exportData.stories.forEach((story) => {
const row = [
escapeCsvField(story.title),
escapeCsvField(story.finalEstimate || ""),
story.voteCount.toString(),📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| lines.push("Story Details"); | |
| lines.push( | |
| [ | |
| "Title", | |
| "Description", | |
| "Final Estimate", | |
| "Vote Count", | |
| "Average Vote", | |
| "Median Vote", | |
| "Consensus %", | |
| "Time (min)", | |
| "Participants", | |
| "Votes", | |
| ].join(",") | |
| ); | |
| exportData.stories.forEach((story) => { | |
| const row = [ | |
| escapeCsvField(story.title), | |
| escapeCsvField(story.finalEstimate || ""), | |
| story.voteCount.toString(), | |
| story.averageVote !== null ? story.averageVote.toFixed(1) : "N/A", | |
| story.medianVote !== null ? story.medianVote.toString() : "N/A", | |
| story.consensusPercentage.toFixed(1) + "%", | |
| story.estimationTimeMinutes !== null | |
| ? story.estimationTimeMinutes.toFixed(1) | |
| : "N/A", | |
| escapeCsvField(story.participants.join("; ")), | |
| escapeCsvField(story.votes.map((v) => `${v.participantName}:${v.voteValue}`).join("; ")), | |
| ]; | |
| lines.push(row.join(",")); | |
| }); | |
| lines.push("Story Details"); | |
| lines.push( | |
| [ | |
| "Title", | |
| "Final Estimate", | |
| "Vote Count", | |
| "Average Vote", | |
| "Median Vote", | |
| "Consensus %", | |
| "Time (min)", | |
| "Participants", | |
| "Votes", | |
| ].join(",") | |
| ); | |
| exportData.stories.forEach((story) => { | |
| const row = [ | |
| escapeCsvField(story.title), | |
| escapeCsvField(story.finalEstimate || ""), | |
| story.voteCount.toString(), | |
| story.averageVote !== null ? story.averageVote.toFixed(1) : "N/A", | |
| story.medianVote !== null ? story.medianVote.toString() : "N/A", | |
| story.consensusPercentage.toFixed(1) + "%", | |
| story.estimationTimeMinutes !== null | |
| ? story.estimationTimeMinutes.toFixed(1) | |
| : "N/A", | |
| escapeCsvField(story.participants.join("; ")), | |
| escapeCsvField( | |
| story.votes | |
| .map((v) => `${v.participantName}:${v.voteValue}`) | |
| .join("; ") | |
| ), | |
| ]; | |
| lines.push(row.join(",")); | |
| }); |
🤖 Prompt for AI Agents
In src/lib/poker/csv-export.ts around lines 31 to 62 the CSV header includes a
"Description" column but the per-story row omits the story description causing
every subsequent field to shift left; fix this by inserting the story
description into the row as the second element (e.g.
escapeCsvField(story.description || "")) so the row fields align with the
header, keeping the rest of the columns in the same order and ensuring proper
escaping for the description.
| session: { | ||
| title: session.title, | ||
| description: session.description, | ||
| createdAt: new Date(session.created_at).toLocaleString(), | ||
| estimationSequence: session.estimation_sequence, | ||
| }, | ||
| stories: statistics.storyStats, | ||
| summary: { | ||
| totalStories: statistics.estimatedStories, | ||
| averageTime: statistics.averageEstimationTimeMinutes, | ||
| consensusRate: statistics.overallConsensusRate, | ||
| }, |
There was a problem hiding this comment.
Use the actual total story count in the export summary.
The CSV “Total Stories” metric currently repeats estimatedStories, so skipped/pending stories disappear from the summary. Swap in statistics.totalStories to report the real count.
summary: {
- totalStories: statistics.estimatedStories,
+ totalStories: statistics.totalStories,
averageTime: statistics.averageEstimationTimeMinutes,
consensusRate: statistics.overallConsensusRate,
},📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| session: { | |
| title: session.title, | |
| description: session.description, | |
| createdAt: new Date(session.created_at).toLocaleString(), | |
| estimationSequence: session.estimation_sequence, | |
| }, | |
| stories: statistics.storyStats, | |
| summary: { | |
| totalStories: statistics.estimatedStories, | |
| averageTime: statistics.averageEstimationTimeMinutes, | |
| consensusRate: statistics.overallConsensusRate, | |
| }, | |
| session: { | |
| title: session.title, | |
| description: session.description, | |
| createdAt: new Date(session.created_at).toLocaleString(), | |
| estimationSequence: session.estimation_sequence, | |
| }, | |
| stories: statistics.storyStats, | |
| summary: { | |
| totalStories: statistics.totalStories, | |
| averageTime: statistics.averageEstimationTimeMinutes, | |
| consensusRate: statistics.overallConsensusRate, | |
| }, |
🤖 Prompt for AI Agents
In src/lib/poker/csv-export.ts around lines 83 to 94, the export summary's
totalStories currently uses statistics.estimatedStories instead of the actual
count; replace statistics.estimatedStories with statistics.totalStories in the
summary object so the "Total Stories" field reports the real total (including
skipped/pending) rather than only estimated ones.
- Show consensus percentage for non-numeric sequences (T-shirt sizes, custom emojis) - Fix array mutation in SessionSummary participant sorting - Clone array before sorting to preserve React Query cache immutability 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 0
🧹 Nitpick comments (2)
src/components/poker/SessionSummary.tsx (2)
23-32: Consider providing more context in error state.While the error handling is functional, consider exposing error details to help with debugging, especially for facilitators who may need to report issues.
You could enhance the error message:
- <CardDescription>Failed to load session statistics</CardDescription> + <CardDescription> + Failed to load session statistics. Please try refreshing the page. + </CardDescription>
229-260: Consider adding ARIA labels for accessibility.The estimate distribution visualization is well-implemented with smooth animations and clear percentage displays. For improved accessibility, consider adding ARIA attributes to the progress bars.
You could enhance accessibility:
<div className="w-full bg-slate-200 dark:bg-slate-700 rounded-full h-2 overflow-hidden"> <div className="h-full bg-indigo-500 transition-all duration-500" style={{ width: `${percentage}%` }} + role="progressbar" + aria-valuenow={percentage} + aria-valuemin={0} + aria-valuemax={100} + aria-label={`${estimate.estimate}: ${percentage.toFixed(0)}%`} /> </div>
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (2)
src/components/poker/SessionSummary.tsx(1 hunks)src/components/poker/VoteResults.tsx(4 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
src/**/*.{tsx,jsx}
📄 CodeRabbit inference engine (CLAUDE.md)
In App Router components, add the "use client" directive only where client-side interactivity is required
Files:
src/components/poker/SessionSummary.tsxsrc/components/poker/VoteResults.tsx
**/*.{ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
**/*.{ts,tsx}: Use the @/* path alias for imports from ./src/*
Use the provided Supabase clients (import from src/lib/supabase/client.ts on the browser and src/lib/supabase/server.ts on the server) and types from src/lib/supabase/types.ts; do not instantiate ad-hoc clients
Files:
src/components/poker/SessionSummary.tsxsrc/components/poker/VoteResults.tsx
🧬 Code graph analysis (2)
src/components/poker/SessionSummary.tsx (1)
src/hooks/use-poker-statistics.ts (1)
useSessionStatistics(20-34)
src/components/poker/VoteResults.tsx (2)
src/lib/poker/statistics.ts (1)
calculateConsensusPercentage(20-73)src/lib/utils.ts (1)
cn(4-6)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Run Tests (20.x)
🔇 Additional comments (26)
src/components/poker/SessionSummary.tsx (16)
1-18: LGTM!The imports and component setup follow Next.js App Router conventions correctly. The "use client" directive is appropriate for a component using React hooks, and all imports use the proper @/* path alias.
55-72: LGTM!The empty state handling is well-implemented with a clear, user-friendly message that explains when statistics will be available.
86-192: LGTM!The KPI grid is well-structured with responsive design, proper null checks, and clear visual hierarchy. The conditional rendering ensures only relevant metrics are displayed, and the color coding effectively distinguishes different metric types.
194-227: LGTM!The participant breakdown is well-implemented with proper sorting and conditional display of statistics. The layout is clean and informative.
1-18: LGTM!Imports are correctly structured, using the
@/*alias as per guidelines, and the "use client" directive is appropriately placed for a component with client-side interactivity.
23-32: LGTM!Error state handling is appropriate, clearly indicating the failure to load statistics.
34-53: LGTM!Loading skeleton provides good UX with 6 placeholder cards matching the expected KPI grid layout.
55-72: LGTM!Empty state correctly handles the case when no stories have been estimated yet, providing helpful guidance to users.
90-106: LGTM!Stories completed metric correctly displays the ratio and conditionally shows pending/skipped counts when applicable.
108-126: LGTM!Average time per story metric safely checks for null before rendering and displays both average and median when available.
128-142: LGTM!Velocity metric is properly guarded with null check and displays stories per hour with clear labeling.
144-156: LGTM!Consensus rate is displayed with appropriate styling and clear percentage formatting.
158-172: LGTM!Active participants metric correctly checks for non-empty participantStats before rendering.
174-191: Verify array bounds before accessing mostCommonEstimates[0].Line 184 and lines 187-188 access
mostCommonEstimates[0]without verifying the array length is at least 1. Although line 175 checks that the length is > 0, it's safer to explicitly guard against potential race conditions or data inconsistencies.Apply this diff to add explicit bounds checking:
{statistics.mostCommonEstimates.length > 0 && ( <div className="p-4 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg"> <div className="flex items-center gap-2 mb-2"> <BarChart3 className="h-4 w-4 text-indigo-600" /> <p className="text-sm font-medium text-slate-600 dark:text-slate-400"> Most Common Estimate </p> </div> - <p className="text-2xl font-bold text-slate-900 dark:text-slate-100"> - {statistics.mostCommonEstimates[0].estimate} - </p> - <p className="text-xs text-slate-500 mt-1"> - Used {statistics.mostCommonEstimates[0].count}{" "} - {statistics.mostCommonEstimates[0].count === 1 ? "time" : "times"} - </p> + {statistics.mostCommonEstimates[0] && ( + <> + <p className="text-2xl font-bold text-slate-900 dark:text-slate-100"> + {statistics.mostCommonEstimates[0].estimate} + </p> + <p className="text-xs text-slate-500 mt-1"> + Used {statistics.mostCommonEstimates[0].count}{" "} + {statistics.mostCommonEstimates[0].count === 1 ? "time" : "times"} + </p> + </> + )} </div> )}
195-227: LGTM!Participant contributions section correctly sorts by total votes and safely displays participant data with null checks for averageVoteValue.
229-260: LGTM!Estimate distribution visualization correctly maps over mostCommonEstimates, calculates percentages, and renders progress bars with smooth transitions.
src/components/poker/VoteResults.tsx (10)
13-13: LGTM!The import follows the correct path alias convention and brings in the necessary statistics utility.
30-30: LGTM!The type extension is appropriate and maintains type safety throughout the component.
89-91: LGTM!The consensus percentage calculation is correctly integrated into the vote analysis logic. The useMemo dependencies have been properly updated to include
sequence.values, ensuring the calculation re-runs when the sequence changes.Also applies to: 98-98, 102-102
200-247: LGTM!The expanded statistics grid effectively incorporates the consensus metric with responsive design. The conditional styling at the 70% threshold provides clear visual feedback for high consensus, aligning with the PR objectives.
13-13: LGTM!Import correctly uses the
@/*alias and references the new statistics utility.
30-30: LGTM!VoteAnalysis interface properly extended to include consensusPercentage as a number.
89-90: LGTM!Consensus percentage calculation correctly delegates to the imported utility function with proper arguments.
98-98: LGTM!consensusPercentage correctly included in the analysis return object.
102-102: LGTM!useMemo dependencies correctly updated to include sequence.values, ensuring the consensus calculation re-runs when the sequence changes.
200-247: LGTM!The statistics grid has been thoughtfully extended to 5 columns with proper responsive breakpoints. The consensus tile uses conditional styling (green highlight at ≥70%) that aligns with the PR's acceptance criteria for highlighting high consensus.
Summary
Implements comprehensive statistics and analytics for Planning Poker sessions (Issue #23).
Features Implemented
✅ Calculate Average/Median Estimates
✅ Show Vote Distribution Chart
✅ Track Estimation Velocity
✅ Display Consensus Percentage
✅ Create Session Summary
✅ Export Results to CSV
Technical Implementation
Backend Infrastructure
src/lib/poker/statistics.ts: Core calculation functionssrc/lib/poker/csv-export.ts: CSV generation utilitiessrc/lib/poker/actions.ts: NewgetSessionStatisticsserver actionsrc/lib/poker/types.ts: New types for SessionStatistics, StoryStatistics, ParticipantStatistics, ExportDataFrontend Components
src/components/poker/SessionSummary.tsx: Statistics dashboardsrc/components/poker/ExportButton.tsx: CSV export functionalitysrc/components/poker/VoteResults.tsx: Enhanced with consensus %src/hooks/use-poker-statistics.ts: React Query hook for statsIntegration
Testing
Documentation
Screenshots/Demo
Statistics will be visible in session page once stories are estimated. Export button disabled until data is available.
Closes
Closes #23
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Improvements