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 (