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)