feat(call-to-play): retain terminal outcomes
Keep complete running and cancelled histories visible for fifteen minutes so peers retain the roster, chat, and outcome long enough to understand what happened. Compact them to terminal tombstones afterward without charging settled calls against the active-history limit. Model running and cancelled as durable read-only frontend states, exclude them from active badges, prune retired raw events, and document the lifecycle. Add peer scenario S49 to prove a late joiner reconstructs a terminal call with its roster and chat intact. Test Plan: - just fmt - just clippy - just test - just frontend-test - just build - just peer-cli-tests S48 S49 - python3 -m py_compile crates/lanspread-peer-cli/scripts/run_extended_scenarios.py - git diff --cached --check
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Run the peer-cli scenarios S1-S48 through Docker."""
|
"""Run the peer-cli scenarios S1-S49 through Docker."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -357,6 +357,7 @@ class Runner:
|
|||||||
("S46", self.s46_receiver_cancel_mid_stream),
|
("S46", self.s46_receiver_cancel_mid_stream),
|
||||||
("S47", self.s47_multi_archive_streams_in_sorted_order),
|
("S47", self.s47_multi_archive_streams_in_sorted_order),
|
||||||
("S48", self.s48_call_to_play_replication_and_late_join),
|
("S48", self.s48_call_to_play_replication_and_late_join),
|
||||||
|
("S49", self.s49_terminal_call_to_play_late_join),
|
||||||
]
|
]
|
||||||
|
|
||||||
for scenario_id, scenario in scenarios:
|
for scenario_id, scenario in scenarios:
|
||||||
@@ -1824,6 +1825,79 @@ class Runner:
|
|||||||
|
|
||||||
return "live create/RSVP/chat replicated and a late joiner received deduplicated history"
|
return "live create/RSVP/chat replicated and a late joiner received deduplicated history"
|
||||||
|
|
||||||
|
def s49_terminal_call_to_play_late_join(self) -> str:
|
||||||
|
alice = self.peer("s49-alice")
|
||||||
|
bob = self.peer("s49-bob")
|
||||||
|
bob.connect_to(alice)
|
||||||
|
|
||||||
|
now = int(time.time() * 1000)
|
||||||
|
create = {
|
||||||
|
"id": "s49-create",
|
||||||
|
"call_id": "s49-call",
|
||||||
|
"actor_id": "",
|
||||||
|
"actor_name": "Alice",
|
||||||
|
"at": now,
|
||||||
|
"action": {
|
||||||
|
"Create": {
|
||||||
|
"game_id": "cnctw",
|
||||||
|
"max_players": 4,
|
||||||
|
"scheduled_for": None,
|
||||||
|
"deadline": now + 600_000,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ready = {
|
||||||
|
"id": "s49-ready",
|
||||||
|
"call_id": "s49-call",
|
||||||
|
"actor_id": "",
|
||||||
|
"actor_name": "Bob",
|
||||||
|
"at": now + 1,
|
||||||
|
"action": {"Respond": {"ready_at": None}},
|
||||||
|
}
|
||||||
|
message = {
|
||||||
|
"id": "s49-message-event",
|
||||||
|
"call_id": "s49-call",
|
||||||
|
"actor_id": "",
|
||||||
|
"actor_name": "Bob",
|
||||||
|
"at": now + 2,
|
||||||
|
"action": {
|
||||||
|
"SendMessage": {
|
||||||
|
"message_id": "s49-message",
|
||||||
|
"text": "Ready to launch",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
start = {
|
||||||
|
"id": "s49-start",
|
||||||
|
"call_id": "s49-call",
|
||||||
|
"actor_id": "",
|
||||||
|
"actor_name": "Alice",
|
||||||
|
"at": now + 3,
|
||||||
|
"action": "Start",
|
||||||
|
}
|
||||||
|
|
||||||
|
alice.publish_call_to_play(create)
|
||||||
|
wait_call_to_play_events(bob, {"s49-create"})
|
||||||
|
bob.publish_call_to_play(ready)
|
||||||
|
bob.publish_call_to_play(message)
|
||||||
|
wait_call_to_play_events(alice, {"s49-create", "s49-ready", "s49-message-event"})
|
||||||
|
alice.publish_call_to_play(start)
|
||||||
|
wait_call_to_play_events(
|
||||||
|
bob,
|
||||||
|
{"s49-create", "s49-ready", "s49-message-event", "s49-start"},
|
||||||
|
)
|
||||||
|
|
||||||
|
charlie = self.peer("s49-charlie")
|
||||||
|
charlie.connect_to(alice)
|
||||||
|
events = wait_call_to_play_events(
|
||||||
|
charlie,
|
||||||
|
{"s49-create", "s49-ready", "s49-message-event", "s49-start"},
|
||||||
|
)
|
||||||
|
if len(events) != 4:
|
||||||
|
raise ScenarioError(f"terminal late-join history is incomplete: {events}")
|
||||||
|
|
||||||
|
return "late joiner reconstructed terminal call outcome, roster, and chat"
|
||||||
|
|
||||||
|
|
||||||
def run(command: list[str], description: str) -> subprocess.CompletedProcess[str]:
|
def run(command: list[str], description: str) -> subprocess.CompletedProcess[str]:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
|
|||||||
@@ -52,13 +52,15 @@ Call to Play is transient peer-session state rather than database state. The
|
|||||||
peer keeps a bounded event history, deduplicated by event ID. Every event and
|
peer keeps a bounded event history, deduplicated by event ID. Every event and
|
||||||
chat message remains in the snapshot for the full lifetime of an active call,
|
chat message remains in the snapshot for the full lifetime of an active call,
|
||||||
so a peer joining mid-call receives the complete context. A creator's `Start`
|
so a peer joining mid-call receives the complete context. A creator's `Start`
|
||||||
or `Cancel` event replaces that terminal call with one small tombstone; this
|
or `Cancel` makes the call terminal and read-only, but its complete roster and
|
||||||
lets a peer that missed the live action heal on its next handshake without
|
chat history remain in snapshots for 15 minutes so late joiners can see the
|
||||||
retaining the inactive call's full history. Active calls are never partially
|
outcome. After that display window the history compacts to the Start or Cancel
|
||||||
trimmed. If genuinely active history reaches the bound, local publishes return
|
tombstone for the rest of the peer session. Active and recently terminal calls
|
||||||
an error to the caller instead of appearing to succeed. A call whose deadline
|
are never partially trimmed. If genuinely active history reaches the bound,
|
||||||
elapses remains available for five minutes so the creator can start or extend
|
local publishes return an error to the caller instead of appearing to succeed;
|
||||||
it, then its history is evicted as a unit. A
|
terminal histories and tombstones do not consume that active-history capacity.
|
||||||
|
A call whose deadline elapses remains available for five minutes so the creator
|
||||||
|
can start or extend it, then its unresolved history is evicted as a unit. A
|
||||||
local action is applied to that history, sent to the UI, and broadcast to every
|
local action is applied to that history, sent to the UI, and broadcast to every
|
||||||
currently known peer. An incoming live event is applied once and sent to the UI
|
currently known peer. An incoming live event is applied once and sent to the UI
|
||||||
without being rebroadcast, which prevents forwarding loops.
|
without being rebroadcast, which prevents forwarding loops.
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const MAX_GAME_ID_CHARS: usize = 256;
|
|||||||
const MAX_USERNAME_CHARS: usize = 24;
|
const MAX_USERNAME_CHARS: usize = 24;
|
||||||
const MAX_MESSAGE_CHARS: usize = 500;
|
const MAX_MESSAGE_CHARS: usize = 500;
|
||||||
const EXPIRED_RETENTION_MS: i64 = 5 * 60_000;
|
const EXPIRED_RETENTION_MS: i64 = 5 * 60_000;
|
||||||
|
const TERMINAL_RETENTION_MS: i64 = 15 * 60_000;
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub(crate) struct CallToPlayStore {
|
pub(crate) struct CallToPlayStore {
|
||||||
@@ -120,6 +121,7 @@ impl CallToPlayStore {
|
|||||||
.map(|event| event.call_id.clone())
|
.map(|event| event.call_id.clone())
|
||||||
.collect::<HashSet<_>>();
|
.collect::<HashSet<_>>();
|
||||||
let retained_tombstones = terminal_tombstone_call_ids(&retained);
|
let retained_tombstones = terminal_tombstone_call_ids(&retained);
|
||||||
|
let retained_history = HistoryIndex::build(&retained);
|
||||||
let mut applicable = Vec::with_capacity(new_events.len());
|
let mut applicable = Vec::with_capacity(new_events.len());
|
||||||
let mut missing_call_ids = BTreeSet::new();
|
let mut missing_call_ids = BTreeSet::new();
|
||||||
let mut obsolete = 0;
|
let mut obsolete = 0;
|
||||||
@@ -129,6 +131,13 @@ impl CallToPlayStore {
|
|||||||
obsolete += 1;
|
obsolete += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if let Some(terminal) = retained_history.terminal_events.get(&event.call_id)
|
||||||
|
&& (matches!(event.action, CallToPlayAction::Create { .. })
|
||||||
|
|| (event.at, event.id.as_str()) > terminal.order_key())
|
||||||
|
{
|
||||||
|
obsolete += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if !matches!(event.action, CallToPlayAction::Create { .. })
|
if !matches!(event.action, CallToPlayAction::Create { .. })
|
||||||
&& !rooted_calls.contains(event.call_id.as_str())
|
&& !rooted_calls.contains(event.call_id.as_str())
|
||||||
{
|
{
|
||||||
@@ -336,10 +345,13 @@ fn compact_history(events: &mut Vec<CallToPlayEvent>, now: i64) {
|
|||||||
if expired_calls.contains(event.call_id.as_str()) {
|
if expired_calls.contains(event.call_id.as_str()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
index
|
let Some(terminal) = index.terminal_events.get(&event.call_id) else {
|
||||||
.terminal_events
|
return true;
|
||||||
.get(&event.call_id)
|
};
|
||||||
.is_none_or(|terminal| event.id == terminal.event_id)
|
if (event.at, event.id.as_str()) > terminal.order_key() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
now - terminal.at <= TERMINAL_RETENTION_MS || event.id == terminal.event_id
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,7 +548,13 @@ fn validate_nonempty(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use lanspread_proto::{CallToPlayAck, CallToPlayAction, CallToPlayEvent};
|
use lanspread_proto::{CallToPlayAck, CallToPlayAction, CallToPlayEvent};
|
||||||
|
|
||||||
use super::{CallToPlayStore, MAX_EVENTS, MergeError, delivery_resync_reason};
|
use super::{
|
||||||
|
CallToPlayStore,
|
||||||
|
MAX_EVENTS,
|
||||||
|
MergeError,
|
||||||
|
TERMINAL_RETENTION_MS,
|
||||||
|
delivery_resync_reason,
|
||||||
|
};
|
||||||
|
|
||||||
const TEST_NOW: i64 = 8_000_000_000_000;
|
const TEST_NOW: i64 = 8_000_000_000_000;
|
||||||
|
|
||||||
@@ -705,6 +723,11 @@ mod tests {
|
|||||||
store
|
store
|
||||||
.merge_batch_at(vec![create_event("create"), start.clone()], TEST_NOW)
|
.merge_batch_at(vec![create_event("create"), start.clone()], TEST_NOW)
|
||||||
.expect("terminal history should merge");
|
.expect("terminal history should merge");
|
||||||
|
let after_terminal_retention = start.at + TERMINAL_RETENTION_MS + 1;
|
||||||
|
assert_eq!(
|
||||||
|
store.snapshot_at(after_terminal_retention).as_slice(),
|
||||||
|
std::slice::from_ref(&start)
|
||||||
|
);
|
||||||
|
|
||||||
let stale = store
|
let stale = store
|
||||||
.merge_batch_at(
|
.merge_batch_at(
|
||||||
@@ -718,13 +741,13 @@ mod tests {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
TEST_NOW,
|
after_terminal_retention,
|
||||||
)
|
)
|
||||||
.expect("stale history is an obsolete merge outcome");
|
.expect("stale history is an obsolete merge outcome");
|
||||||
|
|
||||||
assert!(stale.applied.is_empty());
|
assert!(stale.applied.is_empty());
|
||||||
assert_eq!(stale.obsolete, 2);
|
assert_eq!(stale.obsolete, 2);
|
||||||
assert_eq!(store.snapshot_at(TEST_NOW), [start]);
|
assert_eq!(store.snapshot_at(after_terminal_retention), [start]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -735,32 +758,31 @@ mod tests {
|
|||||||
|
|
||||||
let merged = store
|
let merged = store
|
||||||
.merge_batch_at(vec![terminal.clone()], TEST_NOW)
|
.merge_batch_at(vec![terminal.clone()], TEST_NOW)
|
||||||
.expect("terminal action should compact the full call");
|
.expect("terminal action should settle the full call");
|
||||||
assert_eq!(merged.applied.as_slice(), std::slice::from_ref(&terminal));
|
assert_eq!(merged.applied.as_slice(), std::slice::from_ref(&terminal));
|
||||||
assert_eq!(store.snapshot_at(TEST_NOW), [terminal]);
|
assert_eq!(store.snapshot_at(TEST_NOW).len(), MAX_EVENTS + 1);
|
||||||
|
assert_eq!(
|
||||||
|
store.snapshot_at(terminal.at + TERMINAL_RETENTION_MS + 1),
|
||||||
|
[terminal]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn terminal_tombstones_do_not_consume_active_history_capacity() {
|
fn terminal_histories_do_not_consume_active_history_capacity() {
|
||||||
let mut store = full_active_store();
|
let mut store = full_active_store();
|
||||||
|
let mut terminal = action_event("call-1-start", "call-1", CallToPlayAction::Start);
|
||||||
|
terminal.at = TEST_NOW + 2_000;
|
||||||
store
|
store
|
||||||
.merge_batch_at(
|
.merge_batch_at(vec![terminal], TEST_NOW)
|
||||||
vec![action_event(
|
.expect("start should settle active history");
|
||||||
"call-1-start",
|
|
||||||
"call-1",
|
|
||||||
CallToPlayAction::Start,
|
|
||||||
)],
|
|
||||||
TEST_NOW,
|
|
||||||
)
|
|
||||||
.expect("start should compact active history");
|
|
||||||
|
|
||||||
let created = create_event_for("call-2", "call-2-create");
|
let created = create_event_for("call-2", "call-2-create");
|
||||||
let merged = store
|
let merged = store
|
||||||
.merge_batch_at(vec![created.clone()], TEST_NOW)
|
.merge_batch_at(vec![created.clone()], TEST_NOW)
|
||||||
.expect("terminal tombstone must not block a new active call");
|
.expect("terminal history must not block a new active call");
|
||||||
assert_eq!(merged.applied, [created]);
|
assert_eq!(merged.applied, [created]);
|
||||||
assert_eq!(store.snapshot_at(TEST_NOW).len(), 2);
|
assert_eq!(store.snapshot_at(TEST_NOW).len(), MAX_EVENTS + 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -804,6 +826,55 @@ mod tests {
|
|||||||
assert!(store.snapshot_at(TEST_NOW).contains(&message));
|
assert!(store.snapshot_at(TEST_NOW).contains(&message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn terminal_history_preserves_roster_and_chat_for_display_window() {
|
||||||
|
let mut store = CallToPlayStore::default();
|
||||||
|
let message = action_event(
|
||||||
|
"message",
|
||||||
|
"call-1",
|
||||||
|
CallToPlayAction::SendMessage {
|
||||||
|
message_id: "message-1".to_string(),
|
||||||
|
text: "Launching now".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let terminal = action_event("terminal", "call-1", CallToPlayAction::Start);
|
||||||
|
let history = vec![
|
||||||
|
create_event("create"),
|
||||||
|
action_event("rsvp", "call-1", CallToPlayAction::Rsvp),
|
||||||
|
message,
|
||||||
|
terminal.clone(),
|
||||||
|
];
|
||||||
|
store
|
||||||
|
.merge_batch_at(history.clone(), TEST_NOW)
|
||||||
|
.expect("terminal history should merge");
|
||||||
|
|
||||||
|
let mut post_terminal = action_event(
|
||||||
|
"post-terminal",
|
||||||
|
"call-1",
|
||||||
|
CallToPlayAction::SendMessage {
|
||||||
|
message_id: "message-2".to_string(),
|
||||||
|
text: "Too late".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
post_terminal.at = terminal.at + 1;
|
||||||
|
let obsolete = store
|
||||||
|
.merge_batch_at(
|
||||||
|
vec![create_event("different-create"), post_terminal],
|
||||||
|
TEST_NOW,
|
||||||
|
)
|
||||||
|
.expect("terminal updates should be obsolete");
|
||||||
|
assert!(obsolete.applied.is_empty());
|
||||||
|
assert_eq!(obsolete.obsolete, 2);
|
||||||
|
assert_eq!(
|
||||||
|
store.snapshot_at(terminal.at + TERMINAL_RETENTION_MS),
|
||||||
|
history
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store.snapshot_at(terminal.at + TERMINAL_RETENTION_MS + 1),
|
||||||
|
[terminal]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn only_failed_or_incomplete_deliveries_request_resync() {
|
fn only_failed_or_incomplete_deliveries_request_resync() {
|
||||||
assert!(delivery_resync_reason(Err(())).is_some());
|
assert!(delivery_resync_reason(Err(())).is_some());
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Icon } from '../Icon';
|
import { Icon } from '../Icon';
|
||||||
|
import { activeCallCount } from '../../lib/callToPlay';
|
||||||
import { Nomination } from '../../lib/types';
|
import { Nomination } from '../../lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -7,7 +8,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const CallToPlayButton = ({ nominations, onClick }: Props) => {
|
export const CallToPlayButton = ({ nominations, onClick }: Props) => {
|
||||||
const activeCount = nominations.filter(nomination => nomination.state !== 'started').length;
|
const activeCount = activeCallCount(nominations);
|
||||||
return (
|
return (
|
||||||
<button className="ctp-btn" onClick={onClick}>
|
<button className="ctp-btn" onClick={onClick}>
|
||||||
<Icon.flag />
|
<Icon.flag />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Modal } from '../Modal';
|
|||||||
import { CreateNominationForm } from './CreateNominationForm';
|
import { CreateNominationForm } from './CreateNominationForm';
|
||||||
import { NominationCard } from './NominationCard';
|
import { NominationCard } from './NominationCard';
|
||||||
import { CallToPlayActions } from '../../hooks/useCallToPlay';
|
import { CallToPlayActions } from '../../hooks/useCallToPlay';
|
||||||
|
import { sortNominations } from '../../lib/callToPlay';
|
||||||
import { Game, Nomination } from '../../lib/types';
|
import { Game, Nomination } from '../../lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -36,10 +37,7 @@ export const CallToPlayOverlay = ({
|
|||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const gameById = new Map(games.map(game => [game.id, game]));
|
const gameById = new Map(games.map(game => [game.id, game]));
|
||||||
const sorted = [...nominations].sort((left, right) =>
|
const sorted = sortNominations(nominations);
|
||||||
Number(left.state === 'started') - Number(right.state === 'started')
|
|
||||||
|| left.deadline - right.deadline
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal onClose={onClose} className="ctp-modal">
|
<Modal onClose={onClose} className="ctp-modal">
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const LABEL = {
|
const LABEL = {
|
||||||
|
running: 'Running',
|
||||||
|
cancelled: 'Cancelled',
|
||||||
scheduled: 'Scheduled',
|
scheduled: 'Scheduled',
|
||||||
call: 'Call to Play',
|
call: 'Call to Play',
|
||||||
soon: 'Starting soon',
|
soon: 'Starting soon',
|
||||||
@@ -29,7 +31,7 @@ const LABEL = {
|
|||||||
expired: 'Time’s up',
|
expired: 'Time’s up',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type TickerStatus = Exclude<CallToPlayStatus, 'started'>;
|
type TickerStatus = CallToPlayStatus;
|
||||||
|
|
||||||
const RANK: Record<TickerStatus, number> = {
|
const RANK: Record<TickerStatus, number> = {
|
||||||
expired: 0,
|
expired: 0,
|
||||||
@@ -37,12 +39,12 @@ const RANK: Record<TickerStatus, number> = {
|
|||||||
soon: 2,
|
soon: 2,
|
||||||
call: 3,
|
call: 3,
|
||||||
scheduled: 3,
|
scheduled: 3,
|
||||||
|
running: 4,
|
||||||
|
cancelled: 4,
|
||||||
};
|
};
|
||||||
|
|
||||||
const tickerStatusOf = (nomination: Nomination, now: number): TickerStatus | null => {
|
const tickerStatusOf = (nomination: Nomination, now: number): TickerStatus =>
|
||||||
const status = statusOf(nomination, now);
|
statusOf(nomination, now);
|
||||||
return status === 'started' ? null : status;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MiniBubbles = ({ nomination, now }: { nomination: Nomination; now: number }) => {
|
const MiniBubbles = ({ nomination, now }: { nomination: Nomination; now: number }) => {
|
||||||
const entries = Object.entries(nomination.participants);
|
const entries = Object.entries(nomination.participants);
|
||||||
@@ -78,27 +80,37 @@ const MiniBubbles = ({ nomination, now }: { nomination: Nomination; now: number
|
|||||||
export const CallToPlayTicker = ({ nominations, games, accent, onOpen }: Props) => {
|
export const CallToPlayTicker = ({ nominations, games, accent, onOpen }: Props) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const gameById = new Map(games.map(game => [game.id, game]));
|
const gameById = new Map(games.map(game => [game.id, game]));
|
||||||
const active = nominations.flatMap(nomination => {
|
const rows = nominations.map(nomination => ({
|
||||||
const status = tickerStatusOf(nomination, now);
|
nomination,
|
||||||
return status === null ? [] : [{ nomination, status }];
|
status: tickerStatusOf(nomination, now),
|
||||||
}).sort((left, right) =>
|
})).sort((left, right) =>
|
||||||
RANK[left.status] - RANK[right.status]
|
RANK[left.status] - RANK[right.status]
|
||||||
|| left.nomination.deadline - right.nomination.deadline
|
|| left.nomination.deadline - right.nomination.deadline
|
||||||
);
|
);
|
||||||
if (active.length === 0) return null;
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ctp-ticker-stack">
|
<div className="ctp-ticker-stack">
|
||||||
{active.map(({ nomination, status }) => {
|
{rows.map(({ nomination, status }) => {
|
||||||
const game = gameById.get(nomination.gameId);
|
const game = gameById.get(nomination.gameId);
|
||||||
const ready = readyCountOf(nomination, now);
|
const ready = readyCountOf(nomination, now);
|
||||||
const total = Object.keys(nomination.participants).length;
|
const total = Object.keys(nomination.participants).length;
|
||||||
const remaining = Math.max(0, nomination.deadline - now);
|
const remaining = Math.max(0, nomination.deadline - now);
|
||||||
const last = nomination.messages[nomination.messages.length - 1];
|
const last = nomination.messages[nomination.messages.length - 1];
|
||||||
const count = status === 'scheduled'
|
const count = status === 'running' || status === 'cancelled'
|
||||||
|
? `${total} players`
|
||||||
|
: status === 'scheduled'
|
||||||
? `${total} in`
|
? `${total} in`
|
||||||
: `${ready}/${nomination.maxPlayers} ready`;
|
: `${ready}/${nomination.maxPlayers} ready`;
|
||||||
const time = status === 'ready'
|
const terminalElapsed = now - (nomination.terminalAt ?? now);
|
||||||
|
const terminalAge = terminalElapsed < 1_000
|
||||||
|
? 'just now'
|
||||||
|
: `${formatCountdownShort(terminalElapsed)} ago`;
|
||||||
|
const time = status === 'running'
|
||||||
|
? `started ${terminalAge}`
|
||||||
|
: status === 'cancelled'
|
||||||
|
? `cancelled ${terminalAge}`
|
||||||
|
: status === 'ready'
|
||||||
? 'waiting to start'
|
? 'waiting to start'
|
||||||
: status === 'expired'
|
: status === 'expired'
|
||||||
? `${formatCountdownShort(now - nomination.deadline)} ago · waiting for caller`
|
? `${formatCountdownShort(now - nomination.deadline)} ago · waiting for caller`
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
formatUntil,
|
formatUntil,
|
||||||
inCountOf,
|
inCountOf,
|
||||||
isReady,
|
isReady,
|
||||||
|
isTerminal,
|
||||||
phaseOf,
|
phaseOf,
|
||||||
readyCountOf,
|
readyCountOf,
|
||||||
statusOf,
|
statusOf,
|
||||||
@@ -82,11 +83,13 @@ export const NominationCard = ({
|
|||||||
const isMe = myStatus !== undefined;
|
const isMe = myStatus !== undefined;
|
||||||
const isCreator = nomination.creatorId === actorId;
|
const isCreator = nomination.creatorId === actorId;
|
||||||
const isDone = nomination.state === 'done';
|
const isDone = nomination.state === 'done';
|
||||||
const isStarted = nomination.state === 'started';
|
const terminal = isTerminal(nomination);
|
||||||
|
const isRunning = nomination.state === 'running';
|
||||||
|
const isCancelled = nomination.state === 'cancelled';
|
||||||
const isExpired = statusOf(nomination, now) === 'expired';
|
const isExpired = statusOf(nomination, now) === 'expired';
|
||||||
const phase = phaseOf(nomination, now);
|
const phase = phaseOf(nomination, now);
|
||||||
const isScheduled = phase === 'scheduled' && !isDone && !isStarted && !isExpired;
|
const isScheduled = phase === 'scheduled' && !isDone && !terminal && !isExpired;
|
||||||
const isCheckin = phase === 'checkin' && !isDone && !isStarted && !isExpired;
|
const isCheckin = phase === 'checkin' && !isDone && !terminal && !isExpired;
|
||||||
const windowStart = nomination.scheduledFor === null
|
const windowStart = nomination.scheduledFor === null
|
||||||
? nomination.createdAt
|
? nomination.createdAt
|
||||||
: nomination.scheduledFor - CHECKIN_LEAD_MS;
|
: nomination.scheduledFor - CHECKIN_LEAD_MS;
|
||||||
@@ -101,8 +104,10 @@ export const NominationCard = ({
|
|||||||
: (game.installed_peer_count ?? game.peer_count) + (game.installed ? 1 : 0);
|
: (game.installed_peer_count ?? game.peer_count) + (game.installed ? 1 : 0);
|
||||||
const lanCount = Math.max(totalPeerCount + 1, installedCount);
|
const lanCount = Math.max(totalPeerCount + 1, installedCount);
|
||||||
|
|
||||||
const timer = isStarted
|
const timer = isRunning
|
||||||
? <div className="ctp-card-timer" data-urgency="off">Launching…</div>
|
? <div className="ctp-card-timer" data-urgency="off">Running</div>
|
||||||
|
: isCancelled
|
||||||
|
? <div className="ctp-card-timer" data-urgency="off">Cancelled</div>
|
||||||
: isExpired
|
: isExpired
|
||||||
? <div className="ctp-card-timer" data-urgency="high">Time’s up</div>
|
? <div className="ctp-card-timer" data-urgency="high">Time’s up</div>
|
||||||
: isDone
|
: isDone
|
||||||
@@ -123,7 +128,9 @@ export const NominationCard = ({
|
|||||||
)
|
)
|
||||||
: <div className="ctp-card-timer" data-urgency={urgency}>{formatCountdown(remaining)}</div>;
|
: <div className="ctp-card-timer" data-urgency={urgency}>{formatCountdown(remaining)}</div>;
|
||||||
|
|
||||||
const rosterLabel = isScheduled
|
const rosterLabel = terminal
|
||||||
|
? `${entries.length} players`
|
||||||
|
: isScheduled
|
||||||
? `${entries.length} in · up to ${nomination.maxPlayers} players`
|
? `${entries.length} in · up to ${nomination.maxPlayers} players`
|
||||||
: isCheckin && inCount > 0
|
: isCheckin && inCount > 0
|
||||||
? `${readyCount}/${nomination.maxPlayers} ready · ${inCount} not checked in yet`
|
? `${readyCount}/${nomination.maxPlayers} ready · ${inCount} not checked in yet`
|
||||||
@@ -132,7 +139,7 @@ export const NominationCard = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={cardRef}
|
ref={cardRef}
|
||||||
className={`ctp-card ${isDone ? 'is-done' : ''} ${isExpired ? 'is-expired' : ''} ${isStarted ? 'is-started' : ''} ${isCheckin ? 'is-checkin' : ''} ${focused ? 'is-focused' : ''}`}
|
className={`ctp-card ${isDone ? 'is-done' : ''} ${isExpired ? 'is-expired' : ''} ${terminal ? 'is-terminal' : ''} ${isRunning ? 'is-running' : ''} ${isCancelled ? 'is-cancelled' : ''} ${isCheckin ? 'is-checkin' : ''} ${focused ? 'is-focused' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="ctp-card-top">
|
<div className="ctp-card-top">
|
||||||
<div className="ctp-card-cover">
|
<div className="ctp-card-cover">
|
||||||
@@ -168,10 +175,10 @@ export const NominationCard = ({
|
|||||||
<div
|
<div
|
||||||
className="ctp-progress-fill"
|
className="ctp-progress-fill"
|
||||||
style={{
|
style={{
|
||||||
width: `${isStarted || isDone ? 100 : percentage}%`,
|
width: `${terminal || isDone ? 100 : percentage}%`,
|
||||||
background: isExpired
|
background: isExpired || isCancelled
|
||||||
? 'var(--danger)'
|
? 'var(--danger)'
|
||||||
: isStarted || isDone
|
: terminal || isDone
|
||||||
? 'var(--ok)'
|
? 'var(--ok)'
|
||||||
: 'var(--accent)',
|
: 'var(--accent)',
|
||||||
}}
|
}}
|
||||||
@@ -211,11 +218,11 @@ export const NominationCard = ({
|
|||||||
<CtpChat
|
<CtpChat
|
||||||
nomination={nomination}
|
nomination={nomination}
|
||||||
actorId={actorId}
|
actorId={actorId}
|
||||||
disabled={isStarted}
|
disabled={terminal}
|
||||||
onSend={text => actions.sendMessage(nomination.id, text)}
|
onSend={text => actions.sendMessage(nomination.id, text)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isCreator && !isStarted && (
|
{isCreator && !terminal && (
|
||||||
confirmCancel
|
confirmCancel
|
||||||
? (
|
? (
|
||||||
<div className="ctp-cancel-confirm">
|
<div className="ctp-cancel-confirm">
|
||||||
@@ -283,15 +290,19 @@ const CardActions = ({
|
|||||||
const isMe = myStatus !== undefined;
|
const isMe = myStatus !== undefined;
|
||||||
const isCreator = nomination.creatorId === actorId;
|
const isCreator = nomination.creatorId === actorId;
|
||||||
const isDone = nomination.state === 'done';
|
const isDone = nomination.state === 'done';
|
||||||
const isStarted = nomination.state === 'started';
|
const terminal = isTerminal(nomination);
|
||||||
const isExpired = statusOf(nomination, now) === 'expired';
|
const isExpired = statusOf(nomination, now) === 'expired';
|
||||||
const scheduled = phaseOf(nomination, now) === 'scheduled' && !isDone && !isStarted;
|
const scheduled = phaseOf(nomination, now) === 'scheduled' && !isDone && !terminal;
|
||||||
const readyCount = readyCountOf(nomination, now);
|
const readyCount = readyCountOf(nomination, now);
|
||||||
|
|
||||||
if (isStarted) {
|
if (terminal) {
|
||||||
return (
|
return (
|
||||||
<div className="ctp-note ctp-note-launch">
|
<div className="ctp-note">
|
||||||
{game ? `Launching ${game.name}…` : 'The match is starting.'}
|
{nomination.state === 'running'
|
||||||
|
? game
|
||||||
|
? `${game.name} is running.`
|
||||||
|
: 'The match is running.'
|
||||||
|
: 'This call was cancelled.'}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -331,7 +342,7 @@ const CardActions = ({
|
|||||||
onClick={() => void actions.startNow(nomination.id).then(accepted => {
|
onClick={() => void actions.startNow(nomination.id).then(accepted => {
|
||||||
if (accepted && game) onLaunch(game);
|
if (accepted && game) onLaunch(game);
|
||||||
})}
|
})}
|
||||||
><Icon.play /><span>{game ? 'Start now' : 'Mark as started'}</span></button>
|
><Icon.play /><span>{game ? 'Start now' : 'Mark as running'}</span></button>
|
||||||
<button className="ghost-btn" onClick={() => actions.addTime(nomination.id)}>
|
<button className="ghost-btn" onClick={() => actions.addTime(nomination.id)}>
|
||||||
Add 5 more minutes
|
Add 5 more minutes
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { listen, UnlistenFn } from '@tauri-apps/api/event';
|
import { listen, UnlistenFn } from '@tauri-apps/api/event';
|
||||||
|
|
||||||
import { callToPlayEvent, reduceCallToPlayEvents } from '../lib/callToPlay';
|
import {
|
||||||
|
callToPlayEvent,
|
||||||
|
pruneCallToPlayEvents,
|
||||||
|
reduceCallToPlayEvents,
|
||||||
|
} from '../lib/callToPlay';
|
||||||
import { CallToPlayAction, CallToPlayEvent, Nomination } from '../lib/types';
|
import { CallToPlayAction, CallToPlayEvent, Nomination } from '../lib/types';
|
||||||
|
|
||||||
export interface CallToPlayActions {
|
export interface CallToPlayActions {
|
||||||
@@ -50,7 +54,11 @@ export const useCallToPlay = (username: string): UseCallToPlay => {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = window.setInterval(() => setNow(Date.now()), 1_000);
|
const timer = window.setInterval(() => {
|
||||||
|
const current = Date.now();
|
||||||
|
setNow(current);
|
||||||
|
setEvents(previous => pruneCallToPlayEvents(previous, current));
|
||||||
|
}, 1_000);
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,20 @@ import {
|
|||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export const CHECKIN_LEAD_MS = 15 * 60_000;
|
export const CHECKIN_LEAD_MS = 15 * 60_000;
|
||||||
export const STARTED_RETENTION_MS = 3_000;
|
|
||||||
export const EXPIRED_RETENTION_MS = 5 * 60_000;
|
export const EXPIRED_RETENTION_MS = 5 * 60_000;
|
||||||
|
export const TERMINAL_RETENTION_MS = 15 * 60_000;
|
||||||
|
|
||||||
export type CallToPlayPhase = 'now' | 'scheduled' | 'checkin';
|
export type CallToPlayPhase = 'now' | 'scheduled' | 'checkin';
|
||||||
export type CallToPlayStatus = 'started' | 'expired' | 'ready' | 'soon' | 'scheduled' | 'call';
|
export type CallToPlayStatus =
|
||||||
|
| 'running'
|
||||||
|
| 'cancelled'
|
||||||
|
| 'expired'
|
||||||
|
| 'ready'
|
||||||
|
| 'soon'
|
||||||
|
| 'scheduled'
|
||||||
|
| 'call';
|
||||||
|
|
||||||
interface MutableNomination extends Nomination {
|
interface MutableNomination extends Nomination {
|
||||||
cancelled: boolean;
|
|
||||||
messageIds: Set<string>;
|
messageIds: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,8 +63,30 @@ export const inCountOf = (nomination: Nomination, now: number): number =>
|
|||||||
!isReady(participant, now) && participant.status === 'in'
|
!isReady(participant, now) && participant.status === 'in'
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
|
export const isTerminal = (nomination: Nomination): boolean =>
|
||||||
|
nomination.state === 'running' || nomination.state === 'cancelled';
|
||||||
|
|
||||||
|
export const activeCallCount = (nominations: ReadonlyArray<Nomination>): number =>
|
||||||
|
nominations.filter(nomination => !isTerminal(nomination)).length;
|
||||||
|
|
||||||
|
export const sortNominations = (
|
||||||
|
nominations: ReadonlyArray<Nomination>,
|
||||||
|
): Nomination[] => [...nominations].sort((left, right) => {
|
||||||
|
const terminalRank = Number(isTerminal(left)) - Number(isTerminal(right));
|
||||||
|
if (terminalRank !== 0) return terminalRank;
|
||||||
|
if (isTerminal(left) && isTerminal(right)) {
|
||||||
|
return (right.terminalAt ?? 0) - (left.terminalAt ?? 0)
|
||||||
|
|| left.id.localeCompare(right.id);
|
||||||
|
}
|
||||||
|
return left.deadline - right.deadline
|
||||||
|
|| right.createdAt - left.createdAt
|
||||||
|
|| left.id.localeCompare(right.id);
|
||||||
|
});
|
||||||
|
|
||||||
export const statusOf = (nomination: Nomination, now: number): CallToPlayStatus => {
|
export const statusOf = (nomination: Nomination, now: number): CallToPlayStatus => {
|
||||||
if (nomination.state === 'started') return 'started';
|
if (nomination.state === 'running' || nomination.state === 'cancelled') {
|
||||||
|
return nomination.state;
|
||||||
|
}
|
||||||
if (now >= nomination.deadline) return 'expired';
|
if (now >= nomination.deadline) return 'expired';
|
||||||
if (nomination.state === 'done' || readyCountOf(nomination, now) >= nomination.maxPlayers) {
|
if (nomination.state === 'done' || readyCountOf(nomination, now) >= nomination.maxPlayers) {
|
||||||
return 'ready';
|
return 'ready';
|
||||||
@@ -71,6 +99,39 @@ export const reduceCallToPlayEvents = (
|
|||||||
input: ReadonlyArray<CallToPlayEvent>,
|
input: ReadonlyArray<CallToPlayEvent>,
|
||||||
now: number,
|
now: number,
|
||||||
): Nomination[] => {
|
): Nomination[] => {
|
||||||
|
const nominations = [...groupEvents(input).values()]
|
||||||
|
.map(events => deriveNomination(events, now))
|
||||||
|
.filter((nomination): nomination is Nomination => nomination !== null);
|
||||||
|
return sortNominations(nominations);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pruneCallToPlayEvents = (
|
||||||
|
previous: ReadonlyMap<string, CallToPlayEvent>,
|
||||||
|
now: number,
|
||||||
|
): ReadonlyMap<string, CallToPlayEvent> => {
|
||||||
|
const retiredEventIds = new Set<string>();
|
||||||
|
for (const events of groupEvents([...previous.values()]).values()) {
|
||||||
|
if (deriveNomination(events, now) !== null) continue;
|
||||||
|
|
||||||
|
const hasCreate = events.some(event => createPayload(event.action) !== null);
|
||||||
|
const expiredTombstone = events.some(event =>
|
||||||
|
(event.action === 'Start' || event.action === 'Cancel')
|
||||||
|
&& now - event.at > TERMINAL_RETENTION_MS
|
||||||
|
);
|
||||||
|
if (hasCreate || expiredTombstone) {
|
||||||
|
for (const event of events) retiredEventIds.add(event.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (retiredEventIds.size === 0) return previous;
|
||||||
|
|
||||||
|
const next = new Map(previous);
|
||||||
|
for (const eventId of retiredEventIds) next.delete(eventId);
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupEvents = (
|
||||||
|
input: ReadonlyArray<CallToPlayEvent>,
|
||||||
|
): Map<string, CallToPlayEvent[]> => {
|
||||||
const unique = new Map(input.map(event => [event.id, event]));
|
const unique = new Map(input.map(event => [event.id, event]));
|
||||||
const byCall = new Map<string, CallToPlayEvent[]>();
|
const byCall = new Map<string, CallToPlayEvent[]>();
|
||||||
for (const event of unique.values()) {
|
for (const event of unique.values()) {
|
||||||
@@ -78,14 +139,18 @@ export const reduceCallToPlayEvents = (
|
|||||||
events.push(event);
|
events.push(event);
|
||||||
byCall.set(event.call_id, events);
|
byCall.set(event.call_id, events);
|
||||||
}
|
}
|
||||||
|
return byCall;
|
||||||
|
};
|
||||||
|
|
||||||
const nominations: Nomination[] = [];
|
const deriveNomination = (
|
||||||
for (const events of byCall.values()) {
|
events: CallToPlayEvent[],
|
||||||
|
now: number,
|
||||||
|
): Nomination | null => {
|
||||||
events.sort(compareEvents);
|
events.sort(compareEvents);
|
||||||
const create = events.find(event => createPayload(event.action) !== null);
|
const create = events.find(event => createPayload(event.action) !== null);
|
||||||
if (!create) continue;
|
if (!create) return null;
|
||||||
const payload = createPayload(create.action);
|
const payload = createPayload(create.action);
|
||||||
if (!payload) continue;
|
if (!payload) return null;
|
||||||
|
|
||||||
const nomination: MutableNomination = {
|
const nomination: MutableNomination = {
|
||||||
id: create.call_id,
|
id: create.call_id,
|
||||||
@@ -105,40 +170,32 @@ export const reduceCallToPlayEvents = (
|
|||||||
},
|
},
|
||||||
messages: [],
|
messages: [],
|
||||||
state: 'open',
|
state: 'open',
|
||||||
cancelled: false,
|
terminalAt: null,
|
||||||
messageIds: new Set(),
|
messageIds: new Set(),
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
if (compareEvents(event, create) <= 0 || nomination.cancelled) continue;
|
if (compareEvents(event, create) > 0) applyEvent(nomination, event);
|
||||||
applyEvent(nomination, event);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nomination.cancelled) continue;
|
|
||||||
if (nomination.state === 'open'
|
if (nomination.state === 'open'
|
||||||
&& (readyCountOf(nomination, now) >= nomination.maxPlayers || now >= nomination.deadline)
|
&& (readyCountOf(nomination, now) >= nomination.maxPlayers || now >= nomination.deadline)
|
||||||
) {
|
) {
|
||||||
nomination.state = 'done';
|
nomination.state = 'done';
|
||||||
}
|
}
|
||||||
if (nomination.state === 'started'
|
if (isTerminal(nomination)) {
|
||||||
&& now - (nomination.startedAt ?? now) > STARTED_RETENTION_MS
|
if (now - (nomination.terminalAt ?? now) > TERMINAL_RETENTION_MS) return null;
|
||||||
) {
|
} else if (now - nomination.deadline > EXPIRED_RETENTION_MS) {
|
||||||
continue;
|
return null;
|
||||||
}
|
|
||||||
if (nomination.state !== 'started'
|
|
||||||
&& now - nomination.deadline > EXPIRED_RETENTION_MS
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { cancelled: _, messageIds: __, ...result } = nomination;
|
const { messageIds: _, ...result } = nomination;
|
||||||
nominations.push(result);
|
return result;
|
||||||
}
|
|
||||||
|
|
||||||
return nominations.sort((a, b) => b.createdAt - a.createdAt || a.id.localeCompare(b.id));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void => {
|
const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void => {
|
||||||
|
if (isTerminal(nomination)) return;
|
||||||
|
|
||||||
const action = event.action;
|
const action = event.action;
|
||||||
if (typeof action === 'string') {
|
if (typeof action === 'string') {
|
||||||
applyUnitAction(nomination, event, action);
|
applyUnitAction(nomination, event, action);
|
||||||
@@ -147,7 +204,6 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
|
|||||||
|
|
||||||
const response = respondPayload(action);
|
const response = respondPayload(action);
|
||||||
if (response) {
|
if (response) {
|
||||||
if (nomination.state === 'started') return;
|
|
||||||
const existing = nomination.participants[event.actor_id];
|
const existing = nomination.participants[event.actor_id];
|
||||||
nomination.participants[event.actor_id] = {
|
nomination.participants[event.actor_id] = {
|
||||||
name: event.actor_name,
|
name: event.actor_name,
|
||||||
@@ -159,7 +215,7 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
const message = messagePayload(action);
|
const message = messagePayload(action);
|
||||||
if (message && nomination.state !== 'started' && !nomination.messageIds.has(message.message_id)) {
|
if (message && !nomination.messageIds.has(message.message_id)) {
|
||||||
nomination.messageIds.add(message.message_id);
|
nomination.messageIds.add(message.message_id);
|
||||||
nomination.messages.push({
|
nomination.messages.push({
|
||||||
id: message.message_id,
|
id: message.message_id,
|
||||||
@@ -175,7 +231,6 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
|
|||||||
const extension = addTimePayload(action);
|
const extension = addTimePayload(action);
|
||||||
if (extension
|
if (extension
|
||||||
&& event.actor_id === nomination.creatorId
|
&& event.actor_id === nomination.creatorId
|
||||||
&& nomination.state !== 'started'
|
|
||||||
) {
|
) {
|
||||||
nomination.deadline = extension.deadline;
|
nomination.deadline = extension.deadline;
|
||||||
nomination.state = 'open';
|
nomination.state = 'open';
|
||||||
@@ -189,7 +244,6 @@ const applyUnitAction = (
|
|||||||
): void => {
|
): void => {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'Rsvp': {
|
case 'Rsvp': {
|
||||||
if (nomination.state === 'started') return;
|
|
||||||
const existing = nomination.participants[event.actor_id];
|
const existing = nomination.participants[event.actor_id];
|
||||||
nomination.participants[event.actor_id] = {
|
nomination.participants[event.actor_id] = {
|
||||||
name: event.actor_name,
|
name: event.actor_name,
|
||||||
@@ -199,19 +253,20 @@ const applyUnitAction = (
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'Leave':
|
case 'Leave':
|
||||||
if (event.actor_id !== nomination.creatorId && nomination.state !== 'started') {
|
if (event.actor_id !== nomination.creatorId) {
|
||||||
delete nomination.participants[event.actor_id];
|
delete nomination.participants[event.actor_id];
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'Cancel':
|
case 'Cancel':
|
||||||
if (event.actor_id === nomination.creatorId && nomination.state !== 'started') {
|
if (event.actor_id === nomination.creatorId) {
|
||||||
nomination.cancelled = true;
|
nomination.state = 'cancelled';
|
||||||
|
nomination.terminalAt = event.at;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'Start':
|
case 'Start':
|
||||||
if (event.actor_id === nomination.creatorId && nomination.state !== 'started') {
|
if (event.actor_id === nomination.creatorId) {
|
||||||
nomination.state = 'started';
|
nomination.state = 'running';
|
||||||
nomination.startedAt = event.at;
|
nomination.terminalAt = event.at;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,8 +113,8 @@ export interface Nomination {
|
|||||||
deadline: number;
|
deadline: number;
|
||||||
participants: Record<string, CallToPlayParticipant>;
|
participants: Record<string, CallToPlayParticipant>;
|
||||||
messages: CallToPlayMessage[];
|
messages: CallToPlayMessage[];
|
||||||
state: 'open' | 'done' | 'started';
|
state: 'open' | 'done' | 'running' | 'cancelled';
|
||||||
startedAt?: number;
|
terminalAt: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CallToPlayAction =
|
export type CallToPlayAction =
|
||||||
|
|||||||
@@ -1920,7 +1920,9 @@
|
|||||||
}
|
}
|
||||||
.ctp-card.is-done { border-color: color-mix(in srgb, var(--ok) 45%, var(--bd-2)); }
|
.ctp-card.is-done { border-color: color-mix(in srgb, var(--ok) 45%, var(--bd-2)); }
|
||||||
.ctp-card.is-expired { border-color: color-mix(in srgb, var(--danger) 45%, var(--bd-2)); }
|
.ctp-card.is-expired { border-color: color-mix(in srgb, var(--danger) 45%, var(--bd-2)); }
|
||||||
.ctp-card.is-started { opacity: 0.6; }
|
.ctp-card.is-terminal { opacity: 0.72; }
|
||||||
|
.ctp-card.is-running { border-color: color-mix(in srgb, var(--ok) 35%, var(--bd-2)); }
|
||||||
|
.ctp-card.is-cancelled { border-color: color-mix(in srgb, var(--danger) 35%, var(--bd-2)); }
|
||||||
.ctp-card.is-focused { border-color: var(--accent); animation: ctp-cardflash 1.4s ease-out 1; }
|
.ctp-card.is-focused { border-color: var(--accent); animation: ctp-cardflash 1.4s ease-out 1; }
|
||||||
@keyframes ctp-cardflash {
|
@keyframes ctp-cardflash {
|
||||||
0% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 55%, transparent); }
|
0% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 55%, transparent); }
|
||||||
@@ -2213,7 +2215,7 @@
|
|||||||
/* ─── Quick-bar status variants — LED + label + row tint per status:
|
/* ─── Quick-bar status variants — LED + label + row tint per status:
|
||||||
SCHEDULED (neutral) · CALL TO PLAY (accent, pulsing) ·
|
SCHEDULED (neutral) · CALL TO PLAY (accent, pulsing) ·
|
||||||
STARTING SOON (amber, glowing) · READY (green, steady) ·
|
STARTING SOON (amber, glowing) · READY (green, steady) ·
|
||||||
TIME'S UP (red, steady) ─── */
|
TIME'S UP (red, steady) · RUNNING / CANCELLED (muted receipts) ─── */
|
||||||
.ctp-ticker[data-status="scheduled"] {
|
.ctp-ticker[data-status="scheduled"] {
|
||||||
background: var(--bg-2);
|
background: var(--bg-2);
|
||||||
border-color: var(--bd-2);
|
border-color: var(--bd-2);
|
||||||
@@ -2240,10 +2242,22 @@
|
|||||||
border-color: color-mix(in srgb, var(--danger) 45%, var(--bd-2));
|
border-color: color-mix(in srgb, var(--danger) 45%, var(--bd-2));
|
||||||
}
|
}
|
||||||
.ctp-ticker[data-status="expired"]:hover { background: color-mix(in srgb, var(--danger) 13%, var(--bg-2)); }
|
.ctp-ticker[data-status="expired"]:hover { background: color-mix(in srgb, var(--danger) 13%, var(--bg-2)); }
|
||||||
|
.ctp-ticker[data-status="running"] {
|
||||||
|
background: color-mix(in srgb, var(--ok) 5%, var(--bg-2));
|
||||||
|
border-color: color-mix(in srgb, var(--ok) 25%, var(--bd-2));
|
||||||
|
}
|
||||||
|
.ctp-ticker[data-status="running"]:hover { background: color-mix(in srgb, var(--ok) 9%, var(--bg-2)); }
|
||||||
|
.ctp-ticker[data-status="cancelled"] {
|
||||||
|
background: color-mix(in srgb, var(--danger) 4%, var(--bg-2));
|
||||||
|
border-color: color-mix(in srgb, var(--danger) 22%, var(--bd-2));
|
||||||
|
}
|
||||||
|
.ctp-ticker[data-status="cancelled"]:hover { background: color-mix(in srgb, var(--danger) 8%, var(--bg-2)); }
|
||||||
.ctp-ticker-dot[data-status="scheduled"] { background: var(--t-3); animation: none; box-shadow: none; }
|
.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="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); }
|
.ctp-ticker-dot[data-status="ready"] { background: var(--ok); animation: none; box-shadow: 0 0 6px var(--ok); }
|
||||||
.ctp-ticker-dot[data-status="expired"] { background: var(--danger); animation: none; box-shadow: none; }
|
.ctp-ticker-dot[data-status="expired"] { background: var(--danger); animation: none; box-shadow: none; }
|
||||||
|
.ctp-ticker-dot[data-status="running"] { background: var(--ok); animation: none; box-shadow: none; opacity: 0.75; }
|
||||||
|
.ctp-ticker-dot[data-status="cancelled"] { background: var(--danger); animation: none; box-shadow: none; opacity: 0.65; }
|
||||||
@keyframes ctp-tickerpulse-warn {
|
@keyframes ctp-tickerpulse-warn {
|
||||||
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--warn) 55%, transparent); }
|
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--warn) 55%, transparent); }
|
||||||
70% { box-shadow: 0 0 0 6px transparent; }
|
70% { box-shadow: 0 0 0 6px transparent; }
|
||||||
@@ -2253,9 +2267,13 @@
|
|||||||
.ctp-ticker-label[data-status="soon"] { color: var(--warn); }
|
.ctp-ticker-label[data-status="soon"] { color: var(--warn); }
|
||||||
.ctp-ticker-label[data-status="ready"] { color: var(--ok); }
|
.ctp-ticker-label[data-status="ready"] { color: var(--ok); }
|
||||||
.ctp-ticker-label[data-status="expired"] { color: var(--danger); }
|
.ctp-ticker-label[data-status="expired"] { color: var(--danger); }
|
||||||
|
.ctp-ticker-label[data-status="running"] { color: color-mix(in srgb, var(--ok) 75%, var(--t-2)); }
|
||||||
|
.ctp-ticker-label[data-status="cancelled"] { color: color-mix(in srgb, var(--danger) 70%, var(--t-2)); }
|
||||||
.ctp-ticker[data-status="soon"] .ctp-ticker-cta { color: var(--warn); }
|
.ctp-ticker[data-status="soon"] .ctp-ticker-cta { color: var(--warn); }
|
||||||
.ctp-ticker[data-status="ready"] .ctp-ticker-cta { color: var(--ok); }
|
.ctp-ticker[data-status="ready"] .ctp-ticker-cta { color: var(--ok); }
|
||||||
.ctp-ticker[data-status="expired"] .ctp-ticker-cta { color: var(--danger); }
|
.ctp-ticker[data-status="expired"] .ctp-ticker-cta { color: var(--danger); }
|
||||||
|
.ctp-ticker[data-status="running"] .ctp-ticker-cta,
|
||||||
|
.ctp-ticker[data-status="cancelled"] .ctp-ticker-cta { color: var(--t-2); }
|
||||||
|
|
||||||
/* ─── Quick-bar inline chat preview ─── */
|
/* ─── Quick-bar inline chat preview ─── */
|
||||||
.ctp-ticker-chat {
|
.ctp-ticker-chat {
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
CHECKIN_LEAD_MS,
|
CHECKIN_LEAD_MS,
|
||||||
EXPIRED_RETENTION_MS,
|
EXPIRED_RETENTION_MS,
|
||||||
|
TERMINAL_RETENTION_MS,
|
||||||
|
activeCallCount,
|
||||||
phaseOf,
|
phaseOf,
|
||||||
bumpTime,
|
bumpTime,
|
||||||
normalizeTimeInput,
|
normalizeTimeInput,
|
||||||
|
pruneCallToPlayEvents,
|
||||||
readyCountOf,
|
readyCountOf,
|
||||||
reduceCallToPlayEvents,
|
reduceCallToPlayEvents,
|
||||||
|
sortNominations,
|
||||||
statusOf,
|
statusOf,
|
||||||
} from '../src/lib/callToPlay.ts';
|
} from '../src/lib/callToPlay.ts';
|
||||||
import { type CallToPlayAction, type CallToPlayEvent } from '../src/lib/types.ts';
|
import { type CallToPlayAction, type CallToPlayEvent } from '../src/lib/types.ts';
|
||||||
@@ -148,13 +152,15 @@ Deno.test('creator can extend, start, and cancel a call', () => {
|
|||||||
create(),
|
create(),
|
||||||
event('start', 'Alice', 'Start', NOW + 1),
|
event('start', 'Alice', 'Start', NOW + 1),
|
||||||
], NOW + 2)[0];
|
], NOW + 2)[0];
|
||||||
assertEquals(started.state, 'started', 'creator start');
|
assertEquals(started.state, 'running', 'creator start');
|
||||||
|
assertEquals(started.terminalAt, NOW + 1, 'running timestamp');
|
||||||
|
|
||||||
const cancelled = reduceCallToPlayEvents([
|
const [cancelled] = reduceCallToPlayEvents([
|
||||||
create(),
|
create(),
|
||||||
event('cancel', 'Alice', 'Cancel', NOW + 1),
|
event('cancel', 'Alice', 'Cancel', NOW + 1),
|
||||||
], NOW + 2);
|
], NOW + 2);
|
||||||
assertEquals(cancelled.length, 0, 'creator cancel removes call');
|
assertEquals(cancelled.state, 'cancelled', 'creator cancel');
|
||||||
|
assertEquals(cancelled.terminalAt, NOW + 1, 'cancel timestamp');
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test('reduction is order-independent and deduplicates events and messages', () => {
|
Deno.test('reduction is order-independent and deduplicates events and messages', () => {
|
||||||
@@ -181,10 +187,75 @@ Deno.test('actions timestamped before creation cannot mutate a call', () => {
|
|||||||
assertEquals(Object.keys(nomination.participants).length, 1, 'pre-creation response ignored');
|
assertEquals(Object.keys(nomination.participants).length, 1, 'pre-creation response ignored');
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test('started calls expire from local presentation history', () => {
|
Deno.test('terminal calls retain complete read-only history for fifteen minutes', () => {
|
||||||
const events = [create(), event('start', 'Alice', 'Start', NOW + 1)];
|
const events = [
|
||||||
assertEquals(reduceCallToPlayEvents(events, NOW + 2).length, 1, 'fresh started call remains');
|
create(),
|
||||||
assertEquals(reduceCallToPlayEvents(events, NOW + 5_000).length, 0, 'old started call expires');
|
event('join', 'Bob', { Respond: { ready_at: null } }, NOW + 1),
|
||||||
|
event('message', 'Bob', {
|
||||||
|
SendMessage: { message_id: 'message-1', text: 'Launching' },
|
||||||
|
}, NOW + 2),
|
||||||
|
event('start', 'Alice', 'Start', NOW + 3),
|
||||||
|
event('late-message', 'Bob', {
|
||||||
|
SendMessage: { message_id: 'message-2', text: 'Too late' },
|
||||||
|
}, NOW + 4),
|
||||||
|
];
|
||||||
|
const [running] = reduceCallToPlayEvents(events, NOW + TERMINAL_RETENTION_MS);
|
||||||
|
assertEquals(running.state, 'running', 'running receipt remains');
|
||||||
|
assertEquals(Object.keys(running.participants).length, 2, 'terminal roster retained');
|
||||||
|
assertEquals(running.messages.length, 1, 'pre-terminal chat retained');
|
||||||
|
assertEquals(
|
||||||
|
reduceCallToPlayEvents(events, NOW + 3 + TERMINAL_RETENTION_MS + 1).length,
|
||||||
|
0,
|
||||||
|
'terminal receipt retires after display window',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('terminal calls sort last and do not contribute to the badge', () => {
|
||||||
|
const active = reduceCallToPlayEvents([create()], NOW)[0];
|
||||||
|
const running = reduceCallToPlayEvents([
|
||||||
|
create(),
|
||||||
|
event('start', 'Alice', 'Start', NOW + 1),
|
||||||
|
], NOW + 2)[0];
|
||||||
|
const cancelled = reduceCallToPlayEvents([
|
||||||
|
create(),
|
||||||
|
event('cancel', 'Alice', 'Cancel', NOW + 2),
|
||||||
|
], NOW + 3)[0];
|
||||||
|
|
||||||
|
const sorted = sortNominations([running, cancelled, active]);
|
||||||
|
assertEquals(sorted[0].state, 'open', 'actionable call sorts first');
|
||||||
|
assertEquals(activeCallCount(sorted), 1, 'terminal calls excluded from badge');
|
||||||
|
assertEquals(statusOf(running, NOW + 2), 'running', 'running ticker status');
|
||||||
|
assertEquals(statusOf(cancelled, NOW + 3), 'cancelled', 'cancelled ticker status');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('retired calls are pruned from the frontend raw event map', () => {
|
||||||
|
const terminalEvents = [
|
||||||
|
create(),
|
||||||
|
event('start', 'Alice', 'Start', NOW + 1),
|
||||||
|
];
|
||||||
|
const map = new Map(terminalEvents.map(item => [item.id, item]));
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
pruneCallToPlayEvents(map, NOW + 1 + TERMINAL_RETENTION_MS).size,
|
||||||
|
2,
|
||||||
|
'visible terminal history stays cached',
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
pruneCallToPlayEvents(map, NOW + 1 + TERMINAL_RETENTION_MS + 1).size,
|
||||||
|
0,
|
||||||
|
'retired terminal history is pruned',
|
||||||
|
);
|
||||||
|
|
||||||
|
const tombstone = event('terminal-only', 'Alice', 'Cancel', NOW + 1);
|
||||||
|
const tombstoneMap = new Map([[tombstone.id, tombstone]]);
|
||||||
|
assertEquals(
|
||||||
|
pruneCallToPlayEvents(
|
||||||
|
tombstoneMap,
|
||||||
|
NOW + 1 + TERMINAL_RETENTION_MS + 1,
|
||||||
|
).size,
|
||||||
|
0,
|
||||||
|
'backend tombstone is pruned too',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test('scheduled time input accepts design formats and wraps steppers', () => {
|
Deno.test('scheduled time input accepts design formats and wraps steppers', () => {
|
||||||
|
|||||||
+29
-17
@@ -98,7 +98,7 @@ The default screen. A grid of game cards over a dark, gradient-tinted background
|
|||||||
- **Right zone (col 3, flex space-between with two sub-groups):**
|
- **Right zone (col 3, flex space-between with two sub-groups):**
|
||||||
- **Sort menu** (pinned left, hugging search) — 36 px button, same surface style as search. Label `Sort: <bold value>` plus 13 px sort-bars icon and 11 px chevron. Click reveals dropdown menu below. Options: `Name (A–Z)`, `Size (largest)`, `Recently Played`, `Status`. This is the only thing on the *left* side of the right zone — it's part of the search cluster, so it hugs the search.
|
- **Sort menu** (pinned left, hugging search) — 36 px button, same surface style as search. Label `Sort: <bold value>` plus 13 px sort-bars icon and 11 px chevron. Click reveals dropdown menu below. Options: `Name (A–Z)`, `Size (largest)`, `Recently Played`, `Status`. This is the only thing on the *left* side of the right zone — it's part of the search cluster, so it hugs the search.
|
||||||
- **Kebab menu** (`⋮`, pinned far-right) — 36×36 button with same surface as search. Menu items: `Settings` (opens Settings dialog), `Refresh library`, separator, `Unpack logs`, `About SoftLAN`. This is the only "app-level" control left in the top bar; the game-folder picker has moved into Settings.
|
- **Kebab menu** (`⋮`, pinned far-right) — 36×36 button with same surface as search. Menu items: `Settings` (opens Settings dialog), `Refresh library`, separator, `Unpack logs`, `About SoftLAN`. This is the only "app-level" control left in the top bar; the game-folder picker has moved into Settings.
|
||||||
- **Call to Play button** (`.ctp-btn`, sits just left of the kebab in the far-right sub-group) — 36 px pill, flag icon + `Call to Play` label. Carries an accent-filled **badge** with the count of active (non-started) calls. Opens the Call to Play overlay. See the **"Call to Play"** section for the full feature. In variant B it lives in row 1's right group, between the storage meter and the kebab.
|
- **Call to Play button** (`.ctp-btn`, sits just left of the kebab in the far-right sub-group) — 36 px pill, flag icon + `Call to Play` label. Carries an accent-filled **badge** with the count of active, non-terminal calls. Opens the Call to Play overlay. See the **"Call to Play"** section for the full feature. In variant B it lives in row 1's right group, between the storage meter and the kebab.
|
||||||
|
|
||||||
**Narrow-window fallback** (container width < 1100 px): the grid is replaced by a single `display: flex; flex-wrap: nowrap; gap: 16px` row. All items align left-to-right in source order (brand → filter → search → sort → kebab). The search field becomes `flex: 1 1 auto` so it absorbs remaining slack. The geometric centering is abandoned at narrow widths because there isn't enough horizontal slack for it to read cleanly. Implement via container query (`@container launcher (max-width: 1100px)`) on the launcher root; a viewport media query is an acceptable fallback if you're not using container queries yet.
|
**Narrow-window fallback** (container width < 1100 px): the grid is replaced by a single `display: flex; flex-wrap: nowrap; gap: 16px` row. All items align left-to-right in source order (brand → filter → search → sort → kebab). The search field becomes `flex: 1 1 auto` so it absorbs remaining slack. The geometric centering is abandoned at narrow widths because there isn't enough horizontal slack for it to read cleanly. Implement via container query (`@container launcher (max-width: 1100px)`) on the launcher root; a viewport media query is an acceptable fallback if you're not using container queries yet.
|
||||||
|
|
||||||
@@ -466,7 +466,7 @@ the call and its history expire as a unit.
|
|||||||
### Three surfaces
|
### Three surfaces
|
||||||
|
|
||||||
1. **Top-bar button** (`CallToPlayButton` / `.ctp-btn`) — flag icon + label +
|
1. **Top-bar button** (`CallToPlayButton` / `.ctp-btn`) — flag icon + label +
|
||||||
an accent badge counting active (non-`started`) calls. Opens the overlay.
|
an accent badge counting active, non-terminal calls. Opens the overlay.
|
||||||
2. **Quick bars** (`CallToPlayTicker` / `.ctp-ticker-stack`) — a persistent
|
2. **Quick bars** (`CallToPlayTicker` / `.ctp-ticker-stack`) — a persistent
|
||||||
stack rendered at the top of the grid area, **one row per active call**.
|
stack rendered at the top of the grid area, **one row per active call**.
|
||||||
Sorted **ready → starting-soon → the rest**, ties broken by whichever
|
Sorted **ready → starting-soon → the rest**, ties broken by whichever
|
||||||
@@ -474,7 +474,8 @@ the call and its history expire as a unit.
|
|||||||
then by `deadline`). Clicking a row opens the overlay focused on that call.
|
then by `deadline`). Clicking a row opens the overlay focused on that call.
|
||||||
3. **Overlay** (`CallToPlayOverlay`) — a modal (same scrim/panel treatment as
|
3. **Overlay** (`CallToPlayOverlay`) — a modal (same scrim/panel treatment as
|
||||||
the other dialogs) with a header, a **Call a new match** button, the create
|
the other dialogs) with a header, a **Call a new match** button, the create
|
||||||
form, and a list of **nomination cards** (started calls sink to the bottom).
|
form, and a list of **nomination cards** (Running and Cancelled calls sink
|
||||||
|
to the bottom).
|
||||||
|
|
||||||
### Status model
|
### Status model
|
||||||
|
|
||||||
@@ -482,14 +483,16 @@ Two derived values drive everything (`calltoplay.jsx`):
|
|||||||
|
|
||||||
- `phaseOf(call)` → `'now'` (no `scheduledFor`) · `'scheduled'` (>15 min out) ·
|
- `phaseOf(call)` → `'now'` (no `scheduledFor`) · `'scheduled'` (>15 min out) ·
|
||||||
`'checkin'` (within the 15-min lead).
|
`'checkin'` (within the 15-min lead).
|
||||||
- `statusOf(call)` (quick-bar/label status) → `'started'` · `'expired'`
|
- `statusOf(call)` (quick-bar/label status) → `'running'` · `'cancelled'` ·
|
||||||
(deadline elapsed) · `'ready'` (`readyCount >= maxPlayers`, or state `done`) ·
|
`'expired'` (deadline elapsed) · `'ready'` (`readyCount >= maxPlayers`, or
|
||||||
`'soon'` (`deadline - now ≤ 15 min`) · `'scheduled'` (has a clock time) ·
|
state `done`) · `'soon'` (`deadline - now ≤ 15 min`) · `'scheduled'` (has a
|
||||||
`'call'` (a plain play-now call).
|
clock time) · `'call'` (a plain play-now call).
|
||||||
|
|
||||||
Quick-bar labels + LED colors: **SCHEDULED**, **CALL TO PLAY**, **STARTING
|
Quick-bar labels + LED colors: **SCHEDULED**, **CALL TO PLAY**, **STARTING
|
||||||
SOON**, **READY**, **TIME'S UP** (`TICKER_LABEL`), each with its own dot color via
|
SOON**, **READY**, **TIME'S UP**, **RUNNING**, **CANCELLED** (`TICKER_LABEL`),
|
||||||
`.ctp-ticker-dot[data-status]`.
|
each with its own dot color via `.ctp-ticker-dot[data-status]`. Running and
|
||||||
|
Cancelled receipts remain for 15 minutes, sort after actionable calls, and do
|
||||||
|
not contribute to the top-bar badge.
|
||||||
|
|
||||||
**Per-participant ready state:** `ready` (explicitly readied, or their `readyAt`
|
**Per-participant ready state:** `ready` (explicitly readied, or their `readyAt`
|
||||||
countdown has elapsed) · `in` (RSVP'd to a scheduled call but not checked in
|
countdown has elapsed) · `in` (RSVP'd to a scheduled call but not checked in
|
||||||
@@ -502,7 +505,7 @@ as larger `AvatarChip`s in the nomination card roster.
|
|||||||
|
|
||||||
Top to bottom:
|
Top to bottom:
|
||||||
|
|
||||||
1. **Header** — square game cover + title + a sub-line: `Called by <creator> · N/M peers have it installed` (or `Scheduled by <creator> · starts at HH:MM · …`), and a **timer** on the right: a live `M:SS` countdown for play-now/check-in, the **clock time + "in N min"** for a scheduled call, `Ready`, `Time's up`, or `Launching…`. If catalog data is temporarily unavailable, the card still renders the caller, game ID, roster, chat, and coordination actions with a clear `Game unavailable here` label. Countdown urgency (`data-urgency` high/mid/low) tints it as time runs low.
|
1. **Header** — square game cover + title + a sub-line: `Called by <creator> · N/M peers have it installed` (or `Scheduled by <creator> · starts at HH:MM · …`), and a **timer** on the right: a live `M:SS` countdown for play-now/check-in, the **clock time + "in N min"** for a scheduled call, `Ready`, `Time's up`, `Running`, or `Cancelled`. If catalog data is temporarily unavailable, the card still renders the caller, game ID, roster, chat, and coordination actions with a clear `Game unavailable here` label. Countdown urgency (`data-urgency` high/mid/low) tints it as time runs low.
|
||||||
2. **Check-in note** — only in the `checkin` phase: a clock icon + "Starting soon — check-in is open" (or a personalized nudge if you RSVP'd).
|
2. **Check-in note** — only in the `checkin` phase: a clock icon + "Starting soon — check-in is open" (or a personalized nudge if you RSVP'd).
|
||||||
3. **Progress bar** — time remaining as a fill (accent, → green when done); hidden while a call is still in the far-out `scheduled` phase.
|
3. **Progress bar** — time remaining as a fill (accent, → green when done); hidden while a call is still in the far-out `scheduled` phase.
|
||||||
4. **Roster** — `readyCount/maxPlayers ready` (scheduled shows `N in · up to M players`; check-in adds `· K not checked in yet`), then avatar chips for each participant plus empty slots up to `maxPlayers`.
|
4. **Roster** — `readyCount/maxPlayers ready` (scheduled shows `N in · up to M players`; check-in adds `· K not checked in yet`), then avatar chips for each participant plus empty slots up to `maxPlayers`.
|
||||||
@@ -510,6 +513,10 @@ Top to bottom:
|
|||||||
6. **Chat** — the collapsible per-call chat panel.
|
6. **Chat** — the collapsible per-call chat panel.
|
||||||
7. **Cancel** — creators get a `Cancel this call` link with an inline confirm.
|
7. **Cancel** — creators get a `Cancel this call` link with an inline confirm.
|
||||||
|
|
||||||
|
Running and Cancelled cards are read-only: the complete roster and chat remain
|
||||||
|
visible, but participant controls, creator controls, chat composition, and
|
||||||
|
cancel actions are disabled.
|
||||||
|
|
||||||
### Create form (`CreateNominationForm`)
|
### Create form (`CreateNominationForm`)
|
||||||
|
|
||||||
Game search (typeahead over the catalog) → on pick, max-players defaults to the
|
Game search (typeahead over the catalog) → on pick, max-players defaults to the
|
||||||
@@ -546,8 +553,8 @@ type Nomination = {
|
|||||||
readyAt?: number; // ms epoch a 'pending' buffer elapses
|
readyAt?: number; // ms epoch a 'pending' buffer elapses
|
||||||
}>;
|
}>;
|
||||||
messages: { id: string; fromId: string; from: string; text: string; at: number }[];
|
messages: { id: string; fromId: string; from: string; text: string; at: number }[];
|
||||||
state: 'open' | 'done' | 'started';
|
state: 'open' | 'done' | 'running' | 'cancelled';
|
||||||
startedAt?: number;
|
terminalAt: number | null;
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -570,11 +577,16 @@ create, response, RSVP, chat, leave, cancel, start, or deadline-extension action
|
|||||||
is an immutable, uniquely identified event. Connected peers receive new events
|
is an immutable, uniquely identified event. Connected peers receive new events
|
||||||
immediately, while `Hello` / `HelloAck` exchange the bounded, deduplicated event
|
immediately, while `Hello` / `HelloAck` exchange the bounded, deduplicated event
|
||||||
history so a late joiner reconstructs every event and chat message for active
|
history so a late joiner reconstructs every event and chat message for active
|
||||||
calls. Started and cancelled calls compact to terminal tombstones; expired
|
calls. Running and Cancelled calls retain their complete history for 15 minutes
|
||||||
calls are removed after the five-minute grace period. The frontend reducer
|
so late joiners can see the outcome, roster, and chat, then compact to a Start
|
||||||
turns that event history into the `Nomination` state above and derives
|
or Cancel tombstone for the rest of the peer session. Unresolved calls are
|
||||||
time-based phase changes locally. Stable peer IDs identify actors and enforce
|
removed after the five-minute post-deadline recovery period. The frontend
|
||||||
creator controls; `settings.username` is only the display name.
|
reducer turns that event history into the `Nomination` state above, derives
|
||||||
|
time-based phase changes locally, and prunes retired raw events. Stable peer IDs
|
||||||
|
identify actors and enforce creator controls; `settings.username` is only the
|
||||||
|
display name. These deadlines use event wall-clock timestamps, so LAN clocks
|
||||||
|
are assumed to be reasonably close; no clock-synchronization protocol is
|
||||||
|
attempted.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user