[EPIC-003] Story 4: Real-time Voting Status (Supabase)#51
Conversation
|
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 real-time participant presence and voting-status UI for planning poker: new ParticipantStatus component, a React Query + Supabase participants hook (with join mutation), sessionId threaded into VotingInterface via StoryManager, tests for hooks, and a DB migration enabling poker tables in Supabase realtime publication. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
participant U as User
participant VI as VotingInterface
participant PS as ParticipantStatus
participant HP as use-poker-participants
participant RQ as React Query
participant SB as Supabase (DB + Realtime)
U->>VI: Open voting view (story, sessionId)
VI->>PS: Render ParticipantStatus(story, sessionId)
PS->>HP: subscribe useSessionParticipants(sessionId)
HP->>RQ: useQuery(fetch participants)
RQ->>SB: SELECT participants by sessionId
SB-->>RQ: participants list
RQ-->>PS: return participants
Note over HP,SB: HP opens Supabase realtime & presence channel
SB-->>HP: broadcast insert/update/delete/presence
HP->>RQ: invalidate session participants key
RQ->>SB: refetch updated participants
RQ-->>PS: updated data -> UI rerender
sequenceDiagram
autonumber
participant U as User
participant VI as VotingInterface
participant V as Vote logic
participant SB as Supabase Realtime
participant PS as ParticipantStatus
U->>VI: Cast vote
VI->>V: submit vote
V->>SB: insert/update vote
SB-->>PS: realtime vote/participant updates
PS->>VI: update counts (voted vs waiting)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
Pre-merge checks and finishing touches✅ Passed checks (5 passed)
✨ Finishing touches
🧪 Generate unit tests
Comment |
There was a problem hiding this comment.
Pull Request Overview
Implements real-time voting status tracking for Planning Poker sessions using Supabase real-time subscriptions and Presence. Participants can see who has voted/is pending in real-time, with participant count displays and online status tracking.
- Real-time participant list with vote status indicators using Supabase subscriptions
- Supabase Presence integration for online/offline tracking
- New
ParticipantStatuscomponent showing voting progress and participant details
Reviewed Changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| supabase/migrations/20251001000000_enable_poker_realtime.sql | Enables real-time publication for poker tables to support live updates |
| src/hooks/use-poker-participants.ts | New hook for fetching participants with real-time subscriptions and mutations |
| src/components/poker/VotingInterface.tsx | Integrates ParticipantStatus component and passes sessionId prop |
| src/components/poker/StoryManager.tsx | Updates to pass sessionId to VotingInterface |
| src/components/poker/ParticipantStatus.tsx | New component displaying real-time participant status and voting progress |
Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (5)
src/hooks/use-poker-participants.ts (5)
29-33: Loosened options type can degrade inference; consider a narrowed options type.UseQueryOptions here is too generic; prefer an Omit that reserves ownership of queryKey/queryFn/enabled for this hook.
Example:
+type SessionParticipantsKey = ReturnType<typeof pokerParticipantKeys.session>; +type SessionParticipantsOptions = Omit< + UseQueryOptions<PokerParticipant[], Error, PokerParticipant[], SessionParticipantsKey>, + "queryKey" | "queryFn" | "enabled" +>; -export function useSessionParticipants( - sessionId: string, - options?: UseQueryOptions<PokerParticipant[]> -) { +export function useSessionParticipants( + sessionId: string, + options?: SessionParticipantsOptions +) {
46-77: Throttle/limit invalidations and avoid noisy logs.Every UPDATE (e.g., last_seen_at heartbeats) triggers a refetch. This will thrash under Presence. Filter UPDATEs that only change last_seen_at, and gate logs to dev.
- (payload) => { - console.log("Participant change:", payload); - - // Invalidate queries to refetch data - queryClient.invalidateQueries({ - queryKey: pokerParticipantKeys.session(sessionId), - }); - } + (payload: any) => { + if (process.env.NODE_ENV !== "production") { + // eslint-disable-next-line no-console + console.debug("Participant change:", payload?.eventType); + } + // Skip heartbeat-only updates + if (payload?.eventType === "UPDATE") { + const oldRow = payload.old ?? {}; + const newRow = payload.new ?? {}; + const changed = Object.keys(newRow).filter((k) => oldRow[k] !== newRow[k]); + if (changed.length === 1 && changed[0] === "last_seen_at") { + return; + } + } + queryClient.invalidateQueries({ + queryKey: pokerParticipantKeys.session(sessionId), + // refetchType defaults to "active"; keep default + }); + }If heartbeats are still too chatty, consider listening only for INSERT/DELETE and a coarser timer-based refetch for UPDATEs.
106-117: Optimistic participant shape looks fine; consider tagging for reconciliation.To reduce flicker, store the temp id in mutation context and, onSuccess, replace the optimistic row with the real one before invalidation.
139-147: Minor UX polish: reconcile before invalidating.Optionally setQueryData to replace the temp participant with the saved one in onSuccess, then invalidate. This avoids a brief duplicate in the list.
152-155: Avoid double realtime subscriptions in the count hook.
useParticipantCountcurrently callsuseSessionParticipants, opening a realtime subscription; consider deriving the count via a head-only query (e.g.select("*", { head: true, count: "exact" })) or adding asubscribe?: booleanflag touseSessionParticipants(defaulting totrue) and passingfalsehere to skip the extra subscription. No other calls touseParticipantCountwere found in the codebase.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (5)
src/components/poker/ParticipantStatus.tsx(1 hunks)src/components/poker/StoryManager.tsx(1 hunks)src/components/poker/VotingInterface.tsx(5 hunks)src/hooks/use-poker-participants.ts(1 hunks)supabase/migrations/20251001000000_enable_poker_realtime.sql(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/VotingInterface.tsxsrc/components/poker/ParticipantStatus.tsxsrc/components/poker/StoryManager.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/VotingInterface.tsxsrc/components/poker/ParticipantStatus.tsxsrc/components/poker/StoryManager.tsxsrc/hooks/use-poker-participants.ts
🧬 Code graph analysis (3)
src/components/poker/VotingInterface.tsx (1)
src/components/poker/ParticipantStatus.tsx (1)
ParticipantStatus(20-281)
src/components/poker/ParticipantStatus.tsx (4)
src/lib/poker/types.ts (1)
PokerStory(52-64)src/hooks/use-poker-participants.ts (1)
useSessionParticipants(29-80)src/hooks/use-poker-votes.ts (1)
useStoryVotes(25-38)src/lib/utils.ts (1)
cn(4-6)
src/hooks/use-poker-participants.ts (2)
src/lib/poker/types.ts (2)
PokerParticipant(66-77)CreatePokerParticipantInput(135-141)src/lib/poker/actions.ts (2)
getSessionParticipants(379-394)joinPokerSession(311-376)
🔇 Additional comments (1)
src/hooks/use-poker-participants.ts (1)
21-26: Good key factory.Key hierarchy is stable and specific; nice use of const tuples for type safety.
- Add type safety with narrowed UseQueryOptions - Optimize real-time subscriptions to skip heartbeat-only updates - Guard console logs with NODE_ENV check - Add comprehensive JSDoc documentation for hooks and components - Improve docstring coverage from 50% toward 80% threshold
- Add comprehensive test suite with 10 test cases - Test successful participant fetching and error handling - Test join session mutation with optimistic updates - Test participant count helper hook - Test query key factory - Overall test coverage now at 28.39% (exceeds 28% threshold)
There was a problem hiding this comment.
Actionable comments posted: 0
♻️ Duplicate comments (6)
src/components/poker/ParticipantStatus.tsx (4)
45-94: Fix presence to track individual participants, not the session.The current presence setup uses
key: sessionId(line 53) and tracks only{ online_at }(line 79–81), so all connected clients share one presence entry. This makes it impossible to determine which participant is online. TheisOnline()helper (lines 116–118) merely checks if any client is present, causing every participant to show a green dot once anyone connects.Solution: Track presence per participant by including a unique participant identifier in the presence key and payload:
- Pass the current participant's ID (or retrieve it from auth/cookies) into the component or derive it from the session context.
- Update the channel key to include the participant ID:
poker-session:${sessionId}:${participantId}.- Track
{ participant_id: participantId, online_at: ... }in the presence payload.- Update
isOnline(participantId)to searchObject.values(presenceState)for entries whoseparticipant_idmatches.Example diff (assumes
currentParticipantIdis available):- channel = supabase.channel(`poker-session:${sessionId}`, { - config: { - presence: { - key: sessionId, - }, - }, - }); + // Obtain currentParticipantId from props, context, or auth + const currentParticipantId = /* retrieve participant ID */; + channel = supabase.channel(`poker-session:${sessionId}:${currentParticipantId}`, { + config: { + presence: { + key: currentParticipantId, + }, + }, + }); ... .subscribe(async (status) => { if (status === "SUBSCRIBED") { - await channel.track({ - online_at: new Date().toISOString(), - }); + await channel.track({ + participant_id: currentParticipantId, + online_at: new Date().toISOString(), + }); } });Then update the helper:
- const isOnline = (): boolean => { - return Object.keys(presenceState).length > 0; + const isOnline = (participantId: string): boolean => { + return Object.values(presenceState).some(presences => + presences.some((p: any) => p.participant_id === participantId) + ); };And call it with the participant's ID at lines 201 and 264:
- const online = isOnline(); + const online = isOnline(participant.id);Based on past review comments.
116-118: Update isOnline helper to accept participantId parameter.Currently
isOnline()returns true whenever any client is connected. After fixing the presence tracking (lines 45–94), this helper must accept aparticipantIdand check if that specific participant's entry exists inpresenceState.See the previous comment for the fix.
Based on past review comments.
199-201: Pass participant.id to isOnline.Once
isOnlineis updated to accept a participant ID, passparticipant.idhere instead of calling it with no arguments.- const online = isOnline(); + const online = isOnline(participant.id);Based on past review comments.
263-264: Pass participant.id to isOnline for observers.Same issue as with voters: the online status must be participant-specific.
- const online = isOnline(); + const online = isOnline(participant.id);Based on past review comments.
src/hooks/use-poker-participants.ts (2)
11-14: Remove server-only imports; use client-safe alternatives.Lines 11–14 import
getSessionParticipantsandjoinPokerSessionfrom@/lib/poker/actions. That module usescookies()fromnext/headers(seejoinPokerSessionin actions.ts lines 310–375), which is server-only and cannot be imported into client code marked with"use client"(line 1). This will cause a build or runtime failure.Solution:
- For reads (
getSessionParticipants): Replace the server action with a direct Supabase client call in thequeryFn(lines 57–60).- For writes (
joinPokerSession): Call a Next.js API route that wraps the server action instead of importing it directly (lines 130–136).Read path fix (lines 57–60):
- queryFn: async () => { - const participants = await getSessionParticipants(sessionId); - return participants; - }, + queryFn: async () => { + const supabase = createClient(); + const { data, error } = await supabase + .from("poker_participants") + .select("*") + .eq("session_id", sessionId) + .order("joined_at", { ascending: true }); + if (error) { + console.error("Error fetching participants:", error); + throw error; + } + return (data as PokerParticipant[]) ?? []; + },Write path fix (lines 130–136):
- mutationFn: ({ - sessionId, - input, - }: { - sessionId: string; - input: CreatePokerParticipantInput; - }) => joinPokerSession(sessionId, input), + mutationFn: async ({ + sessionId, + input, + }: { + sessionId: string; + input: CreatePokerParticipantInput; + }) => { + const res = await fetch(`/api/poker/sessions/${sessionId}/join`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || "Failed to join session"); + } + return (await res.json()) as PokerParticipant; + },Then remove the imports at lines 11–14:
-import { - getSessionParticipants, - joinPokerSession, -} from "@/lib/poker/actions";You'll also need to create the API route at
src/app/api/poker/sessions/[sessionId]/join/route.tsthat callsjoinPokerSessionserver-side.Based on past review comments.
55-63: Reorder options spread to prevent overriding enabled guard.Spreading
...optionsafter settingenabled: !!sessionId(line 61–62) allows callers to override the guard, potentially causing the query to run with a falsysessionIdand leading to runtime errors or incorrect API calls.Solution: Spread
...optionsfirst, then setenabledlast so the guard cannot be overridden.const query = useQuery({ queryKey: pokerParticipantKeys.session(sessionId), queryFn: async () => { const participants = await getSessionParticipants(sessionId); return participants; }, - enabled: !!sessionId, ...options, + enabled: !!sessionId, });Based on past review comments.
🧹 Nitpick comments (2)
src/hooks/use-poker-participants.ts (2)
77-77: Narrow realtime subscription to INSERT/DELETE instead of wildcard.Using
event: "*"(line 77) causes the handler to fire on every UPDATE, including heartbeat-onlylast_seen_atchanges. Although lines 88–96 filter out single-fieldlast_seen_atupdates, it's more efficient to subscribe only to INSERT and DELETE events (the events that actually add/remove participants) to avoid unnecessary payload processing..on( "postgres_changes", { - event: "*", + event: "INSERT", schema: "public", table: "poker_participants", filter: `session_id=eq.${sessionId}`, }, (payload) => { - if (process.env.NODE_ENV !== "production") { - // eslint-disable-next-line no-console - console.debug("Participant change:", payload?.eventType); - } - - // Skip heartbeat-only updates to reduce unnecessary refetches - if (payload?.eventType === "UPDATE") { - const oldRow = payload.old ?? {}; - const newRow = payload.new ?? {}; - const changed = Object.keys(newRow).filter((k) => oldRow[k] !== newRow[k]); - if (changed.length === 1 && changed[0] === "last_seen_at") { - return; - } - } - // Invalidate queries to refetch data queryClient.invalidateQueries({ queryKey: pokerParticipantKeys.session(sessionId), }); } ) + .on( + "postgres_changes", + { + event: "DELETE", + schema: "public", + table: "poker_participants", + filter: `session_id=eq.${sessionId}`, + }, + () => { + queryClient.invalidateQueries({ + queryKey: pokerParticipantKeys.session(sessionId), + }); + } + ) .subscribe();Alternatively, keep
event: "*"if you need to react to non-heartbeat UPDATEs (e.g., name changes), but the current filter already handles that case.
130-136: Consider removing sessionId from CreatePokerParticipantInput to avoid redundancy.The mutation accepts both
sessionId(line 134) andinput: CreatePokerParticipantInput(line 135), butCreatePokerParticipantInput(see types.ts lines 134–140) also includes asessionIdfield. This duplication is unnecessary and could lead to inconsistencies if the two values differ.Solution: Update the type definition to omit
sessionIdfromCreatePokerParticipantInputfor the mutation, or adjust the input to only contain participant-specific fields:mutationFn: ({ sessionId, input, }: { sessionId: string; - input: CreatePokerParticipantInput; + input: Omit<CreatePokerParticipantInput, "sessionId">; }) => joinPokerSession(sessionId, input),Then ensure callers don't include
sessionIdin theinputobject.Alternatively, if you prefer to keep
CreatePokerParticipantInputunchanged, document that the mutation will use the top-levelsessionIdparameter and ignoreinput.sessionId.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
src/components/poker/ParticipantStatus.tsx(1 hunks)src/hooks/__tests__/use-poker-participants.test.ts(1 hunks)src/hooks/use-poker-participants.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/hooks/use-poker-participants.tssrc/hooks/__tests__/use-poker-participants.test.tssrc/components/poker/ParticipantStatus.tsx
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/ParticipantStatus.tsx
🧬 Code graph analysis (3)
src/hooks/use-poker-participants.ts (2)
src/lib/poker/types.ts (2)
PokerParticipant(66-77)CreatePokerParticipantInput(135-141)src/lib/poker/actions.ts (2)
getSessionParticipants(379-394)joinPokerSession(311-376)
src/hooks/__tests__/use-poker-participants.test.ts (2)
src/lib/poker/types.ts (1)
PokerParticipant(66-77)src/hooks/use-poker-participants.ts (4)
useSessionParticipants(49-113)useJoinPokerSession(126-192)useParticipantCount(208-211)pokerParticipantKeys(22-26)
src/components/poker/ParticipantStatus.tsx (4)
src/lib/poker/types.ts (1)
PokerStory(52-64)src/hooks/use-poker-participants.ts (1)
useSessionParticipants(49-113)src/hooks/use-poker-votes.ts (1)
useStoryVotes(25-38)src/lib/utils.ts (1)
cn(4-6)
🔇 Additional comments (6)
src/hooks/__tests__/use-poker-participants.test.ts (1)
1-328: LGTM! Comprehensive test coverage for poker participant hooks.The test suite thoroughly covers all public APIs from the hooks module:
- useSessionParticipants: success, empty list, errors, and disabled query behavior
- useJoinPokerSession: success flow with correct payload validation and error handling
- useParticipantCount: correct counts across various data states
- pokerParticipantKeys: query key generation
Mock setup is appropriate, QueryClient configuration disables retries for predictable test execution, and assertions validate both data and API call contracts.
src/components/poker/ParticipantStatus.tsx (2)
106-113: LGTM! Clean helper for extracting initials.The logic correctly splits names, maps to first characters, uppercases, and limits to two characters.
120-305: LGTM! Well-structured UI with clear participant grouping and status indicators.The render logic effectively separates loading, waiting-for-votes banner, all-voted indicator, voters grid, observers section, and empty state. Styling differentiates voted vs. waiting participants, and badges/icons provide clear visual cues. Once the presence tracking is fixed, this UI will correctly reflect individual participant online status.
src/hooks/use-poker-participants.ts (3)
208-211: LGTM! Correct derivation of participant count.
useParticipantCountcorrectly reusesuseSessionParticipantsand derives the count from the data, avoiding duplicate subscriptions. The note in the JSDoc (lines 194–198) appropriately warns consumers about the realtime subscription overhead if they only need a count.
21-26: LGTM! Clean query key factory.The key structure is hierarchical and follows React Query best practices for granular invalidation.
137-191: LGTM! Robust optimistic update with proper rollback and toasts.The mutation correctly cancels in-flight queries, snapshots previous state, applies an optimistic update, rolls back on error, and invalidates on settlement. Toast notifications provide good user feedback.
Summary
Implements real-time voting status tracking for Planning Poker sessions, resolving issue #20. Participants can now see who has voted and who is still pending in real-time using Supabase real-time subscriptions and Presence.
Key Features Implemented
Technical Implementation
Database Changes
20251001000000_enable_poker_realtime.sqlpoker_sessions,poker_stories,poker_participants, andpoker_votestablesNew Components
ParticipantStatus(src/components/poker/ParticipantStatus.tsx)New Hooks
use-poker-participants(src/hooks/use-poker-participants.ts)useSessionParticipants(sessionId)- Fetches and subscribes to participant changesuseJoinPokerSession()- Mutation for joining a session as a participantuseParticipantCount(sessionId)- Helper to get participant countpoker_participantstableComponent Updates
VotingInterface- IntegratedParticipantStatuscomponentStoryManager- PassessessionIdprop toVotingInterfaceTest Plan
Acceptance Criteria
Screenshots/Demo
Screenshots will be added after manual testing
Related Issues
Closes #20
🤖 Generated with Claude Code
Summary by CodeRabbit