From 35d53d82c6cb97609abda39cebc6e81ad4e7cf3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Asger=20Juul=20Brunsh=C3=B8j?= Date: Fri, 16 Jun 2023 21:14:53 +0200 Subject: [PATCH] feat: set timed reveals --- Cargo.lock | 103 +++++++++++++- Cargo.toml | 8 +- crates/backend/Cargo.toml | 19 +-- crates/backend/src/main.rs | 30 +++- crates/common/Cargo.toml | 3 +- crates/common/src/lib.rs | 8 ++ crates/frontend/Cargo.toml | 24 ++-- crates/frontend/src/components/achievement.rs | 1 + .../src/components/achievement_reveal_time.rs | 130 ++++++++++++++++++ .../components/achievement_reveal_times.rs | 32 +++++ .../src/components/create_achievement.rs | 1 + crates/frontend/src/components/mod.rs | 2 + crates/frontend/src/lib.rs | 4 + crates/frontend/src/services/rest.rs | 7 + todo.md | 3 + 15 files changed, 350 insertions(+), 25 deletions(-) create mode 100644 crates/frontend/src/components/achievement_reveal_time.rs create mode 100644 crates/frontend/src/components/achievement_reveal_times.rs create mode 100644 todo.md diff --git a/Cargo.lock b/Cargo.lock index d7d9dd4..11b9ad9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.3.2" @@ -132,6 +147,7 @@ name = "backend" version = "0.1.0" dependencies = [ "axum", + "chrono", "clap", "common", "futures-util", @@ -219,6 +235,22 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec837a71355b28f6556dbd569b37b3f363091c0bd4b2e735674521b4c5fd9bc5" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "time", + "wasm-bindgen", + "winapi", +] + [[package]] name = "clap" version = "4.3.3" @@ -271,6 +303,7 @@ checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" name = "common" version = "0.1.0" dependencies = [ + "chrono", "serde", "thiserror", "uuid", @@ -286,6 +319,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" + [[package]] name = "cpufeatures" version = "0.2.6" @@ -355,6 +394,7 @@ dependencies = [ name = "frontend" version = "0.1.0" dependencies = [ + "chrono", "common", "futures", "log", @@ -362,6 +402,7 @@ dependencies = [ "serde", "serde_json", "thiserror", + "uuid", "wasm-bindgen", "wasm-bindgen-futures", "wasm-logger", @@ -479,7 +520,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", "wasm-bindgen", ] @@ -803,6 +844,29 @@ dependencies = [ "want", ] +[[package]] +name = "iana-time-zone" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fad5b825842d2b38bd206f3e81d6957625fd7f0a361e345c30e01a0ae2dd613" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "idna" version = "0.3.0" @@ -939,7 +1003,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2" dependencies = [ "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys", ] @@ -953,6 +1017,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-traits" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +dependencies = [ + "autocfg", +] + [[package]] name = "num_cpus" version = "1.15.0" @@ -1389,6 +1462,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "time" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b797afad3f312d1c66a56d11d0316f916356d11bd158fbc6ca6389ff6bf805a" +dependencies = [ + "libc", + "wasi 0.10.0+wasi-snapshot-preview1", + "winapi", +] + [[package]] name = "tinyvec" version = "1.6.0" @@ -1700,6 +1784,12 @@ dependencies = [ "try-lock", ] +[[package]] +name = "wasi" +version = "0.10.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -1817,6 +1907,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.48.0" diff --git a/Cargo.toml b/Cargo.toml index 2b9b5b9..124af7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,4 +9,10 @@ edition = "2021" [workspace.dependencies] serde = { version = "1", features = ["derive"] } common = { path = "crates/common" } -uuid = { vserion = "1.3", features = ["serde", "v4", "js"] } +uuid = { vserion = "1.3", features = ["serde", "v4"] } +chrono = { version = "0.4", features = ["serde"] } +thiserror = "1" +serde_json = "1" +log = "0.4" +futures = "0.3" +futures-util = "0.3" diff --git a/crates/backend/Cargo.toml b/crates/backend/Cargo.toml index 6b38b9e..86439f8 100644 --- a/crates/backend/Cargo.toml +++ b/crates/backend/Cargo.toml @@ -6,16 +6,17 @@ edition.workspace = true [dependencies] serde.workspace = true +uuid.workspace = true +common.workspace = true +thiserror.workspace = true axum = { version = "0.6", features = ["ws", "headers"] } -serde_json = "1" tokio = { version = "1", features = ["full"] } tracing = "0.1" tracing-subscriber = "0.3" -common.workspace = true -thiserror = "1.0.40" -tower = "0.4.13" -uuid.workspace = true -tower-http = { version = "0.4.0", features = ["fs", "trace", "cors"] } -tokio-stream = { version = "0.1.14", features = ["sync"] } -clap = { version = "4.3.3", features = ["derive"] } -futures-util = "0.3.28" +tower = "0.4" +tower-http = { version = "0.4", features = ["fs", "trace", "cors"] } +chrono.workspace = true +tokio-stream = { version = "0.1", features = ["sync"] } +clap = { version = "4", features = ["derive"] } +futures-util.workspace = true +serde_json.workspace = true diff --git a/crates/backend/src/main.rs b/crates/backend/src/main.rs index 9098fbc..eea1e1d 100644 --- a/crates/backend/src/main.rs +++ b/crates/backend/src/main.rs @@ -20,6 +20,7 @@ use common::DeleteMilestone; use common::Milestone; use common::RestResponse; use common::ToggleAchievement; +use common::UpdateAchievementTimeOfReveal; use futures_util::SinkExt; use futures_util::StreamExt; use serde::Deserialize; @@ -133,6 +134,10 @@ async fn main() { .route("/api/v1/toggle", post(toggle_achievement)) .route("/api/v1/create-milestone", post(create_milestone)) .route("/api/v1/delete-milestone", post(delete_milestone)) + .route( + "/api/v1/update-time-of-reveal", + post(update_achievement_time_of_reveal), + ) .route("/api/ws", get(ws_handler)) .layer( ServiceBuilder::new() @@ -245,10 +250,12 @@ async fn create_achievement( Json(create_achievement): Json, ) -> Response<()> { tracing::debug!("Creating achievement: {create_achievement:?}."); + let CreateAchievement { goal, time_of_reveal } = create_achievement; let achievement = Achievement { - goal: create_achievement.goal, + goal, completed: false, uuid: uuid::Uuid::new_v4(), + time_of_reveal, }; let mut lock = app_state.write().await; lock.app_state.state.achievements.push(achievement); @@ -326,6 +333,27 @@ async fn toggle_achievement( Ok((StatusCode::OK, Json(Ok(())))) } +async fn update_achievement_time_of_reveal( + Extension(app_state): Extension, + Json(update): Json, +) -> Response<()> { + tracing::debug!("Updating achievement time of reveal: {update:?}."); + let mut lock = app_state.write().await; + if let Some(achievement) = lock + .app_state + .state + .achievements + .iter_mut() + .find(|x| x.uuid == update.uuid) + { + achievement.time_of_reveal = update.time_of_reveal; + lock.watcher_tx + .send(lock.app_state.state.clone()) + .expect("watch channel is closed, every receiver was dropped."); + } + Ok((StatusCode::OK, Json(Ok(())))) +} + async fn delete_achievement( Extension(app_state): Extension, Json(delete_achievement): Json, diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index a42c5c9..d40b787 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -6,5 +6,6 @@ edition.workspace = true [dependencies] serde.workspace = true -thiserror = "1" +thiserror.workspace = true uuid.workspace = true +chrono.workspace = true diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index e01724a..202d4f5 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -48,11 +48,13 @@ pub struct Achievement { pub goal: String, pub completed: bool, pub uuid: uuid::Uuid, + pub time_of_reveal: Option, } #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct CreateAchievement { pub goal: String, + pub time_of_reveal: Option, } #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] @@ -60,6 +62,12 @@ pub struct ToggleAchievement { pub uuid: uuid::Uuid, } +#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] +pub struct UpdateAchievementTimeOfReveal { + pub uuid: uuid::Uuid, + pub time_of_reveal: Option, +} + #[derive(PartialEq, Eq, Debug, Serialize, Clone, Deserialize)] pub struct DeleteAchievement { pub uuid: uuid::Uuid, diff --git a/crates/frontend/Cargo.toml b/crates/frontend/Cargo.toml index e69f058..093cfb3 100644 --- a/crates/frontend/Cargo.toml +++ b/crates/frontend/Cargo.toml @@ -5,17 +5,19 @@ authors.workspace = true edition.workspace = true [dependencies] -log = "0.4.19" -wasm-logger = "0.2.0" +log.workspace = true +wasm-logger = "0.2" yew = { version = "0.20", features = ["csr"] } -yew-router = "0.17.0" +yew-router = "0.17" common.workspace = true -futures = "0.3.28" -wasm-bindgen-futures = "0.4.36" -reqwasm = "0.5.0" -yew-agent = "0.2.0" +futures.workspace = true +wasm-bindgen-futures = "0.4" +reqwasm = "0.5" +chrono.workspace = true +yew-agent = "0.2" serde.workspace = true -serde_json = "1.0.96" -web-sys = "0.3.63" -wasm-bindgen = "0.2.86" -thiserror = "1.0.40" +serde_json.workspace = true +web-sys = "0.3" +wasm-bindgen = "0.2" +thiserror.workspace = true +uuid = { workspace = true, features = ["js"] } diff --git a/crates/frontend/src/components/achievement.rs b/crates/frontend/src/components/achievement.rs index 16602c4..379f474 100644 --- a/crates/frontend/src/components/achievement.rs +++ b/crates/frontend/src/components/achievement.rs @@ -23,6 +23,7 @@ pub fn AchievementComponent(props: &Props) -> Html { goal, completed, uuid, + time_of_reveal: _, } = &props.achievement; let uuid = *uuid; diff --git a/crates/frontend/src/components/achievement_reveal_time.rs b/crates/frontend/src/components/achievement_reveal_time.rs new file mode 100644 index 0000000..0152dc2 --- /dev/null +++ b/crates/frontend/src/components/achievement_reveal_time.rs @@ -0,0 +1,130 @@ +use crate::services::confirm::ConfirmService; +use crate::services::rest::RestService; +use common::Achievement; +use common::DeleteAchievement; +use common::ToggleAchievement; +use common::UpdateAchievementTimeOfReveal; +use std::ops::Deref; +use wasm_bindgen::JsCast; +use wasm_bindgen_futures::spawn_local; +use yew::classes; +use yew::function_component; +use yew::html; +use yew::use_state; +use yew::Callback; +use yew::Html; +use yew::Properties; + +#[derive(Properties, PartialEq)] +pub struct Props { + pub achievement: Achievement, + pub number: usize, +} + +#[function_component] +pub fn AchievementRevealTime(props: &Props) -> Html { + let achievement = &props.achievement; + let uuid = achievement.uuid; + + let time_of_reveal: Option = achievement + .time_of_reveal + .map(|naive_time| naive_time.format("%H:%M").to_string()); + + let timed_reveal_enabled = use_state(|| time_of_reveal.is_some()); + let input_time = use_state(|| time_of_reveal.clone().unwrap_or("".to_string())); + let awaiting_response = use_state(|| false); + + let onsubmit = { + let awaiting_response = awaiting_response.clone(); + let timed_reveal_enabled = timed_reveal_enabled.clone(); + let input_time = input_time.clone(); + Callback::from(move |e: web_sys::SubmitEvent| { + e.prevent_default(); + + let new_time_of_reveal = if *timed_reveal_enabled { + if let Ok(naive_time) = chrono::NaiveTime::parse_from_str(&input_time, "%H:%M") { + Some(naive_time) + } else { + // TODO: show UI error + log::debug!("Could not parse time: {}", *input_time); + return; + } + } else { + None + }; + + let payload = UpdateAchievementTimeOfReveal { + time_of_reveal: new_time_of_reveal, + uuid, + }; + awaiting_response.set(true); + let awaiting_response = awaiting_response.clone(); + spawn_local(async move { + let res = RestService::update_time_of_reveal(payload).await; + awaiting_response.set(false); + }); + }) + }; + + let oninput_time = { + let input_time = input_time.clone(); + Callback::from(move |e: web_sys::InputEvent| { + let Some(input) = e + .target() + .and_then(|t| t.dyn_into::().ok()) else { unreachable!() }; + log::debug!("{:?}", input.value()); + input_time.set(input.value()); + }) + }; + + let oninput_timed_reveal_checkbox = { + let timed_reveal_enabled = timed_reveal_enabled.clone(); + Callback::from(move |e: web_sys::InputEvent| { + let Some(input) = e + .target() + .and_then(|t| t.dyn_into::().ok()) else { unreachable!() }; + timed_reveal_enabled.set(input.checked()); + }) + }; + + let new_value: Option<&str> = timed_reveal_enabled.then_some(&**input_time); + let show_submit_button: bool = time_of_reveal.as_deref() != new_value; + + html! { +
+ +
+ // Achievement number +
+

{format!("{}.", props.number)}

+
+ + // Achievement text +
+

{&achievement.goal}

+
+
+ + // Enable timed reveal checkbox + + + { if *timed_reveal_enabled { html! { + // Time input +
+ + +
+ }} else { html! {}}} + + // Submit button + { if show_submit_button { html! { + + }} else { html! {}}} + +
+
+ } +} diff --git a/crates/frontend/src/components/achievement_reveal_times.rs b/crates/frontend/src/components/achievement_reveal_times.rs new file mode 100644 index 0000000..973dc02 --- /dev/null +++ b/crates/frontend/src/components/achievement_reveal_times.rs @@ -0,0 +1,32 @@ +use crate::components::achievement_reveal_time::AchievementRevealTime; +use yew::functional::*; +use yew::prelude::*; +use yew_router::prelude::*; + +#[function_component] +pub fn AchievementRevealTimes() -> Html { + let nav = use_navigator().expect("cannot get navigator"); + let app_state = use_context::().expect("no app state ctx found"); + + let achievements = app_state + .state + .achievements + .iter() + .cloned() + .enumerate() + .map(|(idx, a)| (idx + 1, a)) + .map(|(n, a)| { + html! { + + } + }) + .collect::(); + + html! { + <> +

{"Achievement Timed Reveals"}

+
+ {achievements} + + } +} diff --git a/crates/frontend/src/components/create_achievement.rs b/crates/frontend/src/components/create_achievement.rs index c695cec..70a9bde 100644 --- a/crates/frontend/src/components/create_achievement.rs +++ b/crates/frontend/src/components/create_achievement.rs @@ -51,6 +51,7 @@ impl Component for CreateAchievementComponent { log::info!("Creating achievement"); let payload = CreateAchievement { goal: self.input_value.clone(), + time_of_reveal: None, }; let link = ctx.link().clone(); spawn_local(async move { diff --git a/crates/frontend/src/components/mod.rs b/crates/frontend/src/components/mod.rs index ee47d61..d362d4c 100644 --- a/crates/frontend/src/components/mod.rs +++ b/crates/frontend/src/components/mod.rs @@ -1,4 +1,6 @@ pub mod achievement; +pub mod achievement_reveal_time; +pub mod achievement_reveal_times; pub mod admin; pub mod create_achievement; pub mod create_milestone; diff --git a/crates/frontend/src/lib.rs b/crates/frontend/src/lib.rs index 3b731ac..022b7a4 100644 --- a/crates/frontend/src/lib.rs +++ b/crates/frontend/src/lib.rs @@ -2,6 +2,7 @@ use crate::components::error::error_component::ErrorComponent; use crate::components::error::error_provider::ErrorProvider; use crate::event_bus::EventBus; use crate::services::websocket::WebsocketService; +use components::achievement_reveal_times::AchievementRevealTimes; use components::admin::Admin; use components::create_achievement::CreateAchievementComponent; use components::create_milestone::CreateMilestoneComponent; @@ -27,6 +28,8 @@ enum Route { CreateAchievement, #[at("/create-milestone")] CreateMilestone, + #[at("/reveal-times")] + RevealTimes, #[not_found] #[at("/404")] NotFound, @@ -39,6 +42,7 @@ fn switch(selected_route: Route) -> Html { Route::CreateAchievement => html! {}, Route::CreateMilestone => html! {}, Route::NotFound => html! {

{"404 not found"}

}, + Route::RevealTimes => html! {}, } } diff --git a/crates/frontend/src/services/rest.rs b/crates/frontend/src/services/rest.rs index 6bae1fa..6180d4e 100644 --- a/crates/frontend/src/services/rest.rs +++ b/crates/frontend/src/services/rest.rs @@ -4,6 +4,7 @@ use common::DeleteAchievement; use common::DeleteMilestone; use common::RestResponse; use common::ToggleAchievement; +use common::UpdateAchievementTimeOfReveal; use reqwasm::http::Request; use serde::de::DeserializeOwned; use serde::Serialize; @@ -67,4 +68,10 @@ impl RestService { pub async fn delete_milestone(payload: DeleteMilestone) -> Result<(), RestServiceError> { Self::post_json(payload, "/api/v1/delete-milestone").await } + + pub async fn update_time_of_reveal( + payload: UpdateAchievementTimeOfReveal, + ) -> Result<(), RestServiceError> { + Self::post_json(payload, "/api/v1/update-time-of-reveal").await + } } diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..e9de734 --- /dev/null +++ b/todo.md @@ -0,0 +1,3 @@ +- UI errors from failed requests +- websocket reconnect +- disable timed reveal