diff --git a/Cargo.lock b/Cargo.lock index a3425ffa..f2dd2dd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -661,9 +661,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.6.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a12916984aab3fa6e39d655a33e09c0071eb36d6ab3aea5c2d78551f1df6d952" +checksum = "fca2be1d5c43812bae364ee3f30b3afcb7877cf59f4aeb94c66f313a41d2fac9" [[package]] name = "bytestring" @@ -1452,7 +1452,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap 2.2.6", + "indexmap 2.3.0", "slab", "tokio", "tokio-util", @@ -1846,9 +1846,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.2.6" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" +checksum = "de3fc2e30ba82dd1b3911c8de1ffc143c74a914a14e99514d7637e3099df5ea0" dependencies = [ "equivalent", "hashbrown 0.14.5", @@ -1978,9 +1978,9 @@ dependencies = [ [[package]] name = "leptos-use" -version = "0.11.0" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "118fc5800f2ad58888ee849f0c133d9d62a63244c99f6e67170bf3558241b562" +checksum = "ac79c02d0e2998569116aa36d26fd00bfa8cadbe8cb630eb771b4d1676412a16" dependencies = [ "async-trait", "cfg-if", @@ -2047,7 +2047,7 @@ dependencies = [ "futures", "getrandom", "html-escape", - "indexmap 2.2.6", + "indexmap 2.3.0", "itertools 0.12.1", "js-sys", "leptos_reactive", @@ -2073,7 +2073,7 @@ checksum = "71eb2b309ff0e526d147e32afcbbbf39b43c1ed5b7264b7abfb6635c388c0e9e" dependencies = [ "anyhow", "camino", - "indexmap 2.2.6", + "indexmap 2.3.0", "parking_lot", "proc-macro2", "quote", @@ -2142,7 +2142,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "206825db2cb802a9b06c1f33c08569086706a7fa4d8acb86e5ed6892a8dd2cec" dependencies = [ "cfg-if", - "indexmap 2.2.6", + "indexmap 2.3.0", "leptos", "tracing", "wasm-bindgen", @@ -2158,7 +2158,7 @@ dependencies = [ "base64 0.22.1", "cfg-if", "futures", - "indexmap 2.2.6", + "indexmap 2.3.0", "js-sys", "oco_ref", "paste", @@ -3062,7 +3062,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.2.6", + "indexmap 2.3.0", "serde", "serde_derive", "serde_json", @@ -3463,9 +3463,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.16" +version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81967dd0dd2c1ab0bc3468bd7caecc32b8a4aa47d0c8c695d8c2b2108168d62c" +checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" dependencies = [ "serde", "serde_spanned", @@ -3475,20 +3475,20 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.7" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fb9f64314842840f1d940ac544da178732128f1c78c21772e876579e0da1db" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" dependencies = [ "serde", ] [[package]] name = "toml_edit" -version = "0.22.17" +version = "0.22.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d9f8729f5aea9562aac1cc0441f5d6de3cff1ee0c5d67293eeca5eb36ee7c16" +checksum = "583c44c02ad26b0c3f3066fe629275e50627026c51ac2e595cca4c230ce1ce1d" dependencies = [ - "indexmap 2.2.6", + "indexmap 2.3.0", "serde", "serde_spanned", "toml_datetime", @@ -3950,9 +3950,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.6.16" +version = "0.6.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b480ae9340fc261e6be3e95a1ba86d54ae3f9171132a73ce8d4bbaf68339507c" +checksum = "68a9bda4691f099d435ad181000724da8e5899daa10713c2d432552b9ccd3a6f" dependencies = [ "memchr", ] diff --git a/apis/src/app.rs b/apis/src/app.rs index 8341ffa0..8e4399dc 100644 --- a/apis/src/app.rs +++ b/apis/src/app.rs @@ -14,9 +14,9 @@ use crate::{ games::provide_games, navigation_controller::provide_navigation_controller, online_users::provide_users, provide_alerts, provide_auth, provide_challenge_params, provide_color_scheme, provide_config, provide_notifications, provide_ping, provide_sounds, - refocus::provide_refocus, timer::provide_timer, tournament_ready::provide_tournament_ready, - tournaments::provide_tournaments, user_search::provide_user_search, - websocket::provide_websocket, + refocus::provide_refocus, schedules::provide_schedules, timer::provide_timer, + tournament_ready::provide_tournament_ready, tournaments::provide_tournaments, + user_search::provide_user_search, websocket::provide_websocket, }, }; use leptos::*; @@ -47,6 +47,7 @@ pub fn App() -> impl IntoView { provide_tournaments(); provide_notifications(); provide_tournament_ready(); + provide_schedules(); provide_sounds(); view! { diff --git a/apis/src/common/client_message.rs b/apis/src/common/client_message.rs index 7a393ae7..eadba729 100644 --- a/apis/src/common/client_message.rs +++ b/apis/src/common/client_message.rs @@ -1,4 +1,5 @@ use super::game_action::GameAction; +use super::schedule_action::ScheduleAction; use super::{challenge_action::ChallengeAction, TournamentAction}; use serde::{Deserialize, Serialize}; use shared_types::{ChatMessageContainer, GameId}; @@ -10,6 +11,7 @@ pub enum ClientRequest { Challenge(ChallengeAction), Game { game_id: GameId, action: GameAction }, Pong(u64), + Schedule(ScheduleAction), Tournament(TournamentAction), // leptos-use idle or window unfocused will send Away, // Online and Offline are not needed because they will be handled by the WS connection diff --git a/apis/src/common/mod.rs b/apis/src/common/mod.rs index 6a624b21..1fb1453e 100644 --- a/apis/src/common/mod.rs +++ b/apis/src/common/mod.rs @@ -8,6 +8,7 @@ mod hex_stack; mod move_info; mod piece_type; mod rating_change_info; +mod schedule_action; mod server_result; mod svg_pos; mod time_signals; @@ -23,9 +24,10 @@ pub use hex_stack::HexStack; pub use move_info::MoveInfo; pub use piece_type::PieceType; pub use rating_change_info::RatingChangeInfo; +pub use schedule_action::ScheduleAction; pub use server_result::{ ChallengeUpdate, CommonMessage, ExternalServerError, GameActionResponse, GameUpdate, - ServerMessage, ServerResult, TournamentUpdate, UserStatus, UserUpdate, + ScheduleUpdate, ServerMessage, ServerResult, TournamentUpdate, UserStatus, UserUpdate, }; pub use svg_pos::SvgPos; pub use time_signals::TimeSignals; diff --git a/apis/src/common/schedule_action.rs b/apis/src/common/schedule_action.rs new file mode 100644 index 00000000..e65c135c --- /dev/null +++ b/apis/src/common/schedule_action.rs @@ -0,0 +1,26 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use shared_types::{GameId, TournamentId}; +use std::fmt; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ScheduleAction { + Propose(DateTime, GameId), + Accept(Uuid), + Cancel(Uuid), + TournamentPublic(TournamentId), + TournamentOwn(TournamentId), +} + +impl fmt::Display for ScheduleAction { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Propose(date, game_id) => write!(f, "Propose({date}, {game_id})"), + Self::Accept(game_id) => write!(f, "Accept({game_id})"), + Self::Cancel(game_id) => write!(f, "Cancel({game_id})"), + Self::TournamentPublic(id) => write!(f, "TournamentPublic({id})"), + Self::TournamentOwn(id) => write!(f, "TournamentOwn({id})"), + } + } +} diff --git a/apis/src/common/server_result.rs b/apis/src/common/server_result.rs index 05ffa39a..6acc3d57 100644 --- a/apis/src/common/server_result.rs +++ b/apis/src/common/server_result.rs @@ -1,12 +1,14 @@ use super::game_reaction::GameReaction; use super::ClientRequest; use crate::responses::{ - ChallengeResponse, GameResponse, HeartbeatResponse, TournamentResponse, UserResponse, + ChallengeResponse, GameResponse, HeartbeatResponse, ScheduleResponse, TournamentResponse, + UserResponse, }; use http::StatusCode; use serde::{Deserialize, Serialize}; use shared_types::{ChallengeId, ChatMessageContainer}; use shared_types::{GameId, TournamentId}; +use std::collections::HashMap; use std::fmt; use uuid::Uuid; @@ -53,6 +55,7 @@ pub enum ServerMessage { // sent to everyone in the game when a user joins the game Join(UserResponse), Error(String), + Schedule(ScheduleUpdate), } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -107,3 +110,12 @@ pub enum UserStatus { Offline, Away, } + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ScheduleUpdate { + Proposed(ScheduleResponse), + Accepted(ScheduleResponse), + Deleted(ScheduleResponse), + TournamentSchedules(HashMap>), + OwnTournamentSchedules(HashMap>), +} diff --git a/apis/src/components/atoms/date_time_picker.rs b/apis/src/components/atoms/date_time_picker.rs new file mode 100644 index 00000000..2fb0130f --- /dev/null +++ b/apis/src/components/atoms/date_time_picker.rs @@ -0,0 +1,44 @@ +use chrono::{DateTime, Duration, Local, NaiveDateTime, Utc}; +use leptos::*; + +#[component] +pub fn DateTimePicker( + text: &'static str, + min: DateTime, + max: DateTime, + success_callback: Callback>, + #[prop(optional)] failure_callback: Option>, +) -> impl IntoView { + view! { + + + } +} diff --git a/apis/src/components/atoms/mod.rs b/apis/src/components/atoms/mod.rs index 3e5cc415..fbe9e28d 100644 --- a/apis/src/components/atoms/mod.rs +++ b/apis/src/components/atoms/mod.rs @@ -1,5 +1,6 @@ pub mod active; pub mod create_challenge_button; +pub mod date_time_picker; pub mod direct_challenge_button; pub mod download_pgn; pub mod game_type; @@ -17,6 +18,7 @@ pub mod piece; pub mod profile_link; pub mod progress_bar; pub mod rating; +pub mod schedule_controls; pub mod select_options; pub mod simple_hex; pub mod simple_switch; @@ -25,3 +27,4 @@ pub mod target; pub mod title; pub mod toggle_controls; pub mod uninvite_button; +// pub mod typed_select_option; diff --git a/apis/src/components/atoms/piece.rs b/apis/src/components/atoms/piece.rs index 9c20f327..9c37fd5f 100644 --- a/apis/src/components/atoms/piece.rs +++ b/apis/src/components/atoms/piece.rs @@ -191,12 +191,7 @@ pub fn Piece( // TODO: hand in tile_design and don't get it all the time from config ) -> impl IntoView { if simple { - return view! { - - }; - } - view! { - + return view! { }; } - .into_view() + view! { }.into_view() } diff --git a/apis/src/components/atoms/progress_bar.rs b/apis/src/components/atoms/progress_bar.rs index 0ba05fc7..69cd38be 100644 --- a/apis/src/components/atoms/progress_bar.rs +++ b/apis/src/components/atoms/progress_bar.rs @@ -11,21 +11,25 @@ pub fn ProgressBar(current: Signal, total: usize) -> impl IntoView { Signal::derive(move || format!("transform: translateX(-{}%)", 100.0 - progress.get())); view! { 0 }> -
- Progress: - {current} - / - {total} - - - +
+
+ Games played: + {current} + / + {total} +
+
+ + + +
} diff --git a/apis/src/components/atoms/schedule_controls.rs b/apis/src/components/atoms/schedule_controls.rs new file mode 100644 index 00000000..df2d517d --- /dev/null +++ b/apis/src/components/atoms/schedule_controls.rs @@ -0,0 +1,89 @@ +use crate::common::ScheduleAction; +use crate::components::atoms::date_time_picker::DateTimePicker; +use crate::providers::ApiRequests; +use crate::responses::ScheduleResponse; +use chrono::{DateTime, Duration, Local, Utc}; +use leptos::*; +use shared_types::GameId; +use uuid::Uuid; + +#[component] +pub fn GameDateControls(player_id: Uuid, schedule: ScheduleResponse) -> impl IntoView { + let start_date = schedule.start_t; + let agreed = schedule.agreed; + let id = schedule.id; + let proposer_id = schedule.proposer_id; + let formated_game_date = move |time: DateTime| { + let to_date = time - Utc::now(); + + let agreed_str = if agreed { "To play" } else { "Proposed" }; + format!( + "{} in {} days, {} hours, {} minutes ({})", + agreed_str, + to_date.num_days(), + to_date.num_hours() % 24, + to_date.num_minutes() % 60, + time.with_timezone(&Local).format("%m-%d %H:%M") + ) + }; + let accept = Callback::from(move |id| { + let api = ApiRequests::new(); + api.schedule_action(ScheduleAction::Accept(id)); + }); + view! { +
+
{formated_game_date(start_date)}
+ + + + +
+ } +} + +#[component] +pub fn ProposeDateControls(game_id: GameId) -> impl IntoView { + let selected_time = RwSignal::new(Utc::now() + Duration::minutes(10)); + let propose = Callback::from(move |date| { + let api = ApiRequests::new(); + api.schedule_action(ScheduleAction::Propose(date, game_id.clone())); + }); + view! { +
+ + + +
+ } +} diff --git a/apis/src/components/molecules/challenge_row.rs b/apis/src/components/molecules/challenge_row.rs index f4d41ede..9abedeba 100644 --- a/apis/src/components/molecules/challenge_row.rs +++ b/apis/src/components/molecules/challenge_row.rs @@ -153,7 +153,7 @@ pub fn ChallengeRow(challenge: StoredValue, single: bool) ->
diff --git a/apis/src/components/molecules/game_info.rs b/apis/src/components/molecules/game_info.rs index c3cdf6b5..3bef8735 100644 --- a/apis/src/components/molecules/game_info.rs +++ b/apis/src/components/molecules/game_info.rs @@ -10,7 +10,7 @@ pub fn GameInfo(#[prop(optional)] extend_tw_classes: &'static str) -> impl IntoV gs.game_response.as_ref().map(|gr| { ( TimeInfo { - mode: gr.time_mode.clone(), + mode: gr.time_mode, base: gr.time_base, increment: gr.time_increment, }, @@ -51,7 +51,7 @@ pub fn GameInfo(#[prop(optional)] extend_tw_classes: &'static str) -> impl IntoV view! {
- + {rated} {name()} diff --git a/apis/src/components/molecules/game_previews.rs b/apis/src/components/molecules/game_previews.rs index 9c8e264b..fd152789 100644 --- a/apis/src/components/molecules/game_previews.rs +++ b/apis/src/components/molecules/game_previews.rs @@ -60,11 +60,12 @@ pub fn GamePreviews( } }; view! { +
{ let time_info = store_value(TimeInfo { - mode: game.time_mode.clone(), + mode: game.time_mode, base: game.time_base, increment: game.time_increment, }); @@ -81,7 +82,7 @@ pub fn GamePreviews(
{if game().rated { "RATED " } else { "CASUAL " }} - +
@@ -99,5 +100,6 @@ pub fn GamePreviews( }
+
} } diff --git a/apis/src/components/molecules/game_row.rs b/apis/src/components/molecules/game_row.rs index bb3d6626..8a3b438f 100644 --- a/apis/src/components/molecules/game_row.rs +++ b/apis/src/components/molecules/game_row.rs @@ -88,7 +88,7 @@ pub fn GameRow(game: StoredValue) -> impl IntoView { let conclusion = move || game().conclusion.pretty_string(); let ratings = store_value(RatingChangeInfo::from_game_response(&game())); let time_info = TimeInfo { - mode: game().time_mode.clone(), + mode: game().time_mode, base: game().time_base, increment: game().time_increment, }; @@ -101,7 +101,7 @@ pub fn GameRow(game: StoredValue) -> impl IntoView {
- {rated_string} + {rated_string} @@ -151,7 +151,10 @@ pub fn GameRow(game: StoredValue) -> impl IntoView {
- {result_string} {conclusion} + {result_string} + +
{conclusion}
+
{history_string} diff --git a/apis/src/components/molecules/mod.rs b/apis/src/components/molecules/mod.rs index 22c523e6..18244844 100644 --- a/apis/src/components/molecules/mod.rs +++ b/apis/src/components/molecules/mod.rs @@ -15,6 +15,8 @@ pub mod hover_ratings; pub mod invite_user; pub mod live_timer; pub mod modal; +pub mod myschedules; +pub mod pending_game_row; pub mod ping; pub mod rating_and_change; pub mod rl_banner; diff --git a/apis/src/components/molecules/myschedules.rs b/apis/src/components/molecules/myschedules.rs new file mode 100644 index 00000000..0e02e713 --- /dev/null +++ b/apis/src/components/molecules/myschedules.rs @@ -0,0 +1,127 @@ +use crate::components::atoms::{ + profile_link::ProfileLink, + schedule_controls::{GameDateControls, ProposeDateControls}, +}; +use crate::providers::schedules::SchedulesContext; +use crate::responses::GameResponse; +use crate::responses::ScheduleResponse; +use chrono::{Duration, Utc}; +use hive_lib::GameStatus; +use leptos::*; +use shared_types::GameId; +use std::collections::HashMap; +use uuid::Uuid; + +#[component] +pub fn MySchedules( + games_hashmap: Memo>, + user_id: Signal>, +) -> impl IntoView { + let has_schedules = move || { + user_id().map_or(false, |user_id| { + games_hashmap.get().iter().any(|(_game_id, game)| { + game.game_status == GameStatus::NotStarted + && (game.white_player.uid == user_id || game.black_player.uid == user_id) + }) + }) + }; + + view! { + + + + } +} + +#[component] +fn MySchedulesInner( + games_hashmap: Memo>, + user_id: Signal, +) -> impl IntoView { + let ctx = expect_context::(); + let get_game = move |game_id: GameId| games_hashmap().get(&game_id).cloned(); + let own_slice = move || ctx.own.get(); + let get_schedules = move |game_id: GameId| { + own_slice() + .get(&game_id) + //context has all my games so this should never fail + .unwrap_or(&HashMap::new()) + .values() + .filter(|s| s.start_t + Duration::hours(1) > Utc::now()) + .cloned() + .collect::>() + }; + let my_schedules = move || { + let mut ret = Vec::new(); + let user_id = user_id(); + own_slice().iter().for_each(|(key, value)| { + if let Some(game) = get_game(key.clone()) { + if game.white_player.uid == user_id || game.black_player.uid == user_id { + ret.push((game, value.len())) + } + } + }); + ret + }; + view! { +
+ My Schedules: + + + { + let (gr, _) = game; + let game_id = StoredValue::new(gr.game_id); + let white_username = gr.white_player.username; + let black_username = gr.black_player.username; + let white_patreon = gr.white_player.patreon; + let black_patreon = gr.black_player.patreon; + view! { +
+
+
+ + vs. + +
+
+ + + + + + + + "Join Game" + +
+ } + } + +
+
+ } +} diff --git a/apis/src/components/molecules/pending_game_row.rs b/apis/src/components/molecules/pending_game_row.rs new file mode 100644 index 00000000..e088c9df --- /dev/null +++ b/apis/src/components/molecules/pending_game_row.rs @@ -0,0 +1,48 @@ +use crate::components::atoms::profile_link::ProfileLink; +use crate::responses::GameResponse; +use chrono::{DateTime, Local, Utc}; +use leptos::*; + +#[component] +pub fn PendingGameRow(schedule: Option>, game: GameResponse) -> impl IntoView { + let date_str = if let Some(time) = schedule { + format!( + "Scheduled at {}", + time.with_timezone(&Local).format("%Y-%m-%d %H:%M"), + ) + } else { + "Not yet scheduled".to_owned() + }; + view! { +
+
+
+
+ + {format!("({})", game.white_rating())} +
+ vs. +
+ + {format!("({})", game.black_rating())} +
+
+
{date_str}
+
+ + "Join Game" + +
+ } +} diff --git a/apis/src/components/molecules/score_row.rs b/apis/src/components/molecules/score_row.rs index ff779ed5..37f8e9e5 100644 --- a/apis/src/components/molecules/score_row.rs +++ b/apis/src/components/molecules/score_row.rs @@ -22,28 +22,31 @@ pub fn ScoreRow( /> } }; + let td_class = "xs:py-1 xs:px-1 sm:py-2 sm:px-2"; view! { -
-
-
{standing}
- + + +
{standing}
+ +
{profile_link()}
- - {tiebreakers - .iter() - .map(|tiebreaker| { - view! { -
+ + {tiebreakers + .iter() + .map(|tiebreaker| { + view! { + +
{*scores.get(tiebreaker).unwrap_or(&0.0)}
- } - }) - .collect_view()} -
-
+ + } + }) + .collect_view()} + } } diff --git a/apis/src/components/molecules/time_row.rs b/apis/src/components/molecules/time_row.rs index 4e28c841..701d9eaf 100644 --- a/apis/src/components/molecules/time_row.rs +++ b/apis/src/components/molecules/time_row.rs @@ -5,11 +5,12 @@ use shared_types::{GameSpeed, TimeInfo, TimeMode}; #[component] pub fn TimeRow( - time_info: TimeInfo, + time_info: MaybeSignal, #[prop(optional)] extend_tw_classes: &'static str, ) -> impl IntoView { - let time_mode = store_value(time_info.mode); + let time_mode = store_value(time_info.get_untracked().mode); let icon = move || { + let time_info = time_info(); let speed = match time_mode() { TimeMode::Untimed => GameSpeed::Untimed, TimeMode::Correspondence => GameSpeed::Correspondence, @@ -19,26 +20,29 @@ pub fn TimeRow( }; view! { } }; - let text = move || match time_mode() { - TimeMode::Untimed => "No time limit".to_owned(), - TimeMode::RealTime => format!( - "{}m + {}s", - time_info.base.expect("Time exists") / 60, - time_info.increment.expect("Increment exists"), - ), + let text = move || { + let time_info = time_info(); + match time_mode() { + TimeMode::Untimed => "No time limit".to_owned(), + TimeMode::RealTime => format!( + "{}m + {}s", + time_info.base.expect("Time exists") / 60, + time_info.increment.expect("Increment exists"), + ), - TimeMode::Correspondence if time_info.base.is_some() => { - format!("{} days/side", time_info.base.expect("Time exists") / 86400) - } + TimeMode::Correspondence if time_info.base.is_some() => { + format!("{} days/side", time_info.base.expect("Time exists") / 86400) + } - TimeMode::Correspondence if time_info.increment.is_some() => { - format!( - "{} days/move ", - time_info.increment.expect("Time exists") / 86400 - ) - } + TimeMode::Correspondence if time_info.increment.is_some() => { + format!( + "{} days/move ", + time_info.increment.expect("Time exists") / 86400 + ) + } - _ => unreachable!(), + _ => unreachable!(), + } }; view! {
diff --git a/apis/src/components/molecules/tournament_invitation_row.rs b/apis/src/components/molecules/tournament_invitation_row.rs index fdc1eadd..4dc720ba 100644 --- a/apis/src/components/molecules/tournament_invitation_row.rs +++ b/apis/src/components/molecules/tournament_invitation_row.rs @@ -37,7 +37,7 @@ pub fn TournamentInvitationNotification(tournament: RwSignal
{tournament().name}
- +
Players: {seats_taken}
diff --git a/apis/src/components/molecules/tournament_row.rs b/apis/src/components/molecules/tournament_row.rs index ac20bc0a..0dc94dd1 100644 --- a/apis/src/components/molecules/tournament_row.rs +++ b/apis/src/components/molecules/tournament_row.rs @@ -52,7 +52,7 @@ pub fn TournamentRow(tournament: TournamentResponse) -> impl IntoView {
{tournament.mode}
- +
{seats_taken}
diff --git a/apis/src/components/molecules/user_row.rs b/apis/src/components/molecules/user_row.rs index d0b1ea10..25aa247e 100644 --- a/apis/src/components/molecules/user_row.rs +++ b/apis/src/components/molecules/user_row.rs @@ -56,16 +56,16 @@ pub fn UserRow( for action in actions { match action { UserAction::Challenge => { - views.push(view! { }); + views.push(view! { }); } UserAction::Invite(tournament_id) => { - views.push(view! { }); + views.push(view! { }); } UserAction::Uninvite(tournament_id) => { - views.push(view! { }); + views.push(view! { }); } UserAction::Kick(tournament) => { - views.push(view! { }); + views.push(view! { }); } _ => {} }; diff --git a/apis/src/components/organisms/mod.rs b/apis/src/components/organisms/mod.rs index b45eb270..02a40422 100644 --- a/apis/src/components/organisms/mod.rs +++ b/apis/src/components/organisms/mod.rs @@ -17,9 +17,11 @@ pub mod quickplay; pub mod reserve; pub mod side_board; pub mod sound_toggle; +pub mod standings; pub mod tile_design_toggle; pub mod tile_dots_toggle; pub mod tile_rotation_toggle; pub mod time_select; +pub mod tournament_admin; pub mod tv; pub mod unstarted; diff --git a/apis/src/components/organisms/standings.rs b/apis/src/components/organisms/standings.rs new file mode 100644 index 00000000..f517a881 --- /dev/null +++ b/apis/src/components/organisms/standings.rs @@ -0,0 +1,72 @@ +use crate::components::molecules::score_row::ScoreRow; +use crate::responses::TournamentResponse; +use leptos::*; +use uuid::Uuid; + +#[component] +pub fn Standings(tournament: Signal) -> impl IntoView { + let th_class = "py-1 px-1 md:py-2 md:px-2 lg:px-3 font-bold uppercase"; + view! { + + + + + + {tournament() + .tiebreakers + .iter() + .map(|tiebreaker| { + view! { } + }) + .collect_view()} + + + + >() + } + + let:players_at_position + > + + { + let players_at_position = store_value(players_at_position); + view! { + + + { + let (uuid, position, hash) = player; + let uuid = store_value(uuid); + let user = store_value( + tournament() + .players + .get(&uuid()) + .expect("User in tournament") + .clone(), + ); + view! { + + } + } + + + } + } + + + +
PosPlayer{tiebreaker.pretty_str().to_owned()}
+ } +} diff --git a/apis/src/components/organisms/tournament_admin.rs b/apis/src/components/organisms/tournament_admin.rs new file mode 100644 index 00000000..0de86137 --- /dev/null +++ b/apis/src/components/organisms/tournament_admin.rs @@ -0,0 +1,53 @@ +use crate::components::molecules::invite_user::InviteUser; +use crate::components::molecules::user_row::UserRow; +use crate::{common::UserAction, responses::TournamentResponse}; +use leptos::*; + +#[component] +pub fn TournamentAdminControls( + user_is_organizer: bool, + tournament: TournamentResponse, +) -> impl IntoView { + let tournament = store_value(tournament); + let user_kick = move || { + if user_is_organizer { + vec![UserAction::Kick(Box::new(tournament()))] + } else { + vec![] + } + }; + let user_uninvite = move || { + if user_is_organizer { + vec![UserAction::Uninvite(tournament().tournament_id)] + } else { + vec![] + } + }; + view! { +
+ +

Players

+ + + +
+
+
+ +

Invitees

+ + + +
+ +

Invite players

+ +
+
+ } +} diff --git a/apis/src/pages/display_games.rs b/apis/src/pages/display_games.rs index 68da755b..561ff112 100644 --- a/apis/src/pages/display_games.rs +++ b/apis/src/pages/display_games.rs @@ -1,11 +1,11 @@ -use html::Div; -use leptos::*; +use super::profile_view::ProfileGamesView; use crate::{ - functions::users::get::get_finished_games_in_batches, pages::profile_view::ProfileGamesContext, components::molecules::game_row::GameRow, components::organisms::display_profile::DisplayProfile, + functions::users::get::get_finished_games_in_batches, pages::profile_view::ProfileGamesContext, }; -use super::profile_view::ProfileGamesView; +use html::Div; +use leptos::*; use leptos_router::A; use leptos_use::{use_infinite_scroll_with_options, UseInfiniteScrollOptions}; diff --git a/apis/src/pages/tournament.rs b/apis/src/pages/tournament.rs index f2f9d374..4336f5fe 100644 --- a/apis/src/pages/tournament.rs +++ b/apis/src/pages/tournament.rs @@ -1,22 +1,26 @@ -use crate::common::{TournamentAction, UserAction}; -use crate::components::molecules::score_row::ScoreRow; +use crate::common::{ScheduleAction, TournamentAction}; use crate::components::{ atoms::progress_bar::ProgressBar, molecules::{ - game_previews::GamePreviews, invite_user::InviteUser, time_row::TimeRow, user_row::UserRow, + game_previews::GamePreviews, myschedules::MySchedules, pending_game_row::PendingGameRow, + time_row::TimeRow, user_row::UserRow, + }, + organisms::{ + chat::ChatWindow, standings::Standings, tournament_admin::TournamentAdminControls, }, - organisms::chat::ChatWindow, }; use crate::providers::{ - navigation_controller::NavigationControllerSignal, tournaments::TournamentStateSignal, - ApiRequests, AuthContext, + navigation_controller::NavigationControllerSignal, schedules::SchedulesContext, + tournaments::TournamentStateSignal, ApiRequests, AuthContext, }; -use chrono::Local; +use crate::responses::{GameResponse, TournamentResponse}; +use chrono::{DateTime, Duration, Local, Utc}; +use hive_lib::GameStatus; use leptos::*; use leptos_router::use_navigate; -use shared_types::PrettyString; +use shared_types::{GameId, PrettyString}; use shared_types::{GameSpeed, TimeInfo, TournamentStatus}; -use uuid::Uuid; +use std::collections::HashMap; const BUTTON_STYLE: &str = "flex gap-1 justify-center items-center px-4 py-2 font-bold text-white rounded bg-button-dawn dark:bg-button-twilight hover:bg-pillbug-teal active:scale-95 disabled:opacity-25 disabled:cursor-not-allowed disabled:hover:bg-transparent"; @@ -35,160 +39,266 @@ pub fn Tournament() -> impl IntoView { .cloned() }) }; + + view! { +
+
+ + + +
+
+ } +} + +#[component] +fn LoadedTournament(tournament: Signal) -> impl IntoView { let auth_context = expect_context::(); let account = move || match (auth_context.user)() { Some(Ok(Some(account))) => Some(account), _ => None, }; - let number_of_players = move || current_tournament().map_or(0, |t| t.players.len()); + let user_id = Signal::derive(move || account().map(|a| a.user.uid)); + let schedules_signal = expect_context::(); + let tournament_schedules = move || schedules_signal.tournament.get(); + let time_info = Signal::derive(move || { + let tournament = tournament(); + TimeInfo { + mode: tournament.time_mode, + base: tournament.time_base, + increment: tournament.time_increment, + } + }); + let tournament_id = Memo::new(move |_| tournament().tournament_id); + create_effect(move |_| { + if tournament().status != TournamentStatus::NotStarted { + let api = ApiRequests::new(); + api.schedule_action(ScheduleAction::TournamentPublic(tournament_id())); + if user_id().is_some() { + api.schedule_action(ScheduleAction::TournamentOwn(tournament_id())); + } + } + }); + + let games_hashmap = Memo::new(move |_| { + if tournament().status != TournamentStatus::NotStarted { + let mut games_hashmap = HashMap::new(); + for game in tournament().games { + games_hashmap.insert(game.game_id.clone(), game); + } + games_hashmap + } else { + HashMap::new() + } + }); + + let number_of_players = Memo::new(move |_| tournament().players.len() as i32); let user_joined = move || { if let Some(account) = account() { - current_tournament() - .map_or(false, |t| t.players.iter().any(|(id, _)| *id == account.id)) + tournament().players.iter().any(|(id, _)| *id == account.id) } else { false } }; + let user_is_organizer = move || { if let Some(account) = account() { - current_tournament().map_or(false, |t| t.organizers.iter().any(|p| p.uid == account.id)) + tournament().organizers.iter().any(|p| p.uid == account.id) } else { false } }; - let delete = move |_| { - if let Some(tournament_id) = tournament_id() { - if user_is_organizer() { - let action = TournamentAction::Delete(tournament_id); - let api = ApiRequests::new(); - api.tournament(action); - let navigate = use_navigate(); - navigate("/tournaments", Default::default()); - } + if user_is_organizer() { + let action = TournamentAction::Delete(tournament_id()); + let api = ApiRequests::new(); + api.tournament(action); + let navigate = use_navigate(); + navigate("/tournaments", Default::default()); } }; let start = move |_| { - if let Some(tournament_id) = tournament_id() { - if user_is_organizer() { - let action = TournamentAction::Start(tournament_id); - let api = ApiRequests::new(); - api.tournament(action); - } + if user_is_organizer() { + let action = TournamentAction::Start(tournament_id()); + let api = ApiRequests::new(); + api.tournament(action); } }; let leave = move |_| { - if let Some(tournament_id) = tournament_id() { - let api = ApiRequests::new(); - api.tournament(TournamentAction::Leave(tournament_id)); + let api = ApiRequests::new(); + api.tournament(TournamentAction::Leave(tournament_id())); + }; + let join = move |_| { + let api = ApiRequests::new(); + api.tournament(TournamentAction::Join(tournament_id())); + }; + let start_disabled = move || tournament().min_seats > number_of_players(); + let join_disabled = move || { + let tournament = tournament(); + if tournament.seats <= number_of_players() { + return true; + } + if let Some(account) = account() { + let user = account.user; + if tournament.invite_only { + if tournament + .invitees + .iter() + .any(|invitee| invitee.uid == user.uid) + { + return false; + } + if tournament + .organizers + .iter() + .any(|organizer| organizer.uid == user.uid) + { + return false; + } + return true; + } + let game_speed = + GameSpeed::from_base_increment(tournament.time_base, tournament.time_increment); + let rating = user.rating_for_speed(&game_speed) as i32; + match (tournament.band_lower, tournament.band_upper) { + (None, None) => false, + (None, Some(upper)) => rating >= upper, + (Some(lower), None) => rating <= lower, + (Some(lower), Some(upper)) => rating <= lower || rating >= upper, + } + } else { + true } }; - let join = move |_| { - if let Some(tournament_id) = tournament_id() { - let api = ApiRequests::new(); - api.tournament(TournamentAction::Join(tournament_id)); + let starts = move || { + let tournament = tournament(); + if matches!(tournament.status, TournamentStatus::NotStarted) { + match tournament.starts_at { + None => "Start up to organizer".to_string(), + Some(time) => time + .with_timezone(&Local) + .format("Starts: %d/%m/%Y %H:%M") + .to_string(), + } + } else { + let pretty = tournament.status.pretty_string(); + if let Some(started_at) = tournament.started_at { + let start = started_at + .with_timezone(&Local) + .format("started: %d/%m/%Y %H:%M") + .to_string(); + format!("{pretty}, {start}") + } else { + pretty + } } }; + let total_games = move || tournament().games.len(); + let finished_games = + Signal::derive(move || tournament().games.iter().filter(|g| g.finished).count()); + let not_started = Memo::new(move |_| tournament().status == TournamentStatus::NotStarted); - let display_tournament = move || { - current_tournament().and_then(|tournament| { - let time_info = TimeInfo{mode:tournament.time_mode.clone() ,base: tournament.time_base, increment: tournament.time_increment}; - let tournament = store_value(tournament); - let start_disabled = move || {let tournament =tournament(); tournament.min_seats > tournament.players.len() as i32} ; - let join_disabled = move || { - let tournament = tournament(); - if tournament.seats <= tournament.players.len() as i32 { - return true; - } - if let Some(account) = account() { - let user = account.user; - if tournament.invite_only { - if tournament.invitees.iter().any(|invitee| invitee.uid == user.uid ) { return false; } - if tournament.organizers.iter().any(|organizer| organizer.uid == user.uid ) { return false; } - return true; - } - let game_speed = - GameSpeed::from_base_increment(tournament.time_base, tournament.time_increment); - let rating = user.rating_for_speed(&game_speed) as i32; - match (tournament.band_lower, tournament.band_upper) { - (None, None) => false, - (None, Some(upper)) => rating >= upper, - (Some(lower), None) => rating <= lower, - (Some(lower), Some(upper)) => rating <= lower || rating >= upper, - } - } else {true} + let game_previews = Callback::new(move |_| { + games_hashmap + .get() + .iter() + .filter_map(|(_, g)| match g.game_status { + GameStatus::NotStarted => None, + _ => Some(g.clone()), + }) + .collect::>() + }); - }; - let user_kick = move || { - if user_is_organizer() { - vec![UserAction::Kick(Box::new(tournament()))] - } else { - vec![] - } - }; - let user_uninvite = move || { - if user_is_organizer() { - vec![UserAction::Uninvite(tournament().tournament_id)] - } else { - vec![] - } - }; - let starts = move || { - let tournament = tournament(); - if matches!(tournament.status, TournamentStatus::NotStarted) { - match tournament.starts_at { - None => "Start up to organizer".to_string(), - Some(time) => time - .with_timezone(&Local) - .format("Starts: %d/%m/%Y %H:%M") - .to_string(), + let pending_games = move || { + let mut result: HashMap>, GameResponse)> = HashMap::new(); + games_hashmap().iter().for_each(|(game_id, game)| { + if game.game_status == GameStatus::NotStarted { + let mut should_insert_none = true; + if let Some(schedules) = tournament_schedules().get(game_id) { + for schedule in schedules.values() { + if (schedule.start_t + Duration::hours(1)) > Utc::now() && schedule.agreed { + result.insert(game_id.clone(), (Some(schedule.start_t), game.clone())); + should_insert_none = false; + } } - } else { - let pretty = tournament.status.pretty_string(); - if let Some(started_at) = tournament.started_at { - let start = started_at.with_timezone(&Local) - .format("started: %d/%m/%Y %H:%M") - .to_string(); - format! ("{pretty}, {start}") - } else {pretty} } - }; - let total_games = tournament().games.len(); - let finished_games = Signal::derive(move || tournament().games.iter().filter(|g| g.finished).count()); - let not_started = move || tournament().status == TournamentStatus::NotStarted; - view! { -
-

- {tournament().name} -

+ + if should_insert_none { + result + .entry(game_id.clone()) + .or_insert((None, game.clone())); + } + } + }); + + result + }; + let pending_games_string = move || { + let nr = pending_games().len(); + if nr == 1 { + String::from("1 Pending game") + } else { + format!("{nr} Pending games") + } + }; + let tournament_style = move || { + if not_started() { + "flex flex-col gap-1 w-full items-center" + } else { + "flex flex-col gap-1 w-full sm:flex-row sm:flex-wrap" + } + }; + + view! { +
+

+ {move || tournament().name} +

+
+ {move || tournament().description} +
+
+
+ +
+
Tournament Info
+
+ "Time control: " +
-
-
- {tournament().description} -
+
+ "Players: " + {number_of_players} + / + {move || tournament().seats}
-
-
- "Time control: " - + +
+ "Minimum players: " + {move || tournament().min_seats}
-
- "Players: " - {number_of_players} - / - {tournament().seats} + +
{starts}
+
+
+

Organized by:

+ +
+ +
+
- -
- "Minimum players: " - {tournament().min_seats} -
-
-
{starts}
- // Progress bar - +
+
impl IntoView {
+
-
-
-
-

Organizers

- - key=|users| (users.uid) - let:user - > -
- -
-
-
+ + + + +
+ {pending_games_string} + - - -

Players

- - - -
-
-
- -

Invitees

- - - -
- -

Invite players

- -
-
- } - } - > - -
-

Standings

-
-
-
Position
- -
Player
- - {tournament() - .tiebreakers - .iter() - .map(|tiebreaker| { - view! { -
- {tiebreaker.pretty_str().to_owned()} -
- } - }) - .collect_view()} -
-
- >() - } - - let:players_at_position - > - - { - let players_at_position = store_value(players_at_position); - view! { - + key=|(time, game)| (time.to_owned(), game.game_id.clone()) - { - let (uuid, position, hash) = player; - let uuid = store_value(uuid); - let user = store_value( - tournament() - .players - .get(&uuid()) - .expect("User in tournament") - .clone(), - ); - view! { - - } - } + let:tuple + > - - } - } + { + let (schedule, game) = tuple; + view! { } + } - - Tournament Games: -
- -
-
- -
+ + + + + +
+ Tournament chat +
-
- } - .into() - }) - }; - view! { -
-
{display_tournament}
+ + + +
+ Finished or ongoing games: + +
+
} } diff --git a/apis/src/pages/tournament_create.rs b/apis/src/pages/tournament_create.rs index 63a1c29e..b0bb427f 100644 --- a/apis/src/pages/tournament_create.rs +++ b/apis/src/pages/tournament_create.rs @@ -3,11 +3,12 @@ use crate::components::organisms::time_select::TimeSelect; use crate::components::update_from_event::{update_from_input, update_from_input_parsed}; use crate::{ components::atoms::{ - input_slider::InputSlider, select_options::SelectOption, simple_switch::SimpleSwitch, + date_time_picker::DateTimePicker, input_slider::InputSlider, select_options::SelectOption, + simple_switch::SimpleSwitch, }, providers::{ApiRequests, AuthContext}, }; -use chrono::{DateTime, Duration, Local, NaiveDateTime, Utc}; +use chrono::{DateTime, Duration, Local, Utc}; use leptos::*; use leptos_router::use_navigate; use shared_types::PrettyString; @@ -326,47 +327,20 @@ pub fn TournamentCreate() -> impl IntoView {
- - -
diff --git a/apis/src/providers/api_requests.rs b/apis/src/providers/api_requests.rs index f625a8f8..2343ecaf 100644 --- a/apis/src/providers/api_requests.rs +++ b/apis/src/providers/api_requests.rs @@ -1,7 +1,7 @@ use super::challenges::ChallengeStateSignal; use super::games::GamesSignal; use super::AuthContext; -use crate::common::{ChallengeAction, TournamentAction}; +use crate::common::{ChallengeAction, ScheduleAction, TournamentAction}; use crate::common::{ClientRequest, GameAction}; use crate::providers::websocket::WebsocketContext; use crate::responses::create_challenge_handler; @@ -145,4 +145,9 @@ impl ApiRequests { self.websocket.send(&msg); } } + + pub fn schedule_action(&self, action: ScheduleAction) { + let msg = ClientRequest::Schedule(action); + self.websocket.send(&msg); + } } diff --git a/apis/src/providers/mod.rs b/apis/src/providers/mod.rs index c52db923..65332120 100644 --- a/apis/src/providers/mod.rs +++ b/apis/src/providers/mod.rs @@ -13,6 +13,7 @@ mod notifications; pub mod online_users; mod ping; pub mod refocus; +pub mod schedules; mod sounds; pub mod timer; pub mod tournament_ready; diff --git a/apis/src/providers/schedules.rs b/apis/src/providers/schedules.rs new file mode 100644 index 00000000..daec014b --- /dev/null +++ b/apis/src/providers/schedules.rs @@ -0,0 +1,15 @@ +use crate::responses::ScheduleResponse; +use leptos::{provide_context, RwSignal}; +use shared_types::GameId; +use std::collections::HashMap; +use uuid::Uuid; + +#[derive(Clone, Debug, Default)] +pub struct SchedulesContext { + pub own: RwSignal>>, + pub tournament: RwSignal>>, +} + +pub fn provide_schedules() { + provide_context(SchedulesContext::default()) +} diff --git a/apis/src/providers/timer.rs b/apis/src/providers/timer.rs index dc55b959..96173bc6 100644 --- a/apis/src/providers/timer.rs +++ b/apis/src/providers/timer.rs @@ -46,7 +46,7 @@ impl TimerSignal { timer.time_increment = game .time_increment .map(|inc| Duration::from_secs(inc as u64)); - timer.time_mode = game.time_mode.clone(); + timer.time_mode = game.time_mode; timer.last_interaction = game.last_interaction; timer.time_base = game.time_base.map(|base| Duration::from_secs(base as u64)); }); diff --git a/apis/src/providers/websocket/mod.rs b/apis/src/providers/websocket/mod.rs index 3b124a1b..f761567c 100644 --- a/apis/src/providers/websocket/mod.rs +++ b/apis/src/providers/websocket/mod.rs @@ -4,6 +4,7 @@ mod context; pub mod game; pub mod ping; pub mod response_handler; +pub mod schedule; pub mod tournament; pub mod user_search; pub mod user_status; diff --git a/apis/src/providers/websocket/response_handler.rs b/apis/src/providers/websocket/response_handler.rs index 25c68487..1337803e 100644 --- a/apis/src/providers/websocket/response_handler.rs +++ b/apis/src/providers/websocket/response_handler.rs @@ -5,8 +5,9 @@ use leptos::*; use super::{ challenge::handler::handle_challenge, chat::handle::handle_chat, game::handler::handle_game, - ping::handle::handle_ping, tournament::handler::handle_tournament, - user_search::handle::handle_user_search, user_status::handle::handle_user_status, + ping::handle::handle_ping, schedule::handler::handle_schedule, + tournament::handler::handle_tournament, user_search::handle::handle_user_search, + user_status::handle::handle_user_status, }; pub fn handle_response(m: &CommonMessage) { @@ -20,6 +21,7 @@ pub fn handle_response(m: &CommonMessage) { Chat(message) => handle_chat(message), UserSearch(results) => handle_user_search(results), Tournament(tournament_update) => handle_tournament(tournament_update), + Schedule(schedule_update) => handle_schedule(schedule_update), todo => { log!("Got {todo:?} which is currently still unimplemented"); } diff --git a/apis/src/providers/websocket/schedule/handler.rs b/apis/src/providers/websocket/schedule/handler.rs new file mode 100644 index 00000000..209405f9 --- /dev/null +++ b/apis/src/providers/websocket/schedule/handler.rs @@ -0,0 +1,61 @@ +use std::collections::HashMap; + +use crate::{ + common::ScheduleUpdate::{self, *}, + providers::schedules::SchedulesContext, +}; +use leptos::{expect_context, SignalSet, SignalUpdate}; + +pub fn handle_schedule(schedule_update: ScheduleUpdate) { + let ctx = expect_context::(); + + match schedule_update { + Accepted(response) => { + ctx.tournament.update(|h| { + if let Some(schedules) = h.get_mut(&response.game_id) { + for (_, schedule) in schedules.iter_mut() { + schedule.agreed = false; + } + schedules.insert(response.id, response.clone()); + } else { + let mut new_schedules = HashMap::new(); + new_schedules.insert(response.id, response.clone()); + h.insert(response.game_id.clone(), new_schedules); + } + }); + ctx.own.update(|h| { + if let Some(schedules) = h.get_mut(&response.game_id) { + for (_, schedule) in schedules.iter_mut() { + schedule.agreed = false; + } + schedules.insert(response.id, response); + } + }); + } + Proposed(response) => { + ctx.own.update(|h| { + if let Some(schedules) = h.get_mut(&response.game_id) { + schedules.insert(response.id, response); + } else { + let mut new_schedules = HashMap::new(); + new_schedules.insert(response.id, response.clone()); + h.insert(response.game_id, new_schedules); + } + }); + } + Deleted(response) => { + ctx.tournament.update(|h| { + if let Some(schedules) = h.get_mut(&response.game_id) { + schedules.remove(&response.id); + } + }); + ctx.own.update(|h| { + if let Some(schedules) = h.get_mut(&response.game_id) { + schedules.remove(&response.id); + } + }); + } + TournamentSchedules(schedules) => ctx.tournament.set(schedules), + OwnTournamentSchedules(schedules) => ctx.own.set(schedules), + } +} diff --git a/apis/src/providers/websocket/schedule/mod.rs b/apis/src/providers/websocket/schedule/mod.rs new file mode 100644 index 00000000..062ae9d9 --- /dev/null +++ b/apis/src/providers/websocket/schedule/mod.rs @@ -0,0 +1 @@ +pub mod handler; diff --git a/apis/src/responses/mod.rs b/apis/src/responses/mod.rs index 74899e3c..6138a5cd 100644 --- a/apis/src/responses/mod.rs +++ b/apis/src/responses/mod.rs @@ -4,6 +4,7 @@ mod game; mod heartbeat; mod invitation; mod rating; +mod schedules; mod tournament; mod tournament_series; mod user; @@ -13,6 +14,7 @@ pub use game::GameResponse; pub use heartbeat::HeartbeatResponse; pub use invitation::InvitationResponse; pub use rating::RatingResponse; +pub use schedules::ScheduleResponse; pub use tournament::TournamentAbstractResponse; pub use tournament::TournamentResponse; pub use user::UserResponse; diff --git a/apis/src/responses/schedules.rs b/apis/src/responses/schedules.rs new file mode 100644 index 00000000..797c8d26 --- /dev/null +++ b/apis/src/responses/schedules.rs @@ -0,0 +1,40 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use shared_types::{GameId, TournamentId}; +use uuid::Uuid; + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct ScheduleResponse { + pub id: Uuid, + pub tournament_id: TournamentId, + pub proposer_id: Uuid, + pub opponent_id: Uuid, + pub game_id: GameId, + pub start_t: DateTime, + pub agreed: bool, +} + +use cfg_if::cfg_if; +cfg_if! { if #[cfg(feature = "ssr")] { +use anyhow::Result; +use db_lib::{ + models::{Game, Schedule, Tournament}, + DbConn, +}; +impl ScheduleResponse { + pub async fn from_model(schedule: Schedule, conn: &mut DbConn<'_>) -> Result { + let tournament_id = + TournamentId(Tournament::find(schedule.tournament_id, conn).await?.nanoid); + let game_id = GameId(Game::find_by_uuid(&schedule.game_id, conn).await?.nanoid); + Ok(Self { + id: schedule.id, + tournament_id, + proposer_id: schedule.proposer_id, + opponent_id: schedule.opponent_id, + game_id, + start_t: schedule.start_t, + agreed: schedule.agreed, + }) + } +} +}} diff --git a/apis/src/websockets/api/mod.rs b/apis/src/websockets/api/mod.rs index d8d2aebc..7255ebd5 100644 --- a/apis/src/websockets/api/mod.rs +++ b/apis/src/websockets/api/mod.rs @@ -3,6 +3,7 @@ pub mod chat; pub mod game; pub mod pong; pub mod request_handler; +pub mod schedules; pub mod search; pub mod tournaments; pub mod user_status; diff --git a/apis/src/websockets/api/request_handler.rs b/apis/src/websockets/api/request_handler.rs index 7e21d1a6..433fe347 100644 --- a/apis/src/websockets/api/request_handler.rs +++ b/apis/src/websockets/api/request_handler.rs @@ -1,5 +1,6 @@ use super::chat::handler::ChatHandler; use super::game::handler::GameActionHandler; +use super::schedules::ScheduleHandler; use super::search::handler::UserSearchHandler; use crate::common::{ClientRequest, GameAction}; use crate::lag_tracking::lags::Lags; @@ -136,6 +137,16 @@ impl RequestHandler { .await? } ClientRequest::Away => UserStatusHandler::new().await?.handle().await?, + ClientRequest::Schedule(action) => { + match action { + crate::common::ScheduleAction::TournamentPublic(_) => {} + _ => self.ensure_auth()?, + } + ScheduleHandler::new(self.user_id, action, &self.pool) + .await? + .handle() + .await? + } }; Ok(messages) } diff --git a/apis/src/websockets/api/schedules/handler.rs b/apis/src/websockets/api/schedules/handler.rs new file mode 100644 index 00000000..acacbeff --- /dev/null +++ b/apis/src/websockets/api/schedules/handler.rs @@ -0,0 +1,131 @@ +use crate::{ + common::{ + ScheduleAction::{self, Accept, Cancel, Propose, TournamentOwn, TournamentPublic}, + ScheduleUpdate, ServerMessage, + }, + responses::ScheduleResponse, + websockets::internal_server_message::{InternalServerMessage, MessageDestination}, +}; +use anyhow::Result; +use db_lib::{ + get_conn, + models::{Game, NewSchedule, Schedule, Tournament}, + DbPool, +}; +use diesel_async::{scoped_futures::ScopedFutureExt, AsyncConnection}; +use shared_types::GameId; +use std::{collections::HashMap, vec}; +use uuid::Uuid; + +pub struct ScheduleHandler { + pool: DbPool, + user_id: Uuid, + action: ScheduleAction, +} + +impl ScheduleHandler { + pub async fn new(user_id: Uuid, action: ScheduleAction, pool: &DbPool) -> Result { + Ok(Self { + pool: pool.clone(), + user_id, + action, + }) + } + + pub async fn handle(&self) -> Result> { + let mut conn = get_conn(&self.pool).await?; + let (update, destinations) = conn + .transaction::<_, anyhow::Error, _>(move |tc| { + async move { + Ok(match self.action.clone() { + Accept(id) => { + let mut schedule = Schedule::from_id(id, tc).await?; + schedule.accept(self.user_id, tc).await?; + let schedule = ScheduleResponse::from_model(schedule, tc).await?; + ( + ScheduleUpdate::Accepted(schedule), + vec![MessageDestination::Global], + ) + } + Cancel(id) => { + let mut schedule = Schedule::from_id(id, tc).await?; + schedule.cancel(self.user_id, tc).await?; + let schedule = ScheduleResponse::from_model(schedule, tc).await?; + ( + ScheduleUpdate::Deleted(schedule), + vec![MessageDestination::Global], + ) + } + Propose(date, game_id) => { + let schedule = + NewSchedule::new(self.user_id, &game_id, date, tc).await?; + let schedule = Schedule::create(schedule, self.user_id, tc).await?; + let opponent_id = schedule.opponent_id; + let schedule = ScheduleResponse::from_model(schedule, tc).await?; + let destinations = vec![ + MessageDestination::User(self.user_id), + MessageDestination::User(opponent_id), + ]; + (ScheduleUpdate::Proposed(schedule), destinations) + } + TournamentPublic(id) => { + let tournament = Tournament::from_nanoid(&id.to_string(), tc).await?; + let game_ids = + Game::get_ongoing_ids_for_tournament(tournament.id, tc).await?; + + let mut all_schedules = HashMap::new(); + for id in game_ids { + let game_schedules = + Schedule::all_from_nanoid(id.clone(), tc).await?; + let mut game_schedules_map = HashMap::new(); + for schedule in game_schedules { + let response = + ScheduleResponse::from_model(schedule, tc).await?; + game_schedules_map.insert(response.id, response); + } + all_schedules.insert(GameId(id), game_schedules_map); + } + ( + ScheduleUpdate::OwnTournamentSchedules(all_schedules), + vec![MessageDestination::User(self.user_id)], + ) + } + TournamentOwn(id) => { + let tournament = Tournament::from_nanoid(&id.to_string(), tc).await?; + let game_ids = Game::get_ongoing_ids_for_tournament_by_user( + tournament.id, + self.user_id, + tc, + ) + .await?; + let mut all_schedules = HashMap::new(); + for id in game_ids { + let game_schedules = + Schedule::all_from_nanoid(id.clone(), tc).await?; + let mut game_schedules_map = HashMap::new(); + for schedule in game_schedules { + let response = + ScheduleResponse::from_model(schedule, tc).await?; + game_schedules_map.insert(response.id, response); + } + all_schedules.insert(GameId(id), game_schedules_map); + } + ( + ScheduleUpdate::OwnTournamentSchedules(all_schedules), + vec![MessageDestination::User(self.user_id)], + ) + } + }) + } + .scope_boxed() + }) + .await?; + Ok(destinations + .into_iter() + .map(|d| InternalServerMessage { + destination: d.clone(), + message: ServerMessage::Schedule(update.clone()), + }) + .collect()) + } +} diff --git a/apis/src/websockets/api/schedules/mod.rs b/apis/src/websockets/api/schedules/mod.rs new file mode 100644 index 00000000..488fd3a6 --- /dev/null +++ b/apis/src/websockets/api/schedules/mod.rs @@ -0,0 +1,2 @@ +mod handler; +pub use handler::ScheduleHandler; diff --git a/db/migrations/2024-07-16-021141_add_schedules/down.sql b/db/migrations/2024-07-16-021141_add_schedules/down.sql new file mode 100644 index 00000000..a4bb4285 --- /dev/null +++ b/db/migrations/2024-07-16-021141_add_schedules/down.sql @@ -0,0 +1 @@ +DROP TABLE schedules; diff --git a/db/migrations/2024-07-16-021141_add_schedules/up.sql b/db/migrations/2024-07-16-021141_add_schedules/up.sql new file mode 100644 index 00000000..75823a53 --- /dev/null +++ b/db/migrations/2024-07-16-021141_add_schedules/up.sql @@ -0,0 +1,9 @@ +CREATE TABLE schedules ( + id uuid default gen_random_uuid() primary key not null, + game_id uuid references games(id) on delete cascade not null, + tournament_id uuid references tournaments(id) on delete cascade not null, + proposer_id uuid references users(id) on delete cascade not null, + opponent_id uuid references users(id) on delete cascade not null, + start_t TIMESTAMP WITH TIME ZONE not null, + agreed BOOLEAN NOT NULL DEFAULT false +); diff --git a/db/src/models/game.rs b/db/src/models/game.rs index 00a6d16e..4fc71570 100644 --- a/db/src/models/game.rs +++ b/db/src/models/game.rs @@ -1049,6 +1049,38 @@ impl Game { .await?) } + pub async fn get_ongoing_ids_for_tournament( + tournament_id_: Uuid, + conn: &mut DbConn<'_>, + ) -> Result, DbError> { + Ok(games::table + .filter( + games::tournament_id + .eq(tournament_id_) + .and(games::finished.eq(false)), + ) + .select(games::nanoid) + .get_results(conn) + .await?) + } + + pub async fn get_ongoing_ids_for_tournament_by_user( + tournament_id_: Uuid, + user_id: Uuid, + conn: &mut DbConn<'_>, + ) -> Result, DbError> { + Ok(games::table + .filter( + games::tournament_id.eq(tournament_id_).and( + games::finished + .eq(false) + .and(games::white_id.eq(user_id).or(games::black_id.eq(user_id))), + ), + ) + .select(games::nanoid) + .get_results(conn) + .await?) + } pub async fn get_x_finished_games_for_username( username: &str, conn: &mut DbConn<'_>, diff --git a/db/src/models/mod.rs b/db/src/models/mod.rs index 1534bc15..a4152408 100644 --- a/db/src/models/mod.rs +++ b/db/src/models/mod.rs @@ -2,6 +2,7 @@ mod challenge; mod game; mod game_user; mod rating; +mod schedule; mod tournament; mod tournament_invitation; mod tournament_organizer; @@ -13,6 +14,7 @@ pub use challenge::{Challenge, NewChallenge}; pub use game::{Game, NewGame}; pub use game_user::GameUser; pub use rating::{NewRating, Rating}; +pub use schedule::{NewSchedule, Schedule}; pub use tournament::{NewTournament, Tournament}; pub use tournament_invitation::TournamentInvitation; pub use tournament_organizer::TournamentOrganizer; diff --git a/db/src/models/schedule.rs b/db/src/models/schedule.rs new file mode 100644 index 00000000..203d57dc --- /dev/null +++ b/db/src/models/schedule.rs @@ -0,0 +1,143 @@ +use super::Game; +use crate::schema::schedules; +use crate::DbConn; +use crate::{db_error::DbError, schema::games}; +use chrono::{DateTime, Utc}; +use diesel::prelude::*; +use diesel_async::RunQueryDsl; +use serde::{Deserialize, Serialize}; +use shared_types::GameId; +use uuid::Uuid; + +#[derive(Insertable, Debug)] +#[diesel(table_name = schedules)] +pub struct NewSchedule { + game_id: Uuid, + tournament_id: Uuid, + proposer_id: Uuid, + start_t: DateTime, + opponent_id: Uuid, + agreed: bool, +} + +impl NewSchedule { + pub async fn new( + user_id: Uuid, + game_id: &GameId, + start_t: DateTime, + conn: &mut DbConn<'_>, + ) -> Result { + let game = Game::find_by_game_id(game_id, conn).await?; + if !game.user_is_player(user_id) { + return Err(DbError::Unauthorized); + } + if game.tournament_id.is_none() { + return Err(DbError::InvalidInput { + info: String::from("NewSchedule::new failed"), + error: String::from("Tournament id was None"), + }); + } + let opponent_id = if game.white_id == user_id { + game.black_id + } else { + game.white_id + }; + Ok(Self { + game_id: game.id, + tournament_id: game.tournament_id.expect("Game has tournament_id"), + proposer_id: user_id, + start_t, + opponent_id, + agreed: false, + }) + } +} + +#[derive( + Queryable, Identifiable, Serialize, Clone, Deserialize, Debug, AsChangeset, Selectable, +)] +#[diesel(table_name = schedules)] +#[diesel(primary_key(id))] +pub struct Schedule { + pub id: Uuid, + pub game_id: Uuid, + pub tournament_id: Uuid, + pub proposer_id: Uuid, + pub opponent_id: Uuid, + pub start_t: DateTime, + pub agreed: bool, +} + +impl Schedule { + pub async fn accept(&mut self, user_id: Uuid, conn: &mut DbConn<'_>) -> Result { + if self.opponent_id != user_id { + return Err(DbError::Unauthorized); + } + //unset all schedules for this game + diesel::update(schedules::table.filter(schedules::game_id.eq(self.game_id))) + .set(schedules::agreed.eq(false)) + .execute(conn) + .await?; + //set this schedule only + let ret = Ok(diesel::update(schedules::table.find(self.id)) + .set(schedules::agreed.eq(true)) + .execute(conn) + .await?); + self.agreed = true; + ret + } + + pub async fn create( + schedule: NewSchedule, + user_id: Uuid, + conn: &mut DbConn<'_>, + ) -> Result { + if schedule.proposer_id != user_id || schedule.opponent_id == user_id { + return Err(DbError::Unauthorized); + } + Ok(schedule + .insert_into(schedules::table) + .get_result(conn) + .await?) + } + + pub async fn cancel(&mut self, user_id: Uuid, conn: &mut DbConn<'_>) -> Result { + if !self.is_player(user_id) { + return Err(DbError::Unauthorized); + } + Ok(diesel::delete(schedules::table.find(self.id)) + .execute(conn) + .await?) + } + + pub async fn get_first_agreed(game_id: Uuid, conn: &mut DbConn<'_>) -> Result { + Ok(schedules::table + .filter( + schedules::game_id + .eq(game_id) + .and(schedules::agreed.eq(true)), + ) + .first(conn) + .await?) + } + + pub async fn from_id(id: Uuid, conn: &mut DbConn<'_>) -> Result { + Ok(schedules::table.find(id).get_result(conn).await?) + } + + pub async fn all_from_nanoid( + game_id: String, + conn: &mut DbConn<'_>, + ) -> Result, DbError> { + Ok(schedules::table + .inner_join(games::table) + .filter(games::nanoid.eq(game_id)) + .select(schedules::all_columns) + .get_results(conn) + .await?) + } + + fn is_player(&self, user_id: Uuid) -> bool { + user_id == self.proposer_id || user_id == self.opponent_id + } +} diff --git a/db/src/schema.rs b/db/src/schema.rs index 0bc21b56..074a259d 100644 --- a/db/src/schema.rs +++ b/db/src/schema.rs @@ -81,6 +81,18 @@ diesel::table! { } } +diesel::table! { + schedules (id) { + id -> Uuid, + game_id -> Uuid, + tournament_id -> Uuid, + proposer_id -> Uuid, + opponent_id -> Uuid, + start_t -> Timestamptz, + agreed -> Bool, + } +} + diesel::table! { tournament_series (id) { id -> Uuid, @@ -168,6 +180,8 @@ diesel::table! { diesel::joinable!(games_users -> games (game_id)); diesel::joinable!(games_users -> users (user_id)); diesel::joinable!(ratings -> users (user_uid)); +diesel::joinable!(schedules -> games (game_id)); +diesel::joinable!(schedules -> tournaments (tournament_id)); diesel::joinable!(tournament_series_organizers -> tournament_series (tournament_series_id)); diesel::joinable!(tournament_series_organizers -> users (organizer_id)); diesel::joinable!(tournaments -> tournament_series (series)); @@ -183,6 +197,7 @@ diesel::allow_tables_to_appear_in_same_query!( games, games_users, ratings, + schedules, tournament_series, tournament_series_organizers, tournaments, diff --git a/shared_types/src/time_info.rs b/shared_types/src/time_info.rs index e24914df..b0471dee 100644 --- a/shared_types/src/time_info.rs +++ b/shared_types/src/time_info.rs @@ -1,6 +1,6 @@ use crate::TimeMode; -#[derive(Clone, PartialEq)] +#[derive(Clone, Copy, PartialEq)] pub struct TimeInfo { pub mode: TimeMode, pub base: Option, diff --git a/shared_types/src/time_mode.rs b/shared_types/src/time_mode.rs index c442e4f0..81aecf34 100644 --- a/shared_types/src/time_mode.rs +++ b/shared_types/src/time_mode.rs @@ -4,7 +4,7 @@ use std::fmt; use std::str::FromStr; use std::time::Duration; -#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] pub enum TimeMode { Untimed, Correspondence,