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.
This commit is contained in:
Maurice
2026-10-09 10:39:24 +02:00
parent 7298285a43
commit 428b4e6006
4 changed files with 440 additions and 217 deletions
+17 -123
View File
@@ -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<void>
@@ -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 (
<article className="group overflow-hidden rounded-2xl border border-edge-faint bg-surface-card">
@@ -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 (
@@ -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> = {}): 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<CollabPollsOptions> = {}) {
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
const hook = renderHook(
(props: Partial<CollabPollsOptions>) => useCollabPolls({ tripId: 1, t, toast, ...over, ...props }),
{ initialProps: {} }
);
return { ...hook, toast };
}
async function loaded(over: Partial<CollabPollsOptions> = {}) {
const view = setup(over);
await waitFor(() => expect(view.result.current.loading).toBe(false));
return view;
}
function wsHandler(): (event: Record<string, unknown>) => void {
const calls = vi.mocked(addListener).mock.calls;
return calls[calls.length - 1][0] as (event: Record<string, unknown>) => 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]);
});
});
@@ -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<typeof useTranslation>['t'];
toast: ReturnType<typeof useToast>;
/** 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<CollabPollData[]>([]);
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<string, unknown>) => {
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 };
}
@@ -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<CollabPollData[]>([])
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<number | null>(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<string, unknown>) => {
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 (