// 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 (
);
}
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 (
);
}
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'}