From 1c88a428906ee303832ae1babbdf808738c87e74 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 11:19:56 +0200 Subject: [PATCH] refactor(collab): keep the phone's own poll reconciliation in the shared hook The shared polls hook had taken the desktop rules for both views. It now keeps each view's logic as it was before the move. The phone again replaces a poll with the one a live closed event carries, puts a vote answer on the poll it voted on, and stops the countdown tick once a deadline passed. The desktop panel again ticks until a poll is closed. --- .../components/Collab/useCollabPolls.test.ts | 75 +++++++++++++++---- .../src/components/Collab/useCollabPolls.ts | 29 +++++-- .../mobile/screens/trip/tabs/MCollabPolls.tsx | 9 ++- 3 files changed, 90 insertions(+), 23 deletions(-) diff --git a/client/src/components/Collab/useCollabPolls.test.ts b/client/src/components/Collab/useCollabPolls.test.ts index dbacaedc0..9b012ad74 100644 --- a/client/src/components/Collab/useCollabPolls.test.ts +++ b/client/src/components/Collab/useCollabPolls.test.ts @@ -1,6 +1,7 @@ -// FE-COLLAB-POLLS-001 to FE-COLLAB-POLLS-013: the poll logic behind the desktop Collab -// panel (empties on a failed load, flips the closed flag itself) and the phone's polls tab -// (keeps the list, shows the closed poll the server answers with). +// FE-COLLAB-POLLS-001 to FE-COLLAB-POLLS-015: the poll logic behind the desktop Collab +// panel (empties on a failed load, flips the closed flag itself, ticks until a poll is +// closed) and the phone's polls tab (keeps the list, shows the closed poll the server +// sends, puts a vote answer on the poll it voted on, stops ticking once a deadline passed). import { act, renderHook, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -227,25 +228,46 @@ describe('useCollabPolls', () => { expect(renders).toBeGreaterThan(before); }); - it('FE-COLLAB-POLLS-012: no tick for closed, expired or deadline free polls', async () => { + it('FE-COLLAB-POLLS-012: no tick for closed or deadline free polls, an expired one ticks only on desktop', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); const past = new Date(Date.now() - 1000).toISOString(); const ahead = new Date(Date.now() + 3_600_000).toISOString(); - vi.mocked(collabApi.getPolls).mockResolvedValue({ - polls: [poll(1), poll(2, { deadline: past }), poll(3, { deadline: ahead, is_closed: true })], - }); - let renders = 0; const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }; - const { result } = renderHook(() => { - renders += 1; - return useCollabPolls({ tripId: 1, t, toast }); - }); - await waitFor(() => expect(result.current.loading).toBe(false)); - const before = renders; + const renderCounted = (polls: CollabPollData[], over: Partial) => { + vi.mocked(collabApi.getPolls).mockResolvedValue({ polls }); + const counter = { renders: 0 }; + const view = renderHook(() => { + counter.renders += 1; + return useCollabPolls({ tripId: 1, t, toast, ...over }); + }); + return { ...view, counter }; + }; + + const quiet = renderCounted([poll(1), poll(3, { deadline: ahead, is_closed: true })], {}); + await waitFor(() => expect(quiet.result.current.loading).toBe(false)); + const quietBefore = quiet.counter.renders; act(() => { vi.advanceTimersByTime(90_000); }); - expect(renders).toBe(before); + expect(quiet.counter.renders).toBe(quietBefore); + quiet.unmount(); + + const phone = renderCounted([poll(2, { deadline: past })], { tickOnlyWhileActive: true }); + await waitFor(() => expect(phone.result.current.loading).toBe(false)); + const phoneBefore = phone.counter.renders; + act(() => { + vi.advanceTimersByTime(90_000); + }); + expect(phone.counter.renders).toBe(phoneBefore); + phone.unmount(); + + const desktop = renderCounted([poll(2, { deadline: past })], {}); + await waitFor(() => expect(desktop.result.current.loading).toBe(false)); + const desktopBefore = desktop.counter.renders; + act(() => { + vi.advanceTimersByTime(30_000); + }); + expect(desktop.counter.renders).toBeGreaterThan(desktopBefore); }); it('FE-COLLAB-POLLS-013: a poll create answered with the bare poll is read too', async () => { @@ -256,4 +278,27 @@ describe('useCollabPolls', () => { }); expect(result.current.polls.map((p) => p.id)).toEqual([11, 1, 2]); }); + + it('FE-COLLAB-POLLS-014: on the phone a live closed event replaces the poll with the one it carries', async () => { + const { result } = await loaded({ replaceClosedPoll: true }); + act(() => { + wsHandler()({ type: 'collab:poll:closed', tripId: 1, poll: { id: 2, question: 'closed live' } }); + }); + expect(result.current.polls[1]).toEqual({ id: 2, question: 'closed live' }); + }); + + it('FE-COLLAB-POLLS-015: a vote answer lands on the answered id on desktop and on the voted poll on the phone', async () => { + vi.mocked(collabApi.votePoll).mockResolvedValue({ poll: poll(2, { question: 'answer' }) }); + const desktop = await loaded(); + await act(async () => { + await desktop.result.current.votePoll(1, 0); + }); + expect(desktop.result.current.polls.map((p) => p.question)).toEqual(['Poll 1', 'answer']); + + const phone = await loaded({ matchVoteByPollId: true }); + await act(async () => { + await phone.result.current.votePoll(1, 0); + }); + expect(phone.result.current.polls.map((p) => p.question)).toEqual(['answer', 'Poll 2']); + }); }); diff --git a/client/src/components/Collab/useCollabPolls.ts b/client/src/components/Collab/useCollabPolls.ts index 15ef0655b..e188b4812 100644 --- a/client/src/components/Collab/useCollabPolls.ts +++ b/client/src/components/Collab/useCollabPolls.ts @@ -19,8 +19,15 @@ export interface CollabPollsOptions { toast: ReturnType; /** The desktop panel outlives a trip change, so a failed load there empties the list. */ resetOnLoadError?: boolean; - /** The phone shows the closed poll the server answers with; the desktop panel only flips its flag. */ + /** + * The phone shows the closed poll the server sends, both as the close answer and as the + * live event; the desktop panel flips its flag and merges the live event into the poll. + */ replaceClosedPoll?: boolean; + /** The phone puts a vote answer on the poll it voted on; the desktop panel on the id in the answer. */ + matchVoteByPollId?: boolean; + /** The phone stops the countdown tick once a deadline passes; the desktop panel ticks until the poll is closed. */ + tickOnlyWhileActive?: boolean; } /** @@ -35,6 +42,8 @@ export function useCollabPolls({ toast, resetOnLoadError = false, replaceClosedPoll = false, + matchVoteByPollId = false, + tickOnlyWhileActive = false, }: CollabPollsOptions) { const [polls, setPolls] = useState([]); const [loading, setLoading] = useState(true); @@ -72,7 +81,12 @@ export function useCollabPolls({ setPolls((prev) => prev.map((p) => (p.id === poll.id ? poll : p))); } if (msg.type === 'collab:poll:closed' && poll) { - setPolls((prev) => prev.map((p) => (p.id === poll.id ? { ...p, ...poll, is_closed: true } : p))); + setPolls((prev) => + prev.map((p) => { + if (p.id !== poll.id) return p; + return replaceClosedPoll ? poll : { ...p, ...poll, is_closed: true }; + }) + ); } if (msg.type === 'collab:poll:deleted') { const id = (msg.pollId as number | undefined) || poll?.id; @@ -81,14 +95,14 @@ export function useCollabPolls({ }; addListener(handler); return () => removeListener(handler); - }, [tripId]); + }, [tripId, replaceClosedPoll]); // Re-render every 30s while a deadline is still counting down. useEffect(() => { - if (!polls.some((p) => p.deadline && isPollActive(p))) return; + if (!polls.some((p) => p.deadline && (tickOnlyWhileActive ? isPollActive(p) : !p.is_closed))) return; const iv = setInterval(() => setTick((v) => v + 1), 30000); return () => clearInterval(iv); - }, [polls]); + }, [polls, tickOnlyWhileActive]); /** Rethrows after the toast, so the form that called it stays open. */ const createPoll = useCallback( @@ -108,12 +122,13 @@ export function useCollabPolls({ async (pollId: number, optionIndex: number) => { try { const updated = readPoll(await collabApi.votePoll(tripId, pollId, optionIndex)); - setPolls((prev) => prev.map((p) => (p.id === updated.id ? updated : p))); + const target = matchVoteByPollId ? pollId : updated.id; + setPolls((prev) => prev.map((p) => (p.id === target ? updated : p))); } catch { toast.error(t('common.error')); } }, - [tripId, toast, t] + [tripId, matchVoteByPollId, toast, t] ); const closePoll = useCallback( diff --git a/client/src/mobile/screens/trip/tabs/MCollabPolls.tsx b/client/src/mobile/screens/trip/tabs/MCollabPolls.tsx index 05c5e957d..85cdf43cd 100644 --- a/client/src/mobile/screens/trip/tabs/MCollabPolls.tsx +++ b/client/src/mobile/screens/trip/tabs/MCollabPolls.tsx @@ -48,7 +48,14 @@ export default function MCollabPolls({ planner }: MCollabPollsProps) { const currentUserId = user?.id ?? null const { polls, loading, createPoll, votePoll: handleVote, closePoll: handleClosePoll, deletePoll: handleDelete } = - useCollabPolls({ tripId, t, toast, replaceClosedPoll: true }) + useCollabPolls({ + tripId, + t, + toast, + replaceClosedPoll: true, + matchVoteByPollId: true, + tickOnlyWhileActive: true, + }) const [showForm, setShowForm] = useState(false) const [pendingDeleteId, setPendingDeleteId] = useState(null) const [collapsedClosed, setCollapsedClosed] = useState(false)