// Barcode Games Panel // Purpose: Shows barcode game selection, current game status, and player points in the Activities tab. // Scope: Keeps the driver-side UI compact but informative; game-specific rules stay in server game modules. import { useEffect, useMemo, useState } from 'react'; import useBarcodeGameState from '../../barcodeGames/useBarcodeGameState.js'; import CardFrame from '../CardFrame/index.jsx'; import { useSessionSelector } from '../../context/SessionContext.jsx'; import { isFeatureEnabled } from '../../lib/features.js'; function clampRgbChannel(value) { if (!Number.isFinite(value)) return null; return Math.max(0, Math.min(255, Math.round(value))); } function normalizeRgb(themeColor) { const r = clampRgbChannel(themeColor?.r); const g = clampRgbChannel(themeColor?.g); const b = clampRgbChannel(themeColor?.b); if (r === null || g === null || b === null) return null; return { r, g, b }; } function rgba(rgb, alpha) { return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`; } function getGameTheme(themeColor) { const rgb = normalizeRgb(themeColor); if (!rgb) { return { textStyle: undefined, buttonStyle: undefined, titleBoxStyle: undefined, boxStyle: undefined, }; } // The game supplies only identity color. This component chooses opacity and // placement so every game stays visually consistent with the dark CardFrame // UI while still being recognizable at a glance. return { textStyle: { color: rgba(rgb, 0.96) }, buttonStyle: { borderLeftColor: rgba(rgb, 0.9), }, selectedButtonStyle: { borderLeftColor: rgba(rgb, 1), backgroundColor: rgba(rgb, 0.12), }, titleBoxStyle: { borderLeftColor: rgba(rgb, 0.88), }, }; } function useClock(enabled) { const [now, setNow] = useState(() => Date.now()); useEffect(() => { if (!enabled) return undefined; const timer = window.setInterval(() => { setNow(Date.now()); }, 500); return () => window.clearInterval(timer); }, [enabled]); return now; } function formatTimer(endsAt, now) { if (!Number.isFinite(endsAt)) return null; const totalSeconds = Math.max(0, Math.ceil((endsAt - now) / 1000)); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return minutes > 0 ? `${minutes}:${String(seconds).padStart(2, '0')}` : `${seconds}s`; } function GameChoice({ game, disabled, onVote }) { const theme = getGameTheme(game.themeColor); const style = game.active || game.selected ? theme.selectedButtonStyle || theme.buttonStyle : theme.buttonStyle; return ( ); } function StatGrid({ stats, theme }) { if (!Array.isArray(stats) || !stats.length) return null; return (
{stats.slice(0, 6).map((stat) => (

{stat.label}

{stat.value}

))}
); } function getSectionItemText(item) { // Game modules may send section items as simple strings or as objects with a // status flag. The panel accepts both so games can stay lightweight until // they need richer per-row state like scan quest's active route item. if (typeof item === 'string') return item; if (!item || typeof item !== 'object') return ''; return String(item.label || item.value || item.text || '').trim(); } function DisplaySections({ sections }) { // display.sections is the shared rich-status contract for barcode games. This // renderer deliberately stays generic: game modules decide the content, while // the Activities panel only applies compact CardFrame-style formatting. const visibleSections = Array.isArray(sections) ? sections .map((section) => ({ title: String(section?.title || '').trim(), items: Array.isArray(section?.items) ? section.items .map((item) => ({ text: getSectionItemText(item), status: typeof item === 'object' && item ? item.status : null, })) .filter((item) => item.text) : [], })) .filter((section) => section.title || section.items.length) : []; if (!visibleSections.length) return null; return (
{visibleSections.map((section, sectionIndex) => (
{section.title ? (

{section.title}

) : null}
{section.items.slice(0, 8).map((item, itemIndex) => (

{item.text}

))}
))}
); } function ParticipantsBlock({ participants, theme }) { const knownParticipants = Array.isArray(participants) ? participants : []; // Participants are server-owned because rover scans, identity lookup, and // score eligibility all happen outside this panel. The UI only formats the // current round list so users can tell whether their rover was counted. const displayNames = knownParticipants .map((participant) => participant?.nickname || participant?.roverId) .filter(Boolean); return (

Participants

{knownParticipants.length}

{displayNames.length ? displayNames.join(', ') : 'Scan rover to join'}

); } function CounterList({ title, entries }) { if (!Array.isArray(entries) || !entries.length) return null; return (

{title}

{entries.slice(0, 4).map((entry) => (
{entry.label || entry.entityId || entry.code} {entry.count || 0}
))}
); } function Leaderboard({ players, ownPlayer }) { const hasOwnPoints = ownPlayer && Number.isFinite(ownPlayer.totalPoints); return (

Your points

{ownPlayer?.rank ? `Rank ${ownPlayer.rank}` : 'No rank yet'}

{hasOwnPoints ? ownPlayer.totalPoints : 0}

Leaderboard

{Array.isArray(players) && players.length ? (
{players.slice(0, 4).map((player, idx) => (
{idx + 1} {player.nickname} {player.totalPoints}
))}
) : (

No points yet

)}
); } export default function BarcodeGamesPanel() { const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'barcodeGames')); /* Barcode games depend on the optional scanner station. The panel owns the feature gate so disabled installs do not show empty game voting controls. */ if (!enabled) return null; return ; } function BarcodeGamesPanelContent() { const { state, voteForGame } = useBarcodeGameState(); const [pendingGameId, setPendingGameId] = useState(null); const activeGame = state.activeGame; const display = activeGame?.display || {}; // Voting should stop once the shared game system has moved past selection. // The joining phase is included because it is still part of starting the // selected game, even though game rules are not running until the countdown. const votingDisabled = state.phase === 'joining' || state.phase === 'starting' || state.phase === 'running'; const timerEndsAt = display.timer?.endsAt; const now = useClock(Number.isFinite(timerEndsAt)); const timerText = formatTimer(timerEndsAt, now); const games = useMemo(() => (Array.isArray(state.games) ? state.games : []), [state.games]); const participants = Array.isArray(state.participants) ? state.participants : []; const activeTheme = getGameTheme(activeGame?.themeColor); const handleVote = async (gameId) => { setPendingGameId(gameId); try { await voteForGame(gameId); } finally { setPendingGameId(null); } }; return (
{games.map((game) => ( ))}
{/* The game summary becomes a three-part dashboard only when the card can fit its fixed timer and participant blocks without crushing the title. */}

{display.title || activeGame?.title || 'No game active'}

{display.primary || 'Vote for a game to start'}

{display.secondary ? (

{display.secondary}

) : null}

{display.timer?.label || 'Time'}

{timerText || '--'}

{timerText ? 'active timer' : 'no timer'}

); }