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:
woggioni committed 2026-10-06 17:41:44 +08:00
1 parent b821274f0b
commit 18877b8109
9 files changed
+446 -4

No files matched your search

+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") {