feat(call-to-play)!: coordinate game sessions across peers

Implement the launcher design as a production peer-to-peer feature. Call to
Play actions are immutable, validated events broadcast over the existing QUIC
control channel, deduplicated in a bounded in-memory history, and exchanged in
Hello/HelloAck so late joiners reconstruct current calls.

Add the Tauri bridge and modular launcher surfaces for play-now and scheduled
calls, check-in, readiness buffers, role-aware controls, chat, tickers, and
actual caller launch. A deterministic frontend reducer derives presentation
state from replicated history. Extend the JSONL peer harness with publish/list
commands and a three-peer live-delivery and late-join scenario.

This intentionally raises the only supported wire protocol from version 5 to
version 6; older builds are not supported. Document the transport architecture
and exclude generated peer-test state from Docker build contexts.

Test Plan:
- `just fmt` -- passed
- `just clippy` -- passed
- `just test` -- passed
- `just frontend-test` -- passed, 20 tests
- `just build` -- passed
- `just peer-cli-tests S2 S48` -- passed
- `git diff --cached --check` -- passed
This commit is contained in:
2026-07-21 22:30:11 +02:00
parent 8f151e38b4
commit 0f53bc4b78
31 changed files with 3032 additions and 21 deletions
@@ -44,7 +44,7 @@ walkdir = { workspace = true }
[build-dependencies]
tauri-build = { version = "2", features = [] }
[target.'cfg(windows)'.dependencies]
[target."cfg(windows)".dependencies]
windows = { workspace = true }
[lints.clippy]
@@ -18,6 +18,7 @@ use lanspread_db::db::{Availability, Game, GameCatalog, GameDB, GameFileDescript
use lanspread_peer::{
ActiveOperation,
ActiveOperationKind,
CallToPlayEvent,
ExternalUnrarStreamProvider,
NoopStreamInstallProvider,
PeerCommand,
@@ -161,6 +162,7 @@ struct LauncherGame {
game: Game,
can_host_server: bool,
active_outbound_transfers: usize,
installed_peer_count: u32,
}
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
@@ -272,6 +274,35 @@ async fn request_games(state: tauri::State<'_, LanSpreadState>) -> tauri::Result
Ok(())
}
#[tauri::command]
async fn request_call_to_play_events(
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<bool> {
let peer_ctrl = state.inner().peer_ctrl.read().await.clone();
let Some(peer_ctrl) = peer_ctrl else {
log::warn!("Peer system not initialized yet");
return Ok(false);
};
Ok(peer_ctrl.send(PeerCommand::GetCallToPlayEvents).is_ok())
}
#[tauri::command]
async fn publish_call_to_play(
event: CallToPlayEvent,
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<bool> {
let peer_ctrl = state.inner().peer_ctrl.read().await.clone();
let Some(peer_ctrl) = peer_ctrl else {
log::warn!("Peer system not initialized yet");
return Ok(false);
};
Ok(peer_ctrl
.send(PeerCommand::PublishCallToPlay(event))
.is_ok())
}
#[tauri::command]
async fn install_game(
id: String,
@@ -1031,6 +1062,19 @@ fn clear_all_local_game_states(game_db: &mut GameDB) {
async fn emit_games_list(app_handle: &AppHandle) {
let state = app_handle.state::<LanSpreadState>();
let installed_peer_counts = state
.peer_game_db
.read()
.await
.peer_snapshots()
.into_iter()
.flat_map(|peer| peer.games)
.filter(|game| game.installed)
.fold(HashMap::<String, u32>::new(), |mut counts, game| {
*counts.entry(game.id).or_default() += 1;
counts
});
let games_db_lock = state.games.clone();
let game_db = games_db_lock.read().await;
let games_folder = state.games_folder.read().await.clone();
@@ -1051,6 +1095,7 @@ async fn emit_games_list(app_handle: &AppHandle) {
LauncherGame {
can_host_server: game_can_host_server(&games_folder, &game),
active_outbound_transfers,
installed_peer_count: installed_peer_counts.get(&game.id).copied().unwrap_or(0),
game,
}
})
@@ -2178,6 +2223,11 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
}
emit_games_list(app_handle).await;
}
PeerEvent::CallToPlayEvents(events) => {
if let Err(err) = app_handle.emit("call-to-play-events", Some(events)) {
log::error!("Failed to emit call-to-play-events event: {err}");
}
}
PeerEvent::OutboundTransferCountChanged => {
log::info!("PeerEvent::OutboundTransferCountChanged received");
schedule_outbound_transfer_emit(app_handle).await;
@@ -2348,6 +2398,8 @@ pub fn run() {
.plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![
request_games,
request_call_to_play_events,
publish_call_to_play,
install_game,
stream_install_game,
run_game,
@@ -105,4 +105,35 @@ export const Icon = {
<path d="M5 9h2M6 8v2M10 9h.01M11 8h.01" />
</svg>
),
flag: (p: Props) => (
<svg viewBox="0 0 16 16" width="13" height="13" strokeWidth={1.6} {...baseStroke} {...p}>
<path d="M3 14V2.5M3.5 3h8l-1.4 2.5L11.5 8h-8" />
</svg>
),
clock: (p: Props) => (
<svg viewBox="0 0 16 16" width="13" height="13" strokeWidth={1.5} {...baseStroke} {...p}>
<circle cx="8" cy="8" r="5.5" />
<path d="M8 4.5V8l2.5 1.5" />
</svg>
),
chat: (p: Props) => (
<svg viewBox="0 0 16 16" width="13" height="13" strokeWidth={1.5} {...baseStroke} {...p}>
<path d="M2.5 3.5h11v8h-6L4 14v-2.5H2.5z" />
</svg>
),
send: (p: Props) => (
<svg viewBox="0 0 16 16" width="13" height="13" strokeWidth={1.5} {...baseStroke} {...p}>
<path d="m2 3 12 5-12 5 2-5zM4 8h6" />
</svg>
),
caretUp: (p: Props) => (
<svg viewBox="0 0 16 16" width="10" height="10" strokeWidth={1.8} {...baseStroke} {...p}>
<path d="m4 10 4-4 4 4" />
</svg>
),
caretDown: (p: Props) => (
<svg viewBox="0 0 16 16" width="10" height="10" strokeWidth={1.8} {...baseStroke} {...p}>
<path d="m4 6 4 4 4-4" />
</svg>
),
} satisfies Record<string, (p: Props) => JSX.Element>;
@@ -0,0 +1,18 @@
import { Icon } from '../Icon';
import { Nomination } from '../../lib/types';
interface Props {
nominations: ReadonlyArray<Nomination>;
onClick: () => void;
}
export const CallToPlayButton = ({ nominations, onClick }: Props) => {
const activeCount = nominations.filter(nomination => nomination.state !== 'started').length;
return (
<button className="ctp-btn" onClick={onClick}>
<Icon.flag />
<span>Call to Play</span>
{activeCount > 0 && <span className="ctp-badge">{activeCount}</span>}
</button>
);
};
@@ -0,0 +1,103 @@
import { useState } from 'react';
import { Icon } from '../Icon';
import { Modal } from '../Modal';
import { CreateNominationForm } from './CreateNominationForm';
import { NominationCard } from './NominationCard';
import { CallToPlayActions } from '../../hooks/useCallToPlay';
import { Game, Nomination } from '../../lib/types';
interface Props {
nominations: ReadonlyArray<Nomination>;
games: ReadonlyArray<Game>;
username: string;
actions: CallToPlayActions;
focusId: string | null;
transportReady: boolean;
error: string | null;
getThumbnail: (gameId: string) => string | null | undefined;
totalPeerCount: number;
onLaunch: (game: Game) => void;
onClose: () => void;
}
export const CallToPlayOverlay = ({
nominations,
games,
username,
actions,
focusId,
transportReady,
error,
getThumbnail,
totalPeerCount,
onLaunch,
onClose,
}: Props) => {
const [showCreate, setShowCreate] = useState(false);
const gameById = new Map(games.map(game => [game.id, game]));
const sorted = [...nominations].sort((left, right) =>
Number(left.state === 'started') - Number(right.state === 'started')
|| left.deadline - right.deadline
);
return (
<Modal onClose={onClose} className="ctp-modal">
<button className="modal-close" onClick={onClose} aria-label="Close">
<Icon.close />
</button>
<div className="ctp-head">
<h2>Call to Play</h2>
<p className="ctp-head-sub">
Rally the LAN around a game and a time right now, or scheduled for later with
an Im in RSVP. The caller decides when it actually starts.
</p>
{!showCreate && (
<button
className="act-btn act-play ctp-head-new"
disabled={!transportReady}
onClick={() => setShowCreate(true)}
><Icon.flag /><span>Call a new match</span></button>
)}
{!transportReady && !error && (
<div className="ctp-transport-note">Connecting Call to Play to the LAN</div>
)}
{error && <div className="ctp-transport-note is-error">{error}</div>}
</div>
<div className="ctp-body">
{showCreate && (
<CreateNominationForm
games={games}
onCancel={() => setShowCreate(false)}
onCreate={(gameId, maxPlayers, duration, scheduledFor) => {
actions.createNomination(gameId, maxPlayers, duration, scheduledFor);
setShowCreate(false);
}}
/>
)}
{sorted.length === 0 && !showCreate && (
<div className="ctp-empty">
No active calls right now be the one to start something.
</div>
)}
{sorted.map(nomination => {
const game = gameById.get(nomination.gameId);
if (!game) return null;
return (
<NominationCard
key={nomination.id}
nomination={nomination}
game={game}
username={username}
actions={actions}
focused={nomination.id === focusId}
thumbnailUrl={getThumbnail(game.id)}
totalPeerCount={totalPeerCount}
onLaunch={onLaunch}
/>
);
})}
</div>
</Modal>
);
};
@@ -0,0 +1,124 @@
import { CSSProperties } from 'react';
import { Icon } from '../Icon';
import {
avatarColor,
formatClock,
formatCountdown,
formatCountdownShort,
formatUntil,
isReady,
readyCountOf,
statusOf,
} from '../../lib/callToPlay';
import { Game, Nomination } from '../../lib/types';
interface Props {
nominations: ReadonlyArray<Nomination>;
games: ReadonlyArray<Game>;
accent: string;
onOpen: (callId: string) => void;
}
const LABEL = {
scheduled: 'Scheduled',
call: 'Call to Play',
soon: 'Starting soon',
ready: 'Ready',
} as const;
const RANK = { ready: 0, soon: 1, call: 2, scheduled: 2, started: 3 } as const;
const MiniBubbles = ({ nomination, now }: { nomination: Nomination; now: number }) => {
const entries = Object.entries(nomination.participants);
return (
<span className="ctp-ticker-bubbles">
{entries.slice(0, 6).map(([name, participant]) => {
const ready = isReady(participant, now);
const state = ready ? 'ready' : participant.status === 'in' ? 'in' : 'pending';
const initials = name.replace(/[^a-z0-9]/gi, '').slice(0, 2).toUpperCase();
const remaining = (participant.readyAt ?? now) - now;
return (
<span
key={name}
className="ctp-mini"
data-state={state}
title={`${name}${ready ? 'ready' : state === 'in' ? 'in' : `ready ${formatCountdownShort(remaining)}`}`}
style={{ background: avatarColor(name) }}
>
{initials}
{ready && <span className="ctp-mini-check"><Icon.check /></span>}
{state === 'pending' && (
<i className="ctp-mini-tag">{formatCountdownShort(remaining)}</i>
)}
</span>
);
})}
{entries.length > 6 && <span className="ctp-mini ctp-mini-more">+{entries.length - 6}</span>}
</span>
);
};
export const CallToPlayTicker = ({ nominations, games, accent, onOpen }: Props) => {
const now = Date.now();
const gameById = new Map(games.map(game => [game.id, game]));
const active = nominations
.filter(nomination => nomination.state !== 'started')
.sort((left, right) =>
RANK[statusOf(left, now)] - RANK[statusOf(right, now)]
|| left.deadline - right.deadline
);
if (active.length === 0) return null;
return (
<div className="ctp-ticker-stack">
{active.map(nomination => {
const game = gameById.get(nomination.gameId);
if (!game) return null;
const status = statusOf(nomination, now);
if (status === 'started') return null;
const ready = readyCountOf(nomination, now);
const total = Object.keys(nomination.participants).length;
const remaining = Math.max(0, nomination.deadline - now);
const last = nomination.messages[nomination.messages.length - 1];
const count = status === 'scheduled'
? `${total} in`
: `${ready}/${nomination.maxPlayers} ready`;
const time = status === 'ready'
? 'waiting to start'
: status === 'scheduled'
? `${formatClock(nomination.scheduledFor!)} · ${formatUntil(nomination.scheduledFor! - now)}`
: nomination.scheduledFor !== null
? `starts ${formatClock(nomination.scheduledFor)} · ${formatCountdown(remaining)}`
: formatCountdown(remaining);
return (
<button
key={nomination.id}
className="ctp-ticker"
data-status={status}
style={{ '--accent': accent } as CSSProperties}
onClick={() => onOpen(nomination.id)}
>
<span className="ctp-ticker-dot" data-status={status} />
<span className="ctp-ticker-label" data-status={status}>{LABEL[status]}</span>
<span className="ctp-ticker-game">{game.name}</span>
<span className="ctp-ticker-by">by {nomination.creator}</span>
<span className="ctp-ticker-ready">{count}</span>
<span className="ctp-ticker-time">{time}</span>
{last ? (
<span className="ctp-ticker-chat" title={`${last.from}: ${last.text}`}>
<Icon.chat />
<b style={{ color: avatarColor(last.from) }}>{last.from}:</b>
<span className="ctp-ticker-chat-text">{last.text}</span>
</span>
) : <span className="ctp-ticker-chat" />}
<MiniBubbles nomination={nomination} now={now} />
<span className="ctp-ticker-cta">
{status === 'soon' ? 'Check in' : 'View'}<Icon.chevron />
</span>
</button>
);
})}
</div>
);
};
@@ -0,0 +1,278 @@
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Icon } from '../Icon';
import { Game } from '../../lib/types';
import { bumpTime, normalizeTimeInput, sanitizeTimeDraft } from '../../lib/callToPlay';
interface Props {
games: ReadonlyArray<Game>;
onCancel: () => void;
onCreate: (
gameId: string,
maxPlayers: number,
durationMinutes: number,
scheduledFor: number | null,
) => void;
}
const nextTimeSlot = (): { time: string; dayOffset: number } => {
const now = new Date();
const slot = new Date(now.getTime() + 40 * 60_000);
slot.setMinutes(slot.getMinutes() <= 30 ? 30 : 60, 0, 0);
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const slotDay = new Date(slot.getFullYear(), slot.getMonth(), slot.getDate());
return {
time: `${String(slot.getHours()).padStart(2, '0')}:${String(slot.getMinutes()).padStart(2, '0')}`,
dayOffset: Math.round((slotDay.getTime() - today.getTime()) / 86_400_000),
};
};
const suggestedPlayers = (game: Game): number => Math.max(2, Math.min(64, game.max_players ?? 8));
const dayChips = (): ReadonlyArray<{ offset: number; label: string }> => {
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
return [0, 1, 2].map(offset => {
const date = new Date();
date.setDate(date.getDate() + offset);
return { offset, label: offset === 0 ? 'Today' : days[date.getDay()] };
});
};
export const CreateNominationForm = ({ games, onCancel, onCreate }: Props) => {
const [initialSchedule] = useState(nextTimeSlot);
const [query, setQuery] = useState('');
const [gameId, setGameId] = useState<string | null>(null);
const [maxPlayers, setMaxPlayers] = useState(8);
const [duration, setDuration] = useState(10);
const [when, setWhen] = useState<'now' | 'later'>('now');
const [dayOffset, setDayOffset] = useState(initialSchedule.dayOffset);
const [time, setTime] = useState(initialSchedule.time);
const [editingTime, setEditingTime] = useState(false);
const [timeDraft, setTimeDraft] = useState(time);
const timeInputRef = useRef<HTMLInputElement>(null);
useLayoutEffect(() => {
if (editingTime) {
timeInputRef.current?.focus();
timeInputRef.current?.select();
}
}, [editingTime]);
const selected = games.find(game => game.id === gameId);
const matches = useMemo(() => {
const needle = query.trim().toLocaleLowerCase();
if (!needle) return [];
return games.filter(game => game.name.toLocaleLowerCase().includes(needle)).slice(0, 6);
}, [games, query]);
const days = dayChips();
const pickGame = (game: Game) => {
setGameId(game.id);
setMaxPlayers(suggestedPlayers(game));
setQuery(game.name);
};
const scheduledTimestamp = (): number => {
const [hours, minutes] = time.split(':').map(Number);
const date = new Date();
date.setDate(date.getDate() + dayOffset);
date.setHours(hours, minutes, 0, 0);
return date.getTime();
};
const scheduledFor = scheduledTimestamp();
const scheduledTimeIsFuture = scheduledFor >= Date.now() + 60_000;
const commitTimeDraft = () => {
const normalized = normalizeTimeInput(timeDraft);
if (normalized) setTime(normalized);
setEditingTime(false);
};
return (
<div className="ctp-create">
<div className="ctp-create-row">
<label className="ctp-create-label" htmlFor="ctp-game-search">Game</label>
<div className="search ctp-create-search">
<Icon.search />
<input
id="ctp-game-search"
type="text"
placeholder="Search the catalog…"
autoComplete="off"
value={query}
onChange={event => {
setQuery(event.target.value);
setGameId(null);
}}
/>
</div>
{!selected && query.trim() && (
<div className="ctp-create-matches">
{matches.map(game => (
<button key={game.id} onClick={() => pickGame(game)}>
<span>{game.name}</span>
<span className="ctp-create-match-meta">
up to {suggestedPlayers(game)} players
</span>
</button>
))}
{matches.length === 0 && (
<div className="ctp-create-nomatch">No games match {query}</div>
)}
</div>
)}
</div>
{selected && (
<>
<div className="ctp-create-row ctp-create-row-inline">
<label className="ctp-create-label" htmlFor="ctp-max-players">Max players</label>
<input
id="ctp-max-players"
type="number"
className="ctp-create-num"
min={2}
max={64}
value={maxPlayers}
onChange={event => setMaxPlayers(
Math.max(2, Math.min(64, Number(event.target.value) || 2)),
)}
/>
<span className="ctp-create-hint">
Suggested for {selected.name}: {suggestedPlayers(selected)}
</span>
</div>
<div className="ctp-create-row">
<label className="ctp-create-label">When</label>
<div className="ctp-duration-opts">
<button
className={`ctp-duration-btn ${when === 'now' ? 'is-active' : ''}`}
onClick={() => setWhen('now')}
>Now</button>
<button
className={`ctp-duration-btn ${when === 'later' ? 'is-active' : ''}`}
onClick={() => setWhen('later')}
>Schedule</button>
</div>
{when === 'later' && (
<>
<div className="ctp-timepick">
{editingTime ? (
<input
ref={timeInputRef}
className="ctp-time-editinput"
type="text"
inputMode="numeric"
maxLength={5}
placeholder="20:00"
value={timeDraft}
onChange={event => setTimeDraft(previous =>
sanitizeTimeDraft(event.target.value, previous)
)}
onBlur={commitTimeDraft}
onKeyDown={event => {
if (event.key === 'Enter') commitTimeDraft();
if (event.key === 'Escape') setEditingTime(false);
}}
/>
) : (
<>
<div className="ctp-timepick-field">
<button
type="button"
className="ctp-time-step"
aria-label="Hour up"
onClick={() => setTime(value => bumpTime(value, 'hours', 1))}
><Icon.caretUp /></button>
<span className="ctp-time-cell">{time.slice(0, 2)}</span>
<button
type="button"
className="ctp-time-step"
aria-label="Hour down"
onClick={() => setTime(value => bumpTime(value, 'hours', -1))}
><Icon.caretDown /></button>
</div>
<span className="ctp-time-colon">:</span>
<div className="ctp-timepick-field">
<button
type="button"
className="ctp-time-step"
aria-label="Minute up"
onClick={() => setTime(value => bumpTime(value, 'minutes', 15))}
><Icon.caretUp /></button>
<span className="ctp-time-cell">{time.slice(3)}</span>
<button
type="button"
className="ctp-time-step"
aria-label="Minute down"
onClick={() => setTime(value => bumpTime(value, 'minutes', -15))}
><Icon.caretDown /></button>
</div>
<button
type="button"
className="ctp-time-type"
onClick={() => {
setTimeDraft(time);
setEditingTime(true);
}}
>Type a time</button>
</>
)}
</div>
<div className="ctp-time-row ctp-day-row">
<span className="ctp-day-label">Day</span>
{days.map(day => (
<button
key={day.offset}
className={`ctp-duration-btn ctp-day-btn ${dayOffset === day.offset ? 'is-active' : ''}`}
onClick={() => setDayOffset(day.offset)}
>{day.label}</button>
))}
</div>
<span className="ctp-create-hint">
{scheduledTimeIsFuture
? '24-hour clock. Check-in to ready up opens 15 min before start.'
: 'Choose a time at least one minute from now.'}
</span>
</>
)}
</div>
{when === 'now' && (
<div className="ctp-create-row">
<label className="ctp-create-label">Give people</label>
<div className="ctp-duration-opts">
{[5, 10, 15, 30, 60].map(minutes => (
<button
key={minutes}
className={`ctp-duration-btn ${duration === minutes ? 'is-active' : ''}`}
onClick={() => setDuration(minutes)}
>{minutes}m</button>
))}
</div>
</div>
)}
<div className="ctp-create-foot">
<button className="ghost-btn" onClick={onCancel}>Cancel</button>
<button
className="act-btn act-play"
disabled={when === 'later' && !scheduledTimeIsFuture}
onClick={() => onCreate(
selected.id,
maxPlayers,
duration,
when === 'later' ? scheduledFor : null,
)}
>
{when === 'later'
? `Schedule it — ${selected.name} · ${dayOffset > 0 ? `${days[dayOffset].label} ` : ''}${time}`
: `Call it — ${selected.name}`}
</button>
</div>
</>
)}
{!selected && (
<div className="ctp-create-foot">
<button className="ghost-btn" onClick={onCancel}>Cancel</button>
</div>
)}
</div>
);
};
@@ -0,0 +1,91 @@
import { useEffect, useRef, useState } from 'react';
import { Icon } from '../Icon';
import { avatarColor, formatClock } from '../../lib/callToPlay';
import { Nomination } from '../../lib/types';
interface Props {
nomination: Nomination;
username: string;
disabled: boolean;
onSend: (text: string) => void;
}
export const CtpChat = ({ nomination, username, disabled, onSend }: Props) => {
const [open, setOpen] = useState(false);
const [seen, setSeen] = useState(nomination.messages.length);
const [draft, setDraft] = useState('');
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (open) setSeen(nomination.messages.length);
}, [open, nomination.messages.length]);
useEffect(() => {
if (open && listRef.current) listRef.current.scrollTop = listRef.current.scrollHeight;
}, [open, nomination.messages.length]);
const unread = Math.max(0, nomination.messages.length - seen);
const last = nomination.messages[nomination.messages.length - 1];
const submit = () => {
const text = draft.trim();
if (!text) return;
onSend(text);
setDraft('');
};
return (
<div className={`ctp-chat ${open ? 'is-open' : ''}`}>
<button className="ctp-chat-toggle" onClick={() => setOpen(value => !value)}>
<Icon.chat />
<span>Chat</span>
{nomination.messages.length > 0 && (
<span className="ctp-chat-count">{nomination.messages.length}</span>
)}
{!open && unread > 0 && <span className="ctp-chat-unread">{unread}</span>}
{!open && last && (
<span className="ctp-chat-preview">
<b style={{ color: avatarColor(last.from) }}>{last.from}:</b> {last.text}
</span>
)}
<span className="ctp-chat-chevron"><Icon.chevron /></span>
</button>
{open && (
<>
<div className="ctp-chat-list" ref={listRef}>
{nomination.messages.length === 0 && (
<div className="ctp-chat-empty">No messages yet say hi.</div>
)}
{nomination.messages.map(message => (
<div
key={message.id}
className={`ctp-chat-msg ${message.from === username ? 'is-me' : ''}`}
>
<b style={{ color: avatarColor(message.from) }}>{message.from}</b>
<span className="ctp-chat-time">{formatClock(message.at)}</span>
<span className="ctp-chat-text"> {message.text}</span>
</div>
))}
</div>
{!disabled && (
<div className="ctp-chat-form">
<input
type="text"
className="ctp-chat-input"
placeholder="Message the group…"
maxLength={500}
value={draft}
onChange={event => setDraft(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') submit();
}}
/>
<button className="ctp-chat-send" onClick={submit} aria-label="Send">
<Icon.send />
</button>
</div>
)}
</>
)}
</div>
);
};
@@ -0,0 +1,361 @@
import { useEffect, useRef, useState } from 'react';
import { Icon } from '../Icon';
import { GameCover } from '../grid/GameCover';
import { CtpChat } from './CtpChat';
import { CallToPlayActions } from '../../hooks/useCallToPlay';
import {
avatarColor,
CHECKIN_LEAD_MS,
formatClock,
formatCountdown,
formatCountdownShort,
formatUntil,
inCountOf,
isReady,
phaseOf,
readyCountOf,
} from '../../lib/callToPlay';
import { CallToPlayParticipant, Game, Nomination } from '../../lib/types';
interface Props {
nomination: Nomination;
game: Game;
username: string;
actions: CallToPlayActions;
focused: boolean;
thumbnailUrl?: string | null;
totalPeerCount: number;
onLaunch: (game: Game) => void;
}
const AvatarChip = ({
name,
participant,
now,
}: {
name: string;
participant: CallToPlayParticipant;
now: number;
}) => {
const ready = isReady(participant, now);
const isIn = !ready && participant.status === 'in';
const remaining = Math.max(0, (participant.readyAt ?? now) - now);
const initials = name.replace(/[^a-z0-9]/gi, '').slice(0, 2).toUpperCase();
return (
<div
className={`ctp-avatar ${ready ? 'is-ready' : isIn ? 'is-in' : 'is-pending'}`}
title={`${name}${ready ? 'ready' : isIn ? 'in, not checked in' : `ready in ${formatCountdown(remaining)}`}`}
>
<span className="ctp-avatar-dot" style={{ background: avatarColor(name) }}>{initials}</span>
{ready
? <span className="ctp-avatar-check"><Icon.check /></span>
: isIn
? <span className="ctp-avatar-in">in</span>
: <span className="ctp-avatar-pending">{formatCountdownShort(remaining)}</span>}
</div>
);
};
export const NominationCard = ({
nomination,
game,
username,
actions,
focused,
thumbnailUrl,
totalPeerCount,
onLaunch,
}: Props) => {
const [confirmCancel, setConfirmCancel] = useState(false);
const cardRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (focused) cardRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' });
}, [focused]);
const now = Date.now();
const entries = Object.entries(nomination.participants);
const readyCount = readyCountOf(nomination, now);
const inCount = inCountOf(nomination, now);
const myStatus = nomination.participants[username];
const isMe = myStatus !== undefined;
const isCreator = nomination.creator === username;
const isDone = nomination.state === 'done';
const isStarted = nomination.state === 'started';
const phase = phaseOf(nomination, now);
const isScheduled = phase === 'scheduled' && !isDone && !isStarted;
const isCheckin = phase === 'checkin' && !isDone && !isStarted;
const windowStart = nomination.scheduledFor === null
? nomination.createdAt
: nomination.scheduledFor - CHECKIN_LEAD_MS;
const remaining = Math.max(0, nomination.deadline - now);
const percentage = Math.max(
0,
Math.min(100, (remaining / Math.max(1, nomination.deadline - windowStart)) * 100),
);
const urgency = percentage < 20 ? 'high' : percentage < 50 ? 'mid' : 'low';
const installedCount = (game.installed_peer_count ?? game.peer_count) + (game.installed ? 1 : 0);
const lanCount = Math.max(totalPeerCount + 1, installedCount);
const timer = isStarted
? <div className="ctp-card-timer" data-urgency="off">Launching</div>
: isDone
? <div className="ctp-card-timer" data-urgency="off">Ready</div>
: isScheduled
? (
<div className="ctp-card-timer is-sched" data-urgency="off">
<span className="ctp-card-clock">{formatClock(nomination.scheduledFor!)}</span>
<span className="ctp-card-until">{formatUntil(nomination.scheduledFor! - now)}</span>
</div>
)
: isCheckin
? (
<div className="ctp-card-timer is-sched" data-urgency={urgency}>
<span className="ctp-card-clock">{formatCountdown(remaining)}</span>
<span className="ctp-card-until">starts {formatClock(nomination.scheduledFor!)}</span>
</div>
)
: <div className="ctp-card-timer" data-urgency={urgency}>{formatCountdown(remaining)}</div>;
const rosterLabel = isScheduled
? `${entries.length} in · up to ${nomination.maxPlayers} players`
: isCheckin && inCount > 0
? `${readyCount}/${nomination.maxPlayers} ready · ${inCount} not checked in yet`
: `${readyCount}/${nomination.maxPlayers} ready`;
return (
<div
ref={cardRef}
className={`ctp-card ${isDone ? 'is-done' : ''} ${isStarted ? 'is-started' : ''} ${isCheckin ? 'is-checkin' : ''} ${focused ? 'is-focused' : ''}`}
>
<div className="ctp-card-top">
<div className="ctp-card-cover">
<GameCover game={game} aspect="square" thumbnailUrl={thumbnailUrl} />
</div>
<div className="ctp-card-info">
<div className="ctp-card-title">{game.name}</div>
<div className="ctp-card-sub">
{nomination.scheduledFor === null ? 'Called' : 'Scheduled'} by{' '}
<strong>{nomination.creator}</strong>
{nomination.scheduledFor !== null && ` · starts at ${formatClock(nomination.scheduledFor)}`}
{` · ${installedCount}/${lanCount} peers have it installed`}
</div>
</div>
{timer}
</div>
{isCheckin && (
<div className="ctp-checkin-note">
<Icon.clock />
<span>{isMe && myStatus.status === 'in'
? 'Starting soon — you said youre in. Check in below.'
: 'Starting soon — check-in is open.'}</span>
</div>
)}
{!isScheduled && (
<div className="ctp-progress">
<div
className="ctp-progress-fill"
style={{
width: `${isStarted || isDone ? 100 : percentage}%`,
background: isStarted || isDone ? 'var(--ok)' : 'var(--accent)',
}}
/>
</div>
)}
<div className="ctp-roster">
<div className="ctp-roster-count">{rosterLabel}</div>
<div className="ctp-avatars">
{entries.map(([name, participant]) => (
<AvatarChip key={name} name={name} participant={participant} now={now} />
))}
{Array.from({ length: Math.max(0, nomination.maxPlayers - entries.length) })
.map((_, index) => (
<div key={index} className="ctp-avatar ctp-avatar-empty" />
))}
</div>
</div>
<div className="ctp-actions">
<CardActions
nomination={nomination}
game={game}
username={username}
actions={actions}
onLaunch={onLaunch}
now={now}
/>
</div>
<CtpChat
nomination={nomination}
username={username}
disabled={isStarted}
onSend={text => actions.sendMessage(nomination.id, text)}
/>
{isCreator && !isStarted && (
confirmCancel
? (
<div className="ctp-cancel-confirm">
<span>Cancel this call for everyone?</span>
<div className="ctp-cancel-confirm-btns">
<button
className="ghost-btn ghost-danger"
onClick={() => actions.cancel(nomination.id)}
>Yes, cancel it</button>
<button className="ghost-btn" onClick={() => setConfirmCancel(false)}>
No, keep it
</button>
</div>
</div>
)
: (
<button className="ctp-cancel-link" onClick={() => setConfirmCancel(true)}>
<Icon.trash /><span>Cancel this call</span>
</button>
)
)}
</div>
);
};
interface CardActionsProps {
nomination: Nomination;
game: Game;
username: string;
actions: CallToPlayActions;
onLaunch: (game: Game) => void;
now: number;
}
const ReadyButtons = ({ nomination, actions, includeThirty = false }: {
nomination: Nomination;
actions: CallToPlayActions;
includeThirty?: boolean;
}) => (
<>
<button className="act-btn act-play" onClick={() => actions.respond(nomination.id, 'ready')}>
<Icon.play /><span>Ready now</span>
</button>
<div className="ctp-buffer-group">
{[5, 10, 15, ...(includeThirty ? [30] : [])].map(minutes => (
<button
key={minutes}
className="ctp-buffer-btn"
onClick={() => actions.respond(nomination.id, minutes)}
>+{minutes}m</button>
))}
</div>
</>
);
const CardActions = ({
nomination,
game,
username,
actions,
onLaunch,
now,
}: CardActionsProps) => {
const myStatus = nomination.participants[username];
const isMe = myStatus !== undefined;
const isCreator = nomination.creator === username;
const isDone = nomination.state === 'done';
const isStarted = nomination.state === 'started';
const scheduled = phaseOf(nomination, now) === 'scheduled' && !isDone && !isStarted;
const readyCount = readyCountOf(nomination, now);
if (isStarted) {
return <div className="ctp-note ctp-note-launch">Launching {game.name}</div>;
}
if (scheduled) {
if (isCreator) {
return (
<div className="ctp-note">
Scheduled for {formatClock(nomination.scheduledFor!)} check-in opens 15 min before start.
</div>
);
}
if (isMe) {
return (
<>
<div className="ctp-me-status">Youre in well nudge you when check-in opens</div>
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(nomination.id)}>
Cant make it
</button>
</>
);
}
return (
<>
<button className="act-btn act-play" onClick={() => actions.rsvp(nomination.id)}>
<Icon.check /><span>Im in</span>
</button>
<div className="ctp-note">Check-in opens 15 min before start</div>
</>
);
}
if (isCreator) {
if (isDone) {
return (
<>
<button
className="act-btn act-play"
onClick={() => void actions.startNow(nomination.id).then(accepted => {
if (accepted) onLaunch(game);
})}
><Icon.play /><span>Start now</span></button>
<button className="ghost-btn" onClick={() => actions.addTime(nomination.id)}>
Add 5 more minutes
</button>
</>
);
}
if (myStatus?.status === 'in') {
return <ReadyButtons nomination={nomination} actions={actions} />;
}
return <div className="ctp-note">Waiting for players to ready up</div>;
}
if (isDone) {
return (
<div className="ctp-note">
{readyCount >= nomination.maxPlayers
? 'Everyones ready.'
: `Its time — waiting for ${nomination.creator} to start.`}
</div>
);
}
if (isMe) {
if (myStatus.status === 'in') {
return (
<>
<div className="ctp-me-status">You said youre in check in:</div>
<ReadyButtons nomination={nomination} actions={actions} />
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(nomination.id)}>
Leave
</button>
</>
);
}
const ready = isReady(myStatus, now);
return (
<>
<div className="ctp-me-status">
{ready ? 'Youre in — ready' : `You: ready in ${formatCountdown((myStatus.readyAt ?? now) - now)}`}
</div>
{!ready && (
<button className="ghost-btn" onClick={() => actions.respond(nomination.id, 'ready')}>
Im ready now
</button>
)}
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(nomination.id)}>
Leave
</button>
</>
);
}
return <ReadyButtons nomination={nomination} actions={actions} includeThirty />;
};
@@ -3,9 +3,10 @@ import { SegmentedFilters } from './SegmentedFilters';
import { SearchField } from './SearchField';
import { SortMenu } from './SortMenu';
import { KebabMenu, KebabItem } from './KebabMenu';
import { CallToPlayButton } from '../calltoplay/CallToPlayButton';
import { FilterCounts } from '../../lib/gameState';
import { GameFilter, GameSort } from '../../lib/types';
import { GameFilter, GameSort, Nomination } from '../../lib/types';
interface Props {
accent: string;
@@ -18,6 +19,8 @@ interface Props {
sort: GameSort;
setSort: (value: GameSort) => void;
kebabItems: ReadonlyArray<KebabItem>;
nominations: ReadonlyArray<Nomination>;
onOpenCallToPlay: () => void;
}
export const TopBar = ({
@@ -31,6 +34,8 @@ export const TopBar = ({
sort,
setSort,
kebabItems,
nominations,
onOpenCallToPlay,
}: Props) => (
<header className="topbar">
<div className="topbar-left">
@@ -47,6 +52,7 @@ export const TopBar = ({
<SortMenu value={sort} onChange={setSort} />
</div>
<div className="topbar-right-tail">
<CallToPlayButton nominations={nominations} onClick={onOpenCallToPlay} />
<KebabMenu items={kebabItems} />
</div>
</div>
@@ -0,0 +1,173 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen, UnlistenFn } from '@tauri-apps/api/event';
import { callToPlayEvent, reduceCallToPlayEvents } from '../lib/callToPlay';
import { CallToPlayAction, CallToPlayEvent, Nomination } from '../lib/types';
export interface CallToPlayActions {
createNomination: (
gameId: string,
maxPlayers: number,
durationMinutes: number,
scheduledFor: number | null,
) => void;
respond: (callId: string, choice: 'ready' | number) => void;
rsvp: (callId: string) => void;
sendMessage: (callId: string, text: string) => void;
leave: (callId: string) => void;
cancel: (callId: string) => void;
startNow: (callId: string) => Promise<boolean>;
addTime: (callId: string, minutes?: number) => void;
}
export interface UseCallToPlay {
nominations: Nomination[];
actions: CallToPlayActions;
username: string;
transportReady: boolean;
error: string | null;
}
const mergeEvents = (
previous: ReadonlyMap<string, CallToPlayEvent>,
incoming: ReadonlyArray<CallToPlayEvent>,
): Map<string, CallToPlayEvent> => {
const next = new Map(previous);
for (const event of incoming) next.set(event.id, event);
return next;
};
export const useCallToPlay = (username: string): UseCallToPlay => {
const actor = useMemo(() => {
const trimmed = username.trim();
return trimmed ? Array.from(trimmed).slice(0, 24).join('') : 'Commander';
}, [username]);
const [events, setEvents] = useState<ReadonlyMap<string, CallToPlayEvent>>(() => new Map());
const [now, setNow] = useState(Date.now());
const [transportReady, setTransportReady] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1_000);
return () => window.clearInterval(timer);
}, []);
useEffect(() => {
let cancelled = false;
let unlisten: UnlistenFn | undefined;
let retry: number | undefined;
const requestSnapshot = async (): Promise<boolean> => {
try {
const ready = await invoke<boolean>('request_call_to_play_events');
if (cancelled) return false;
setTransportReady(ready);
if (ready && retry !== undefined) {
window.clearInterval(retry);
retry = undefined;
}
return ready;
} catch (err) {
if (!cancelled) {
setTransportReady(false);
console.error('request_call_to_play_events failed:', err);
}
return false;
}
};
const register = async () => {
try {
unlisten = await listen<CallToPlayEvent[]>('call-to-play-events', event => {
setEvents(previous => mergeEvents(previous, event.payload));
});
if (cancelled) {
unlisten();
return;
}
const ready = await requestSnapshot();
if (!cancelled && !ready) {
retry = window.setInterval(() => void requestSnapshot(), 2_000);
}
} catch (err) {
console.error('Failed to register Call to Play listener:', err);
if (!cancelled) setError('Call to Play networking is unavailable.');
}
};
void register();
return () => {
cancelled = true;
unlisten?.();
if (retry !== undefined) window.clearInterval(retry);
};
}, []);
const publish = useCallback(async (
callId: string,
action: CallToPlayAction,
): Promise<boolean> => {
const event = callToPlayEvent(callId, actor, action);
try {
const accepted = await invoke<boolean>('publish_call_to_play', { event });
if (!accepted) {
setTransportReady(false);
setError('Call to Play needs an active LAN peer. Choose a game folder first.');
return false;
}
setTransportReady(true);
setError(null);
return true;
} catch (err) {
console.error('publish_call_to_play failed:', err);
setError('Could not send this Call to Play update.');
return false;
}
}, [actor]);
const actions = useMemo<CallToPlayActions>(() => ({
createNomination: (gameId, maxPlayers, durationMinutes, scheduledFor) => {
const createdAt = Date.now();
const callId = globalThis.crypto.randomUUID();
const deadline = scheduledFor ?? createdAt + durationMinutes * 60_000;
void publish(callId, {
Create: {
game_id: gameId,
max_players: Math.max(2, Math.min(64, Math.round(maxPlayers))),
scheduled_for: scheduledFor,
deadline,
},
});
},
respond: (callId, choice) => void publish(callId, {
Respond: {
ready_at: choice === 'ready' ? null : Date.now() + choice * 60_000,
},
}),
rsvp: callId => void publish(callId, 'Rsvp'),
sendMessage: (callId, text) => {
const trimmed = text.trim().slice(0, 500);
if (!trimmed) return;
void publish(callId, {
SendMessage: {
message_id: globalThis.crypto.randomUUID(),
text: trimmed,
},
});
},
leave: callId => void publish(callId, 'Leave'),
cancel: callId => void publish(callId, 'Cancel'),
startNow: callId => publish(callId, 'Start'),
addTime: (callId, minutes = 5) => void publish(callId, {
AddTime: { deadline: Date.now() + minutes * 60_000 },
}),
}), [publish]);
const nominations = useMemo(
() => reduceCallToPlayEvents([...events.values()], now),
[events, now],
);
return { nominations, actions, username: actor, transportReady, error };
};
@@ -0,0 +1,290 @@
import {
CallToPlayAction,
CallToPlayEvent,
CallToPlayParticipant,
Nomination,
} from './types';
export const CHECKIN_LEAD_MS = 15 * 60_000;
export const STARTED_RETENTION_MS = 3_000;
export type CallToPlayPhase = 'now' | 'scheduled' | 'checkin';
export type CallToPlayStatus = 'started' | 'ready' | 'soon' | 'scheduled' | 'call';
interface MutableNomination extends Nomination {
cancelled: boolean;
messageIds: Set<string>;
}
const compareEvents = (a: CallToPlayEvent, b: CallToPlayEvent): number =>
a.at - b.at || a.id.localeCompare(b.id);
type CreatePayload = Extract<CallToPlayAction, { Create: unknown }>['Create'];
type RespondPayload = Extract<CallToPlayAction, { Respond: unknown }>['Respond'];
type MessagePayload = Extract<CallToPlayAction, { SendMessage: unknown }>['SendMessage'];
type AddTimePayload = Extract<CallToPlayAction, { AddTime: unknown }>['AddTime'];
const createPayload = (action: CallToPlayAction): CreatePayload | null =>
typeof action === 'object' && 'Create' in action ? action.Create : null;
const respondPayload = (action: CallToPlayAction): RespondPayload | null =>
typeof action === 'object' && 'Respond' in action ? action.Respond : null;
const messagePayload = (action: CallToPlayAction): MessagePayload | null =>
typeof action === 'object' && 'SendMessage' in action ? action.SendMessage : null;
const addTimePayload = (action: CallToPlayAction): AddTimePayload | null =>
typeof action === 'object' && 'AddTime' in action ? action.AddTime : null;
export const phaseOf = (nomination: Nomination, now: number): CallToPlayPhase => {
if (nomination.scheduledFor === null) return 'now';
return now < nomination.scheduledFor - CHECKIN_LEAD_MS ? 'scheduled' : 'checkin';
};
export const isReady = (
participant: CallToPlayParticipant | undefined,
now: number,
): boolean => Boolean(
participant && (
participant.status === 'ready'
|| (participant.readyAt !== undefined && now >= participant.readyAt)
),
);
export const readyCountOf = (nomination: Nomination, now: number): number =>
Object.values(nomination.participants).filter(participant => isReady(participant, now)).length;
export const inCountOf = (nomination: Nomination, now: number): number =>
Object.values(nomination.participants).filter(participant =>
!isReady(participant, now) && participant.status === 'in'
).length;
export const statusOf = (nomination: Nomination, now: number): CallToPlayStatus => {
if (nomination.state === 'started') return 'started';
if (nomination.state === 'done' || readyCountOf(nomination, now) >= nomination.maxPlayers) {
return 'ready';
}
if (nomination.deadline - now <= CHECKIN_LEAD_MS) return 'soon';
return nomination.scheduledFor === null ? 'call' : 'scheduled';
};
export const reduceCallToPlayEvents = (
input: ReadonlyArray<CallToPlayEvent>,
now: number,
): Nomination[] => {
const unique = new Map(input.map(event => [event.id, event]));
const byCall = new Map<string, CallToPlayEvent[]>();
for (const event of unique.values()) {
const events = byCall.get(event.call_id) ?? [];
events.push(event);
byCall.set(event.call_id, events);
}
const nominations: Nomination[] = [];
for (const events of byCall.values()) {
events.sort(compareEvents);
const create = events.find(event => createPayload(event.action) !== null);
if (!create) continue;
const payload = createPayload(create.action);
if (!payload) continue;
const nomination: MutableNomination = {
id: create.call_id,
gameId: payload.game_id,
creator: create.actor,
maxPlayers: payload.max_players,
createdAt: create.at,
scheduledFor: payload.scheduled_for,
deadline: payload.deadline,
participants: {
[create.actor]: {
status: payload.scheduled_for === null ? 'ready' : 'in',
joinedAt: create.at,
},
},
messages: [],
state: 'open',
cancelled: false,
messageIds: new Set(),
};
for (const event of events) {
if (compareEvents(event, create) <= 0 || nomination.cancelled) continue;
applyEvent(nomination, event);
}
if (nomination.cancelled) continue;
if (nomination.state === 'open'
&& (readyCountOf(nomination, now) >= nomination.maxPlayers || now >= nomination.deadline)
) {
nomination.state = 'done';
}
if (nomination.state === 'started'
&& now - (nomination.startedAt ?? now) > STARTED_RETENTION_MS
) {
continue;
}
const { cancelled: _, messageIds: __, ...result } = nomination;
nominations.push(result);
}
return nominations.sort((a, b) => b.createdAt - a.createdAt || a.id.localeCompare(b.id));
};
const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void => {
const action = event.action;
if (typeof action === 'string') {
applyUnitAction(nomination, event, action);
return;
}
const response = respondPayload(action);
if (response) {
if (nomination.state === 'started') return;
const existing = nomination.participants[event.actor];
nomination.participants[event.actor] = {
status: response.ready_at === null ? 'ready' : 'pending',
joinedAt: existing?.joinedAt ?? event.at,
...(response.ready_at === null ? {} : { readyAt: response.ready_at }),
};
return;
}
const message = messagePayload(action);
if (message && nomination.state !== 'started' && !nomination.messageIds.has(message.message_id)) {
nomination.messageIds.add(message.message_id);
nomination.messages.push({
id: message.message_id,
from: event.actor,
text: message.text,
at: event.at,
});
nomination.messages.sort((a, b) => a.at - b.at || a.id.localeCompare(b.id));
return;
}
const extension = addTimePayload(action);
if (extension && event.actor === nomination.creator && nomination.state !== 'started') {
nomination.deadline = extension.deadline;
nomination.state = 'open';
}
};
const applyUnitAction = (
nomination: MutableNomination,
event: CallToPlayEvent,
action: Extract<CallToPlayAction, string>,
): void => {
switch (action) {
case 'Rsvp': {
if (nomination.state === 'started') return;
const existing = nomination.participants[event.actor];
nomination.participants[event.actor] = {
status: 'in',
joinedAt: existing?.joinedAt ?? event.at,
};
break;
}
case 'Leave':
if (event.actor !== nomination.creator && nomination.state !== 'started') {
delete nomination.participants[event.actor];
}
break;
case 'Cancel':
if (event.actor === nomination.creator && nomination.state !== 'started') {
nomination.cancelled = true;
}
break;
case 'Start':
if (event.actor === nomination.creator && nomination.state !== 'started') {
nomination.state = 'started';
nomination.startedAt = event.at;
}
break;
}
};
export const callToPlayEvent = (
callId: string,
actor: string,
action: CallToPlayAction,
at = Date.now(),
): CallToPlayEvent => ({
id: globalThis.crypto.randomUUID(),
call_id: callId,
actor,
at,
action,
});
export const formatClock = (timestamp: number): string => {
const date = new Date(timestamp);
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
};
export const formatCountdown = (milliseconds: number): string => {
if (milliseconds <= 0) return '0:00';
const total = Math.ceil(milliseconds / 1_000);
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
};
export const formatCountdownShort = (milliseconds: number): string => {
if (milliseconds <= 0) return 'now';
const total = Math.ceil(milliseconds / 1_000);
return total < 60 ? `${total}s` : `${Math.ceil(total / 60)}m`;
};
export const formatUntil = (milliseconds: number): string => {
if (milliseconds <= 60_000) return 'in <1 min';
const minutes = Math.round(milliseconds / 60_000);
if (minutes < 60) return `in ${minutes} min`;
const hours = Math.floor(minutes / 60);
const remainder = minutes % 60;
return remainder ? `in ${hours}h ${remainder}m` : `in ${hours}h`;
};
const AVATAR_COLORS = [
'#60a5fa', '#34d399', '#c084fc', '#fbbf24',
'#f472b6', '#38bdf8', '#a3e635', '#fb7185',
];
export const avatarColor = (name: string): string => {
const hash = Array.from(name).reduce((sum, character) => sum + character.charCodeAt(0), 0);
return AVATAR_COLORS[hash % AVATAR_COLORS.length];
};
export const sanitizeTimeDraft = (raw: string, previous: string): string => {
const value = raw.replace(/[^\d:]/g, '');
if ((value.match(/:/g) ?? []).length > 1) return previous;
const colon = value.indexOf(':');
if (colon !== -1) {
if (value.slice(0, colon).length > 2 || value.slice(colon + 1).length > 2) {
return previous;
}
return value;
}
return value.length > 4 ? previous : value;
};
export const normalizeTimeInput = (raw: string): string | null => {
const match = raw.trim().match(/^(\d{1,2})(?:[:.\s]?(\d{2}))?$/);
if (!match) return null;
const hours = Number(match[1]);
const minutes = Number(match[2] ?? 0);
if (hours > 23 || minutes > 59) return null;
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
};
export const bumpTime = (
time: string,
field: 'hours' | 'minutes',
delta: number,
): string => {
let [hours, minutes] = time.split(':').map(Number);
if (field === 'hours') {
hours = (hours + delta + 24) % 24;
} else {
const total = ((hours * 60 + minutes + delta) % 1_440 + 1_440) % 1_440;
hours = Math.floor(total / 60);
minutes = total % 60;
}
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
};
@@ -60,6 +60,7 @@ export interface Game {
peer_count: number;
can_host_server?: boolean;
active_outbound_transfers?: number;
installed_peer_count?: number;
}
export interface ActiveOperation {
@@ -83,3 +84,50 @@ export type DerivedState = 'installed' | 'local' | 'downloading' | 'none' | 'bus
/** Two-character language code passed through to game scripts. */
export type LauncherLanguage = 'en' | 'de';
export type CallToPlayParticipantStatus = 'ready' | 'in' | 'pending';
export interface CallToPlayParticipant {
status: CallToPlayParticipantStatus;
joinedAt: number;
readyAt?: number;
}
export interface CallToPlayMessage {
id: string;
from: string;
text: string;
at: number;
}
export interface Nomination {
id: string;
gameId: string;
creator: string;
maxPlayers: number;
createdAt: number;
scheduledFor: number | null;
deadline: number;
participants: Record<string, CallToPlayParticipant>;
messages: CallToPlayMessage[];
state: 'open' | 'done' | 'started';
startedAt?: number;
}
export type CallToPlayAction =
| { Create: { game_id: string; max_players: number; scheduled_for: number | null; deadline: number } }
| { Respond: { ready_at: number | null } }
| 'Rsvp'
| { SendMessage: { message_id: string; text: string } }
| 'Leave'
| 'Cancel'
| 'Start'
| { AddTime: { deadline: number } };
export interface CallToPlayEvent {
id: string;
call_id: string;
actor: string;
at: number;
action: CallToPlayAction;
}
@@ -1783,3 +1783,645 @@
background: var(--accent);
border-color: transparent;
}
/* ═══════════════════════════════════════════════════════════════════
Call to Play — rally a game + a time
═══════════════════════════════════════════════════════════════════ */
/* ─── Top-bar entry button ─── */
.ctp-btn {
position: relative;
display: inline-flex; align-items: center; gap: 8px;
height: 36px; padding: 0 14px;
background: color-mix(in srgb, var(--accent) 14%, var(--bg-2));
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2));
border-radius: 8px;
color: var(--t-1);
font: inherit; font-size: 12.5px; font-weight: 700;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
transition: background .15s, border-color .15s;
}
.ctp-btn svg { color: var(--accent); flex-shrink: 0; }
.ctp-btn:hover {
background: color-mix(in srgb, var(--accent) 22%, var(--bg-2));
border-color: color-mix(in srgb, var(--accent) 65%, var(--bd-2));
}
.ctp-badge {
display: inline-grid; place-items: center;
min-width: 18px; height: 18px;
padding: 0 5px;
border-radius: 999px;
background: var(--accent);
color: white;
font-size: 10.5px; font-weight: 800;
font-variant-numeric: tabular-nums;
}
/* ─── Ticker strip ─── */
.ctp-ticker-stack {
display: flex; flex-direction: column; gap: 8px;
margin-bottom: 14px;
}
.ctp-ticker-stack .ctp-ticker { margin-bottom: 0; }
.ctp-ticker {
display: grid;
grid-template-columns:
10px /* LED */
98px /* status */
220px /* game */
130px /* by */
88px /* count */
170px /* time */
minmax(0, 1fr) /* chat — sole flexible track, absorbs bubbles variance */
max-content /* bubbles */
82px; /* cta */
align-items: center;
column-gap: 12px;
width: 100%;
padding: 10px 16px;
background: color-mix(in srgb, var(--accent) 10%, var(--bg-2));
border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--bd-2));
border-radius: 10px;
color: var(--t-1);
font: inherit; font-size: 12.5px;
cursor: pointer;
text-align: left;
transition: background .15s, border-color .15s;
}
.ctp-ticker:hover { background: color-mix(in srgb, var(--accent) 16%, var(--bg-2)); }
.ctp-ticker-dot {
width: 8px; height: 8px; border-radius: 999px; flex-shrink: 0;
background: var(--accent);
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 60%, transparent);
animation: ctp-tickerpulse 1.6s ease-out infinite;
}
@keyframes ctp-tickerpulse {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); }
70% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 0%, transparent); }
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); }
}
.ctp-ticker-label {
font-weight: 700;
color: var(--accent);
text-transform: uppercase;
font-size: 10.5px;
letter-spacing: 0.06em;
flex-shrink: 0;
}
.ctp-ticker-game { font-weight: 700; color: var(--t-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ctp-ticker-by { color: var(--t-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ctp-ticker-sep { display: none; }
.ctp-ticker-ready, .ctp-ticker-time { color: var(--t-2); font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ctp-ticker-more {
color: var(--t-3);
font-size: 11.5px;
flex-shrink: 0;
}
.ctp-ticker-cta {
display: inline-flex; align-items: center; justify-content: flex-end; gap: 4px;
font-weight: 700;
color: var(--accent);
}
.ctp-ticker-cta svg { transform: rotate(-90deg); }
/* ─── Overlay modal ─── */
.ctp-modal { width: min(720px, 100%); }
.ctp-head-row {
display: flex; align-items: center; justify-content: space-between;
gap: 12px;
margin-right: 36px;
}
.ctp-head-new { height: 40px; padding: 0 18px; font-size: 13px; margin-top: 14px; width: 100%; }
.ctp-head { padding: 26px 28px 14px; border-bottom: 1px solid var(--bd-1); }
.ctp-head h2 { margin: 0; font-size: 20px; font-weight: 700; letter-spacing: -0.01em; color: var(--t-1); }
.ctp-head-sub { margin: 6px 0 0; font-size: 12.5px; color: var(--t-3); max-width: 52ch; }
.ctp-body {
padding: 18px 24px 24px;
display: flex; flex-direction: column; gap: 14px;
max-height: 66vh;
overflow: auto;
}
.ctp-empty {
padding: 28px 8px;
text-align: center;
color: var(--t-3);
font-size: 13px;
}
/* ─── Nomination card ─── */
.ctp-card {
display: flex; flex-direction: column; gap: 12px;
padding: 14px;
background: rgba(255,255,255,0.025);
border: 1px solid var(--bd-1);
border-radius: 12px;
}
.ctp-card.is-done { border-color: color-mix(in srgb, var(--ok) 45%, var(--bd-2)); }
.ctp-card.is-started { opacity: 0.6; }
.ctp-card.is-focused { border-color: var(--accent); animation: ctp-cardflash 1.4s ease-out 1; }
@keyframes ctp-cardflash {
0% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 55%, transparent); }
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); }
}
.ctp-card-top { display: flex; align-items: flex-start; gap: 12px; position: relative; }
.ctp-card-cover {
position: relative;
width: 52px; height: 52px;
flex-shrink: 0;
border-radius: 8px;
overflow: hidden;
}
.ctp-card-info { flex: 1; min-width: 0; }
.ctp-card-title { font-size: 14.5px; font-weight: 700; color: var(--t-1); }
.ctp-card-sub { margin-top: 3px; font-size: 11.5px; color: var(--t-3); }
.ctp-card-sub strong { color: var(--t-2); font-weight: 700; }
.ctp-card-timer {
font-variant-numeric: tabular-nums;
font-size: 18px;
font-weight: 700;
color: var(--t-1);
flex-shrink: 0;
padding-top: 2px;
}
.ctp-card-timer[data-urgency="mid"] { color: var(--warn); }
.ctp-card-timer[data-urgency="high"] { color: var(--danger); }
.ctp-card.is-done .ctp-card-timer { color: var(--ok); font-size: 14px; text-transform: uppercase; letter-spacing: 0.04em; }
.ctp-cancel-link {
display: inline-flex; align-items: center; gap: 6px;
align-self: flex-start;
margin-top: -2px;
padding: 6px 2px;
background: transparent;
border: 0;
color: var(--t-3);
font: inherit; font-size: 11.5px; font-weight: 600;
cursor: pointer;
transition: color .15s;
}
.ctp-cancel-link:hover { color: #fca5a5; }
.ctp-cancel-confirm {
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap;
gap: 10px;
padding: 10px 12px;
background: rgba(239,68,68,0.08);
border: 1px solid rgba(239,68,68,0.35);
border-radius: 8px;
font-size: 12.5px;
color: #fca5a5;
font-weight: 600;
}
.ctp-cancel-confirm-btns { display: inline-flex; gap: 8px; flex-shrink: 0; }
.ctp-cancel-confirm-btns .ghost-btn { height: 32px; padding: 0 12px; font-size: 12px; }
.ctp-progress {
height: 5px;
border-radius: 3px;
background: rgba(255,255,255,0.06);
overflow: hidden;
}
.ctp-progress-fill { height: 100%; transition: width 1s linear; }
.ctp-roster { display: flex; flex-direction: column; gap: 8px; }
.ctp-roster-count { font-size: 11px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--t-3); }
.ctp-avatars { display: flex; flex-wrap: wrap; gap: 6px; }
.ctp-avatar {
position: relative;
display: inline-flex;
}
.ctp-avatar-dot {
width: 30px; height: 30px;
display: grid; place-items: center;
border-radius: 999px;
color: white;
font-size: 10.5px; font-weight: 800;
letter-spacing: 0.01em;
}
.ctp-avatar.is-pending .ctp-avatar-dot { opacity: 0.55; }
.ctp-avatar-check {
position: absolute; bottom: -2px; right: -2px;
width: 14px; height: 14px;
display: grid; place-items: center;
border-radius: 999px;
background: var(--ok);
color: #06240f;
border: 2px solid var(--bg-2);
}
.ctp-avatar-pending {
position: absolute; bottom: -6px; left: 50%;
transform: translateX(-50%);
font-size: 9px; font-weight: 700;
color: var(--t-2);
background: var(--bg-3);
border: 1px solid var(--bd-2);
padding: 0 4px;
border-radius: 999px;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.ctp-avatar-empty {
width: 30px; height: 30px;
border-radius: 999px;
border: 1.5px dashed var(--bd-2);
}
.ctp-actions {
display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
padding-top: 2px;
}
.ctp-actions .act-btn { height: 36px; padding: 0 16px; font-size: 12.5px; }
.ctp-actions .ghost-btn { height: 36px; padding: 0 14px; font-size: 12.5px; }
.ctp-note { font-size: 12.5px; color: var(--t-3); }
.ctp-note-launch { color: var(--ok); font-weight: 600; }
.ctp-me-status { font-size: 12.5px; font-weight: 600; color: var(--t-2); }
.ctp-buffer-group { display: inline-flex; gap: 6px; }
.ctp-buffer-btn {
height: 36px; padding: 0 11px;
background: rgba(255,255,255,0.04);
border: 1px solid var(--bd-2);
border-radius: 7px;
color: var(--t-1);
font: inherit; font-size: 12px; font-weight: 600;
cursor: pointer;
transition: background .15s, border-color .15s;
}
.ctp-buffer-btn:hover { background: rgba(255,255,255,0.08); border-color: var(--bd-3); }
/* ─── Create-nomination form ─── */
.ctp-create-cta {
display: flex; align-items: center; justify-content: center; gap: 8px;
height: 48px;
background: transparent;
border: 1.5px dashed var(--bd-3);
border-radius: 12px;
color: var(--t-2);
font: inherit; font-size: 13px; font-weight: 700;
cursor: pointer;
transition: border-color .15s, color .15s, background .15s;
}
.ctp-create-cta:hover { border-color: var(--accent); color: var(--t-1); background: rgba(255,255,255,0.02); }
.ctp-create {
display: flex; flex-direction: column; gap: 14px;
padding: 16px;
background: rgba(255,255,255,0.03);
border: 1px solid var(--bd-2);
border-radius: 12px;
}
.ctp-create-row { display: flex; flex-direction: column; gap: 6px; position: relative; }
.ctp-create-row-inline { flex-direction: row; align-items: center; gap: 10px; flex-wrap: wrap; }
.ctp-create-label {
font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
color: var(--t-3);
}
.ctp-create-search { width: 100%; }
.ctp-create-matches {
position: absolute;
top: calc(100% + 4px);
left: 0; right: 0;
z-index: 20;
max-height: 220px;
overflow: auto;
padding: 4px;
background: var(--bg-3);
border: 1px solid var(--bd-2);
border-radius: 10px;
box-shadow: 0 16px 40px -8px rgba(0,0,0,0.5);
}
.ctp-create-matches button {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
width: 100%;
padding: 9px 10px;
background: transparent;
border: 0;
border-radius: 6px;
color: var(--t-1);
font: inherit; font-size: 12.5px; font-weight: 600;
text-align: left;
cursor: pointer;
}
.ctp-create-matches button:hover { background: rgba(255,255,255,0.06); }
.ctp-create-match-meta { color: var(--t-3); font-size: 11px; font-weight: 500; }
.ctp-create-nomatch { padding: 10px; font-size: 12px; color: var(--t-3); }
.ctp-create-num {
width: 70px; height: 34px;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 7px;
color: var(--t-1);
font: inherit; font-size: 13px; font-weight: 600;
text-align: center;
}
.ctp-create-hint { font-size: 11.5px; color: var(--t-3); }
.ctp-duration-opts { display: inline-flex; gap: 6px; flex-wrap: wrap; }
.ctp-duration-btn {
height: 32px; padding: 0 13px;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 7px;
color: var(--t-2);
font: inherit; font-size: 12.5px; font-weight: 700;
cursor: pointer;
transition: background .15s, color .15s, border-color .15s;
}
.ctp-duration-btn:hover { color: var(--t-1); }
.ctp-duration-btn.is-active { color: white; }
.ctp-create-foot {
display: flex; justify-content: flex-end; gap: 10px;
padding-top: 4px;
}
.ctp-create-foot .act-btn,
.ctp-create-foot .ghost-btn { height: 40px; padding: 0 18px; }
.ctp-time-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 4px; }
/* ─── Time stepper ─── */
.ctp-timepick {
display: flex; align-items: center; gap: 6px;
margin-top: 6px;
}
.ctp-timepick-field {
display: flex; flex-direction: column; align-items: center; gap: 3px;
}
.ctp-time-step {
width: 54px; height: 24px;
display: grid; place-items: center;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 7px;
color: var(--t-2);
cursor: pointer;
transition: background .12s, color .12s, border-color .12s;
}
.ctp-time-step:hover { background: var(--bg-4); color: var(--t-1); border-color: var(--bd-3); }
.ctp-time-step:active { background: color-mix(in srgb, var(--accent) 30%, var(--bg-3)); }
.ctp-time-cell {
width: 54px; height: 44px;
display: grid; place-items: center;
background: var(--bg-3);
border: 1px solid var(--bd-2);
border-radius: 9px;
font-size: 26px; font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--t-1);
letter-spacing: 0.02em;
}
.ctp-time-editinput {
width: 132px; height: 44px;
padding: 0;
background: var(--bg-3);
border: 1px solid color-mix(in srgb, var(--accent) 70%, var(--bd-2));
border-radius: 9px;
font: inherit; font-size: 26px; font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--t-1);
text-align: center;
letter-spacing: 0.06em;
}
.ctp-time-editinput:focus { outline: none; }
.ctp-time-editinput::placeholder { color: var(--t-4); }
.ctp-time-colon { font-size: 26px; font-weight: 700; color: var(--t-2); padding-bottom: 2px; }
.ctp-time-type {
align-self: center;
margin-left: 8px;
height: 30px; padding: 0 12px;
background: transparent;
border: 1px dashed var(--bd-3);
border-radius: 7px;
color: var(--t-3);
font: inherit; font-size: 11.5px; font-weight: 600;
cursor: pointer;
transition: color .12s, border-color .12s;
}
.ctp-time-type:hover { color: var(--t-1); border-color: var(--accent); }
.ctp-day-row { gap: 5px; }
.ctp-day-label {
font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
color: var(--t-3);
margin-right: 4px;
}
.ctp-day-btn { height: 26px; padding: 0 10px; font-size: 11px; border-radius: 6px; }
/* ─── Quick-bar status variants — LED + label + row tint per status:
SCHEDULED (neutral) · CALL TO PLAY (accent, pulsing) ·
STARTING SOON (amber, glowing) · READY (green, steady) ─── */
.ctp-ticker[data-status="scheduled"] {
background: var(--bg-2);
border-color: var(--bd-2);
}
.ctp-ticker[data-status="scheduled"]:hover { background: var(--bg-3); }
.ctp-ticker[data-status="soon"] {
background: color-mix(in srgb, var(--warn) 10%, var(--bg-2));
border-color: color-mix(in srgb, var(--warn) 50%, var(--bd-2));
animation: ctp-soon-glow 2.4s ease-in-out infinite;
}
.ctp-ticker[data-status="soon"]:hover { background: color-mix(in srgb, var(--warn) 16%, var(--bg-2)); }
@keyframes ctp-soon-glow {
0%, 100% { box-shadow: 0 0 0 0 transparent; }
50% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 28%, transparent); }
}
.ctp-ticker[data-status="ready"] {
background: color-mix(in srgb, var(--ok) 11%, var(--bg-2));
border-color: color-mix(in srgb, var(--ok) 55%, var(--bd-2));
box-shadow: 0 0 14px -2px color-mix(in srgb, var(--ok) 35%, transparent);
}
.ctp-ticker[data-status="ready"]:hover { background: color-mix(in srgb, var(--ok) 17%, var(--bg-2)); }
.ctp-ticker-dot[data-status="scheduled"] { background: var(--t-3); animation: none; box-shadow: none; }
.ctp-ticker-dot[data-status="soon"] { background: var(--warn); animation: ctp-tickerpulse-warn 1.6s ease-out infinite; }
.ctp-ticker-dot[data-status="ready"] { background: var(--ok); animation: none; box-shadow: 0 0 6px var(--ok); }
@keyframes ctp-tickerpulse-warn {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--warn) 55%, transparent); }
70% { box-shadow: 0 0 0 6px transparent; }
100% { box-shadow: 0 0 0 0 transparent; }
}
.ctp-ticker-label[data-status="scheduled"] { color: var(--t-2); }
.ctp-ticker-label[data-status="soon"] { color: var(--warn); }
.ctp-ticker-label[data-status="ready"] { color: var(--ok); }
.ctp-ticker[data-status="soon"] .ctp-ticker-cta { color: var(--warn); }
.ctp-ticker[data-status="ready"] .ctp-ticker-cta { color: var(--ok); }
/* ─── Quick-bar inline chat preview ─── */
.ctp-ticker-chat {
min-width: 0;
display: inline-flex; align-items: center; gap: 5px;
color: var(--t-3);
font-size: 12px;
}
.ctp-ticker-chat svg { flex-shrink: 0; opacity: 0.7; }
.ctp-ticker-chat b { flex-shrink: 0; font-weight: 700; }
.ctp-ticker-chat-text {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
color: var(--t-2);
}
.ctp-card.is-checkin { border-color: color-mix(in srgb, var(--accent) 70%, var(--bd-2)); }
.ctp-checkin-note {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px;
background: color-mix(in srgb, var(--accent) 13%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 38%, transparent);
border-radius: 8px;
font-size: 12.5px; font-weight: 600;
color: var(--t-1);
}
.ctp-checkin-note svg { color: var(--accent); flex-shrink: 0; }
.ctp-card-timer.is-sched {
display: flex; flex-direction: column; align-items: flex-end; gap: 2px;
}
.ctp-card-clock { font-size: 18px; line-height: 1; }
.ctp-card-until { font-size: 10.5px; font-weight: 600; color: var(--t-3); letter-spacing: 0.01em; }
.ctp-avatar.is-in .ctp-avatar-dot { opacity: 0.8; }
.ctp-avatar-in {
position: absolute; bottom: -6px; left: 50%;
transform: translateX(-50%);
font-size: 8.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em;
color: var(--t-2);
background: var(--bg-3);
border: 1px solid var(--bd-2);
padding: 0 4px;
border-radius: 999px;
white-space: nowrap;
}
/* ─── Ticker mini ready-bubbles ─── */
.ctp-ticker-bubbles {
display: inline-flex; align-items: center;
justify-self: end;
}
.ctp-mini {
position: relative;
width: 22px; height: 22px;
display: grid; place-items: center;
border-radius: 999px;
margin-left: -6px;
border: 2px solid var(--bg-2);
color: white;
font-size: 8px; font-weight: 800; letter-spacing: 0.02em;
}
.ctp-mini:first-child { margin-left: 0; }
.ctp-mini[data-state="ready"] { box-shadow: 0 0 0 1.5px var(--ok); }
.ctp-mini[data-state="pending"] { opacity: 0.75; }
.ctp-mini[data-state="in"] { opacity: 0.85; }
.ctp-mini-check {
position: absolute; bottom: -3px; right: -4px;
width: 11px; height: 11px;
display: grid; place-items: center;
border-radius: 999px;
background: var(--ok);
color: #06240f;
border: 1.5px solid var(--bg-2);
}
.ctp-mini-check svg { width: 7px; height: 7px; }
.ctp-mini-tag {
position: absolute; bottom: -6px; right: -7px;
font-style: normal;
font-size: 8px; font-weight: 700;
line-height: 11px;
padding: 0 3px;
border-radius: 999px;
background: var(--bg-3);
border: 1px solid var(--bd-2);
color: var(--t-2);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.ctp-mini-more { background: var(--bg-4); color: var(--t-2); font-size: 8.5px; }
/* ─── Per-call chat ─── */
.ctp-chat {
display: flex; flex-direction: column;
border-top: 1px solid var(--bd-1);
margin-top: 2px;
padding-top: 8px;
}
.ctp-chat-toggle {
display: flex; align-items: center; gap: 8px;
width: 100%;
padding: 4px 2px;
background: transparent;
border: 0;
color: var(--t-3);
font: inherit; font-size: 11.5px; font-weight: 700;
cursor: pointer;
transition: color .15s;
}
.ctp-chat-toggle:hover { color: var(--t-1); }
.ctp-chat-toggle svg { flex-shrink: 0; }
.ctp-chat-count { color: var(--t-4); font-weight: 600; }
.ctp-chat-unread {
min-width: 16px; height: 16px;
display: grid; place-items: center;
padding: 0 4px;
border-radius: 999px;
background: var(--accent);
color: white;
font-size: 9.5px; font-weight: 800;
flex-shrink: 0;
}
.ctp-chat-preview {
flex: 1; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-weight: 500;
color: var(--t-3);
text-align: left;
}
.ctp-chat-preview b { font-weight: 700; }
.ctp-chat-chevron { margin-left: auto; flex-shrink: 0; transition: transform .15s; }
.ctp-chat.is-open .ctp-chat-chevron { transform: rotate(180deg); }
.ctp-chat-list {
display: flex; flex-direction: column; gap: 6px;
max-height: 168px;
overflow-y: auto;
margin: 8px 0;
padding: 10px 12px;
background: rgba(0,0,0,0.22);
border: 1px solid var(--bd-1);
border-radius: 8px;
}
.ctp-chat-msg { font-size: 12px; line-height: 1.45; color: var(--t-2); overflow-wrap: anywhere; }
.ctp-chat-msg b { font-weight: 700; }
.ctp-chat-text { color: var(--t-1); }
.ctp-chat-time { margin-left: 6px; font-size: 10px; color: var(--t-4); font-variant-numeric: tabular-nums; }
.ctp-chat-empty { font-size: 11.5px; color: var(--t-4); text-align: center; padding: 6px 0; }
.ctp-chat-form { display: flex; gap: 6px; }
.ctp-chat-input {
flex: 1; height: 32px;
padding: 0 10px;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 7px;
color: var(--t-1);
font: inherit; font-size: 12px;
}
.ctp-chat-input::placeholder { color: var(--t-4); }
.ctp-chat-input:focus { outline: none; border-color: color-mix(in srgb, var(--accent) 60%, var(--bd-2)); }
.ctp-chat-send {
width: 32px; height: 32px;
display: grid; place-items: center;
background: color-mix(in srgb, var(--accent) 24%, var(--bg-3));
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2));
border-radius: 7px;
color: var(--t-1);
cursor: pointer;
flex-shrink: 0;
transition: background .15s;
}
.ctp-chat-send:hover { background: color-mix(in srgb, var(--accent) 40%, var(--bg-3)); }
.ctp-transport-note {
margin-top: 10px;
font-size: 11.5px;
color: var(--t-3);
}
.ctp-transport-note.is-error { color: var(--danger); }
@container launcher (max-width: 1280px) {
.ctp-ticker {
grid-template-columns: 10px 86px minmax(120px, 1fr) 78px 130px max-content 70px;
}
.ctp-ticker-by,
.ctp-ticker-chat { display: none; }
}
@container launcher (max-width: 800px) {
.ctp-ticker {
grid-template-columns: 10px 78px minmax(100px, 1fr) 76px 64px;
}
.ctp-ticker-time,
.ctp-ticker-bubbles { display: none; }
}
@@ -10,12 +10,15 @@ import { ConfirmRemoveDownloadModal } from '../components/modals/ConfirmRemoveDo
import { SettingsDialog } from '../components/modals/SettingsDialog';
import { NoDirectoryState } from '../components/empty/NoDirectoryState';
import { EmptyResultsState } from '../components/empty/EmptyResultsState';
import { CallToPlayTicker } from '../components/calltoplay/CallToPlayTicker';
import { CallToPlayOverlay } from '../components/calltoplay/CallToPlayOverlay';
import { useGameDirectory } from '../hooks/useGameDirectory';
import { useGames } from '../hooks/useGames';
import { useGameActions } from '../hooks/useGameActions';
import { useThumbnails } from '../hooks/useThumbnails';
import { useSettings } from '../hooks/useSettings';
import { useCallToPlay } from '../hooks/useCallToPlay';
import { Game } from '../lib/types';
import { applyFilterAndSort, countByFilter, needsUpdate } from '../lib/gameState';
@@ -72,10 +75,13 @@ export const MainWindow = () => {
const games = useGames(rescan);
const actions = useGameActions(games, settings);
const thumbnails = useThumbnails();
const callToPlay = useCallToPlay(settings.username);
const [openGameId, setOpenGameId] = useState<string | null>(null);
const [removeGameId, setRemoveGameId] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [callToPlayOpen, setCallToPlayOpen] = useState(false);
const [focusedCallId, setFocusedCallId] = useState<string | null>(null);
const visibleGames = useMemo(
() => hasGameDirectory ? games.games : [],
[games.games, hasGameDirectory],
@@ -154,8 +160,22 @@ export const MainWindow = () => {
sort={settings.sort}
setSort={(v) => setSetting('sort', v)}
kebabItems={kebabItems}
nominations={callToPlay.nominations}
onOpenCallToPlay={() => {
setFocusedCallId(null);
setCallToPlayOpen(true);
}}
/>
<main className="grid-wrap">
<CallToPlayTicker
nominations={callToPlay.nominations}
games={games.games}
accent={settings.accent}
onOpen={(callId) => {
setFocusedCallId(callId);
setCallToPlayOpen(true);
}}
/>
{hasGameDirectory ? (
<>
<ResultsBar shown={filteredGames.length} total={counts.all} />
@@ -220,6 +240,25 @@ export const MainWindow = () => {
onClose={() => setSettingsOpen(false)}
/>
)}
{callToPlayOpen && (
<CallToPlayOverlay
nominations={callToPlay.nominations}
games={games.games}
username={callToPlay.username}
actions={callToPlay.actions}
focusId={focusedCallId}
transportReady={callToPlay.transportReady}
error={callToPlay.error}
getThumbnail={thumbnails.get}
totalPeerCount={games.totalPeerCount}
onLaunch={handlePrimary}
onClose={() => {
setCallToPlayOpen(false);
setFocusedCallId(null);
}}
/>
)}
</div>
);
};
@@ -0,0 +1,154 @@
import {
CHECKIN_LEAD_MS,
phaseOf,
bumpTime,
normalizeTimeInput,
readyCountOf,
reduceCallToPlayEvents,
statusOf,
} from '../src/lib/callToPlay.ts';
import { type CallToPlayAction, type CallToPlayEvent } from '../src/lib/types.ts';
const NOW = 1_000_000;
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
const assertEquals = <T>(actual: T, expected: T, message: string) => {
if (actual !== expected) throw new Error(`${message}: expected ${expected}, got ${actual}`);
};
const event = (
id: string,
actor: string,
action: CallToPlayAction,
at = NOW,
): CallToPlayEvent => ({ id, call_id: 'call-1', actor, at, action });
const create = (
scheduledFor: number | null = null,
deadline = NOW + 30 * 60_000,
): CallToPlayEvent => event('create', 'Alice', {
Create: {
game_id: 'game-1',
max_players: 3,
scheduled_for: scheduledFor,
deadline,
},
});
Deno.test('play-now call starts with its creator ready', () => {
const [nomination] = reduceCallToPlayEvents([create()], NOW);
assert(nomination, 'call should exist');
assertEquals(nomination.creator, 'Alice', 'creator');
assertEquals(nomination.participants.Alice.status, 'ready', 'creator status');
assertEquals(phaseOf(nomination, NOW), 'now', 'phase');
assertEquals(statusOf(nomination, NOW), 'call', 'status outside starting-soon window');
});
Deno.test('scheduled RSVP becomes check-in and pending response becomes ready over time', () => {
const scheduledFor = NOW + 60 * 60_000;
const events = [
create(scheduledFor, scheduledFor),
event('rsvp', 'Bob', 'Rsvp', NOW + 1),
event('respond', 'Bob', { Respond: { ready_at: scheduledFor - 5 * 60_000 } }, NOW + 2),
];
const [far] = reduceCallToPlayEvents(events, NOW);
assertEquals(phaseOf(far, NOW), 'scheduled', 'far-out phase');
assertEquals(far.participants.Bob.status, 'pending', 'buffered response state');
const checkinNow = scheduledFor - CHECKIN_LEAD_MS;
const [checkin] = reduceCallToPlayEvents(events, checkinNow);
assertEquals(phaseOf(checkin, checkinNow), 'checkin', 'check-in phase');
assertEquals(readyCountOf(checkin, checkinNow), 0, 'nobody ready at check-in opening');
const [ready] = reduceCallToPlayEvents(events, scheduledFor - 4 * 60_000);
assertEquals(readyCountOf(ready, scheduledFor - 4 * 60_000), 1, 'elapsed buffer is ready');
});
Deno.test('call resolves when the roster fills or its deadline elapses', () => {
const full = [
create(),
event('bob', 'Bob', { Respond: { ready_at: null } }, NOW + 1),
event('carol', 'Carol', { Respond: { ready_at: null } }, NOW + 2),
];
assertEquals(reduceCallToPlayEvents(full, NOW + 2)[0].state, 'done', 'full roster');
assertEquals(
reduceCallToPlayEvents([create()], NOW + 31 * 60_000)[0].state,
'done',
'elapsed deadline',
);
});
Deno.test('creator-only controls cannot be forged by another participant', () => {
const forged = [
create(),
event('cancel', 'Mallory', 'Cancel', NOW + 1),
event('start', 'Mallory', 'Start', NOW + 2),
event('extend', 'Mallory', { AddTime: { deadline: NOW + 90 * 60_000 } }, NOW + 3),
];
const [nomination] = reduceCallToPlayEvents(forged, NOW + 4);
assert(nomination, 'forged cancel should not remove call');
assertEquals(nomination.state, 'open', 'forged start ignored');
assertEquals(nomination.deadline, NOW + 30 * 60_000, 'forged extension ignored');
});
Deno.test('creator can extend, start, and cancel a call', () => {
const extended = reduceCallToPlayEvents([
create(),
event('extend', 'Alice', { AddTime: { deadline: NOW + 90 * 60_000 } }, NOW + 1),
], NOW + 40 * 60_000)[0];
assertEquals(extended.state, 'open', 'extension reopens an elapsed call');
const started = reduceCallToPlayEvents([
create(),
event('start', 'Alice', 'Start', NOW + 1),
], NOW + 2)[0];
assertEquals(started.state, 'started', 'creator start');
const cancelled = reduceCallToPlayEvents([
create(),
event('cancel', 'Alice', 'Cancel', NOW + 1),
], NOW + 2);
assertEquals(cancelled.length, 0, 'creator cancel removes call');
});
Deno.test('reduction is order-independent and deduplicates events and messages', () => {
const message = event('message-event', 'Bob', {
SendMessage: { message_id: 'message-1', text: 'Ready?' },
}, NOW + 2);
const duplicateMessage = event('other-event', 'Bob', {
SendMessage: { message_id: 'message-1', text: 'duplicate' },
}, NOW + 3);
const events = [message, create(), message, duplicateMessage];
const [nomination] = reduceCallToPlayEvents(events, NOW + 4);
assertEquals(nomination.messages.length, 1, 'unique message id');
assertEquals(nomination.messages[0].text, 'Ready?', 'first message wins');
});
Deno.test('actions timestamped before creation cannot mutate a call', () => {
const events = [
event('early-cancel', 'Alice', 'Cancel', NOW - 1),
event('early-response', 'Bob', { Respond: { ready_at: null } }, NOW - 1),
create(),
];
const [nomination] = reduceCallToPlayEvents(events, NOW + 1);
assert(nomination, 'call should survive a pre-creation cancel');
assertEquals(Object.keys(nomination.participants).length, 1, 'pre-creation response ignored');
});
Deno.test('started calls expire from local presentation history', () => {
const events = [create(), event('start', 'Alice', 'Start', NOW + 1)];
assertEquals(reduceCallToPlayEvents(events, NOW + 2).length, 1, 'fresh started call remains');
assertEquals(reduceCallToPlayEvents(events, NOW + 5_000).length, 0, 'old started call expires');
});
Deno.test('scheduled time input accepts design formats and wraps steppers', () => {
assertEquals(normalizeTimeInput('20:00'), '20:00', 'colon format');
assertEquals(normalizeTimeInput('2000'), '20:00', 'compact format');
assertEquals(normalizeTimeInput('9:30'), '09:30', 'single-digit hour');
assertEquals(normalizeTimeInput('24:00'), null, 'invalid hour');
assertEquals(bumpTime('23:45', 'minutes', 15), '00:00', 'minute wrap');
assertEquals(bumpTime('00:00', 'hours', -1), '23:00', 'hour wrap');
});