diff --git a/.helix/languages.toml b/.helix/languages.toml index 6cbd9aa..ba7a252 100644 --- a/.helix/languages.toml +++ b/.helix/languages.toml @@ -5,9 +5,15 @@ language-servers = ["rust-analyzer", "tailwindcss-ls"] [language-server.rust-analyzer.config] # procMacro = { ignored = { leptos_macro = ["server"] } } cargo = { features = ["ssr", "hydrate"] } +check = { command = "check" } -[language-server.rust-analyzer.config.check] -command = "clippy" +rustfmt = { overrideCommand = ["leptosfmt", "--stdin", "--rustfmt"] } + +# rustfmt = { overrideCommand = [ +# "sh", +# "-c", +# "set -euo pipefail; rustfmt --emit stdout --edition 2024 | leptosfmt --stdin", +# ] } [language-server.tailwindcss-ls] config = { userLanguages = { rust = "html", "*.rs" = "html" } } diff --git a/Cargo.lock b/Cargo.lock index c9e2bb9..11d6c2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1760,9 +1760,9 @@ checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" [[package]] name = "leptos" -version = "0.7.5" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78329c12843d64766d8f00216aae665416d804327302ce8e0ab83884dfa91887" +checksum = "88613d81f70f4e267473b2ee107e1ee70cf765a3c3dfee945929c8e9c520b957" dependencies = [ "any_spawner", "base64 0.22.1", @@ -1823,9 +1823,9 @@ dependencies = [ [[package]] name = "leptos_config" -version = "0.7.5" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "132a18e8ffc4fbe2d624f3743d88a1b4989bff2d5e12be2b0d2749201d9dfb52" +checksum = "4172cfee12576224775ccfbb9d3e76625017a8b4207c4641a2f9b96a70e6d524" dependencies = [ "config", "regex", @@ -1836,9 +1836,9 @@ dependencies = [ [[package]] name = "leptos_dom" -version = "0.7.5" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d468f638f2f13d70d99d9952be98d671a75366034472f3828e586ba62d770049" +checksum = "a41f6dc3ddaa09d876d7015f08f4f3905787da4ea5460cef130c365419483a89" dependencies = [ "js-sys", "or_poisoned", @@ -1852,9 +1852,9 @@ dependencies = [ [[package]] name = "leptos_hot_reload" -version = "0.7.5" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ba37d76693fc6228554e0bb06a9aa41c59e2b5180caf423c7913557b81d01dd" +checksum = "31f5c961e5d9b2aa6deab39d5d842272e8b1b165744b5caf674770d5cf0daa04" dependencies = [ "anyhow", "camino", @@ -1885,9 +1885,9 @@ dependencies = [ [[package]] name = "leptos_macro" -version = "0.7.5" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "064d0c8b144b93f8d7e84b30c16d1da0e64a63c7e91b9a872f7be63601c5868b" +checksum = "2b9165909eabb02188a4b33b0ab6acff408bdf440018bf65b30bba0d38d61b19" dependencies = [ "attribute-derive", "cfg-if", @@ -1961,9 +1961,9 @@ dependencies = [ [[package]] name = "leptos_server" -version = "0.7.5" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb1779f1f0570915066c132fb11f999add8b13d02ca5221735193eb02b3fa69a" +checksum = "4fee9ed4526484b17561bc8ce1532c613e37be2c01788fed3d1c4104db674dd9" dependencies = [ "any_spawner", "base64 0.22.1", @@ -2663,9 +2663,9 @@ dependencies = [ [[package]] name = "reactive_graph" -version = "0.1.5" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "059aede5acae8f5c25b1d34b6df34700006418b3c493db3698b7ebcd4a8a6287" +checksum = "9996b4c0f501d64a755ff3dfbe9276e9f834d105d7d45059ad4bd6d2a56477d0" dependencies = [ "any_spawner", "async-lock", @@ -2685,9 +2685,9 @@ dependencies = [ [[package]] name = "reactive_stores" -version = "0.1.5" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7edacf4298579a5772285b8e2dc0b9953c8fbaa9c3f56c3dd69d56e5af7a48" +checksum = "74c3d2a20d8edd8ac6628718209f743da86349d7f10a4458304666c2ddfc082e" dependencies = [ "guardian", "itertools 0.13.0", @@ -2700,9 +2700,9 @@ dependencies = [ [[package]] name = "reactive_stores_macro" -version = "0.1.5" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "178b1cd8b2871a45bfc8e13ff8076049b6e9a5132e72414e5cab3894c4a6adb3" +checksum = "6d4d8e40112b8ee1424e5ec636fcbc9764c1a099e81f8fa818f6762b43cc10cd" dependencies = [ "convert_case", "proc-macro-error2", @@ -2941,9 +2941,9 @@ dependencies = [ [[package]] name = "server_fn" -version = "0.7.5" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c183c31152fd00e994a3ea0ca43e6017056ccf7812160b0ae008acc3de8241c" +checksum = "055476c2a42c9a98a69e3f0ce29b86aa3acbdef19a84e0523330f095097defcf" dependencies = [ "axum", "bytes", @@ -2978,9 +2978,9 @@ dependencies = [ [[package]] name = "server_fn_macro" -version = "0.7.5" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c43b2266308c118be1a1cc60602f8efb07a64e72deed8d317704d5cfda092ca1" +checksum = "e65737414a9583ce3b43dddd4e5dfb33fe385a6933ed79a9b539b8eb0767cd07" dependencies = [ "const_format", "convert_case", @@ -2992,9 +2992,9 @@ dependencies = [ [[package]] name = "server_fn_macro_default" -version = "0.7.5" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "087eca61bc8f93d868b8c10ca058da358fd7aaeb7bc8415b572f9f3f27ce0b93" +checksum = "563909a43390341403ab76fbc33fde306712613da02244e692eabeae8ffde949" dependencies = [ "server_fn_macro", "syn", @@ -3148,9 +3148,9 @@ dependencies = [ [[package]] name = "tachys" -version = "0.1.5" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59a3bbcf8e3b52cad5f0aa860837d4d1796c7c4873b083c9520a1bbba4747973" +checksum = "4c05fed41ed4e334257090500510df21bb1611680c0cfd3be14acec7ffdf3d95" dependencies = [ "any_spawner", "async-trait", diff --git a/crates/ascend/Cargo.toml b/crates/ascend/Cargo.toml index e7673a6..bf4bcb6 100644 --- a/crates/ascend/Cargo.toml +++ b/crates/ascend/Cargo.toml @@ -23,7 +23,7 @@ derive_more = { version = "2", features = [ ] } http = "1" image = { version = "0.25", optional = true } -leptos = { version = "0.7.4", features = ["tracing"] } +leptos = { version = "0.7.7", features = ["tracing"] } leptos_axum = { version = "0.7", optional = true } leptos_meta = { version = "0.7" } leptos_router = { version = "0.7.0" } diff --git a/crates/ascend/src/app.rs b/crates/ascend/src/app.rs index 8efd20c..bce9710 100644 --- a/crates/ascend/src/app.rs +++ b/crates/ascend/src/app.rs @@ -17,7 +17,7 @@ pub fn shell(options: LeptosOptions) -> impl IntoView { - + diff --git a/crates/ascend/src/components/attempt.rs b/crates/ascend/src/components/attempt.rs new file mode 100644 index 0000000..f46983c --- /dev/null +++ b/crates/ascend/src/components/attempt.rs @@ -0,0 +1,58 @@ +use crate::components::icons; +use crate::models; +use leptos::prelude::*; + +#[component] +#[tracing::instrument(skip_all)] +pub fn Attempt(#[prop(into)] date: Signal>, #[prop(into)] attempt: Signal>) -> impl IntoView { + tracing::trace!("Enter"); + + let s = time_ago(date.get()); + + let text = move || match attempt.get() { + Some(models::Attempt::Flash) => "Flash", + Some(models::Attempt::Send) => "Send", + Some(models::Attempt::Attempt) => "Learning experience", + None => "No attempt", + }; + + let text_color = match attempt.get() { + Some(models::Attempt::Flash) => "text-cyan-500", + Some(models::Attempt::Send) => "text-teal-500", + Some(models::Attempt::Attempt) => "text-pink-500", + None => "", + }; + + let icon = move || match attempt.get() { + Some(models::Attempt::Flash) => view! { }.into_any(), + Some(models::Attempt::Send) => view! { }.into_any(), + Some(models::Attempt::Attempt) => view! { }.into_any(), + None => view! { }.into_any(), + }; + + let classes = format!("flex flex-row gap-3 {}", text_color); + + view! { +
+
{s}
+ +
+ {text} + {icon} +
+
+ } +} + +fn time_ago(dt: chrono::DateTime) -> String { + let now = chrono::Utc::now(); + let duration = now.signed_duration_since(dt); + + if duration.num_days() == 0 { + "Today".to_string() + } else if duration.num_days() == 1 { + "1 day ago".to_string() + } else { + format!("{} days ago", duration.num_days()) + } +} diff --git a/crates/ascend/src/components/button.rs b/crates/ascend/src/components/button.rs index ee2dd67..2d61763 100644 --- a/crates/ascend/src/components/button.rs +++ b/crates/ascend/src/components/button.rs @@ -1,15 +1,79 @@ +use super::icons::Icon; +use crate::components::outlined_box::OutlinedBox; +use crate::gradient::Gradient; use leptos::prelude::*; use web_sys::MouseEvent; #[component] -pub fn Button(#[prop(into)] text: String, onclick: impl Fn(MouseEvent) + 'static) -> impl IntoView { +pub fn Button( + #[prop(into, optional)] icon: MaybeProp, + + #[prop(into)] text: Signal, + + #[prop(optional)] color: Gradient, + + #[prop(into, optional)] highlight: MaybeProp, + + onclick: impl FnMut(MouseEvent) + 'static, +) -> impl IntoView { + let margin = "mx-2 my-1 sm:mx-5 sm:my-2.5"; + + let icon_view = icon.get().map(|i| { + let icon_view = i.into_view(); + let mut classes = "self-center".to_string(); + classes.push(' '); + classes.push_str(margin); + classes.push(' '); + classes.push_str(color.class_text()); + + view! {
{icon_view}
} + }); + + let separator = icon.get().is_some().then(|| { + let mut classes = "w-0.5 bg-gradient-to-br min-w-0.5".to_string(); + classes.push(' '); + classes.push_str(color.class_from()); + classes.push(' '); + classes.push_str(color.class_to()); + + view! {
} + }); + + let text_view = { + let mut classes = "self-center uppercase w-full text-sm sm:text-base md:text-lg font-thin".to_string(); + classes.push(' '); + classes.push_str(margin); + + view! {
{text.get()}
} + }; + view! { - } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn baseline() { + let text = "foo"; + let onclick = |_| {}; + + view! { impl IntoView { let wall = crate::resources::wall_by_uid(wall_uid); let problems = crate::resources::problems_for_wall(wall_uid); - let header_items = HeaderItems { + let header_items = move || HeaderItems { left: vec![HeaderItem { text: "← Ascend".to_string(), - link: Some("/".to_string()), + link: Some(format!("/wall/{}", wall_uid.get())), }], middle: vec![HeaderItem { text: "Routes".to_string(), @@ -63,7 +63,10 @@ pub fn Routes() -> impl IntoView { each=problems_sample key=|problem| problem.uid children=move |problem: models::Problem| { - view! {
} + view! { + +
+ } } />
@@ -75,8 +78,8 @@ pub fn Routes() -> impl IntoView { }; view! { -
- +
+
"loading"

}>{suspend}
@@ -91,7 +94,7 @@ fn Problem(#[prop(into)] dim: Signal, #[prop(into)] prob view! {
- +
diff --git a/crates/ascend/src/pages/settings.rs b/crates/ascend/src/pages/settings.rs index f12bd3a..159979e 100644 --- a/crates/ascend/src/pages/settings.rs +++ b/crates/ascend/src/pages/settings.rs @@ -20,12 +20,11 @@ pub fn Settings() -> impl IntoView { }; view! { -
+
-
- // {move || view! { }} -
+ // {move || view! { }} +
} } @@ -33,7 +32,7 @@ pub fn Settings() -> impl IntoView { #[component] #[tracing::instrument(skip_all)] fn Import(wall_uid: WallUid) -> impl IntoView { - let import_from_mini_moonboard = Action::from(ServerAction::::new()); + let import_from_mini_moonboard = ServerAction::::new(); let onclick = move |_mouse_event| { import_from_mini_moonboard.dispatch(ImportFromMiniMoonboard { wall_uid }); @@ -45,7 +44,7 @@ fn Import(wall_uid: WallUid) -> impl IntoView { } } -#[server(name = ImportFromMiniMoonboard)] +#[server] #[tracing::instrument] async fn import_from_mini_moonboard(wall_uid: WallUid) -> Result<(), ServerFnError> { use crate::server::config::Config; diff --git a/crates/ascend/src/pages/wall.rs b/crates/ascend/src/pages/wall.rs index 5cc0fa8..627dd21 100644 --- a/crates/ascend/src/pages/wall.rs +++ b/crates/ascend/src/pages/wall.rs @@ -1,14 +1,51 @@ +// +--------------- Filter ----------- ↓ -+ +// | | +// | | +// | | +// | | +// | | +// | | +// | | +// +--------------------------------------+ + +// +---------------------------+ +// | Next Problem | +// +---------------------------+ + +// +--------------- Problem --------------+ +// | Name: ... | +// | Method: ... | +// | Set by: ... | +// | | +// | | Flash | Top | Attempt | | +// | | +// +--------------------------------------+ + +// +---------+ +---------+ +---------+ +// | Flash | | Send | | Attempt | +// +---------+ +---------+ +---------+ + +// +---------- ----------+ +// | Today: | +// | 14 days ago: | +// +--------------------------------------+ + use crate::codec::ron::RonEncoded; use crate::components::ProblemInfo; +use crate::components::attempt::Attempt; use crate::components::button::Button; use crate::components::header::HeaderItem; use crate::components::header::HeaderItems; use crate::components::header::StyledHeader; +use crate::components::icons::Icon; +use crate::gradient::Gradient; use crate::models; use crate::models::HoldRole; +use crate::server_functions; use leptos::Params; use leptos::prelude::*; use leptos_router::params::Params; +use web_sys::MouseEvent; #[derive(Params, PartialEq, Clone)] struct RouteParams { @@ -22,8 +59,6 @@ pub fn Wall() -> impl IntoView { let route_params = leptos_router::hooks::use_params::(); - let (problem_uid, set_problem_uid) = leptos_router::hooks::query_signal::("problem"); - let wall_uid = Signal::derive(move || { route_params .get() @@ -34,49 +69,6 @@ pub fn Wall() -> impl IntoView { let wall = crate::resources::wall_by_uid(wall_uid); - let problem_action = Action::new(move |&(wall_uid, problem_uid): &(models::WallUid, models::ProblemUid)| async move { - tracing::info!("fetching"); - crate::server_functions::get_problem(wall_uid, problem_uid) - .await - .map(RonEncoded::into_inner) - }); - let problem_signal = Signal::derive(move || { - let v = problem_action.value().read_only().get(); - v.and_then(Result::ok) - }); - - let fn_next_problem = move |wall: &models::Wall| { - set_problem_uid.set(wall.random_problem()); - }; - - // Set a problem when wall is set (loaded) - Effect::new(move |_prev_value| { - problem_action.value().write_only().set(None); - - match &*wall.read() { - Some(Ok(wall)) => { - if problem_uid.get().is_none() { - tracing::debug!("Setting next problem"); - fn_next_problem(wall); - } - } - Some(Err(err)) => { - tracing::error!("Error getting wall: {err}"); - } - None => {} - } - }); - - // On change of problem UID, dispatch an action to fetch the problem - Effect::new(move |_prev_value| match problem_uid.get() { - Some(problem_uid) => { - problem_action.dispatch((wall_uid.get(), problem_uid)); - } - None => { - problem_action.value().write_only().set(None); - } - }); - let header_items = move || HeaderItems { left: vec![], middle: vec![HeaderItem { @@ -96,37 +88,18 @@ pub fn Wall() -> impl IntoView { }; leptos::view! { -
+
- "Loading..."

} - }> + {move || Suspend::new(async move { - tracing::info!("executing Suspend future"); + tracing::info!("executing main suspend"); let wall = wall.await?; - let v = view! { -
-
- -
- -
-
- }; + let v = view! { }; Ok::<_, ServerFnError>(v) })} - +
} @@ -134,12 +107,271 @@ pub fn Wall() -> impl IntoView { #[component] #[tracing::instrument(skip_all)] -fn Grid(wall: models::Wall, problem: Signal>) -> impl IntoView { +fn WithProblem(#[prop(into)] problem: Signal) -> impl IntoView { + tracing::trace!("Enter"); + + view! { } +} + +#[component] +#[tracing::instrument(skip_all)] +fn WithWall(#[prop(into)] wall: Signal) -> impl IntoView { + tracing::trace!("Enter"); + + let wall_uid = Signal::derive(move || wall.read().uid); + + let (problem_uid, set_problem_uid) = leptos_router::hooks::query_signal::("problem"); + + let problem = crate::resources::problem_by_uid_optional(wall_uid, problem_uid.into()); + let user_interaction = crate::resources::user_interaction(wall_uid, problem_uid.into()); + + let fn_next_problem = move |wall: &models::Wall| { + set_problem_uid.set(wall.random_problem()); + }; + + // Set a problem when wall is set (loaded) + Effect::new(move |_prev_value| { + if problem_uid.get().is_none() { + tracing::debug!("Setting next problem"); + fn_next_problem(&wall.read()); + } + }); + + // merge outer option (resource hasn't resolved yet) with inner option (there is no problem for the wall) + let problem_signal = Signal::derive(move || problem.get().transpose().map(Option::flatten)); + + let grid = { + let wall = wall.clone(); + view! { + + { + let wall = wall.clone(); + move || { + let wall = wall.clone(); + Suspend::new(async move { + let wall = wall.clone(); + tracing::info!("executing grid suspend"); + let view = view! { }; + Ok::<_, ServerFnError>(view) + }) + } + } + + } + }; + + view! { +
+
{grid}
+ +
+
{}
+ + + +
+
+
+
+ + + +
+ + {move || Suspend::new(async move { + tracing::info!("executing problem suspend"); + let problem = problem.await?; + let view = problem + .map(|problem| { + view! { } + }); + Ok::<_, ServerFnError>(view) + })} + +
+ + + + + {move || { + Suspend::new(async move { + tracing::debug!("getting user interaction"); + let user_interaction: Option<_> = user_interaction.await?; + tracing::debug!("got user interaction"); + let Some(problem_uid) = problem_uid.get() else { + return Ok(view! {}.into_any()); + }; + let view = view! { + + } + .into_any(); + Ok::<_, ServerFnError>(view) + }) + }} + +
+
+ } +} + +#[component] +#[tracing::instrument(skip_all)] +fn WithUserInteraction( + #[prop(into)] wall_uid: Signal, + #[prop(into)] problem_uid: Signal, + #[prop(into)] user_interaction: Signal>, +) -> impl IntoView { + tracing::debug!("Enter WithUserInteraction"); + + let user_interaction_rw = RwSignal::new(None); + Effect::new(move || { + let i = user_interaction.get(); + tracing::info!("setting user interaction to parent user interaction value: {i:?}"); + user_interaction_rw.set(i); + }); + + let submit_attempt = ServerAction::>::new(); + let submit_attempt_value = submit_attempt.value(); + Effect::new(move || { + tracing::info!("flaf"); + if let Some(Ok(v)) = submit_attempt_value.get() { + tracing::info!("setting user interaction to action return value: {v:?}"); + user_interaction_rw.set(Some(v.into_inner())); + } + }); + + let latest_attempt = move || -> Option<_> { + let i = user_interaction_rw.read(); + let i = (*i).as_ref(); + let i = i?; + i.attempted_on.last_key_value().map(|(date, attempt)| (date.clone(), attempt.clone())) + }; + + let todays_attempt = move || -> Option<_> { + match latest_attempt() { + Some((datetime, attempt)) => { + let today_local_naive = chrono::Local::now().date_naive(); + let datetime_local_naive = datetime.with_timezone(&chrono::Local).date_naive(); + (datetime_local_naive == today_local_naive).then_some(attempt) + } + None => None, + } + }; + + let ui_is_flash = RwSignal::new(false); + let ui_is_send = RwSignal::new(false); + let ui_is_attempt = RwSignal::new(false); + let ui_is_favorite = RwSignal::new(false); + + Effect::new(move || { + let attempt = todays_attempt(); + ui_is_flash.set(matches!(attempt, Some(models::Attempt::Flash))); + ui_is_send.set(matches!(attempt, Some(models::Attempt::Send))); + ui_is_attempt.set(matches!(attempt, Some(models::Attempt::Attempt))); + }); + + let onclick_flash = move |_| { + submit_attempt.dispatch(RonEncoded(server_functions::UpsertTodaysAttempt { + wall_uid: wall_uid.get(), + problem_uid: problem_uid.get(), + attempt: models::Attempt::Flash, + })); + }; + let onclick_send = move |_| { + submit_attempt.dispatch(RonEncoded(server_functions::UpsertTodaysAttempt { + wall_uid: wall_uid.get(), + problem_uid: problem_uid.get(), + attempt: models::Attempt::Send, + })); + }; + let onclick_attempt = move |_| { + submit_attempt.dispatch(RonEncoded(server_functions::UpsertTodaysAttempt { + wall_uid: wall_uid.get(), + problem_uid: problem_uid.get(), + attempt: models::Attempt::Attempt, + })); + }; + + // TODO: loop over attempts in user_interaction + let v = move || latest_attempt().map(|(date, attempt)| view! { }); + + let placeholder = move || { + latest_attempt().is_none().then(|| { + let today = chrono::Utc::now(); + view! { } + }) + }; + + view! { + + + + +
{placeholder} {v}
+ } +} + +#[component] +#[tracing::instrument(skip_all)] +fn AttemptRadio( + #[prop(into)] flash: Signal, + #[prop(into)] send: Signal, + #[prop(into)] attempt: Signal, + onclick_flash: impl FnMut(MouseEvent) + 'static, + onclick_send: impl FnMut(MouseEvent) + 'static, + onclick_attempt: impl FnMut(MouseEvent) + 'static, +) -> impl IntoView { + tracing::debug!("Enter"); + + view! { +
+
+ } +} + +#[component] +#[tracing::instrument(skip_all)] +fn Grid(wall: models::Wall, #[prop(into)] problem: Signal, ServerFnError>>) -> impl IntoView { tracing::debug!("Enter"); let mut cells = vec![]; for (&hold_position, hold) in &wall.holds { - let role = move || problem.get().and_then(|p| p.holds.get(&hold_position).copied()); + let role = move || problem.get().map(|o| o.and_then(|p| p.holds.get(&hold_position).copied())); let role = Signal::derive(role); let cell = view! { }; cells.push(cell); @@ -158,30 +390,51 @@ fn Grid(wall: models::Wall, problem: Signal>) -> impl In // TODO: refactor this to use the Problem component #[component] #[tracing::instrument(skip_all)] -fn Hold(hold: models::Hold, role: Signal>) -> impl IntoView { +fn Hold(hold: models::Hold, role: Signal, ServerFnError>>) -> impl IntoView { tracing::trace!("Enter"); - let class = move || { - let role_classes = match role.get() { - Some(HoldRole::Start) => Some("outline outline-offset-2 outline-green-500"), - Some(HoldRole::Normal) => Some("outline outline-offset-2 outline-blue-500"), - Some(HoldRole::Zone) => Some("outline outline-offset-2 outline-amber-500"), - Some(HoldRole::End) => Some("outline outline-offset-2 outline-red-500"), - None => Some("brightness-50"), - // None => None, + + move || { + let role = role.get()?; + + let class = { + let role_classes = match role { + Some(HoldRole::Start) => Some("outline outline-offset-2 outline-green-500"), + Some(HoldRole::Normal) => Some("outline outline-offset-2 outline-blue-500"), + Some(HoldRole::Zone) => Some("outline outline-offset-2 outline-amber-500"), + Some(HoldRole::End) => Some("outline outline-offset-2 outline-red-500"), + None => Some("brightness-50"), + }; + let mut s = "bg-sky-100 aspect-square rounded hover:brightness-125".to_string(); + if let Some(c) = role_classes { + s.push(' '); + s.push_str(c); + } + s }; - let mut s = "bg-sky-100 aspect-square rounded hover:brightness-125".to_string(); - if let Some(c) = role_classes { - s.push(' '); - s.push_str(c); - } - s - }; - let img = hold.image.map(|img| { - let srcset = img.srcset(); - view! { } - }); + let img = hold.image.as_ref().map(|img| { + let srcset = img.srcset(); + view! { } + }); - tracing::trace!("view"); - view! {
{img}
} + let view = view! {
{img}
}; + Ok::<_, ServerFnError>(view) + } +} + +#[component] +fn Separator() -> impl IntoView { + view! {
} +} + +#[component] +fn Section(children: Children, #[prop(into)] title: MaybeProp) -> impl IntoView { + view! { +
+
+ {move || title.get()} +
+ {children()} +
+ } } diff --git a/crates/ascend/src/resources.rs b/crates/ascend/src/resources.rs new file mode 100644 index 0000000..ca4b531 --- /dev/null +++ b/crates/ascend/src/resources.rs @@ -0,0 +1,63 @@ +use crate::codec::ron::Ron; +use crate::codec::ron::RonEncoded; +use crate::models::{self}; +use leptos::prelude::Get; +use leptos::prelude::Signal; +use leptos::server::Resource; +use server_fn::ServerFnError; + +type RonResource = Resource, Ron>; + +pub fn wall_by_uid(wall_uid: Signal) -> RonResource { + Resource::new_with_options( + move || wall_uid.get(), + move |wall_uid| async move { crate::server_functions::get_wall_by_uid(wall_uid).await.map(RonEncoded::into_inner) }, + false, + ) +} + +/// Version of [problem_by_uid] that short circuits if the input problem_uid signal is None. +pub fn problem_by_uid_optional( + wall_uid: Signal, + problem_uid: Signal>, +) -> RonResource> { + Resource::new_with_options( + move || (wall_uid.get(), problem_uid.get()), + move |(wall_uid, problem_uid)| async move { + let Some(problem_uid) = problem_uid else { + return Ok(None); + }; + crate::server_functions::get_problem_by_uid(wall_uid, problem_uid) + .await + .map(RonEncoded::into_inner) + .map(Some) + }, + false, + ) +} + +pub fn problems_for_wall(wall_uid: Signal) -> RonResource> { + Resource::new_with_options( + move || wall_uid.get(), + move |wall_uid| async move { crate::server_functions::get_problems_for_wall(wall_uid).await.map(RonEncoded::into_inner) }, + false, + ) +} + +pub fn user_interaction( + wall_uid: Signal, + problem_uid: Signal>, +) -> RonResource> { + Resource::new_with_options( + move || (wall_uid.get(), problem_uid.get()), + move |(wall_uid, problem_uid)| async move { + let Some(problem_uid) = problem_uid else { + return Ok(None); + }; + crate::server_functions::get_user_interaction(wall_uid, problem_uid) + .await + .map(RonEncoded::into_inner) + }, + false, + ) +} diff --git a/crates/ascend/src/server/db.rs b/crates/ascend/src/server/db.rs index 0332361..33ee685 100644 --- a/crates/ascend/src/server/db.rs +++ b/crates/ascend/src/server/db.rs @@ -56,6 +56,16 @@ impl Database { self.read(|dbtx| dbtx.open_table(TABLE_VERSION)?.get(()).map(|o| o.map(|v| v.value())).map_err(Into::into)) .await } + + #[tracing::instrument(skip_all)] + pub async fn set_version(&self, version: Version) -> Result<(), DatabaseOperationError> { + self.write(|txn| { + let mut table = txn.open_table(TABLE_VERSION)?; + table.insert((), version)?; + Ok(()) + }) + .await + } } #[derive(Debug, derive_more::Error, derive_more::Display, derive_more::From)] @@ -75,7 +85,7 @@ impl DatabaseOperationError { } pub const TABLE_VERSION: TableDefinition<(), Bincode> = TableDefinition::new("version"); -#[derive(Serialize, Deserialize, Debug, derive_more::Display)] +#[derive(Serialize, Deserialize, Debug, derive_more::Display, PartialEq, Eq, PartialOrd, Ord)] #[display("{version}")] pub struct Version { pub version: u64, @@ -86,6 +96,7 @@ impl Version { } } +// TODO: implement test #[tracing::instrument(skip_all, err)] pub async fn init_at_current_version(db: &Database) -> Result<(), DatabaseOperationError> { db.write(|txn| { @@ -116,6 +127,13 @@ pub async fn init_at_current_version(db: &Database) -> Result<(), DatabaseOperat let table = txn.open_table(current::TABLE_PROBLEMS)?; assert!(table.is_empty()?); } + + // User table + { + // Opening the table creates the table + let table = txn.open_table(current::TABLE_USER)?; + assert!(table.is_empty()?); + } } Ok(()) @@ -126,7 +144,26 @@ pub async fn init_at_current_version(db: &Database) -> Result<(), DatabaseOperat } use crate::models; -pub use v2 as current; +pub mod current { + use super::v2; + use super::v3; + pub use v2::TABLE_PROBLEMS; + pub use v2::TABLE_ROOT; + pub use v2::TABLE_WALLS; + pub use v3::TABLE_USER; + pub use v3::VERSION; +} + +pub mod v3 { + use crate::models; + use crate::server::db::bincode::Bincode; + use redb::TableDefinition; + + pub const VERSION: u64 = 3; + + pub const TABLE_USER: TableDefinition, Bincode> = + TableDefinition::new("user"); +} pub mod v2 { use crate::models; diff --git a/crates/ascend/src/server/migrations.rs b/crates/ascend/src/server/migrations.rs index b97926f..b51a5ce 100644 --- a/crates/ascend/src/server/migrations.rs +++ b/crates/ascend/src/server/migrations.rs @@ -1,6 +1,34 @@ use super::db::Database; +use super::db::DatabaseOperationError; +use super::db::{self}; #[tracing::instrument(skip_all, err)] -pub async fn run_migrations(_db: &Database) -> Result<(), Box> { +pub async fn run_migrations(db: &Database) -> Result<(), Box> { + if is_at_version(db, 2).await? { + migrate_to_v3(db).await?; + } Ok(()) } + +#[tracing::instrument(skip_all, err)] +pub async fn migrate_to_v3(db: &Database) -> Result<(), Box> { + use redb::ReadableTableMetadata; + tracing::warn!("MIGRATING TO VERSION 3"); + + db.write(|txn| { + // Opening the table creates the table + let table = txn.open_table(db::current::TABLE_USER)?; + assert!(table.is_empty()?); + Ok(()) + }) + .await?; + + db.set_version(db::Version { version: db::v3::VERSION }).await?; + + Ok(()) +} + +async fn is_at_version(db: &Database, version: u64) -> Result { + let v = db.get_version().await?; + Ok(v == Some(db::Version { version })) +} diff --git a/crates/ascend/src/server_functions.rs b/crates/ascend/src/server_functions.rs index fa2249e..418077f 100644 --- a/crates/ascend/src/server_functions.rs +++ b/crates/ascend/src/server_functions.rs @@ -1,6 +1,11 @@ use crate::codec::ron::Ron; use crate::codec::ron::RonEncoded; use crate::models; +use crate::models::UserInteraction; +use derive_more::Display; +use derive_more::Error; +use derive_more::From; +use leptos::prelude::*; use leptos::server; use server_fn::ServerFnError; @@ -14,7 +19,7 @@ pub async fn get_walls() -> Result>, ServerFnError> use crate::server::db::Database; use leptos::prelude::expect_context; use redb::ReadableTable; - tracing::debug!("Enter"); + tracing::trace!("Enter"); let db = expect_context::(); @@ -26,8 +31,6 @@ pub async fn get_walls() -> Result>, ServerFnError> }) .await?; - tracing::debug!("Exit"); - Ok(RonEncoded::new(walls)) } @@ -37,11 +40,11 @@ pub async fn get_walls() -> Result>, ServerFnError> custom = RonEncoded )] #[tracing::instrument(skip_all, err(Debug))] -pub(crate) async fn get_wall(wall_uid: models::WallUid) -> Result, ServerFnError> { +pub(crate) async fn get_wall_by_uid(wall_uid: models::WallUid) -> Result, ServerFnError> { use crate::server::db::Database; use crate::server::db::DatabaseOperationError; use leptos::prelude::expect_context; - tracing::debug!("Enter"); + tracing::trace!("Enter"); #[derive(Debug, derive_more::Error, derive_more::Display)] enum Error { @@ -63,8 +66,6 @@ pub(crate) async fn get_wall(wall_uid: models::WallUid) -> Result Result Result Result Result, ServerFnError> { +#[tracing::instrument(err(Debug))] +pub(crate) async fn get_user_interaction( + wall_uid: models::WallUid, + problem_uid: models::ProblemUid, +) -> Result>, ServerFnError> { use crate::server::db::Database; use crate::server::db::DatabaseOperationError; use leptos::prelude::expect_context; - tracing::debug!("Enter"); + tracing::trace!("Enter"); + + #[derive(Debug, derive_more::Error, derive_more::Display, derive_more::From)] + enum Error { + #[display("Wall not found: {_0:?}")] + WallNotFound(#[error(not(source))] models::WallUid), + + DatabaseOperation(DatabaseOperationError), + } + + async fn inner(wall_uid: models::WallUid, problem_uid: models::ProblemUid) -> Result, Error> { + let db = expect_context::(); + + let user_interaction = db + .read(|txn| { + let user_table = txn.open_table(crate::server::db::current::TABLE_USER)?; + let user_interaction = user_table.get((wall_uid, problem_uid))?.map(|guard| guard.value()); + Ok(user_interaction) + }) + .await?; + + Ok(user_interaction) + } + + let user_interaction = inner(wall_uid, problem_uid) + .await + .map_err(error_reporter::Report::new) + .map_err(ServerFnError::new)?; + Ok(RonEncoded::new(user_interaction)) +} + +#[server( + input = Ron, + output = Ron, + custom = RonEncoded +)] +#[tracing::instrument(skip_all, err(Debug))] +pub(crate) async fn get_problem_by_uid( + wall_uid: models::WallUid, + problem_uid: models::ProblemUid, +) -> Result, ServerFnError> { + use crate::server::db::Database; + use crate::server::db::DatabaseOperationError; + use leptos::prelude::expect_context; + tracing::trace!("Enter"); #[derive(Debug, derive_more::Error, derive_more::Display)] enum Error { @@ -160,3 +206,74 @@ pub(crate) async fn get_problem(wall_uid: models::WallUid, problem_uid: models:: Ok(RonEncoded::new(problem)) } + +/// Inserts or updates today's attempt. +#[server( + input = Ron, + output = Ron, + custom = RonEncoded +)] +#[tracing::instrument(err(Debug))] +pub(crate) async fn upsert_todays_attempt( + wall_uid: models::WallUid, + problem_uid: models::ProblemUid, + attempt: models::Attempt, +) -> Result, ServerFnError> { + use crate::server::db::Database; + use crate::server::db::DatabaseOperationError; + use leptos::prelude::expect_context; + + tracing::trace!("Enter"); + + #[derive(Debug, Error, Display, From)] + enum Error { + #[display("Wall not found: {_0:?}")] + WallNotFound(#[error(not(source))] models::WallUid), + + DatabaseOperation(DatabaseOperationError), + } + + async fn inner(wall_uid: models::WallUid, problem_uid: models::ProblemUid, attempt: models::Attempt) -> Result { + let db = expect_context::(); + + let user_interaction = db + .write(|txn| { + let mut user_table = txn.open_table(crate::server::db::current::TABLE_USER)?; + + let key = (wall_uid, problem_uid); + + // Pop or default + let mut user_interaction = user_table + .remove(key)? + .map(|guard| guard.value()) + .unwrap_or_else(|| models::UserInteraction::new(wall_uid, problem_uid)); + + // If the last entry is from today, remove it + if let Some(entry) = user_interaction.attempted_on.last_entry() { + let today_local_naive = chrono::Local::now().date_naive(); + + let entry_date = entry.key(); + let entry_date_local_naive = entry_date.with_timezone(&chrono::Local).date_naive(); + + if entry_date_local_naive == today_local_naive { + entry.remove(); + } + } + + user_interaction.attempted_on.insert(chrono::Utc::now(), attempt); + + user_table.insert(key, user_interaction.clone())?; + + Ok(user_interaction) + }) + .await?; + + Ok(user_interaction) + } + + inner(wall_uid, problem_uid, attempt) + .await + .map_err(error_reporter::Report::new) + .map_err(ServerFnError::new) + .map(RonEncoded::new) +} diff --git a/crates/ascend/tailwind.config.js b/crates/ascend/tailwind.config.js index 057dd66..aeec1be 100644 --- a/crates/ascend/tailwind.config.js +++ b/crates/ascend/tailwind.config.js @@ -6,6 +6,9 @@ }, // https://tailwindcss.com/docs/content-configuration#using-regular-expressions safelist: [ + { + pattern: /bg-transparent/, + }, { pattern: /grid-cols-.+/, }, diff --git a/flake.lock b/flake.lock index db2effe..c0ab54b 100644 --- a/flake.lock +++ b/flake.lock @@ -10,11 +10,11 @@ ] }, "locked": { - "lastModified": 1740139153, - "narHash": "sha256-Xa1wCQBbsFHCaXgVBjtraZcWywuXBN+YhdqGle4nLVc=", + "lastModified": 1740523129, + "narHash": "sha256-q/k/T9Hf+aCo8/xQnqyw+E7dYx8Nq1u7KQ2ylORcP+M=", "owner": "plul", "repo": "basecamp", - "rev": "0a29da733dc2f7b386dd3667b63a51c55238fbfd", + "rev": "0882906c106ab0bf193b3417c845c5accbec2419", "type": "github" }, "original": { @@ -25,11 +25,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1740396192, - "narHash": "sha256-ATMHHrg3sG1KgpQA5x8I+zcYpp5Sf17FaFj/fN+8OoQ=", + "lastModified": 1740547748, + "narHash": "sha256-Ly2fBL1LscV+KyCqPRufUBuiw+zmWrlJzpWOWbahplg=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "d9b69c3ec2a2e2e971c534065bdd53374bd68b97", + "rev": "3a05eebede89661660945da1f151959900903b6a", "type": "github" }, "original": { @@ -53,11 +53,11 @@ ] }, "locked": { - "lastModified": 1740450604, - "narHash": "sha256-T/lqASXzCzp5lJISCUw+qwfRmImVUnhKgAhn8ymRClI=", + "lastModified": 1740623427, + "narHash": "sha256-3SdPQrZoa4odlScFDUHd4CUPQ/R1gtH4Mq9u8CBiK8M=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "5961ca311c85c31fc5f51925b4356899eed36221", + "rev": "d342e8b5fd88421ff982f383c853f0fc78a847ab", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 7d584f0..0fa115d 100644 --- a/flake.nix +++ b/flake.nix @@ -130,8 +130,10 @@ basecamp.mkShell pkgs { rust.enable = true; rust.toolchain.targets = [ "wasm32-unknown-unknown" ]; + rust.toolchain.components.rust-analyzer.nightly = true; packages = [ + pkgs.bacon pkgs.cargo-leptos pkgs.leptosfmt pkgs.dart-sass @@ -142,7 +144,7 @@ pkgs.binaryen ]; - env.RUST_LOG = "info,ascend=trace"; + env.RUST_LOG = "info,ascend=debug"; env.MOONBOARD_PROBLEMS = "moonboard-problems"; }; }; diff --git a/justfile b/justfile index aa8a50f..ecf56da 100644 --- a/justfile +++ b/justfile @@ -53,3 +53,6 @@ leptos-discord: leptos-issues: xdg-open "https://github.com/leptos-rs/leptos/issues" + +icons: + xdg-open "https://heroicons.com/" diff --git a/leptosfmt.toml b/leptosfmt.toml new file mode 100644 index 0000000..534aa39 --- /dev/null +++ b/leptosfmt.toml @@ -0,0 +1,4 @@ +closing_tag_style = "SelfClosing" + +[attr_values] +class = "Tailwind" diff --git a/rustfmt.toml b/rustfmt.toml index 6edbe10..94a7e08 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1,4 +1,4 @@ -style_edition = "2024" +edition = "2024" unstable_features = true imports_granularity = "Item" group_imports = "One" diff --git a/todo.md b/todo.md index 252c5d3..4ba9922 100644 --- a/todo.md +++ b/todo.md @@ -9,5 +9,6 @@ - decide on holds vs wall-edit terminology - clock - hotkeys (enter =next problem, arrow = shift left/right up/down) -- remove brightness reduction when mousing over holds - impl `sizes` hint next to `srcset` +- add refresh wall button for when a hold is changed +- fix a font, or why does font-thin not do anything?