From d9307ff761b4f57ea1cc501f7de4b21c8a7dbae8 Mon Sep 17 00:00:00 2001 From: Byte Date: Sun, 12 Jul 2026 20:07:18 -0400 Subject: [PATCH] Saved games on user profiles Users can save any game to their public profile with an optional title, matching the saved-games feature from play.battlesnake.com (BS-219e7007). - saved_games table: one row per (user, game), UNIQUE-constrained so re-saving a game upserts the title instead of duplicating. - Save form in the game page aside (logged-in only): pre-fills the existing title and switches to an "Update" label when already saved. - Saved Games section on the public profile: links to the game, falls back to "{game_type} on {board_size}" when untitled, and shows a Remove button to the owner (delete is owner-scoped, 404 otherwise). - Titles are trimmed and capped at 100 chars server-side. Co-Authored-By: Claude Fable 5 --- ...76078dba6989db282b6b159c4ee8b35817422.json | 15 + ...4cab001ab201d55bb2a27ac527ee340b4ef79.json | 53 ++++ ...3453f62519ebff911d2d0cdb83d2e98d178bd.json | 54 ++++ ...5b7b728a47ee94ab587086397593d93c40953.json | 52 ++++ ...40327477490b36260957d6a480e9603f8210b.json | 52 ++++ .../20260712190000_saved_games.down.sql | 1 + migrations/20260712190000_saved_games.up.sql | 14 + server/src/models/mod.rs | 1 + server/src/models/saved_game.rs | 283 ++++++++++++++++++ server/src/routes.rs | 9 + server/src/routes/game/view.rs | 32 ++ server/src/routes/saved_games.rs | 117 ++++++++ server/src/routes/users.rs | 35 ++- 13 files changed, 717 insertions(+), 1 deletion(-) create mode 100644 .sqlx/query-567d7bc0b46c2df98b7ec4bdd0476078dba6989db282b6b159c4ee8b35817422.json create mode 100644 .sqlx/query-a4d5a8cf23af92698a9b3459ab34cab001ab201d55bb2a27ac527ee340b4ef79.json create mode 100644 .sqlx/query-a988cf24305f8559b0b99184fb13453f62519ebff911d2d0cdb83d2e98d178bd.json create mode 100644 .sqlx/query-c7a92942e57b66b41752849cb0f5b7b728a47ee94ab587086397593d93c40953.json create mode 100644 .sqlx/query-d79264e03f41734f6f5ffb273cf40327477490b36260957d6a480e9603f8210b.json create mode 100644 migrations/20260712190000_saved_games.down.sql create mode 100644 migrations/20260712190000_saved_games.up.sql create mode 100644 server/src/models/saved_game.rs create mode 100644 server/src/routes/saved_games.rs diff --git a/.sqlx/query-567d7bc0b46c2df98b7ec4bdd0476078dba6989db282b6b159c4ee8b35817422.json b/.sqlx/query-567d7bc0b46c2df98b7ec4bdd0476078dba6989db282b6b159c4ee8b35817422.json new file mode 100644 index 0000000..dbff431 --- /dev/null +++ b/.sqlx/query-567d7bc0b46c2df98b7ec4bdd0476078dba6989db282b6b159c4ee8b35817422.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM saved_games WHERE saved_game_id = $1 AND user_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "567d7bc0b46c2df98b7ec4bdd0476078dba6989db282b6b159c4ee8b35817422" +} diff --git a/.sqlx/query-a4d5a8cf23af92698a9b3459ab34cab001ab201d55bb2a27ac527ee340b4ef79.json b/.sqlx/query-a4d5a8cf23af92698a9b3459ab34cab001ab201d55bb2a27ac527ee340b4ef79.json new file mode 100644 index 0000000..9e39bfe --- /dev/null +++ b/.sqlx/query-a4d5a8cf23af92698a9b3459ab34cab001ab201d55bb2a27ac527ee340b4ef79.json @@ -0,0 +1,53 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n saved_game_id,\n user_id,\n game_id,\n title,\n created_at,\n updated_at\n FROM saved_games\n WHERE user_id = $1 AND game_id = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "saved_game_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "game_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "title", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "a4d5a8cf23af92698a9b3459ab34cab001ab201d55bb2a27ac527ee340b4ef79" +} diff --git a/.sqlx/query-a988cf24305f8559b0b99184fb13453f62519ebff911d2d0cdb83d2e98d178bd.json b/.sqlx/query-a988cf24305f8559b0b99184fb13453f62519ebff911d2d0cdb83d2e98d178bd.json new file mode 100644 index 0000000..755bc17 --- /dev/null +++ b/.sqlx/query-a988cf24305f8559b0b99184fb13453f62519ebff911d2d0cdb83d2e98d178bd.json @@ -0,0 +1,54 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO saved_games (user_id, game_id, title)\n VALUES ($1, $2, $3)\n ON CONFLICT (user_id, game_id)\n DO UPDATE SET title = EXCLUDED.title, updated_at = NOW()\n RETURNING\n saved_game_id,\n user_id,\n game_id,\n title,\n created_at,\n updated_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "saved_game_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "game_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "title", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "a988cf24305f8559b0b99184fb13453f62519ebff911d2d0cdb83d2e98d178bd" +} diff --git a/.sqlx/query-c7a92942e57b66b41752849cb0f5b7b728a47ee94ab587086397593d93c40953.json b/.sqlx/query-c7a92942e57b66b41752849cb0f5b7b728a47ee94ab587086397593d93c40953.json new file mode 100644 index 0000000..638d77e --- /dev/null +++ b/.sqlx/query-c7a92942e57b66b41752849cb0f5b7b728a47ee94ab587086397593d93c40953.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n saved_game_id,\n user_id,\n game_id,\n title,\n created_at,\n updated_at\n FROM saved_games\n WHERE saved_game_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "saved_game_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "game_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "title", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "c7a92942e57b66b41752849cb0f5b7b728a47ee94ab587086397593d93c40953" +} diff --git a/.sqlx/query-d79264e03f41734f6f5ffb273cf40327477490b36260957d6a480e9603f8210b.json b/.sqlx/query-d79264e03f41734f6f5ffb273cf40327477490b36260957d6a480e9603f8210b.json new file mode 100644 index 0000000..86c3efc --- /dev/null +++ b/.sqlx/query-d79264e03f41734f6f5ffb273cf40327477490b36260957d6a480e9603f8210b.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n sg.saved_game_id,\n sg.game_id,\n sg.title,\n g.game_type,\n g.board_size,\n g.created_at AS game_created_at\n FROM saved_games sg\n JOIN games g ON g.game_id = sg.game_id\n WHERE sg.user_id = $1\n ORDER BY sg.created_at DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "saved_game_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "game_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "title", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "game_type", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "board_size", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "game_created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "d79264e03f41734f6f5ffb273cf40327477490b36260957d6a480e9603f8210b" +} diff --git a/migrations/20260712190000_saved_games.down.sql b/migrations/20260712190000_saved_games.down.sql new file mode 100644 index 0000000..8b8a293 --- /dev/null +++ b/migrations/20260712190000_saved_games.down.sql @@ -0,0 +1 @@ +DROP TABLE saved_games; diff --git a/migrations/20260712190000_saved_games.up.sql b/migrations/20260712190000_saved_games.up.sql new file mode 100644 index 0000000..1324f09 --- /dev/null +++ b/migrations/20260712190000_saved_games.up.sql @@ -0,0 +1,14 @@ +-- Saved games on user profiles (BS-219e7007). +-- +-- Users can save any game with an optional title; saved games are listed on +-- their public profile. One row per (user, game): re-saving the same game +-- updates the title instead of creating a duplicate. +CREATE TABLE saved_games ( + saved_game_id UUID PRIMARY KEY DEFAULT gen_random_uuid (), + user_id UUID NOT NULL REFERENCES users (user_id), + game_id UUID NOT NULL REFERENCES games (game_id), + title TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (user_id, game_id) +); diff --git a/server/src/models/mod.rs b/server/src/models/mod.rs index 8d5c09c..0ea24d6 100644 --- a/server/src/models/mod.rs +++ b/server/src/models/mod.rs @@ -8,6 +8,7 @@ pub mod game_battlesnake; pub mod imported_account; pub mod leaderboard; pub mod rate_limit; +pub mod saved_game; pub mod session; pub mod snake_health_status; pub mod tag; diff --git a/server/src/models/saved_game.rs b/server/src/models/saved_game.rs new file mode 100644 index 0000000..db92b3e --- /dev/null +++ b/server/src/models/saved_game.rs @@ -0,0 +1,283 @@ +use color_eyre::eyre::Context as _; +use sqlx::PgPool; +use uuid::Uuid; + +/// A game a user saved to their public profile. +#[derive(Debug, Clone)] +pub struct SavedGame { + pub saved_game_id: Uuid, + pub user_id: Uuid, + pub game_id: Uuid, + pub title: String, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +/// A saved game joined with the game it points at, shaped for profile +/// rendering: link target, title (possibly empty), and enough game metadata +/// to build a fallback title and show a date. +#[derive(Debug, Clone)] +pub struct SavedGameListing { + pub saved_game_id: Uuid, + pub game_id: Uuid, + pub title: String, + pub game_type: String, + pub board_size: String, + pub game_created_at: chrono::DateTime, +} + +impl SavedGameListing { + /// Title shown on the profile: the user's title, or a description of the + /// game ("Standard on 11x11") when they didn't provide one. + pub fn display_title(&self) -> String { + if self.title.is_empty() { + format!("{} on {}", self.game_type, self.board_size) + } else { + self.title.clone() + } + } +} + +/// Save a game to a user's profile. Upserts on (user_id, game_id): saving a +/// game the user already saved just updates the title. +pub async fn save_game( + pool: &PgPool, + user_id: Uuid, + game_id: Uuid, + title: &str, +) -> cja::Result { + let saved = sqlx::query_as!( + SavedGame, + r#" + INSERT INTO saved_games (user_id, game_id, title) + VALUES ($1, $2, $3) + ON CONFLICT (user_id, game_id) + DO UPDATE SET title = EXCLUDED.title, updated_at = NOW() + RETURNING + saved_game_id, + user_id, + game_id, + title, + created_at, + updated_at + "#, + user_id, + game_id, + title + ) + .fetch_one(pool) + .await + .wrap_err("Failed to save game")?; + + Ok(saved) +} + +/// Get a saved game by its id. +pub async fn get_saved_game_by_id( + pool: &PgPool, + saved_game_id: Uuid, +) -> cja::Result> { + let saved = sqlx::query_as!( + SavedGame, + r#" + SELECT + saved_game_id, + user_id, + game_id, + title, + created_at, + updated_at + FROM saved_games + WHERE saved_game_id = $1 + "#, + saved_game_id + ) + .fetch_optional(pool) + .await + .wrap_err("Failed to fetch saved game")?; + + Ok(saved) +} + +/// Get the viewer's saved-game row for a specific game, if any. Used by the +/// game page to pre-fill the save form with the existing title. +pub async fn get_saved_game_for_user_and_game( + pool: &PgPool, + user_id: Uuid, + game_id: Uuid, +) -> cja::Result> { + let saved = sqlx::query_as!( + SavedGame, + r#" + SELECT + saved_game_id, + user_id, + game_id, + title, + created_at, + updated_at + FROM saved_games + WHERE user_id = $1 AND game_id = $2 + "#, + user_id, + game_id + ) + .fetch_optional(pool) + .await + .wrap_err("Failed to fetch saved game for user and game")?; + + Ok(saved) +} + +/// Delete a saved game, scoped by owner. Returns true if a row was deleted; +/// false means the saved game doesn't exist or belongs to someone else. +pub async fn delete_saved_game( + pool: &PgPool, + saved_game_id: Uuid, + user_id: Uuid, +) -> cja::Result { + let result = sqlx::query!( + "DELETE FROM saved_games WHERE saved_game_id = $1 AND user_id = $2", + saved_game_id, + user_id + ) + .execute(pool) + .await + .wrap_err("Failed to delete saved game")?; + + Ok(result.rows_affected() > 0) +} + +/// List a user's saved games for their profile, newest save first, joined +/// with the games table for fallback-title metadata. +pub async fn list_saved_games_for_user( + pool: &PgPool, + user_id: Uuid, +) -> cja::Result> { + let listings = sqlx::query_as!( + SavedGameListing, + r#" + SELECT + sg.saved_game_id, + sg.game_id, + sg.title, + g.game_type, + g.board_size, + g.created_at AS game_created_at + FROM saved_games sg + JOIN games g ON g.game_id = sg.game_id + WHERE sg.user_id = $1 + ORDER BY sg.created_at DESC + "#, + user_id + ) + .fetch_all(pool) + .await + .wrap_err("Failed to list saved games for user")?; + + Ok(listings) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::game::{CreateGame, GameBoardSize, GameType, create_game}; + + async fn create_user(pool: &PgPool, github_id: i64) -> cja::Result { + let row = sqlx::query!( + "INSERT INTO users (external_github_id, github_login, github_access_token) + VALUES ($1, $2, 'test-token') + RETURNING user_id", + github_id, + format!("gh-user-{github_id}"), + ) + .fetch_one(pool) + .await?; + Ok(row.user_id) + } + + async fn create_test_game(pool: &PgPool) -> cja::Result { + let game = create_game( + pool, + CreateGame { + board_size: GameBoardSize::Medium, + game_type: GameType::Standard, + }, + ) + .await?; + Ok(game.game_id) + } + + #[test] + fn display_title_falls_back_to_game_description() { + let listing = SavedGameListing { + saved_game_id: Uuid::nil(), + game_id: Uuid::nil(), + title: String::new(), + game_type: "Standard".to_string(), + board_size: "11x11".to_string(), + game_created_at: chrono::Utc::now(), + }; + assert_eq!(listing.display_title(), "Standard on 11x11"); + + let titled = SavedGameListing { + title: "Epic comeback".to_string(), + ..listing + }; + assert_eq!(titled.display_title(), "Epic comeback"); + } + + #[sqlx::test(migrations = "../migrations")] + async fn save_game_upserts_on_user_and_game(pool: PgPool) -> cja::Result<()> { + let user_id = create_user(&pool, 9101).await?; + let game_id = create_test_game(&pool).await?; + + let first = save_game(&pool, user_id, game_id, "First title").await?; + assert_eq!(first.title, "First title"); + + // Re-saving the same game updates the title in place. + let second = save_game(&pool, user_id, game_id, "Second title").await?; + assert_eq!(second.saved_game_id, first.saved_game_id); + assert_eq!(second.title, "Second title"); + + let listings = list_saved_games_for_user(&pool, user_id).await?; + assert_eq!(listings.len(), 1); + assert_eq!(listings[0].title, "Second title"); + assert_eq!(listings[0].game_type, "Standard"); + assert_eq!(listings[0].board_size, "11x11"); + + // A different user saving the same game gets their own row. + let other_user_id = create_user(&pool, 9102).await?; + let other = save_game(&pool, other_user_id, game_id, "").await?; + assert_ne!(other.saved_game_id, first.saved_game_id); + + Ok(()) + } + + #[sqlx::test(migrations = "../migrations")] + async fn delete_saved_game_is_owner_scoped(pool: PgPool) -> cja::Result<()> { + let owner_id = create_user(&pool, 9103).await?; + let stranger_id = create_user(&pool, 9104).await?; + let game_id = create_test_game(&pool).await?; + + let saved = save_game(&pool, owner_id, game_id, "Mine").await?; + + // A different user can't delete it. + assert!(!delete_saved_game(&pool, saved.saved_game_id, stranger_id).await?); + assert!( + get_saved_game_by_id(&pool, saved.saved_game_id) + .await? + .is_some() + ); + + // The owner can. + assert!(delete_saved_game(&pool, saved.saved_game_id, owner_id).await?); + assert!( + get_saved_game_by_id(&pool, saved.saved_game_id) + .await? + .is_none() + ); + + Ok(()) + } +} diff --git a/server/src/routes.rs b/server/src/routes.rs index 0672eb4..6ebe0e7 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -41,6 +41,7 @@ pub mod github_auth; pub mod leaderboard; pub mod policy; pub mod redirects; +pub mod saved_games; pub mod settings; pub mod tournament; pub mod users; @@ -158,6 +159,14 @@ pub fn routes(app_state: AppState) -> axum::Router { "/games/{id}/rematch", axum::routing::post(game::rematch_game), ) + .route( + "/games/{id}/save", + axum::routing::post(saved_games::save_game), + ) + .route( + "/saved-games/{id}/delete", + axum::routing::post(saved_games::delete_saved_game), + ) .route("/games/flow/{id}", get(game::show_game_flow)) .route( "/games/flow/{id}/reset", diff --git a/server/src/routes/game/view.rs b/server/src/routes/game/view.rs index 4472f40..e96997b 100644 --- a/server/src/routes/game/view.rs +++ b/server/src/routes/game/view.rs @@ -15,6 +15,7 @@ use crate::{ errors::{ServerResult, WithStatus}, models::game::GameStatus, models::game_battlesnake, + models::saved_game, routes::auth::OptionalUser, state::AppState, }; @@ -98,6 +99,15 @@ pub async fn view_game( ), }; + // The viewer's existing saved-game row, if any: pre-fills the save form + // in the aside so re-saving updates the title. + let saved = match &user { + Some(u) => saved_game::get_saved_game_for_user_and_game(&state.db, u.user_id, game_id) + .await + .wrap_err("Failed to fetch saved game")?, + None => None, + }; + Ok(page_factory.create_theater_page( format!("Game {game_id}"), Box::new(html! { @@ -238,6 +248,28 @@ pub async fn view_game( "});" "})();" } + + @if user.is_some() { + div class="gmeta" { + @if saved.is_some() { + h3 { "Saved to Your Profile" } + } @else { + h3 { "Save Game" } + } + form action={"/games/"(game_id)"/save"} method="post" { + input + type="text" + name="title" + maxlength="100" + placeholder="Title (optional)" + value=[saved.as_ref().map(|s| s.title.as_str())]; + " " + button type="submit" class="btn" { + @if saved.is_some() { "Update" } @else { "Save Game" } + } + } + } + } } } }), diff --git a/server/src/routes/saved_games.rs b/server/src/routes/saved_games.rs new file mode 100644 index 0000000..7432f72 --- /dev/null +++ b/server/src/routes/saved_games.rs @@ -0,0 +1,117 @@ +use axum::{ + Form, + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Redirect}, +}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::{ + errors::{ServerResult, WithStatus}, + models::{game, saved_game, session}, + routes::auth::CurrentUserWithSession, + state::AppState, +}; + +/// Longest title we store; anything past this is silently truncated. +pub const MAX_TITLE_LEN: usize = 100; + +#[derive(Deserialize)] +pub struct SaveGameForm { + pub title: Option, +} + +/// Normalize a user-supplied title: trim surrounding whitespace and cap the +/// length at [`MAX_TITLE_LEN`] characters (on a char boundary). A missing +/// title becomes the empty string, which renders as a game-description +/// fallback on the profile. +fn normalize_title(title: Option<&str>) -> String { + let trimmed = title.unwrap_or_default().trim(); + trimmed.chars().take(MAX_TITLE_LEN).collect() +} + +/// POST /games/{id}/save — save a game to the current user's profile (or +/// update the title if they already saved it). +pub async fn save_game( + State(state): State, + CurrentUserWithSession { user, session }: CurrentUserWithSession, + Path(game_id): Path, + Form(form): Form, +) -> ServerResult { + // Make sure the game exists before upserting (a clean 404 beats an FK error). + game::get_game_by_id(&state.db, game_id) + .await? + .ok_or_else(|| "Game not found".to_string()) + .with_status(StatusCode::NOT_FOUND)?; + + let title = normalize_title(form.title.as_deref()); + + saved_game::save_game(&state.db, user.user_id, game_id, &title).await?; + + session::set_flash_message( + &state.db, + session.session_id, + "Game saved to your profile".to_string(), + session::FLASH_TYPE_SUCCESS, + ) + .await?; + + Ok(Redirect::to(&format!("/games/{game_id}"))) +} + +/// POST /saved-games/{id}/delete — remove a saved game from the current +/// user's profile. Owner-only: anyone else gets a 404. +pub async fn delete_saved_game( + State(state): State, + CurrentUserWithSession { user, session }: CurrentUserWithSession, + Path(saved_game_id): Path, +) -> ServerResult { + let deleted = saved_game::delete_saved_game(&state.db, saved_game_id, user.user_id).await?; + if !deleted { + return Err("Saved game not found".to_string()).with_status(StatusCode::NOT_FOUND); + } + + session::set_flash_message( + &state.db, + session.session_id, + "Removed from your saved games".to_string(), + session::FLASH_TYPE_SUCCESS, + ) + .await?; + + Ok(Redirect::to(&format!("/users/{}", user.github_login))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_title_trims_whitespace() { + assert_eq!(normalize_title(Some(" Epic game ")), "Epic game"); + assert_eq!(normalize_title(Some("\t\n")), ""); + } + + #[test] + fn normalize_title_handles_missing_title() { + assert_eq!(normalize_title(None), ""); + } + + #[test] + fn normalize_title_caps_length_at_100_chars() { + let long = "x".repeat(150); + let normalized = normalize_title(Some(&long)); + assert_eq!(normalized.chars().count(), MAX_TITLE_LEN); + + // Multi-byte characters are counted as chars, not bytes. + let emoji = "🐍".repeat(150); + let normalized = normalize_title(Some(&emoji)); + assert_eq!(normalized.chars().count(), MAX_TITLE_LEN); + } + + #[test] + fn normalize_title_keeps_short_titles_untouched() { + assert_eq!(normalize_title(Some("Standard win")), "Standard win"); + } +} diff --git a/server/src/routes/users.rs b/server/src/routes/users.rs index 0e30115..afbf691 100644 --- a/server/src/routes/users.rs +++ b/server/src/routes/users.rs @@ -11,7 +11,7 @@ use crate::{ errors::{ServerResult, WithStatus}, models::{ battlesnake::{self, Visibility}, - leaderboard, + leaderboard, saved_game, user::{self, User}, }, routes::auth::OptionalUser, @@ -61,6 +61,10 @@ pub async fn show_user_profile( snakes_with_entries.push((snake, entries)); } + let saved_games = saved_game::list_saved_games_for_user(&state.db, user.user_id) + .await + .wrap_err("Failed to fetch saved games")?; + let name = public_name(&user).to_string(); Ok(page_factory.create_page( @@ -137,6 +141,35 @@ pub async fn show_user_profile( } } } + + section class="section" { + h2 { "Saved Games" } + @if saved_games.is_empty() { + p class="empty" { "No saved games yet." } + } @else { + dl class="meta-list" { + @for saved in &saved_games { + div { + dt { + a href={"/games/"(saved.game_id)} { (saved.display_title()) } + } + dd { + (saved.game_created_at.format("%b %-d, %Y")) + @if is_self { + " " + form + action={"/saved-games/"(saved.saved_game_id)"/delete"} + method="post" + style="display:inline" { + button type="submit" class="btn" { "Remove" } + } + } + } + } + } + } + } + } }), )) }