Offer a Continue link for ongoing games in the lobby

A player who accidentally closes the game page can now get back to it
from the lobby.

Backend:
- GameStore.list_ongoing() returns live unfinished sessions (newest
  first), skipping expired keys and finished matches;
- GET /api/me/games lists the games the caller is seated in, with the
  game name/description, players, open seats and your seat (no join
  code);
- store, platform route and app integration tests.

Frontend:
- MyGame/MyGamesPage model plus api::my_games();
- the lobby shows a "Your games" card with phase, seats, players and a
  Continue button linking to /game/<id>; hidden when empty or logged
  out.
This commit is contained in:
2026-10-06 17:41:44 +08:00
parent b821274f0b
commit 18877b8109
9 changed files with 446 additions and 4 deletions
+14
View File
@@ -128,6 +128,20 @@ pub async fn probe_game(game_id: &str) -> GameEntry {
}
}
/// Fetch the live games the caller is seated in, for the lobby's
/// "Continue" list.
pub async fn my_games() -> Result<Vec<MyGame>, String> {
let resp = Request::get("/api/me/games")
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.ok() {
return Err(server_error(resp.status()));
}
let page: MyGamesPage = resp.json().await.map_err(|e| e.to_string())?;
Ok(page.results)
}
pub async fn my_matches(cursor: Option<&str>) -> Result<MatchesPage, String> {
let url = match cursor {
Some(c) => format!("/api/me/matches?limit=10&cursor={c}"),
+95
View File
@@ -498,6 +498,66 @@ pub struct PreviewPlayer {
pub team: Option<String>,
}
/// One ongoing game the caller is seated in (GET /api/me/games): the
/// lobby payload plus the game's display name, for the "Continue" list.
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct MyGame {
pub id: String,
#[serde(default)]
pub game_type: String,
#[serde(default)]
pub game_name: String,
#[serde(default)]
pub game_description: String,
#[serde(default)]
pub phase: String,
#[serde(default)]
pub players: Vec<PreviewPlayer>,
#[serde(default)]
pub seats_open: Option<usize>,
#[serde(default)]
pub your_seat: Option<usize>,
#[serde(default)]
pub created_at: Option<String>,
}
impl MyGame {
/// Human-readable phase for the lobby list.
pub fn phase_label(&self) -> &'static str {
match self.phase.as_str() {
"lobby" => "Waiting for players",
"hand_end" => "Between hands",
_ => "In progress",
}
}
/// Seated/total note, e.g. `"2/4 seated"`.
pub fn seats_text(&self) -> String {
match self.seats_open {
Some(open) => {
format!("{}/{} seated", self.players.len(), self.players.len() + open)
}
None => format!("{} seated", self.players.len()),
}
}
/// The seated players' names, for the list subtitle.
pub fn players_text(&self) -> String {
self.players
.iter()
.map(|p| p.name.clone())
.collect::<Vec<_>>()
.join(", ")
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct MyGamesPage {
#[serde(default)]
pub results: Vec<MyGame>,
}
/// One option to show in the join preview, rendered from the game's
/// schema and the engine-echoed value.
#[derive(Debug, Clone, PartialEq)]
@@ -1218,6 +1278,41 @@ mod tests {
assert_eq!("C", g.player_name_for_team("C"));
}
#[test]
fn my_game_phase_and_seats_text() {
let g: MyGame = serde_json::from_value(json!({
"id": "g1",
"game_type": "briscola_a_quattro",
"game_name": "Briscola a quattro",
"phase": "lobby",
"players": [
{"name": "Alice", "seat": 0, "team": "A"},
{"name": "Bob", "seat": 1, "team": "B"},
],
"seats_open": 2,
}))
.unwrap();
assert_eq!("Waiting for players", g.phase_label());
assert_eq!("2/4 seated", g.seats_text());
assert_eq!("Alice, Bob", g.players_text());
let playing: MyGame = serde_json::from_value(json!({
"id": "g2",
"game_type": "briscola",
"phase": "playing",
}))
.unwrap();
assert_eq!("In progress", playing.phase_label());
assert_eq!("0 seated", playing.seats_text());
let between: MyGame = serde_json::from_value(json!({
"id": "g3",
"phase": "hand_end",
}))
.unwrap();
assert_eq!("Between hands", between.phase_label());
}
#[test]
fn fallback_game_types_cover_the_registered_games() {
let types = fallback_game_types();
+69 -1
View File
@@ -16,7 +16,7 @@ use crate::components::card::card_back;
use crate::components::toast::toast;
use crate::model::{
fallback_game_types, is_individual_game, AuthProvider, GameTypeInfo, JoinPreview,
OptionField, OptionKind, PlayerRating, User,
MyGame, OptionField, OptionKind, PlayerRating, User,
};
/// Human-readable label for a provider name ("google" -> "Google").
@@ -292,6 +292,8 @@ pub fn LobbyPage() -> View {
let auth_providers = create_signal(Vec::<AuthProvider>::new());
// The player's Elo ratings, one row per game type played.
let ratings = create_signal(Vec::<PlayerRating>::new());
// Live games the player is seated in, for the "Continue" list.
let my_games = create_signal(Vec::<MyGame>::new());
let error = create_signal(Option::<String>::None);
let code = create_signal(String::new());
let game_types = create_signal(fallback_game_types());
@@ -386,6 +388,9 @@ pub fn LobbyPage() -> View {
if let Ok(p) = api::my_ratings().await {
ratings.set(p.results);
}
if let Ok(games) = api::my_games().await {
my_games.set(games);
}
});
}
user.set(Some(me));
@@ -549,6 +554,69 @@ pub fn LobbyPage() -> View {
"Play scopone scientifico and briscola with friends."
}
}
// Ongoing games: a "Continue" link back into a
// match whose page was accidentally closed.
(move || {
let games = my_games.get_clone();
if games.is_empty() {
return view! {};
}
let types = game_types.get_clone();
let rows = games
.into_iter()
.map(|g| {
let name = if g.game_name.is_empty() {
types
.iter()
.find(|t| t.id == g.game_type)
.map(|t| t.name.clone())
.unwrap_or_else(|| g.game_type.clone())
} else {
g.game_name.clone()
};
let phase = g.phase_label();
let seats = g.seats_text();
let players = g.players_text();
let players_note = if players.is_empty() {
String::new()
} else {
format!(" · {players}")
};
let href = format!("/game/{}", g.id);
view! {
li(
class="list-group-item d-flex flex-wrap \
justify-content-between \
align-items-center gap-2"
) {
div {
div(class="fw-semibold") { (name) }
div(class="text-secondary small") {
(phase) " · " (seats)
(players_note)
}
}
a(
class="btn btn-sm btn-warning",
href=href,
) {
"Continue"
}
}
}
})
.collect::<Vec<_>>();
view! {
div(class="card mb-4") {
div(class="card-header") { "Your games" }
div(class="card-body p-0") {
ul(class="list-group list-group-flush") {
(rows)
}
}
}
}
})
div(class="row g-4") {
div(class="col-lg-7") {
div(class="card h-100") {