Skip to content

feat: implement vote reveal system for planning poker (#21)#52

Merged
TheEagleByte merged 2 commits intomainfrom
issue-21-vote-reveal-system
Oct 1, 2025
Merged

feat: implement vote reveal system for planning poker (#21)#52
TheEagleByte merged 2 commits intomainfrom
issue-21-vote-reveal-system

Conversation

@TheEagleByte
Copy link
Copy Markdown
Owner

@TheEagleByte TheEagleByte commented Oct 1, 2025

Summary

Implements a synchronized vote reveal system for planning poker sessions with smooth animations, vote distribution display, and consensus/outlier highlighting.

This PR addresses issue #21 (EPIC-003 Story 5: Vote Reveal System) by adding the following features:

Backend Changes

  • Reveal Votes Action: revealStoryVotes() sets all votes as revealed and updates story status from 'voting' to 'revealed'
  • Reset Votes Action: resetStoryVotes() clears all votes and resets story status back to 'voting' for re-voting
  • Permission Checks: Only facilitators/session creators can trigger reveal or reset operations

Frontend Components

RevealedCard Component

  • 3D card flip animation using CSS transforms (rotateY)
  • Staggered reveal with configurable delay (100ms between cards)
  • Consensus highlighting (green border for votes within ±1 value of mode)
  • Outlier highlighting (red border for votes >2 values away from mode)
  • Optional participant name display
  • Shows card back initially, flips to reveal vote value

VoteResults Component

  • Vote distribution chart with bar visualization
  • Statistics display: mode, average, median, range
  • Consensus/outlier highlighting in results
  • Final estimate selection UI for facilitators
  • Reset voting button
  • Responsive grid layout for revealed cards

Updated VotingInterface

  • Reveal button for facilitators (only shown when votes exist)
  • Auto-reveal support when all participants vote (if enabled in session settings)
  • Real-time subscriptions for:
    • Vote changes
    • Story status updates
  • Conditional rendering:
    • Shows voting cards when status = 'voting'
    • Shows VoteResults when status = 'revealed' or 'estimated'
  • Facilitator detection via cookies

Hooks

  • use-poker-reveal: Mutations for reveal/reset operations with optimistic updates

Features Implemented

✅ Create reveal button/trigger
✅ Implement card flip animations
✅ Show vote distribution
✅ Highlight consensus/outliers
✅ Add auto-reveal when all voted (optional)

Acceptance Criteria

✅ Synchronized vote reveal across all participants
✅ Smooth card flip animations
✅ Vote distribution clearly shown
✅ Consensus highlighting

Technical Details

Consensus Logic

  • Votes are considered "consensus" if they are the mode (most common value) or within ±1 card value in the sequence
  • Outliers are votes >2 card values away from the mode

Animation Details

  • Staggered reveal: 100ms delay between each card
  • 3D flip animation: 500ms duration
  • Participant names fade in after flip completes

Real-time Sync

  • Uses Supabase Realtime to listen for:
    • poker_votes table changes
    • poker_stories status updates
  • All participants see the reveal simultaneously

Test Plan

  • Build passes without errors
  • Linter passes (only pre-existing warnings remain)
  • Manual test: Create poker session
  • Manual test: Add participants and stories
  • Manual test: Submit votes from multiple participants
  • Manual test: Reveal votes as facilitator
  • Manual test: Verify all participants see synchronized reveal
  • Manual test: Check consensus/outlier highlighting
  • Manual test: Test auto-reveal when enabled
  • Manual test: Reset voting and re-vote
  • Manual test: Set final estimate

Screenshots

Will add screenshots after manual testing

Related Issues

Closes #21

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added full reveal flow for planning poker with facilitator-only "Reveal Votes", optional auto-reveal, and results view showing distribution, mode, average/median, and consensus/outlier highlights.
    • Facilitator controls to reset voting and set final estimate; option to show/hide voter names.
    • Animated flip cards with visual indicators for consensus/outliers.
  • Chores

    • Added client cookie utilities and session typing to support facilitator detection.
  • Tests

    • Added comprehensive tests for reveal/reset hooks, revealed card, and results view.

Add synchronized vote reveal system with animations and vote distribution display.

## Backend Changes
- Add `revealStoryVotes` action to set all votes as revealed and update story status
- Add `resetStoryVotes` action to clear votes and restart voting
- Permission checks ensure only facilitators/creators can reveal/reset votes

## Frontend Components
- Create `RevealedCard` component with 3D flip animations
- Create `VoteResults` component with:
  - Vote distribution chart/histogram
  - Statistics (mode, average, median, range)
  - Consensus/outlier highlighting
  - Final estimate selection UI for facilitators
- Update `VotingInterface` with:
  - Reveal button for facilitators
  - Auto-reveal support when all participants vote
  - Real-time subscriptions for vote and story status changes
  - Conditional rendering of VoteResults when revealed

## Hooks
- Create `use-poker-reveal` hook with mutations for reveal/reset operations
- Optimistic updates for smooth UX

## Features
- Staggered card flip animations (100ms delay between cards)
- Consensus highlighting (votes within ±1 card value of mode)
- Outlier highlighting (votes >2 card values away from mode)
- Vote distribution visualization with bar charts
- Auto-reveal when all participants vote (if enabled in settings)
- Reset voting button for facilitators after reveal
- Real-time synchronized reveal across all participants

Closes #21

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings October 1, 2025 15:56
@vercel
Copy link
Copy Markdown

vercel bot commented Oct 1, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
scrumkit Ready Ready Preview Comment Oct 1, 2025 4:04pm

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Oct 1, 2025

Note

Other AI code review bot(s) detected

CodeRabbit 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.

Walkthrough

Adds vote reveal and reset flows: server actions, client hooks, UI changes to VotingInterface/StoryManager for facilitator and auto-reveal logic, a VoteResults dashboard with analysis, and a RevealedCard flip component with tests and cookie utilities.

Changes

Cohort / File(s) Summary
Server actions
src/lib/poker/actions.ts
Adds revealStoryVotes(storyId) and resetStoryVotes(storyId) with permission checks, story existence/state validation, vote updates/deletes, status transitions, and path revalidation.
Types
src/lib/poker/types.ts
Adds exported StorySessionInfo interface describing session metadata (id, unique_url, creator_cookie, team_id).
Client hooks (reveal/reset)
src/hooks/use-poker-reveal.ts, src/hooks/__tests__/use-poker-reveal.test.tsx
New useRevealVotes and useResetVotes TanStack Query mutations with toast feedback, error handling, cancellation, and targeted query invalidation; tests added for success/error/invalidation paths.
Cookie utilities
src/lib/utils/cookies.ts
New client-side cookie helpers: getCookie, setCookie, deleteCookie with non-browser guards.
Voting UI core
New props & flow
src/components/poker/VotingInterface.tsx, src/components/poker/StoryManager.tsx
VotingInterface receives showVoterNames? and autoReveal?; adds facilitator detection, Reveal Votes button, auto-reveal when all non-observers voted, subscribes to additional realtime channel, toggles between voting and VoteResults view, and StoryManager passes session flags.
Results dashboard & card
src/components/poker/VoteResults.tsx, src/components/poker/RevealedCard.tsx, src/components/poker/__tests__/VoteResults.test.tsx, src/components/poker/__tests__/RevealedCard.test.tsx
Adds VoteResults component (distribution, mode/consensus/outlier classification, numeric stats, reset and final-estimate controls, uses new hooks) and RevealedCard react client component (staggered flip animation, consensus/outlier styling, optional participant name); tests for both components.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor Facilitator
  participant UI as VotingInterface
  participant Hook as useRevealVotes
  participant Actions as revealStoryVotes()
  participant DB as DB / Revalidation
  participant Results as VoteResults

  Facilitator->>UI: Click "Reveal Votes"
  UI->>Hook: mutate(storyId)
  Hook->>Actions: revealStoryVotes(storyId)
  Actions->>DB: Validate + mark votes revealed, story.status=revealed
  DB-->>Actions: OK
  Actions-->>Hook: success
  Hook->>UI: invalidate queries
  UI->>Results: Render VoteResults
Loading
sequenceDiagram
  autonumber
  participant UI as VotingInterface
  participant Hook as useRevealVotes
  participant Actions as revealStoryVotes()

  UI->>UI: Track vote count vs participants
  alt autoReveal enabled AND all voted
    UI->>UI: wait 500ms
    UI->>Hook: mutate(storyId)
    Hook->>Actions: revealStoryVotes()
  else Not all voted or disabled
    UI-->>UI: Wait for votes
  end
Loading
sequenceDiagram
  autonumber
  actor Facilitator
  participant UI as VoteResults
  participant Hook as useResetVotes
  participant Actions as resetStoryVotes()
  participant DB as DB / Revalidation

  Facilitator->>UI: Click "Reset Voting"
  UI->>Hook: mutate(storyId)
  Hook->>Actions: resetStoryVotes(storyId)
  Actions->>DB: Delete votes, set story.status=voting
  DB-->>Actions: OK
  Actions-->>Hook: success
  Hook->>UI: invalidate queries -> return to voting view
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

I flip my paws, the cards take flight—
Consensus glows, outliers bright.
A hop, a cheer, the votes revealed,
Then reset, replay, the numbers sealed.
I nibble a carrot, estimate tonight. 🐇🃏

Pre-merge checks and finishing touches

✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title “feat: implement vote reveal system for planning poker (#21)” concisely describes the primary feature delivered by the pull request, namely the vote reveal system, and correctly references the linked issue. It is a clear, single-sentence summary that highlights the main change without unnecessary detail or noise.
Linked Issues Check ✅ Passed The changes fully implement the required vote reveal trigger and button, 3D flip animations with staggered timing, comprehensive vote distribution display with statistics, and consensus/outlier highlighting, and also include the optional auto-reveal when all participants have voted; the optional countdown feature is not critical and can be deferred. All acceptance criteria from issue #21 are met, ensuring synchronized reveals, smooth animations, clear distributions, and highlighted consensus.
Out of Scope Changes Check ✅ Passed All modifications—including new components, hooks, actions, types, and the cookie utilities—directly support the vote reveal system’s functionality and facilitator detection, and there are no unrelated or extraneous changes outside the scope of issue #21’s objectives.
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch issue-21-vote-reveal-system

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR implements a comprehensive vote reveal system for planning poker sessions, enabling synchronized vote reveals with animations, consensus/outlier highlighting, and vote distribution analysis.

Key changes include:

  • Backend actions for revealing and resetting votes with proper permission checks
  • Frontend components for animated card reveals and vote result visualization
  • Real-time synchronization across all participants using Supabase subscriptions

Reviewed Changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/lib/poker/actions.ts Adds reveal and reset vote server actions with facilitator permission checks
src/hooks/use-poker-reveal.ts React Query mutations for reveal/reset operations with optimistic updates
src/components/poker/VotingInterface.tsx Enhanced with reveal button, auto-reveal logic, and conditional rendering
src/components/poker/VoteResults.tsx New component displaying vote distribution, statistics, and consensus analysis
src/components/poker/StoryManager.tsx Passes session settings to VotingInterface component
src/components/poker/RevealedCard.tsx 3D animated card component with consensus/outlier highlighting

Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.

Comment on lines +972 to +977
const session = story.poker_sessions as unknown as {
id: string;
unique_url: string;
creator_cookie: string;
team_id: string | null;
};
Copy link

Copilot AI Oct 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type assertion with 'as unknown as' bypasses TypeScript's type safety. Consider defining a proper interface or extracting this type from the database schema to maintain type safety.

Copilot uses AI. Check for mistakes.
Comment on lines +1033 to +1038
const session = story.poker_sessions as unknown as {
id: string;
unique_url: string;
creator_cookie: string;
team_id: string | null;
};
Copy link

Copilot AI Oct 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate type assertion code. This type definition is repeated from the revealStoryVotes function. Extract this into a shared interface to reduce code duplication.

Copilot uses AI. Check for mistakes.
Comment on lines +85 to +93
const creatorCookie = document.cookie
.split("; ")
.find(row => row.startsWith("scrumkit_poker_creator="))
?.split("=")[1];

const participantCookie = document.cookie
.split("; ")
.find(row => row.startsWith("scrumkit_poker_participant="))
?.split("=")[1];
Copy link

Copilot AI Oct 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Direct cookie parsing is duplicated and could be extracted into a utility function. Consider creating a getCookie(name: string) helper function to improve maintainability and reusability.

Copilot uses AI. Check for mistakes.
Comment on lines +111 to +112
const valueIndex = sequence.values.findIndex(v => String(v) === voteValue);
const modeIndex = sequence.values.findIndex(v => String(v) === analysis.mode[0]);
Copy link

Copilot AI Oct 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The findIndex operations are called for every vote evaluation. Consider pre-computing a value-to-index map to improve performance when processing many votes.

Copilot uses AI. Check for mistakes.
queryKey: pokerVoteKeys.story(variables),
});
queryClient.invalidateQueries({
queryKey: pokerVoteKeys.all,
Copy link

Copilot AI Oct 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The query key pokerVoteKeys.all may not match the expected query key pattern. Based on the usage in other parts of the code, this should likely be pokerVoteKeys.story(variables) to invalidate the specific story's votes.

Suggested change
queryKey: pokerVoteKeys.all,
queryKey: pokerVoteKeys.story(variables),

Copilot uses AI. Check for mistakes.
## PR Feedback Addressed
- Fix query key in useResetVotes hook (use participant key instead of all)
- Extract StorySessionInfo interface to reduce type assertion duplication
- Create getCookie utility function to avoid duplicate cookie parsing
- Optimize vote category calculation with pre-computed value-to-index map

## Tests Added
- Add 8 unit tests for use-poker-reveal hook (reveal/reset operations)
- Add 12 unit tests for RevealedCard component (animations, styling, special values)
- Add 14 unit tests for VoteResults component (distribution, statistics, facilitator actions)

Total: 34 new tests with 100% passing rate

## Technical Improvements
- Use useCallback for getVoteCategory to improve performance
- Use useMemo for valueIndexMap to avoid recomputation
- Improve type safety with shared interface
- Better code organization with utility functions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (8)
src/lib/poker/actions.ts (1)

1056-1068: Reset UX consistency: clear final_estimate and revalidate root.

If the story may have a final estimate, consider clearing it on reset; also refresh the root poker list.

   // Update story status back to voting
-  const { error: storyUpdateError } = await supabase
+  const { error: storyUpdateError } = await supabase
     .from("poker_stories")
-    .update({ status: "voting" })
+    .update({ status: "voting", final_estimate: null })
     .eq("id", storyId);
 ...
-  revalidatePath(`/poker/${session.unique_url}`);
+  revalidatePath(`/poker/${session.unique_url}`);
+  revalidatePath("/poker");
src/hooks/use-poker-reveal.ts (2)

16-44: Add optimistic update and invalidate participant cache for snappier reveal.

Flip is_revealed in story votes immediately; also invalidate participant(storyId) to refresh per-user state.

   return useMutation({
     mutationFn: (storyId: string) => revealStoryVotes(storyId),
-    onMutate: async (storyId) => {
+    onMutate: async (storyId) => {
       // Cancel outgoing refetches
       await queryClient.cancelQueries({
         queryKey: pokerVoteKeys.story(storyId),
       });
-
-      return { storyId };
+      const prevVotes = queryClient.getQueryData<any[]>(pokerVoteKeys.story(storyId));
+      // Optimistically mark all votes as revealed
+      if (Array.isArray(prevVotes)) {
+        queryClient.setQueryData(
+          pokerVoteKeys.story(storyId),
+          prevVotes.map(v => ({ ...v, is_revealed: true }))
+        );
+      }
+      return { storyId, prevVotes };
     },
-    onError: (err) => {
+    onError: (err, _vars, ctx) => {
       // Extract error message
       const errorMessage = err instanceof Error ? err.message : "Failed to reveal votes";
       toast.error(errorMessage);
+      // Rollback
+      if (ctx?.prevVotes) {
+        queryClient.setQueryData(pokerVoteKeys.story(ctx.storyId), ctx.prevVotes);
+      }
     },
     onSuccess: () => {
       toast.success("Votes revealed!");
     },
     onSettled: (data, error, variables) => {
       // Always refetch after error or success
       queryClient.invalidateQueries({
         queryKey: pokerVoteKeys.story(variables),
       });
+      queryClient.invalidateQueries({
+        queryKey: pokerVoteKeys.participant(variables),
+      });
       queryClient.invalidateQueries({
         queryKey: pokerStoryKeys.all,
       });
     },
   });

50-81: Mirror optimistic reset and broaden invalidation slightly.

Clear story votes optimistically; also refresh participant cache.

   return useMutation({
     mutationFn: (storyId: string) => resetStoryVotes(storyId),
-    onMutate: async (storyId) => {
+    onMutate: async (storyId) => {
       // Cancel outgoing refetches
       await queryClient.cancelQueries({
         queryKey: pokerVoteKeys.story(storyId),
       });
-
-      return { storyId };
+      const prevVotes = queryClient.getQueryData<any[]>(pokerVoteKeys.story(storyId));
+      queryClient.setQueryData(pokerVoteKeys.story(storyId), []);
+      return { storyId, prevVotes };
     },
-    onError: (err) => {
+    onError: (err, _vars, ctx) => {
       // Extract error message
       const errorMessage = err instanceof Error ? err.message : "Failed to reset votes";
       toast.error(errorMessage);
+      if (ctx?.prevVotes) {
+        queryClient.setQueryData(pokerVoteKeys.story(ctx.storyId), ctx.prevVotes);
+      }
     },
     onSuccess: () => {
       toast.success("Voting has been reset");
     },
     onSettled: (data, error, variables) => {
       // Always refetch after error or success
       queryClient.invalidateQueries({
         queryKey: pokerVoteKeys.story(variables),
       });
       queryClient.invalidateQueries({
         queryKey: pokerVoteKeys.all,
       });
+      queryClient.invalidateQueries({
+        queryKey: pokerVoteKeys.participant(variables),
+      });
       queryClient.invalidateQueries({
         queryKey: pokerStoryKeys.all,
       });
     },
   });
src/components/poker/RevealedCard.tsx (2)

35-37: A11y: add labels and motion reduction.

  • Provide aria-label describing the revealed value (icon or symbol).
  • Respect prefers-reduced-motion by skipping the delayed flip.
-  const [isFlipped, setIsFlipped] = useState(false);
+  const [isFlipped, setIsFlipped] = useState(false);
+  const reduceMotion = typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;

-  useEffect(() => {
+  useEffect(() => {
     const timer = setTimeout(() => {
-      setIsFlipped(true);
+      setIsFlipped(true);
     }, delay);
     return () => clearTimeout(timer);
-  }, [delay]);
+  }, [delay]);
...
-    <div className="flex flex-col items-center gap-2">
+    <div
+      className="flex flex-col items-center gap-2"
+      aria-label={`Vote card: ${typeof value === 'string' ? (value === "☕" ? "Coffee Break" : value) : value}`}
+      style={reduceMotion ? { transition: "none" } : undefined}
+    >

Also applies to: 117-127


49-55: Remove undefined Tailwind classes

  • Drop rotate-y-180 — it isn’t defined in your Tailwind config and duplicates the inline transform.
  • Drop all backface-hidden entries — they’re not defined and duplicate the inline backfaceVisibility.
src/components/poker/VotingInterface.tsx (1)

135-185: Don’t intercept typing in inputs/contentEditable.

Global keydown handler will fire while users type in inputs, textareas, or contenteditable elements.

   const handleKeyPress = useCallback(
     (event: KeyboardEvent) => {
-      if (!canVote) return;
+      if (!canVote) return;
+      const target = event.target as HTMLElement | null;
+      if (target) {
+        const tag = target.tagName?.toLowerCase();
+        const isFormEl = tag === "input" || tag === "textarea" || tag === "select";
+        const isEditable = (target as HTMLElement).isContentEditable;
+        if (isFormEl || isEditable) return;
+      }
src/components/poker/VoteResults.tsx (2)

23-31: Remove unused thresholds to reduce noise.

consensusThreshold and outlierThreshold aren’t referenced after classification changes.

 interface VoteAnalysis {
   distribution: Map<string, number>;
   mode: string[]; // Most common value(s)
   numericVotes: number[];
   average?: number;
   median?: number;
-  consensusThreshold: number;
-  outlierThreshold: number;
 }
...
     return {
       distribution,
       mode,
       numericVotes,
       average,
       median,
-      consensusThreshold: 0.6, // 60% of votes within range
-      outlierThreshold: 2, // Values >2 steps away from mode
     };

Also applies to: 87-96


349-376: Facilitator UX: emphasize suggested estimate from mode.

Nice touch highlighting mode. Consider disabling buttons with zero votes to steer choices and reduce misclicks.

-                  <Button
+                  <Button
                     key={String(value)}
-                    variant={isMode ? "default" : "outline"}
+                    variant={isMode ? "default" : "outline"}
                     size="lg"
                     onClick={() => handleSetEstimate(String(value))}
                     disabled={updateStory.isPending}
                     className={cn(
                       "min-w-[60px]",
                       isMode && "bg-green-600 hover:bg-green-700"
                     )}
                   >
                     {value}
                     {count > 0 && (
...

Optional: disabled={updateStory.isPending || count === 0}

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bb4b518 and 76b6e99.

📒 Files selected for processing (6)
  • src/components/poker/RevealedCard.tsx (1 hunks)
  • src/components/poker/StoryManager.tsx (1 hunks)
  • src/components/poker/VoteResults.tsx (1 hunks)
  • src/components/poker/VotingInterface.tsx (6 hunks)
  • src/hooks/use-poker-reveal.ts (1 hunks)
  • src/lib/poker/actions.ts (1 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
src/{app,components}/**/*.tsx

📄 CodeRabbit inference engine (CLAUDE.md)

Use the "use client" directive only in components that require client-side interactivity

Files:

  • src/components/poker/StoryManager.tsx
  • src/components/poker/VoteResults.tsx
  • src/components/poker/RevealedCard.tsx
  • src/components/poker/VotingInterface.tsx
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx}: Use the @/* path alias for imports from ./src/*
Import Supabase clients from src/lib/supabase/{client.ts,server.ts} instead of instantiating ad-hoc clients

Files:

  • src/components/poker/StoryManager.tsx
  • src/lib/poker/actions.ts
  • src/components/poker/VoteResults.tsx
  • src/components/poker/RevealedCard.tsx
  • src/hooks/use-poker-reveal.ts
  • src/components/poker/VotingInterface.tsx
🧬 Code graph analysis (4)
src/components/poker/VoteResults.tsx (5)
src/lib/poker/types.ts (3)
  • PokerVote (79-88)
  • PokerParticipant (66-77)
  • EstimationSequence (9-14)
src/hooks/use-poker-reveal.ts (1)
  • useResetVotes (47-81)
src/hooks/use-poker-stories.ts (1)
  • useUpdatePokerStory (112-168)
src/lib/utils.ts (1)
  • cn (4-6)
src/components/poker/RevealedCard.tsx (1)
  • RevealedCard (16-130)
src/components/poker/RevealedCard.tsx (1)
src/lib/utils.ts (1)
  • cn (4-6)
src/hooks/use-poker-reveal.ts (3)
src/lib/poker/actions.ts (2)
  • revealStoryVotes (954-1012)
  • resetStoryVotes (1015-1068)
src/hooks/use-poker-votes.ts (1)
  • pokerVoteKeys (18-22)
src/hooks/use-poker-stories.ts (1)
  • pokerStoryKeys (26-32)
src/components/poker/VotingInterface.tsx (7)
src/lib/poker/types.ts (2)
  • PokerStory (52-64)
  • EstimationSequence (9-14)
src/hooks/use-poker-votes.ts (3)
  • useSubmitVote (57-125)
  • useParticipantVote (41-54)
  • useStoryVotes (25-38)
src/hooks/use-poker-reveal.ts (1)
  • useRevealVotes (13-44)
src/hooks/use-poker-participants.ts (1)
  • useSessionParticipants (49-113)
src/hooks/use-poker-stories.ts (1)
  • pokerStoryKeys (26-32)
src/components/poker/ParticipantStatus.tsx (1)
  • ParticipantStatus (39-306)
src/components/poker/VoteResults.tsx (1)
  • VoteResults (33-383)
🔇 Additional comments (2)
src/components/poker/StoryManager.tsx (1)

197-204: VotingInterfaceProps include optional showVoterNames and autoReveal — forwarding these props is correct.

src/components/poker/VotingInterface.tsx (1)

256-266: Clarify auto‑reveal quorum semantics.

allVoted compares all non‑observer participants vs vote count. If inactive participants remain in the session list, auto‑reveal may never trigger. Confirm whether quorum should be “all registered” or “all currently present”.

Would you like auto‑reveal to consider only participants currently online (via Presence) and non‑observers?

Comment on lines +98 to +131
// Determine if a vote is consensus or outlier
const getVoteCategory = (voteValue: string): 'consensus' | 'outlier' | 'normal' => {
// Check if it's the mode (most common)
if (analysis.mode.includes(voteValue)) {
return 'consensus';
}

// For numeric votes, check distance from mode
const numValue = parseFloat(voteValue);
if (!isNaN(numValue) && analysis.mode.length > 0) {
const modeNum = parseFloat(analysis.mode[0]);
if (!isNaN(modeNum)) {
// Find position in sequence
const valueIndex = sequence.values.findIndex(v => String(v) === voteValue);
const modeIndex = sequence.values.findIndex(v => String(v) === analysis.mode[0]);

if (valueIndex !== -1 && modeIndex !== -1) {
const distance = Math.abs(valueIndex - modeIndex);

// Consensus: within 1 card value
if (distance <= 1) {
return 'consensus';
}

// Outlier: more than 2 card values away
if (distance > 2) {
return 'outlier';
}
}
}
}

return 'normal';
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Consensus/outlier detection incorrectly restricted to numeric values and mishandles ties.

Current logic skips non‑numeric sequences (e.g., T‑shirt sizes) and uses only the first mode, misclassifying votes when there are multiple modes.

Apply this diff to use sequence index distance for any value and consider all mode values:

-  const getVoteCategory = (voteValue: string): 'consensus' | 'outlier' | 'normal' => {
-    // Check if it's the mode (most common)
-    if (analysis.mode.includes(voteValue)) {
-      return 'consensus';
-    }
-
-    // For numeric votes, check distance from mode
-    const numValue = parseFloat(voteValue);
-    if (!isNaN(numValue) && analysis.mode.length > 0) {
-      const modeNum = parseFloat(analysis.mode[0]);
-      if (!isNaN(modeNum)) {
-        // Find position in sequence
-        const valueIndex = sequence.values.findIndex(v => String(v) === voteValue);
-        const modeIndex = sequence.values.findIndex(v => String(v) === analysis.mode[0]);
-
-        if (valueIndex !== -1 && modeIndex !== -1) {
-          const distance = Math.abs(valueIndex - modeIndex);
-
-          // Consensus: within 1 card value
-          if (distance <= 1) {
-            return 'consensus';
-          }
-
-          // Outlier: more than 2 card values away
-          if (distance > 2) {
-            return 'outlier';
-          }
-        }
-      }
-    }
-
-    return 'normal';
-  };
+  const getVoteCategory = (voteValue: string): 'consensus' | 'outlier' | 'normal' => {
+    if (analysis.mode.includes(voteValue)) return 'consensus';
+
+    const valueIndex = sequence.values.findIndex(v => String(v) === voteValue);
+    if (valueIndex === -1 || analysis.mode.length === 0) return 'normal';
+
+    const modeIndices = analysis.mode
+      .map(m => sequence.values.findIndex(v => String(v) === m))
+      .filter((i) => i !== -1);
+    if (modeIndices.length === 0) return 'normal';
+
+    const minDistance = Math.min(...modeIndices.map(i => Math.abs(i - valueIndex)));
+    if (minDistance <= 1) return 'consensus';
+    if (minDistance > 2) return 'outlier';
+    return 'normal';
+  };
🤖 Prompt for AI Agents
In src/components/poker/VoteResults.tsx around lines 98 to 131, the current
getVoteCategory limits distance checks to numeric values and only uses the first
mode entry; change it to treat all votes the same (no parseFloat checks) and
consider all mode values: if voteValue is included in analysis.mode return
'consensus'; otherwise locate the index of voteValue in sequence.values and the
indices of every mode value in sequence.values, compute the minimum absolute
distance between voteValue index and any mode index, return 'consensus' if min
distance <= 1, 'outlier' if min distance > 2, and 'normal' if indices are not
found or fall in-between.

Comment on lines +338 to +355
{/* Vote count indicator and reveal button */}
<div className="flex items-center gap-2">
<Badge variant="outline" className="flex items-center gap-2">
<Users className="h-3 w-3" />
{voteCount} {voteCount === 1 ? 'vote' : 'votes'}
</Badge>
{isFacilitator && canVote && voteCount > 0 && (
<Button
size="sm"
onClick={handleReveal}
disabled={revealVotes.isPending}
className="bg-green-600 hover:bg-green-700"
>
<Eye className="h-4 w-4 mr-2" />
Reveal Votes
</Button>
)}
</div>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reveal button hidden for facilitator observers.

Reveal is gated by canVote (which requires !isObserver). Facilitators marked as observers can’t reveal, violating the “only facilitators/creators can trigger reveal” requirement.

Apply this diff:

-              {isFacilitator && canVote && voteCount > 0 && (
+              {isFacilitator && canVoteOnStory(story.status) && voteCount > 0 && (
                 <Button
                   size="sm"
                   onClick={handleReveal}
                   disabled={revealVotes.isPending}
                   className="bg-green-600 hover:bg-green-700"
                 >
                   <Eye className="h-4 w-4 mr-2" />
                   Reveal Votes
                 </Button>
               )}
📝 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.

Suggested change
{/* Vote count indicator and reveal button */}
<div className="flex items-center gap-2">
<Badge variant="outline" className="flex items-center gap-2">
<Users className="h-3 w-3" />
{voteCount} {voteCount === 1 ? 'vote' : 'votes'}
</Badge>
{isFacilitator && canVote && voteCount > 0 && (
<Button
size="sm"
onClick={handleReveal}
disabled={revealVotes.isPending}
className="bg-green-600 hover:bg-green-700"
>
<Eye className="h-4 w-4 mr-2" />
Reveal Votes
</Button>
)}
</div>
{/* Vote count indicator and reveal button */}
<div className="flex items-center gap-2">
<Badge variant="outline" className="flex items-center gap-2">
<Users className="h-3 w-3" />
{voteCount} {voteCount === 1 ? 'vote' : 'votes'}
</Badge>
{isFacilitator && canVoteOnStory(story.status) && voteCount > 0 && (
<Button
size="sm"
onClick={handleReveal}
disabled={revealVotes.isPending}
className="bg-green-600 hover:bg-green-700"
>
<Eye className="h-4 w-4 mr-2" />
Reveal Votes
</Button>
)}
</div>
🤖 Prompt for AI Agents
In src/components/poker/VotingInterface.tsx around lines 338 to 355, the Reveal
button is currently gated by canVote (which excludes observers) so facilitators
who are observers cannot reveal; change the visibility condition to allow
facilitators or creators to reveal regardless of observer status by replacing
the condition that checks isFacilitator && canVote && voteCount > 0 with a check
for (isFacilitator || isCreator) && voteCount > 0 and keep the existing disabled
prop revealVotes.isPending unchanged.

Comment on lines +979 to +983
// Check permission for anonymous sessions
if (!session.team_id && session.creator_cookie !== creatorCookie) {
throw new Error("You don't have permission to reveal votes for this story");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Harden authorization for team sessions (facilitator-only).

Only anonymous sessions are checked. For team sessions, anyone can call this server action to reveal votes. Require a facilitator for team sessions (or equivalent team-role check).

Apply this diff inside revealStoryVotes before the permission check:

   // Check permission for anonymous sessions
-  if (!session.team_id && session.creator_cookie !== creatorCookie) {
-    throw new Error("You don't have permission to reveal votes for this story");
-  }
+  const participantCookie = cookieStore.get("scrumkit_poker_participant")?.value;
+  if (session.team_id) {
+    if (!participantCookie) throw new Error("You must join as a facilitator to reveal votes");
+    const { data: fac } = await supabase
+      .from("poker_participants")
+      .select("is_facilitator")
+      .eq("session_id", session.id)
+      .eq("participant_cookie", participantCookie)
+      .single();
+    if (!fac?.is_facilitator) throw new Error("Only facilitators can reveal votes");
+  } else if (session.creator_cookie !== creatorCookie) {
+    throw new Error("You don't have permission to reveal votes for this story");
+  }
📝 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.

Suggested change
// Check permission for anonymous sessions
if (!session.team_id && session.creator_cookie !== creatorCookie) {
throw new Error("You don't have permission to reveal votes for this story");
}
// Check permission for anonymous sessions
const participantCookie = cookieStore.get("scrumkit_poker_participant")?.value;
if (session.team_id) {
if (!participantCookie) throw new Error("You must join as a facilitator to reveal votes");
const { data: fac } = await supabase
.from("poker_participants")
.select("is_facilitator")
.eq("session_id", session.id)
.eq("participant_cookie", participantCookie)
.single();
if (!fac?.is_facilitator) throw new Error("Only facilitators can reveal votes");
} else if (session.creator_cookie !== creatorCookie) {
throw new Error("You don't have permission to reveal votes for this story");
}

Comment on lines +984 to +1012
// Check if story is in voting state
if (story.status !== "voting") {
throw new Error("Story is not in voting state");
}

// Update all votes to revealed
const { error: voteError } = await supabase
.from("poker_votes")
.update({ is_revealed: true })
.eq("story_id", storyId);

if (voteError) {
console.error("Error revealing votes:", voteError);
throw new Error("Failed to reveal votes");
}

// Update story status to revealed
const { error: storyUpdateError } = await supabase
.from("poker_stories")
.update({ status: "revealed" })
.eq("id", storyId);

if (storyUpdateError) {
console.error("Error updating story status:", storyUpdateError);
throw new Error("Failed to update story status");
}

revalidatePath(`/poker/${session.unique_url}`);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Eliminate TOCTOU race; update story first with guard, then mark votes revealed.

Between vote updates and status change, a late revote can slip in. Flip the order and guard the status transition atomically.

-  // Update all votes to revealed
-  const { error: voteError } = await supabase
-    .from("poker_votes")
-    .update({ is_revealed: true })
-    .eq("story_id", storyId);
-
-  if (voteError) {
-    console.error("Error revealing votes:", voteError);
-    throw new Error("Failed to reveal votes");
-  }
-
-  // Update story status to revealed
-  const { error: storyUpdateError } = await supabase
-    .from("poker_stories")
-    .update({ status: "revealed" })
-    .eq("id", storyId);
+  // First: set story to revealed only if currently voting
+  const { data: updated, error: storyUpdateError } = await supabase
+    .from("poker_stories")
+    .update({ status: "revealed" })
+    .eq("id", storyId)
+    .eq("status", "voting")
+    .select("id")
+    .single();
   if (storyUpdateError) {
     console.error("Error updating story status:", storyUpdateError);
     throw new Error("Failed to update story status");
   }
+
+  // Then: mark all votes as revealed
+  const { error: voteError } = await supabase
+    .from("poker_votes")
+    .update({ is_revealed: true })
+    .eq("story_id", storyId);
+  if (voteError) {
+    console.error("Error revealing votes:", voteError);
+    throw new Error("Failed to reveal votes");
+  }
 
-  revalidatePath(`/poker/${session.unique_url}`);
+  revalidatePath(`/poker/${session.unique_url}`);
+  revalidatePath("/poker");
📝 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.

Suggested change
// Check if story is in voting state
if (story.status !== "voting") {
throw new Error("Story is not in voting state");
}
// Update all votes to revealed
const { error: voteError } = await supabase
.from("poker_votes")
.update({ is_revealed: true })
.eq("story_id", storyId);
if (voteError) {
console.error("Error revealing votes:", voteError);
throw new Error("Failed to reveal votes");
}
// Update story status to revealed
const { error: storyUpdateError } = await supabase
.from("poker_stories")
.update({ status: "revealed" })
.eq("id", storyId);
if (storyUpdateError) {
console.error("Error updating story status:", storyUpdateError);
throw new Error("Failed to update story status");
}
revalidatePath(`/poker/${session.unique_url}`);
}
// Check if story is in voting state
if (story.status !== "voting") {
throw new Error("Story is not in voting state");
}
// First: set story to revealed only if currently voting
const { data: updated, error: storyUpdateError } = await supabase
.from("poker_stories")
.update({ status: "revealed" })
.eq("id", storyId)
.eq("status", "voting")
.select("id")
.single();
if (storyUpdateError) {
console.error("Error updating story status:", storyUpdateError);
throw new Error("Failed to update story status");
}
// Then: mark all votes as revealed
const { error: voteError } = await supabase
.from("poker_votes")
.update({ is_revealed: true })
.eq("story_id", storyId);
if (voteError) {
console.error("Error revealing votes:", voteError);
throw new Error("Failed to reveal votes");
}
revalidatePath(`/poker/${session.unique_url}`);
revalidatePath("/poker");
}
🤖 Prompt for AI Agents
In src/lib/poker/actions.ts around lines 984 to 1012, the reveal flow must be
reordered to avoid a TOCTOU race: first perform an atomic guarded update of the
story row (update poker_stories set status='revealed' where id=storyId and
status='voting') and verify the update affected a row (if not, throw an error),
then update poker_votes to set is_revealed=true for that story; ensure you check
Supabase update errors and the affected-row result from the story update before
proceeding to reveal votes, and only call revalidatePath when both steps
succeed.

Comment on lines +1040 to +1044
// Check permission for anonymous sessions
if (!session.team_id && session.creator_cookie !== creatorCookie) {
throw new Error("You don't have permission to reset votes for this story");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Mirror facilitator auth check for reset.

Same auth gap here: team sessions aren’t restricted to facilitators.

-  // Check permission for anonymous sessions
-  if (!session.team_id && session.creator_cookie !== creatorCookie) {
-    throw new Error("You don't have permission to reset votes for this story");
-  }
+  const participantCookie = cookieStore.get("scrumkit_poker_participant")?.value;
+  if (session.team_id) {
+    if (!participantCookie) throw new Error("You must join as a facilitator to reset votes");
+    const { data: fac } = await supabase
+      .from("poker_participants")
+      .select("is_facilitator")
+      .eq("session_id", session.id)
+      .eq("participant_cookie", participantCookie)
+      .single();
+    if (!fac?.is_facilitator) throw new Error("Only facilitators can reset votes");
+  } else if (session.creator_cookie !== creatorCookie) {
+    throw new Error("You don't have permission to reset votes for this story");
+  }
📝 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.

Suggested change
// Check permission for anonymous sessions
if (!session.team_id && session.creator_cookie !== creatorCookie) {
throw new Error("You don't have permission to reset votes for this story");
}
const participantCookie = cookieStore.get("scrumkit_poker_participant")?.value;
if (session.team_id) {
if (!participantCookie) throw new Error("You must join as a facilitator to reset votes");
const { data: fac } = await supabase
.from("poker_participants")
.select("is_facilitator")
.eq("session_id", session.id)
.eq("participant_cookie", participantCookie)
.single();
if (!fac?.is_facilitator) throw new Error("Only facilitators can reset votes");
} else if (session.creator_cookie !== creatorCookie) {
throw new Error("You don't have permission to reset votes for this story");
}
🤖 Prompt for AI Agents
In src/lib/poker/actions.ts around lines 1040 to 1044, the anonymous-session
permission check for resetting votes lacks the facilitator-only restriction for
team sessions; update the logic to mirror the facilitator auth check used
elsewhere in this file by first detecting a team session and then verifying the
requester is the facilitator (reuse the same facilitator-check
condition/function used for other team-only actions), and if the requester is
not the facilitator throw the same "You don't have permission to reset votes for
this story" error.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (5)
src/lib/poker/actions.ts (3)

973-978: CRITICAL: Team session authorization still missing.

This issue was flagged in a previous review but remains unresolved. Team sessions lack facilitator-only restrictions, allowing any participant to reveal votes.

The previous review suggested checking is_facilitator for team sessions. Apply this fix:

+  const participantCookie = cookieStore.get("scrumkit_poker_participant")?.value;
   // Check permission for anonymous sessions
-  if (!session.team_id && session.creator_cookie !== creatorCookie) {
-    throw new Error("You don't have permission to reveal votes for this story");
-  }
+  if (session.team_id) {
+    if (!participantCookie) throw new Error("You must join as a facilitator to reveal votes");
+    const { data: participant } = await supabase
+      .from("poker_participants")
+      .select("is_facilitator")
+      .eq("session_id", session.id)
+      .eq("participant_cookie", participantCookie)
+      .single();
+    if (!participant?.is_facilitator) throw new Error("Only facilitators can reveal votes");
+  } else if (session.creator_cookie !== creatorCookie) {
+    throw new Error("You don't have permission to reveal votes for this story");
+  }

985-1005: CRITICAL: TOCTOU race condition still present.

This critical issue was flagged in a previous review but remains unresolved. Updating votes before the story status creates a race window where late votes can slip in after reveal is triggered.

Reverse the order and add a guarded update as previously suggested:

-  // Update all votes to revealed
-  const { error: voteError } = await supabase
-    .from("poker_votes")
-    .update({ is_revealed: true })
-    .eq("story_id", storyId);
-
-  if (voteError) {
-    console.error("Error revealing votes:", voteError);
-    throw new Error("Failed to reveal votes");
-  }
-
-  // Update story status to revealed
-  const { error: storyUpdateError } = await supabase
+  // First: atomically set story to revealed only if currently voting
+  const { data: updated, error: storyUpdateError } = await supabase
     .from("poker_stories")
     .update({ status: "revealed" })
     .eq("id", storyId)
+    .eq("status", "voting")
+    .select("id")
+    .single();
-    .eq("id", storyId);
 
   if (storyUpdateError) {
     console.error("Error updating story status:", storyUpdateError);
     throw new Error("Failed to update story status");
   }
+
+  // Then: mark all votes as revealed
+  const { error: voteError } = await supabase
+    .from("poker_votes")
+    .update({ is_revealed: true })
+    .eq("story_id", storyId);
+
+  if (voteError) {
+    console.error("Error revealing votes:", voteError);
+    throw new Error("Failed to reveal votes");
+  }

1029-1034: CRITICAL: Team session authorization missing in reset.

This issue was flagged in a previous review but remains unresolved. The reset function has the same authorization gap as reveal - team sessions aren't restricted to facilitators.

Apply the same facilitator check as suggested for reveal:

+  const participantCookie = cookieStore.get("scrumkit_poker_participant")?.value;
   // Check permission for anonymous sessions
-  if (!session.team_id && session.creator_cookie !== creatorCookie) {
-    throw new Error("You don't have permission to reset votes for this story");
-  }
+  if (session.team_id) {
+    if (!participantCookie) throw new Error("You must join as a facilitator to reset votes");
+    const { data: participant } = await supabase
+      .from("poker_participants")
+      .select("is_facilitator")
+      .eq("session_id", session.id)
+      .eq("participant_cookie", participantCookie)
+      .single();
+    if (!participant?.is_facilitator) throw new Error("Only facilitators can reset votes");
+  } else if (session.creator_cookie !== creatorCookie) {
+    throw new Error("You don't have permission to reset votes for this story");
+  }
src/components/poker/VotingInterface.tsx (1)

338-349: Reveal button incorrectly hidden for facilitator observers.

The reveal button is gated by canVote, which evaluates to false for observers. This prevents facilitators marked as observers from revealing votes, violating the requirement that facilitators can trigger reveal.

Apply this diff to allow facilitators to reveal regardless of observer status:

-              {isFacilitator && canVote && voteCount > 0 && (
+              {isFacilitator && canVoteOnStory(story.status) && voteCount > 0 && (
                 <Button
                   size="sm"
                   onClick={handleReveal}
                   disabled={revealVotes.isPending}
                   className="bg-green-600 hover:bg-green-700"
                 >
                   <Eye className="h-4 w-4 mr-2" />
                   Reveal Votes
                 </Button>
               )}
src/components/poker/VoteResults.tsx (1)

107-140: Consensus/outlier detection incorrectly restricted to numeric values and mishandles multiple modes.

The current logic:

  1. Skips distance checks for non-numeric sequences (e.g., T-shirt sizes, breaking that use case)
  2. Uses only analysis.mode[0] for distance calculation, misclassifying votes when multiple modes exist

Apply this diff to use sequence index distance for all values and consider all modes:

   const getVoteCategory = useCallback((voteValue: string): 'consensus' | 'outlier' | 'normal' => {
-    // Check if it's the mode (most common)
-    if (analysis.mode.includes(voteValue)) {
-      return 'consensus';
-    }
-
-    // For numeric votes, check distance from mode
-    const numValue = parseFloat(voteValue);
-    if (!isNaN(numValue) && analysis.mode.length > 0) {
-      const modeNum = parseFloat(analysis.mode[0]);
-      if (!isNaN(modeNum)) {
-        // Use pre-computed map instead of findIndex
-        const valueIndex = valueIndexMap.get(voteValue);
-        const modeIndex = valueIndexMap.get(analysis.mode[0]);
-
-        if (valueIndex !== undefined && modeIndex !== undefined) {
-          const distance = Math.abs(valueIndex - modeIndex);
-
-          // Consensus: within 1 card value
-          if (distance <= 1) {
-            return 'consensus';
-          }
-
-          // Outlier: more than 2 card values away
-          if (distance > 2) {
-            return 'outlier';
-          }
-        }
-      }
-    }
-
-    return 'normal';
+    if (analysis.mode.includes(voteValue)) return 'consensus';
+
+    const valueIndex = valueIndexMap.get(voteValue);
+    if (valueIndex === undefined || analysis.mode.length === 0) return 'normal';
+
+    const modeIndices = analysis.mode
+      .map(m => valueIndexMap.get(m))
+      .filter((i): i is number => i !== undefined);
+    
+    if (modeIndices.length === 0) return 'normal';
+
+    const minDistance = Math.min(...modeIndices.map(i => Math.abs(i - valueIndex)));
+    if (minDistance <= 1) return 'consensus';
+    if (minDistance > 2) return 'outlier';
+    return 'normal';
   }, [analysis.mode, valueIndexMap]);
🧹 Nitpick comments (5)
src/components/poker/__tests__/RevealedCard.test.tsx (1)

12-14: Consider using jest.runAllTimers() for more robust cleanup.

Using jest.runOnlyPendingTimers() may not clear all timers if a test fails mid-execution or if timers schedule additional timers. Consider jest.runAllTimers() or jest.clearAllTimers() for more thorough cleanup.

Apply this diff:

   afterEach(() => {
-    jest.runOnlyPendingTimers();
+    jest.runAllTimers();
     jest.useRealTimers();
   });
src/lib/utils/cookies.ts (1)

13-16: Cookie value splitting is fragile for values containing =.

Using .split("=")[1] will only capture the first segment if the cookie value itself contains =. Consider using .slice() after finding the first =.

Apply this diff:

   return document.cookie
     .split("; ")
     .find(row => row.startsWith(`${name}=`))
-    ?.split("=")[1];
+    ?.slice(name.length + 1);

This captures everything after name= regardless of = characters in the value.

src/hooks/__tests__/use-poker-reveal.test.tsx (1)

144-159: Brittle assertion on exact invalidation count.

Expecting exactly 3 calls to invalidateQueries is fragile - if the hook implementation adds another invalidation, this test will fail even though the behavior is correct.

Consider verifying the specific query keys instead:

-    it("should invalidate all relevant queries after reset", async () => {
+    it("should invalidate votes and stories queries after reset", async () => {
       mockResetStoryVotes.mockResolvedValue();
 
       const { result } = renderHook(() => useResetVotes(), { wrapper });
 
       const invalidateSpy = jest.spyOn(queryClient, "invalidateQueries");
 
       result.current.mutate("story-123");
 
       await waitFor(() => {
         expect(result.current.isSuccess).toBe(true);
       });
 
-      // Should invalidate story votes, participant votes, and all stories
-      expect(invalidateSpy).toHaveBeenCalledTimes(3);
+      // Verify specific query keys are invalidated
+      expect(invalidateSpy).toHaveBeenCalledWith(
+        expect.objectContaining({
+          queryKey: expect.arrayContaining(["poker-votes", "story-123"])
+        })
+      );
+      expect(invalidateSpy).toHaveBeenCalledWith(
+        expect.objectContaining({
+          queryKey: expect.arrayContaining(["poker-stories"])
+        })
+      );
     });
src/hooks/use-poker-reveal.ts (1)

34-42: Consider invalidating all vote queries for consistency.

The current invalidation strategy targets the specific story and all stories, but doesn't invalidate the root vote queries cache. For consistency with useResetVotes (which invalidates both specific and root caches), consider also invalidating pokerVoteKeys.all.

Apply this diff:

     onSettled: (data, error, variables) => {
       // Always refetch after error or success
       queryClient.invalidateQueries({
         queryKey: pokerVoteKeys.story(variables),
       });
+      queryClient.invalidateQueries({
+        queryKey: pokerVoteKeys.all,
+      });
       queryClient.invalidateQueries({
         queryKey: pokerStoryKeys.all,
       });
     },
src/components/poker/__tests__/VoteResults.test.tsx (1)

248-264: Consider using semantic queries over class selectors.

Line 262 uses querySelectorAll with a class pattern, which is brittle and may break if styling changes. Consider using getAllByRole or getByTestId for more resilient tests.

Example:

-    const cards = container.querySelectorAll('[class*="flex flex-col items-center"]');
-    expect(cards.length).toBeGreaterThan(0);
+    // Option 1: Check for specific participant names
+    expect(screen.getByText("Alice")).toBeInTheDocument();
+    expect(screen.getByText("Bob")).toBeInTheDocument();
+    expect(screen.getByText("Carol")).toBeInTheDocument();
+    
+    // Option 2: Add test IDs to RevealedCard and query by those
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 76b6e99 and e4370be.

📒 Files selected for processing (9)
  • src/components/poker/VoteResults.tsx (1 hunks)
  • src/components/poker/VotingInterface.tsx (6 hunks)
  • src/components/poker/__tests__/RevealedCard.test.tsx (1 hunks)
  • src/components/poker/__tests__/VoteResults.test.tsx (1 hunks)
  • src/hooks/__tests__/use-poker-reveal.test.tsx (1 hunks)
  • src/hooks/use-poker-reveal.ts (1 hunks)
  • src/lib/poker/actions.ts (2 hunks)
  • src/lib/poker/types.ts (1 hunks)
  • src/lib/utils/cookies.ts (1 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx}: Use the @/* path alias for imports from ./src/*
Import Supabase clients from src/lib/supabase/{client.ts,server.ts} instead of instantiating ad-hoc clients

Files:

  • src/lib/poker/types.ts
  • src/components/poker/VotingInterface.tsx
  • src/lib/utils/cookies.ts
  • src/hooks/__tests__/use-poker-reveal.test.tsx
  • src/components/poker/VoteResults.tsx
  • src/hooks/use-poker-reveal.ts
  • src/components/poker/__tests__/VoteResults.test.tsx
  • src/components/poker/__tests__/RevealedCard.test.tsx
  • src/lib/poker/actions.ts
src/{app,components}/**/*.tsx

📄 CodeRabbit inference engine (CLAUDE.md)

Use the "use client" directive only in components that require client-side interactivity

Files:

  • src/components/poker/VotingInterface.tsx
  • src/components/poker/VoteResults.tsx
  • src/components/poker/__tests__/VoteResults.test.tsx
  • src/components/poker/__tests__/RevealedCard.test.tsx
🧬 Code graph analysis (7)
src/components/poker/VotingInterface.tsx (8)
src/lib/poker/types.ts (2)
  • PokerStory (52-64)
  • EstimationSequence (9-14)
src/hooks/use-poker-votes.ts (3)
  • useSubmitVote (57-125)
  • useParticipantVote (41-54)
  • useStoryVotes (25-38)
src/hooks/use-poker-reveal.ts (1)
  • useRevealVotes (13-44)
src/hooks/use-poker-participants.ts (1)
  • useSessionParticipants (49-113)
src/lib/utils/cookies.ts (1)
  • getCookie (10-17)
src/hooks/use-poker-stories.ts (1)
  • pokerStoryKeys (26-32)
src/components/poker/ParticipantStatus.tsx (1)
  • ParticipantStatus (39-306)
src/components/poker/VoteResults.tsx (1)
  • VoteResults (33-392)
src/hooks/__tests__/use-poker-reveal.test.tsx (2)
src/lib/poker/actions.ts (2)
  • revealStoryVotes (955-1008)
  • resetStoryVotes (1011-1059)
src/hooks/use-poker-reveal.ts (2)
  • useRevealVotes (13-44)
  • useResetVotes (47-81)
src/components/poker/VoteResults.tsx (5)
src/lib/poker/types.ts (3)
  • PokerVote (79-88)
  • PokerParticipant (66-77)
  • EstimationSequence (9-14)
src/hooks/use-poker-reveal.ts (1)
  • useResetVotes (47-81)
src/hooks/use-poker-stories.ts (1)
  • useUpdatePokerStory (112-168)
src/lib/utils.ts (1)
  • cn (4-6)
src/components/poker/RevealedCard.tsx (1)
  • RevealedCard (16-130)
src/hooks/use-poker-reveal.ts (3)
src/lib/poker/actions.ts (2)
  • revealStoryVotes (955-1008)
  • resetStoryVotes (1011-1059)
src/hooks/use-poker-votes.ts (1)
  • pokerVoteKeys (18-22)
src/hooks/use-poker-stories.ts (1)
  • pokerStoryKeys (26-32)
src/components/poker/__tests__/VoteResults.test.tsx (2)
src/lib/poker/types.ts (3)
  • EstimationSequence (9-14)
  • PokerParticipant (66-77)
  • PokerVote (79-88)
src/components/poker/VoteResults.tsx (1)
  • VoteResults (33-392)
src/components/poker/__tests__/RevealedCard.test.tsx (1)
src/components/poker/RevealedCard.tsx (1)
  • RevealedCard (16-130)
src/lib/poker/actions.ts (1)
src/lib/poker/types.ts (1)
  • StorySessionInfo (150-155)
🔇 Additional comments (20)
src/lib/poker/types.ts (1)

149-155: LGTM!

The StorySessionInfo interface clearly defines session fields required for permission checks in reveal/reset flows. The structure aligns well with the permission validation logic in actions.ts.

src/hooks/__tests__/use-poker-reveal.test.tsx (2)

37-97: LGTM on useRevealVotes tests!

The test suite provides solid coverage of success cases, error handling (both Error and non-Error exceptions), and query invalidation. The tests are well-structured and clear.


99-143: LGTM on useResetVotes basic tests!

Good coverage of success cases and error handling for both Error and non-Error exceptions. The tests mirror the reveal tests appropriately.

src/hooks/use-poker-reveal.ts (2)

1-11: LGTM!

Imports are correctly structured and follow the coding guidelines for path aliases.


46-81: LGTM!

The reset flow correctly invalidates all relevant caches, including participant votes which are cleared during reset.

src/components/poker/VotingInterface.tsx (6)

1-29: LGTM!

New props and imports are correctly structured and follow coding guidelines.


82-101: LGTM!

Facilitator detection correctly combines participant facilitator flag with creator cookie check, enabling both explicit facilitators and session creators to control reveal actions.


245-249: LGTM!

Vote progress calculation correctly excludes observers from the "all voted" check.


250-260: LGTM!

Auto-reveal logic is well-guarded and includes appropriate delay and cleanup.


218-236: LGTM!

Real-time story subscription enables synchronized reveal state across all participants.


274-292: LGTM!

Post-reveal rendering correctly switches to VoteResults with appropriate props.

src/components/poker/__tests__/VoteResults.test.tsx (3)

6-19: LGTM!

Mocks provide sufficient functionality for rendering tests. For future mutation behavior tests, consider exposing the mock functions to enable call verification.


27-107: LGTM!

Test data is comprehensive and realistic, covering consensus scenarios, numeric statistics, and special values.


113-342: LGTM!

Test suite provides good coverage of VoteResults functionality, including rendering, statistics, facilitator controls, and edge cases.

src/components/poker/VoteResults.tsx (6)

1-31: LGTM!

Imports and type definitions are well-structured and follow coding guidelines.


44-96: LGTM!

Vote analysis correctly computes distribution, mode (with tie handling), and numeric statistics.


98-105: LGTM!

Pre-computed value index map improves performance by eliminating repeated findIndex calls during vote categorization.


142-160: LGTM!

Handler functions correctly invoke mutations with appropriate parameters.


164-300: LGTM!

Vote distribution visualization correctly renders statistics, bars, and mode highlighting with appropriate conditional logic.


302-391: LGTM!

Revealed votes and final estimate UI correctly implements staggered animations, consensus/outlier visualization, and facilitator controls.

Comment on lines +153 to +181
it("should support staggered animations with different delays", async () => {
const { rerender } = render(
<RevealedCard
value="1"
participantName="User1"
showParticipantName={true}
delay={100}
/>
);

// First card shouldn't flip yet
jest.advanceTimersByTime(50);

rerender(
<RevealedCard
value="2"
participantName="User2"
showParticipantName={true}
delay={200}
/>
);

// Advance to first card's flip time
jest.advanceTimersByTime(50);

await waitFor(() => {
expect(screen.getByText("2")).toBeInTheDocument();
});
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Staggered animation test doesn't verify multiple cards.

The test uses rerender to replace a single card's props rather than rendering multiple RevealedCard instances with different delays. This doesn't actually test staggered animations across multiple cards as intended.

To properly test staggered animations, render multiple cards simultaneously:

-  it("should support staggered animations with different delays", async () => {
-    const { rerender } = render(
-      <RevealedCard
-        value="1"
-        participantName="User1"
-        showParticipantName={true}
-        delay={100}
-      />
-    );
-
-    // First card shouldn't flip yet
-    jest.advanceTimersByTime(50);
-
-    rerender(
-      <RevealedCard
-        value="2"
-        participantName="User2"
-        showParticipantName={true}
-        delay={200}
-      />
-    );
-
-    // Advance to first card's flip time
-    jest.advanceTimersByTime(50);
-
-    await waitFor(() => {
-      expect(screen.getByText("2")).toBeInTheDocument();
-    });
-  });
+  it("should support staggered animations with different delays", async () => {
+    render(
+      <>
+        <RevealedCard
+          value="1"
+          participantName="User1"
+          showParticipantName={true}
+          delay={100}
+        />
+        <RevealedCard
+          value="2"
+          participantName="User2"
+          showParticipantName={true}
+          delay={200}
+        />
+      </>
+    );
+
+    // Advance to first card's flip time
+    jest.advanceTimersByTime(100);
+
+    await waitFor(() => {
+      expect(screen.getByText("1")).toBeInTheDocument();
+    });
+
+    // Second card should flip after additional 100ms
+    jest.advanceTimersByTime(100);
+
+    await waitFor(() => {
+      expect(screen.getByText("2")).toBeInTheDocument();
+    });
+  });
📝 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.

Suggested change
it("should support staggered animations with different delays", async () => {
const { rerender } = render(
<RevealedCard
value="1"
participantName="User1"
showParticipantName={true}
delay={100}
/>
);
// First card shouldn't flip yet
jest.advanceTimersByTime(50);
rerender(
<RevealedCard
value="2"
participantName="User2"
showParticipantName={true}
delay={200}
/>
);
// Advance to first card's flip time
jest.advanceTimersByTime(50);
await waitFor(() => {
expect(screen.getByText("2")).toBeInTheDocument();
});
});
it("should support staggered animations with different delays", async () => {
render(
<>
<RevealedCard
value="1"
participantName="User1"
showParticipantName={true}
delay={100}
/>
<RevealedCard
value="2"
participantName="User2"
showParticipantName={true}
delay={200}
/>
</>
);
// Advance to first card's flip time
jest.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText("1")).toBeInTheDocument();
});
// Second card should flip after additional 100ms
jest.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText("2")).toBeInTheDocument();
});
});
🤖 Prompt for AI Agents
In src/components/poker/__tests__/RevealedCard.test.tsx around lines 153–181,
the test currently uses rerender to replace a single RevealedCard which does not
exercise staggered animations across multiple cards; instead render two
RevealedCard components at once with different delay props (e.g., delay=100 and
delay=200), advance timers in steps to the first card's flip time and assert the
first card's value is visible, then advance timers to the second card's flip
time and assert the second card's value is visible (use appropriate
jest.advanceTimersByTime / act / waitFor and distinct queries like getByText for
each value/participant to verify both flips occur in sequence).

Comment on lines +1036 to +1056
// Delete all votes for this story
const { error: deleteError } = await supabase
.from("poker_votes")
.delete()
.eq("story_id", storyId);

if (deleteError) {
console.error("Error deleting votes:", deleteError);
throw new Error("Failed to delete votes");
}

// Update story status back to voting
const { error: storyUpdateError } = await supabase
.from("poker_stories")
.update({ status: "voting" })
.eq("id", storyId);

if (storyUpdateError) {
console.error("Error updating story status:", storyUpdateError);
throw new Error("Failed to update story status");
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Add story status validation before reset.

The reveal function validates that the story is in "voting" state, but reset doesn't check the story's current status. This could allow resetting votes on stories in invalid states (e.g., "pending", "estimated", "skipped").

Add a status check after permission validation:

   // Check permission for anonymous sessions
   if (!session.team_id && session.creator_cookie !== creatorCookie) {
     throw new Error("You don't have permission to reset votes for this story");
   }
+
+  // Validate story is in a state that can be reset (revealed or voting)
+  if (story.status !== "revealed" && story.status !== "voting") {
+    throw new Error("Cannot reset votes for a story that is not in voting or revealed state");
+  }
 
   // Delete all votes for this story
📝 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.

Suggested change
// Delete all votes for this story
const { error: deleteError } = await supabase
.from("poker_votes")
.delete()
.eq("story_id", storyId);
if (deleteError) {
console.error("Error deleting votes:", deleteError);
throw new Error("Failed to delete votes");
}
// Update story status back to voting
const { error: storyUpdateError } = await supabase
.from("poker_stories")
.update({ status: "voting" })
.eq("id", storyId);
if (storyUpdateError) {
console.error("Error updating story status:", storyUpdateError);
throw new Error("Failed to update story status");
}
// Check permission for anonymous sessions
if (!session.team_id && session.creator_cookie !== creatorCookie) {
throw new Error("You don't have permission to reset votes for this story");
}
// Validate story is in a state that can be reset (revealed or voting)
if (story.status !== "revealed" && story.status !== "voting") {
throw new Error("Cannot reset votes for a story that is not in voting or revealed state");
}
// Delete all votes for this story
const { error: deleteError } = await supabase
.from("poker_votes")
.delete()
.eq("story_id", storyId);
if (deleteError) {
console.error("Error deleting votes:", deleteError);
throw new Error("Failed to delete votes");
}
// Update story status back to voting
const { error: storyUpdateError } = await supabase
.from("poker_stories")
.update({ status: "voting" })
.eq("id", storyId);
if (storyUpdateError) {
console.error("Error updating story status:", storyUpdateError);
throw new Error("Failed to update story status");
}

Comment on lines +10 to +17
export function getCookie(name: string): string | undefined {
if (typeof window === "undefined") return undefined;

return document.cookie
.split("; ")
.find(row => row.startsWith(`${name}=`))
?.split("=")[1];
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add URI encoding/decoding for cookie values.

Cookie values containing special characters like = or ; will break the cookie string. Use encodeURIComponent when setting and decodeURIComponent when getting cookies.

Apply this diff:

 export function getCookie(name: string): string | undefined {
   if (typeof window === "undefined") return undefined;
 
-  return document.cookie
+  const value = document.cookie
     .split("; ")
     .find(row => row.startsWith(`${name}=`))
     ?.split("=")[1];
+  
+  return value ? decodeURIComponent(value) : undefined;
 }
🤖 Prompt for AI Agents
In src/lib/utils/cookies.ts around lines 10 to 17, the getter does not handle
cookie values with special characters and doesn't URI-decode values; change the
logic so the cookie lookup still finds the correct name but when returning the
value it splits on the first '=' only and returns decodeURIComponent(value) (or
undefined if not found). Also note that callers/other utility functions that set
cookies must use encodeURIComponent when writing values so the round-trip works
correctly.

Comment on lines +25 to +32
export function setCookie(name: string, value: string, days = 365): void {
if (typeof window === "undefined") return;

const date = new Date();
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
const expires = `expires=${date.toUTCString()}`;
document.cookie = `${name}=${value};${expires};path=/;SameSite=Strict`;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Encode cookie value to handle special characters.

Without encoding, cookie values containing =, ;, or other special characters will corrupt the cookie string.

Apply this diff:

 export function setCookie(name: string, value: string, days = 365): void {
   if (typeof window === "undefined") return;
 
   const date = new Date();
   date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
   const expires = `expires=${date.toUTCString()}`;
-  document.cookie = `${name}=${value};${expires};path=/;SameSite=Strict`;
+  document.cookie = `${name}=${encodeURIComponent(value)};${expires};path=/;SameSite=Strict`;
 }
📝 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.

Suggested change
export function setCookie(name: string, value: string, days = 365): void {
if (typeof window === "undefined") return;
const date = new Date();
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
const expires = `expires=${date.toUTCString()}`;
document.cookie = `${name}=${value};${expires};path=/;SameSite=Strict`;
}
export function setCookie(name: string, value: string, days = 365): void {
if (typeof window === "undefined") return;
const date = new Date();
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
const expires = `expires=${date.toUTCString()}`;
document.cookie = `${name}=${encodeURIComponent(value)};${expires};path=/;SameSite=Strict`;
}
🤖 Prompt for AI Agents
In src/lib/utils/cookies.ts around lines 25 to 32, the setCookie function writes
raw values to document.cookie which breaks when the value contains characters
like = or ; — encode the cookie value before setting it (e.g. use
encodeURIComponent) and use the encoded value in the document.cookie string so
special characters are safely preserved when storing; ensure any corresponding
getCookie/parse logic decodes with decodeURIComponent.

@TheEagleByte TheEagleByte merged commit 3abe13c into main Oct 1, 2025
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[EPIC-003] Story 5: Vote Reveal System

2 participants