From 428b4e6006ba5cc6ef45e3127eb7b5a940a84e92 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 10:39:24 +0200 Subject: [PATCH] refactor(collab): share the polls logic between desktop and phone useCollabPolls holds the poll list with its live updates, the countdown tick, and creating, voting, closing and deleting. The desktop panel reads the vote totals, winner and closed state from collabModel like the phone tab does and keeps its own countdown label. A failed load still empties the desktop list only, and closing still takes the server poll on the phone only. --- client/src/components/Collab/CollabPolls.tsx | 140 ++-------- .../components/Collab/useCollabPolls.test.ts | 259 ++++++++++++++++++ .../src/components/Collab/useCollabPolls.ts | 149 ++++++++++ .../mobile/screens/trip/tabs/MCollabPolls.tsx | 109 +------- 4 files changed, 440 insertions(+), 217 deletions(-) create mode 100644 client/src/components/Collab/useCollabPolls.test.ts create mode 100644 client/src/components/Collab/useCollabPolls.ts diff --git a/client/src/components/Collab/CollabPolls.tsx b/client/src/components/Collab/CollabPolls.tsx index 15a695660..19dffb108 100644 --- a/client/src/components/Collab/CollabPolls.tsx +++ b/client/src/components/Collab/CollabPolls.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useId } from 'react' +import React, { useState, useCallback, useId } from 'react' import { Plus, Trash2, X, Check, BarChart3, Lock, Clock } from 'lucide-react' import { RoundAction } from '../Planner/bookings/bookingParts' import CollabPanelHead, { HEAD_ACTION } from './CollabPanelHead' @@ -9,14 +9,15 @@ import ReactMarkdown, { type Components } from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' import { sanitizedMarkdownComponents, sanitizedMarkdownPlugins } from '../shared/markdownSanitize' -import { collabApi } from '../../api/client' -import { addListener, removeListener } from '../../api/websocket' import { useTranslation } from '../../i18n' import { useToast } from '../shared/Toast' import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' import EmptyState from '../shared/EmptyState' import type { User } from '../../types' +import type { CollabPollCreateRequest } from '@trek/shared' +import { useCollabPolls } from './useCollabPolls' +import { hasUserVoted, isPollActive, pollMaxVoteCount, splitPolls, totalPollVotes, type CollabPollData } from './collabModel' interface PollVoter { user_id: number @@ -24,23 +25,6 @@ interface PollVoter { avatar_url: string | null } -interface PollOption { - id: number - text: string - voters: PollVoter[] -} - -interface Poll { - id: number - question: string - options: PollOption[] - multiple_choice: boolean - is_closed: boolean - deadline: string | null - created_by: number - created_at: string -} - const FONT = "var(--font-system)" // Block styling for the markdown question (#2177). Tailwind's preflight resets @@ -83,15 +67,6 @@ function timeRemaining(deadline) { return `${mins}m` } -function isExpired(deadline) { - if (!deadline) return false - return new Date(deadline).getTime() <= Date.now() -} - -function totalVotes(poll) { - return (poll.options || []).reduce((s, o) => s + (o.voters?.length || 0), 0) -} - // ── Create Poll Modal ──────────────────────────────────────────────────────── interface CreatePollModalProps { onClose: () => void @@ -210,7 +185,7 @@ function PollChip({ icon, tone = 'muted', children }: { icon?: React.ReactNode; // ── Poll Card ──────────────────────────────────────────────────────────────── interface PollCardProps { - poll: Poll + poll: CollabPollData currentUser: User canEdit: boolean onVote: (pollId: number, optionId: number) => Promise @@ -220,12 +195,12 @@ interface PollCardProps { } function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }: PollCardProps) { - const total = totalVotes(poll) - const isClosed = poll.is_closed || isExpired(poll.deadline) + const total = totalPollVotes(poll) + const isClosed = !isPollActive(poll) const remaining = timeRemaining(poll.deadline) - const hasVoted = (poll.options || []).some(o => (o.voters || []).some(v => String(v.user_id) === String(currentUser.id))) + const hasVoted = hasUserVoted(poll, currentUser.id) // Highest vote count across the options; 0 for a poll without options. - const topCount = (poll.options || []).reduce((max, o) => Math.max(max, o.voters?.length || 0), 0) + const topCount = pollMaxVoteCount(poll) return (
@@ -324,98 +299,17 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) { const can = useCanDo() const trip = useTripStore((s) => s.trip) const canEdit = can('collab_edit', trip) - const [polls, setPolls] = useState([]) - const [loading, setLoading] = useState(true) const [showForm, setShowForm] = useState(false) + const { polls, loading, createPoll, votePoll: handleVote, closePoll: handleClose, deletePoll: handleDelete } = useCollabPolls({ + tripId, t, toast, resetOnLoadError: true, + }) - useEffect(() => { - let cancelled = false - setLoading(true) - collabApi.getPolls(tripId).then(data => { - if (!cancelled) setPolls(Array.isArray(data) ? data : data.polls || []) - }).catch(() => { - if (!cancelled) setPolls([]) - }).finally(() => { - if (!cancelled) setLoading(false) - }) - return () => { cancelled = true } - }, [tripId]) + const handleCreate = useCallback(async (data: CollabPollCreateRequest) => { + await createPoll(data) + setShowForm(false) + }, [createPoll]) - // WebSocket - useEffect(() => { - const handler = (msg) => { - if (!msg?.type) return - // The panel is not remounted on a trip change, so an event still in flight - // from the trip we just left must not land in this list. - if (String(msg.tripId) !== String(tripId)) return - if (msg.type === 'collab:poll:created' && msg.poll) { - setPolls(prev => prev.some(p => p.id === msg.poll.id) ? prev : [msg.poll, ...prev]) - } - if (msg.type === 'collab:poll:voted' && msg.poll) { - setPolls(prev => prev.map(p => p.id === msg.poll.id ? msg.poll : p)) - } - if (msg.type === 'collab:poll:closed' && msg.poll) { - setPolls(prev => prev.map(p => p.id === msg.poll.id ? { ...p, ...msg.poll, is_closed: true } : p)) - } - if (msg.type === 'collab:poll:deleted') { - const id = msg.pollId || msg.poll?.id - if (id) setPolls(prev => prev.filter(p => p.id !== id)) - } - } - addListener(handler) - return () => removeListener(handler) - }, [tripId]) - - const handleCreate = useCallback(async (data) => { - try { - const result = await collabApi.createPoll(tripId, data) - const created = result.poll || result - setPolls(prev => prev.some(p => p.id === created.id) ? prev : [created, ...prev]) - setShowForm(false) - } catch (err) { - toast.error(t('common.error')) - throw err - } - }, [tripId, toast, t]) - - const handleVote = useCallback(async (pollId, optionIndex) => { - try { - const result = await collabApi.votePoll(tripId, pollId, optionIndex) - const updated = result.poll || result - setPolls(prev => prev.map(p => p.id === updated.id ? updated : p)) - } catch { - toast.error(t('common.error')) - } - }, [tripId, toast, t]) - - const handleClose = useCallback(async (pollId) => { - try { - await collabApi.closePoll(tripId, pollId) - setPolls(prev => prev.map(p => p.id === pollId ? { ...p, is_closed: true } : p)) - } catch { - toast.error(t('common.error')) - } - }, [tripId, toast, t]) - - const handleDelete = useCallback(async (pollId) => { - try { - await collabApi.deletePoll(tripId, pollId) - setPolls(prev => prev.filter(p => p.id !== pollId)) - } catch { - toast.error(t('common.error')) - } - }, [tripId, toast, t]) - - const activePolls = polls.filter(p => !p.is_closed && !isExpired(p.deadline)) - const closedPolls = polls.filter(p => p.is_closed || isExpired(p.deadline)) - - // Deadline ticker - const [, setTick] = useState(0) - useEffect(() => { - if (!polls.some(p => p.deadline && !p.is_closed)) return - const iv = setInterval(() => setTick(t => t + 1), 30000) - return () => clearInterval(iv) - }, [polls]) + const { active: activePolls, closed: closedPolls } = splitPolls(polls) if (loading) { return ( diff --git a/client/src/components/Collab/useCollabPolls.test.ts b/client/src/components/Collab/useCollabPolls.test.ts new file mode 100644 index 000000000..dbacaedc0 --- /dev/null +++ b/client/src/components/Collab/useCollabPolls.test.ts @@ -0,0 +1,259 @@ +// 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). +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { collabApi } from '../../api/client'; +import { addListener, removeListener } from '../../api/websocket'; +import type { CollabPollData } from './collabModel'; +import { useCollabPolls, type CollabPollsOptions } from './useCollabPolls'; + +const t = (key: string) => key; + +function poll(id: number, over: Partial = {}): CollabPollData { + return { + id, + trip_id: 1, + user_id: 2, + question: `Poll ${id}`, + options: [ + { text: 'A', label: 'A', voters: [] }, + { text: 'B', label: 'B', voters: [] }, + ], + multiple_choice: false, + is_closed: false, + deadline: null, + username: 'alice', + avatar: null, + avatar_url: null, + created_at: '2026-05-01T10:00:00Z', + ...over, + }; +} + +function setup(over: Partial = {}) { + const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }; + const hook = renderHook( + (props: Partial) => useCollabPolls({ tripId: 1, t, toast, ...over, ...props }), + { initialProps: {} } + ); + return { ...hook, toast }; +} + +async function loaded(over: Partial = {}) { + const view = setup(over); + await waitFor(() => expect(view.result.current.loading).toBe(false)); + return view; +} + +function wsHandler(): (event: Record) => void { + const calls = vi.mocked(addListener).mock.calls; + return calls[calls.length - 1][0] as (event: Record) => void; +} + +beforeEach(() => { + vi.mocked(addListener).mockClear(); + vi.mocked(removeListener).mockClear(); + vi.spyOn(collabApi, 'getPolls').mockResolvedValue({ polls: [poll(1), poll(2)] }); + vi.spyOn(collabApi, 'createPoll').mockResolvedValue({ poll: poll(9) }); + vi.spyOn(collabApi, 'votePoll').mockResolvedValue({ poll: poll(1, { question: 'voted' }) }); + vi.spyOn(collabApi, 'closePoll').mockResolvedValue({ poll: poll(1, { is_closed: true, question: 'server' }) }); + vi.spyOn(collabApi, 'deletePoll').mockResolvedValue({ success: true }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('useCollabPolls', () => { + it('FE-COLLAB-POLLS-001: loads the polls, from an object or a bare array', async () => { + const { result } = setup(); + expect(result.current.loading).toBe(true); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(collabApi.getPolls).toHaveBeenCalledWith(1); + expect(result.current.polls.map((p) => p.id)).toEqual([1, 2]); + + vi.mocked(collabApi.getPolls).mockResolvedValue([poll(3)]); + const bare = await loaded(); + expect(bare.result.current.polls.map((p) => p.id)).toEqual([3]); + + vi.mocked(collabApi.getPolls).mockResolvedValue({}); + const empty = await loaded(); + expect(empty.result.current.polls).toEqual([]); + }); + + it('FE-COLLAB-POLLS-002: a failed reload empties the desktop list and keeps the phone list', async () => { + const desktop = await loaded({ resetOnLoadError: true }); + const phone = await loaded(); + vi.mocked(collabApi.getPolls).mockRejectedValue(new Error('boom')); + + desktop.rerender({ tripId: 2 }); + phone.rerender({ tripId: 2 }); + await waitFor(() => expect(desktop.result.current.loading).toBe(false)); + await waitFor(() => expect(phone.result.current.loading).toBe(false)); + expect(desktop.result.current.polls).toEqual([]); + expect(phone.result.current.polls.map((p) => p.id)).toEqual([1, 2]); + }); + + it('FE-COLLAB-POLLS-003: live events create, vote, close and delete, only for this trip', async () => { + const { result } = await loaded(); + const handler = wsHandler(); + act(() => { + handler({ type: 'collab:poll:created', tripId: 2, poll: poll(7) }); + handler({ type: 'collab:poll:created', tripId: '1', poll: poll(7) }); + handler({ type: 'collab:poll:created', tripId: 1, poll: poll(7) }); + handler({ type: 'collab:poll:voted', tripId: 1, poll: poll(1, { question: 'live vote' }) }); + handler({ type: 'collab:poll:closed', tripId: 1, poll: { id: 2 } }); + }); + expect(result.current.polls.map((p) => p.id)).toEqual([7, 1, 2]); + expect(result.current.polls[1].question).toBe('live vote'); + // A closed event folds into the poll it names and always closes it. + expect(result.current.polls[2]).toMatchObject({ question: 'Poll 2', is_closed: true }); + + act(() => { + handler({ type: 'collab:poll:deleted', tripId: 1, pollId: 7 }); + handler({ type: 'collab:poll:deleted', tripId: 1, poll: { id: 1 } }); + handler({ type: 'collab:poll:voted', tripId: 1 }); + handler({}); + }); + expect(result.current.polls.map((p) => p.id)).toEqual([2]); + }); + + it('FE-COLLAB-POLLS-004: unmounting removes the live listener', async () => { + const { unmount } = await loaded(); + const handler = wsHandler(); + unmount(); + expect(removeListener).toHaveBeenCalledWith(handler); + }); + + it('FE-COLLAB-POLLS-005: creating puts the new poll on top once', async () => { + const { result } = await loaded(); + const data = { question: 'Q', options: ['A', 'B'], multiple_choice: true }; + await act(async () => { + await result.current.createPoll(data); + }); + expect(collabApi.createPoll).toHaveBeenCalledWith(1, data); + act(() => wsHandler()({ type: 'collab:poll:created', tripId: 1, poll: poll(9) })); + expect(result.current.polls.map((p) => p.id)).toEqual([9, 1, 2]); + }); + + it('FE-COLLAB-POLLS-006: a failed create toasts and rethrows', async () => { + const err = new Error('boom'); + vi.mocked(collabApi.createPoll).mockRejectedValue(err); + const { result, toast } = await loaded(); + await act(async () => { + await expect(result.current.createPoll({ question: 'Q', options: ['A', 'B'] })).rejects.toBe(err); + }); + expect(toast.error).toHaveBeenCalledWith('common.error'); + expect(result.current.polls).toHaveLength(2); + }); + + it('FE-COLLAB-POLLS-007: voting swaps in the server poll, a failure toasts', async () => { + const { result, toast } = await loaded(); + await act(async () => { + await result.current.votePoll(1, 0); + }); + expect(collabApi.votePoll).toHaveBeenCalledWith(1, 1, 0); + expect(result.current.polls[0].question).toBe('voted'); + + vi.mocked(collabApi.votePoll).mockRejectedValue(new Error('boom')); + await act(async () => { + await result.current.votePoll(1, 1); + }); + expect(toast.error).toHaveBeenCalledWith('common.error'); + }); + + it('FE-COLLAB-POLLS-008: closing flips the flag on desktop and swaps in the server poll on the phone', async () => { + vi.mocked(collabApi.closePoll).mockResolvedValue({ success: true }); + const desktop = await loaded(); + await act(async () => { + await desktop.result.current.closePoll(1); + }); + expect(collabApi.closePoll).toHaveBeenCalledWith(1, 1); + expect(desktop.result.current.polls[0]).toMatchObject({ question: 'Poll 1', is_closed: true }); + expect(desktop.result.current.polls[1].is_closed).toBe(false); + + vi.mocked(collabApi.closePoll).mockResolvedValue({ poll: poll(1, { is_closed: true, question: 'server' }) }); + const phone = await loaded({ replaceClosedPoll: true }); + await act(async () => { + await phone.result.current.closePoll(1); + }); + expect(phone.result.current.polls[0]).toMatchObject({ question: 'server', is_closed: true }); + }); + + it('FE-COLLAB-POLLS-009: a failed close toasts and leaves the poll open', async () => { + vi.mocked(collabApi.closePoll).mockRejectedValue(new Error('boom')); + const { result, toast } = await loaded(); + await act(async () => { + await result.current.closePoll(1); + }); + expect(toast.error).toHaveBeenCalledWith('common.error'); + expect(result.current.polls[0].is_closed).toBe(false); + }); + + it('FE-COLLAB-POLLS-010: deleting drops the poll, a failure toasts and keeps it', async () => { + const { result, toast } = await loaded(); + await act(async () => { + await result.current.deletePoll(1); + }); + expect(collabApi.deletePoll).toHaveBeenCalledWith(1, 1); + expect(result.current.polls.map((p) => p.id)).toEqual([2]); + + vi.mocked(collabApi.deletePoll).mockRejectedValue(new Error('boom')); + await act(async () => { + await result.current.deletePoll(2); + }); + expect(toast.error).toHaveBeenCalledWith('common.error'); + expect(result.current.polls.map((p) => p.id)).toEqual([2]); + }); + + it('FE-COLLAB-POLLS-011: the clock ticks while an open poll still has a deadline ahead', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const ahead = new Date(Date.now() + 3_600_000).toISOString(); + vi.mocked(collabApi.getPolls).mockResolvedValue({ polls: [poll(1, { deadline: ahead })] }); + 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; + act(() => { + vi.advanceTimersByTime(30_000); + }); + expect(renders).toBeGreaterThan(before); + }); + + it('FE-COLLAB-POLLS-012: no tick for closed, expired or deadline free polls', 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; + act(() => { + vi.advanceTimersByTime(90_000); + }); + expect(renders).toBe(before); + }); + + it('FE-COLLAB-POLLS-013: a poll create answered with the bare poll is read too', async () => { + vi.mocked(collabApi.createPoll).mockResolvedValue(poll(11)); + const { result } = await loaded(); + await act(async () => { + await result.current.createPoll({ question: 'Q', options: ['A', 'B'] }); + }); + expect(result.current.polls.map((p) => p.id)).toEqual([11, 1, 2]); + }); +}); diff --git a/client/src/components/Collab/useCollabPolls.ts b/client/src/components/Collab/useCollabPolls.ts new file mode 100644 index 000000000..15ef0655b --- /dev/null +++ b/client/src/components/Collab/useCollabPolls.ts @@ -0,0 +1,149 @@ +import type { CollabPollCreateRequest } from '@trek/shared'; +import { useCallback, useEffect, useState } from 'react'; + +import { collabApi } from '../../api/client'; +import { addListener, removeListener } from '../../api/websocket'; +import type { useTranslation } from '../../i18n'; +import type { useToast } from '../shared/Toast'; +import { isPollActive, type CollabPollData } from './collabModel'; + +type PollResponse = { poll?: CollabPollData } | CollabPollData; + +function readPoll(result: PollResponse): CollabPollData { + return ('poll' in result && result.poll) || (result as CollabPollData); +} + +export interface CollabPollsOptions { + tripId: number | string; + t: ReturnType['t']; + 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. */ + replaceClosedPoll?: boolean; +} + +/** + * The trip polls behind the desktop Collab panel and the phone's polls tab, which draw + * their own cards and create form over it: the list with its live WebSocket updates, a + * 30 second tick while a deadline is still counting down, and creating, voting, closing + * and deleting. Collab has no store slice, so the polls live here. + */ +export function useCollabPolls({ + tripId, + t, + toast, + resetOnLoadError = false, + replaceClosedPoll = false, +}: CollabPollsOptions) { + const [polls, setPolls] = useState([]); + const [loading, setLoading] = useState(true); + const [, setTick] = useState(0); + + useEffect(() => { + let cancelled = false; + setLoading(true); + collabApi + .getPolls(tripId) + .then((data: CollabPollData[] | { polls?: CollabPollData[] }) => { + if (!cancelled) setPolls(Array.isArray(data) ? data : data.polls || []); + }) + .catch(() => { + if (!cancelled && resetOnLoadError) setPolls([]); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [tripId, resetOnLoadError]); + + useEffect(() => { + const handler = (msg: Record) => { + if (!msg?.type) return; + // An event still in flight from a trip just left must not land in this list. + if (String(msg.tripId) !== String(tripId)) return; + const poll = msg.poll as CollabPollData | undefined; + if (msg.type === 'collab:poll:created' && poll) { + setPolls((prev) => (prev.some((p) => p.id === poll.id) ? prev : [poll, ...prev])); + } + if (msg.type === 'collab:poll:voted' && poll) { + 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))); + } + if (msg.type === 'collab:poll:deleted') { + const id = (msg.pollId as number | undefined) || poll?.id; + if (id) setPolls((prev) => prev.filter((p) => p.id !== id)); + } + }; + addListener(handler); + return () => removeListener(handler); + }, [tripId]); + + // Re-render every 30s while a deadline is still counting down. + useEffect(() => { + if (!polls.some((p) => p.deadline && isPollActive(p))) return; + const iv = setInterval(() => setTick((v) => v + 1), 30000); + return () => clearInterval(iv); + }, [polls]); + + /** Rethrows after the toast, so the form that called it stays open. */ + const createPoll = useCallback( + async (data: CollabPollCreateRequest) => { + try { + const created = readPoll(await collabApi.createPoll(tripId, data)); + setPolls((prev) => (prev.some((p) => p.id === created.id) ? prev : [created, ...prev])); + } catch (err) { + toast.error(t('common.error')); + throw err; + } + }, + [tripId, toast, t] + ); + + const votePoll = useCallback( + 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))); + } catch { + toast.error(t('common.error')); + } + }, + [tripId, toast, t] + ); + + const closePoll = useCallback( + async (pollId: number) => { + try { + const result = await collabApi.closePoll(tripId, pollId); + setPolls((prev) => + prev.map((p) => { + if (p.id !== pollId) return p; + return replaceClosedPoll ? readPoll(result) : { ...p, is_closed: true }; + }) + ); + } catch { + toast.error(t('common.error')); + } + }, + [tripId, replaceClosedPoll, toast, t] + ); + + const deletePoll = useCallback( + async (pollId: number) => { + try { + await collabApi.deletePoll(tripId, pollId); + setPolls((prev) => prev.filter((p) => p.id !== pollId)); + } catch { + toast.error(t('common.error')); + } + }, + [tripId, toast, t] + ); + + return { polls, loading, createPoll, votePoll, closePoll, deletePoll }; +} diff --git a/client/src/mobile/screens/trip/tabs/MCollabPolls.tsx b/client/src/mobile/screens/trip/tabs/MCollabPolls.tsx index da003f2f9..05c5e957d 100644 --- a/client/src/mobile/screens/trip/tabs/MCollabPolls.tsx +++ b/client/src/mobile/screens/trip/tabs/MCollabPolls.tsx @@ -5,8 +5,7 @@ import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' import { sanitizedMarkdownPlugins, sanitizedMarkdownComponents } from '../../../../components/shared/markdownSanitize' import MDancingTrek from '../../../components/MDancingTrek' -import { collabApi } from '../../../../api/client' -import { addListener, removeListener } from '../../../../api/websocket' +import { useCollabPolls } from '../../../../components/Collab/useCollabPolls' import { useAuthStore } from '../../../../store/authStore' import ToggleSwitch from '../../../../components/Settings/ToggleSwitch' import MSheet from '../../../components/MSheet' @@ -28,9 +27,6 @@ interface MCollabPollsProps { planner: TripPlanner } -interface GetPollsResponse { polls: CollabPollData[] } -interface PollResponse { poll: CollabPollData } - interface PollFormSubmitData { question: string options: string[] @@ -39,9 +35,9 @@ interface PollFormSubmitData { } /** - * Trip-tab Collab / Polls. Same self-contained architecture as - * MCollabChat/MCollabNotes (own state, own `collabApi` calls, own WebSocket - * listener — 10-tab-databindings.md §8.5). The demo only has a placeholder, + * Trip-tab Collab / Polls. The list, its WebSocket listener and the poll + * actions come from useCollabPolls, the hook the desktop panel reads too + * (10-tab-databindings.md §8.5). The demo only has a placeholder, * so question/options/results/deadline/close/delete below is a new design in * the established mobile visual language, not a port of desktop markup. */ @@ -51,96 +47,21 @@ export default function MCollabPolls({ planner }: MCollabPollsProps) { const canEdit = planner.can('collab_edit', planner.trip) const currentUserId = user?.id ?? null - const [polls, setPolls] = useState([]) - const [loading, setLoading] = useState(true) + const { polls, loading, createPoll, votePoll: handleVote, closePoll: handleClosePoll, deletePoll: handleDelete } = + useCollabPolls({ tripId, t, toast, replaceClosedPoll: true }) const [showForm, setShowForm] = useState(false) const [pendingDeleteId, setPendingDeleteId] = useState(null) const [collapsedClosed, setCollapsedClosed] = useState(false) - const [, setTick] = useState(0) - // ── Load ── - useEffect(() => { - let cancelled = false - setLoading(true) - collabApi.getPolls(tripId).then((data: GetPollsResponse) => { - if (!cancelled) setPolls(data.polls || []) - }).catch(() => { /* leave polls empty */ }).finally(() => { if (!cancelled) setLoading(false) }) - return () => { cancelled = true } - }, [tripId]) - - // ── WebSocket (own listener, not handleRemoteEvent) ── - useEffect(() => { - const handler = (event: Record) => { - if (String(event.tripId) !== String(tripId)) return - if (event.type === 'collab:poll:created') { - const poll = event.poll as CollabPollData - setPolls(prev => (prev.some(p => p.id === poll.id) ? prev : [poll, ...prev])) - } - if (event.type === 'collab:poll:voted' || event.type === 'collab:poll:closed') { - const poll = event.poll as CollabPollData - setPolls(prev => prev.map(p => (p.id === poll.id ? poll : p))) - } - if (event.type === 'collab:poll:deleted') { - const pollId = event.pollId as number - setPolls(prev => prev.filter(p => p.id !== pollId)) - } - } - addListener(handler) - return () => removeListener(handler) - }, [tripId]) - - // ── Deadline countdown ticker — re-render every 30s so formatPollCountdown recomputes ── - useEffect(() => { - if (!polls.some(p => p.deadline && isPollActive(p))) return - const iv = setInterval(() => setTick(v => v + 1), 30000) - return () => clearInterval(iv) - }, [polls]) - - const handleCreate = useCallback(async (data: PollFormSubmitData) => { - try { - const res = (await collabApi.createPoll(tripId, { - question: data.question, - options: data.options, - // Server reads `data.multiple || data.multiple_choice` - // (nest/collab/collab.service.ts) — both are sent so either shape lands. - multiple: data.multipleChoice, - multiple_choice: data.multipleChoice, - deadline: data.deadline || undefined, - })) as PollResponse - setPolls(prev => (prev.some(p => p.id === res.poll.id) ? prev : [res.poll, ...prev])) - } catch { - toast.error(t('common.error')) - throw new Error('create failed') - } - }, [tripId, toast, t]) - - const handleVote = useCallback(async (pollId: number, optionIndex: number) => { - try { - const res = (await collabApi.votePoll(tripId, pollId, optionIndex)) as PollResponse - // Reconcile against the poll we asked about, like handleClosePoll does. - setPolls(prev => prev.map(p => (p.id === pollId ? res.poll : p))) - } catch { - toast.error(t('common.error')) - } - }, [tripId, toast, t]) - - const handleClosePoll = useCallback(async (pollId: number) => { - try { - const res = (await collabApi.closePoll(tripId, pollId)) as PollResponse - setPolls(prev => prev.map(p => (p.id === pollId ? res.poll : p))) - } catch { - toast.error(t('common.error')) - } - }, [tripId, toast, t]) - - const handleDelete = useCallback(async (pollId: number) => { - try { - await collabApi.deletePoll(tripId, pollId) - setPolls(prev => prev.filter(p => p.id !== pollId)) - } catch { - toast.error(t('common.error')) - } - }, [tripId, toast, t]) + const handleCreate = useCallback((data: PollFormSubmitData) => createPoll({ + question: data.question, + options: data.options, + // Server reads `data.multiple || data.multiple_choice` + // (nest/collab/collab.service.ts), so both are sent and either shape lands. + multiple: data.multipleChoice, + multiple_choice: data.multipleChoice, + deadline: data.deadline || undefined, + }), [createPoll]) if (loading) { return (