/* @component-map * App — Main container, role switching between host and voter [app.jsx] * SessionHost — Create/manage sessions, add stories, control reveal [components/SessionHost.jsx] * VoterView — Join session, vote on stories, see results [components/VoterView.jsx] * VotingCard — Individual point card component [components/VotingCard.jsx] * ResultsDisplay — Vote results visualization [components/ResultsDisplay.jsx] * @end-component-map */ import { useEffect, useMemo, useRef, useState } from 'react'; import { playSound, useCollection, useBroadcast, useIdentity } from '@deplixo/sdk'; import { SessionHost } from './components/SessionHost.jsx'; import { VoterView } from './components/VoterView.jsx'; const TIMER_COLLECTION = 'planning-poker-round-state'; const TIMER_BROADCAST = 'planning-poker-round-update'; const DEFAULT_ROUND_SECONDS = 60; function App() { const { user, loading } = useIdentity(); const [role, setRole] = useState(null); const [voterIdentity, setVoterIdentity] = useState(null); const [roundState, setRoundState] = useState(null); const [now, setNow] = useState(Date.now()); const [consensusPlayedFor, setConsensusPlayedFor] = useState(null); const [revealPlayedFor, setRevealPlayedFor] = useState(null); const { items: roundItems, add: addRoundState, update: updateRoundState } = useCollection(TIMER_COLLECTION, { personal: true, }); const { send: sendRoundUpdate } = useBroadcast(TIMER_BROADCAST, (data) => { if (data?.type === 'round-state') { setRoundState(data.payload || null); } }); useEffect(() => { const timer = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(timer); }, []); useEffect(() => { if (roundItems && roundItems.length > 0) { const latest = roundItems[roundItems.length - 1]; setRoundState(latest?.state || latest || null); } }, [roundItems]); const persistRoundState = async (nextState) => { const payload = { ...nextState, updatedAt: Date.now(), }; if (roundItems && roundItems.length > 0) { const latest = roundItems[roundItems.length - 1]; if (latest?.id) { await updateRoundState(latest.id, { state: payload }); } } else { await addRoundState({ state: payload }); } setRoundState(payload); sendRoundUpdate({ type: 'round-state', payload }); }; const handleChooseRole = (nextRole) => { setRole(nextRole); if (nextRole !== 'voter') { setVoterIdentity(null); } }; const handleVoterJoin = (identity) => { setVoterIdentity(identity); setRole('voter'); }; const timerState = useMemo(() => { const active = roundState?.isActive; const endsAt = roundState?.endsAt || null; const remainingMs = active && endsAt ? Math.max(0, endsAt - now) : 0; const remainingSeconds = Math.ceil(remainingMs / 1000); return { active, endsAt, remainingMs, remainingSeconds, locked: !active || remainingMs <= 0, durationSeconds: roundState?.durationSeconds || DEFAULT_ROUND_SECONDS, }; }, [roundState, now]); useEffect(() => { const isRevealed = roundState?.status === 'revealed'; const revealKey = roundState?.updatedAt || roundState?.revealedAt || roundState?.endedAt || roundState?.endsAt || null; if (isRevealed && revealKey && revealPlayedFor !== revealKey) { playSound('@whoosh'); setRevealPlayedFor(revealKey); } }, [roundState, revealPlayedFor]); useEffect(() => { const isConsensus = Boolean(roundState?.consensusReached); const consensusKey = roundState?.updatedAt || roundState?.revealedAt || roundState?.endedAt || roundState?.endsAt || null; if (isConsensus && consensusKey && consensusPlayedFor !== consensusKey) { playSound('@success'); setConsensusPlayedFor(consensusKey); } }, [roundState, consensusPlayedFor]); if (loading) { return (
Joining session…
Agile estimation made easy
Welcome, {user.name}