This commit is contained in:
2025-03-06 22:21:05 +01:00
parent 3b232b048d
commit bcfc155266
8 changed files with 266 additions and 127 deletions

View File

@@ -3,17 +3,17 @@ use leptos::prelude::*;
#[component] #[component]
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
pub fn ProblemInfo(problem: models::Problem) -> impl IntoView { pub fn ProblemInfo(#[prop(into)] problem: Signal<models::Problem>) -> impl IntoView {
tracing::trace!("Enter problem info"); tracing::trace!("Enter problem info");
let name = problem.name; let name = Signal::derive(move || problem.read().name.clone());
let set_by = problem.set_by; let set_by = Signal::derive(move || problem.read().set_by.clone());
let method = problem.method; let method = Signal::derive(move || problem.read().method.to_string());
view! { view! {
<div class="grid grid-rows-none gap-y-1 gap-x-0.5 grid-cols-[auto,1fr]"> <div class="grid grid-rows-none gap-y-1 gap-x-0.5 grid-cols-[auto,1fr]">
<NameValue name="Name:" value=name /> <NameValue name="Name:" value=name />
<NameValue name="Method:" value=method.to_string() /> <NameValue name="Method:" value=method />
<NameValue name="Set By:" value=set_by /> <NameValue name="Set By:" value=set_by />
</div> </div>
} }
@@ -21,9 +21,9 @@ pub fn ProblemInfo(problem: models::Problem) -> impl IntoView {
#[component] #[component]
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
fn NameValue(#[prop(into)] name: String, #[prop(into)] value: String) -> impl IntoView { fn NameValue(#[prop(into)] name: Signal<String>, #[prop(into)] value: Signal<String>) -> impl IntoView {
view! { view! {
<p class="text-sm font-light mr-4 text-right text-orange-300">{name}</p> <p class="text-sm font-light mr-4 text-right text-orange-300">{name.get()}</p>
<p class="text-white">{value}</p> <p class="text-white">{value.get()}</p>
} }
} }

View File

@@ -0,0 +1,30 @@
use leptos::prelude::*;
#[component]
#[tracing::instrument(skip_all)]
pub fn ShowSome<T, C, IV>(#[prop(into)] sig: Signal<Option<T>>, foo: C) -> impl IntoView
where
T: Clone + Send + Sync + 'static,
C: Fn(Signal<T>) -> IV + Sync + Send + 'static,
IV: IntoView,
{
tracing::trace!("Enter");
view! {
// <Show when=move || sig.get().is_some() fallback=|| ()>
// {move || {
// let new = signal()
// sig
// .with(|opt| {
// if let Some(inner) = opt.clone() {
// let new_signal = Signal::derive(move || sig.get().unwrap());
// .into_any()
// } else {
// view! {}.into_any()
// }
// })
// }}
// </Show>
}
}

View File

@@ -20,6 +20,7 @@ pub mod components {
pub mod outlined_box; pub mod outlined_box;
pub mod problem; pub mod problem;
pub mod problem_info; pub mod problem_info;
pub mod show_some;
} }
pub mod gradient; pub mod gradient;

View File

@@ -105,7 +105,7 @@ fn Hold(wall_uid: models::WallUid, hold: models::Hold) -> impl IntoView {
} }
}; };
let upload = Action::from(ServerAction::<SetImage>::new()); let upload = ServerAction::<SetImage>::new();
let hold = Signal::derive(move || { let hold = Signal::derive(move || {
let refreshed = upload.value().get().map(Result::unwrap); let refreshed = upload.value().get().map(Result::unwrap);

View File

@@ -32,7 +32,7 @@ pub fn Settings() -> impl IntoView {
#[component] #[component]
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
fn Import(wall_uid: WallUid) -> impl IntoView { fn Import(wall_uid: WallUid) -> impl IntoView {
let import_from_mini_moonboard = Action::from(ServerAction::<ImportFromMiniMoonboard>::new()); let import_from_mini_moonboard = ServerAction::<ImportFromMiniMoonboard>::new();
let onclick = move |_mouse_event| { let onclick = move |_mouse_event| {
import_from_mini_moonboard.dispatch(ImportFromMiniMoonboard { wall_uid }); import_from_mini_moonboard.dispatch(ImportFromMiniMoonboard { wall_uid });
@@ -44,7 +44,7 @@ fn Import(wall_uid: WallUid) -> impl IntoView {
} }
} }
#[server(name = ImportFromMiniMoonboard)] #[server]
#[tracing::instrument] #[tracing::instrument]
async fn import_from_mini_moonboard(wall_uid: WallUid) -> Result<(), ServerFnError> { async fn import_from_mini_moonboard(wall_uid: WallUid) -> Result<(), ServerFnError> {
use crate::server::config::Config; use crate::server::config::Config;

View File

@@ -30,6 +30,7 @@
// | 14 days ago: <Attempt> | // | 14 days ago: <Attempt> |
// +--------------------------------------+ // +--------------------------------------+
use crate::codec::ron::RonEncoded;
use crate::components::ProblemInfo; use crate::components::ProblemInfo;
use crate::components::attempt::Attempt; use crate::components::attempt::Attempt;
use crate::components::button::Button; use crate::components::button::Button;
@@ -40,6 +41,7 @@ use crate::components::icons::Icon;
use crate::gradient::Gradient; use crate::gradient::Gradient;
use crate::models; use crate::models;
use crate::models::HoldRole; use crate::models::HoldRole;
use crate::server_functions;
use leptos::Params; use leptos::Params;
use leptos::prelude::*; use leptos::prelude::*;
use leptos_router::params::Params; use leptos_router::params::Params;
@@ -57,8 +59,6 @@ pub fn Wall() -> impl IntoView {
let route_params = leptos_router::hooks::use_params::<RouteParams>(); let route_params = leptos_router::hooks::use_params::<RouteParams>();
let (problem_uid, set_problem_uid) = leptos_router::hooks::query_signal::<models::ProblemUid>("problem");
let wall_uid = Signal::derive(move || { let wall_uid = Signal::derive(move || {
route_params route_params
.get() .get()
@@ -68,28 +68,75 @@ pub fn Wall() -> impl IntoView {
}); });
let wall = crate::resources::wall_by_uid(wall_uid); let wall = crate::resources::wall_by_uid(wall_uid);
let problem = crate::resources::problem_by_uid(wall_uid, problem_uid.into());
let header_items = move || HeaderItems {
left: vec![],
middle: vec![HeaderItem {
text: "ASCEND".to_string(),
link: None,
}],
right: vec![
HeaderItem {
text: "Routes".to_string(),
link: Some(format!("/wall/{}/routes", wall_uid.get())),
},
HeaderItem {
text: "Holds".to_string(),
link: Some(format!("/wall/{}/edit", wall_uid.get())),
},
],
};
leptos::view! {
<div class="min-h-screen min-w-screen bg-neutral-950">
<StyledHeader items=Signal::derive(header_items) />
<div class="m-2">
<Transition fallback=|| ()>
{move || Suspend::new(async move {
tracing::info!("executing main suspend");
let wall = wall.await?;
let v = view! { <WithWall wall /> };
Ok::<_, ServerFnError>(v)
})}
</Transition>
</div>
</div>
}
}
#[component]
#[tracing::instrument(skip_all)]
fn WithProblem(#[prop(into)] problem: Signal<models::Problem>) -> impl IntoView {
tracing::trace!("Enter");
view! { <ProblemInfo problem /> }
}
#[component]
#[tracing::instrument(skip_all)]
fn WithWall(#[prop(into)] wall: Signal<models::Wall>) -> impl IntoView {
tracing::trace!("Enter");
let (problem_uid, set_problem_uid) = leptos_router::hooks::query_signal::<models::ProblemUid>("problem");
let wall_uid = Signal::derive(move || wall.read().uid);
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 user_interaction = crate::resources::user_interaction(wall_uid, problem_uid.into());
// merge outer option (resource hasn't resolved yet) with inner option (there is no problem for the wall) let submit_attempt = ServerAction::<RonEncoded<server_functions::UpsertTodaysAttempt>>::new();
let problem_sig2 = Signal::derive(move || problem.get().transpose().map(Option::flatten));
let fn_next_problem = move |wall: &models::Wall| { let fn_next_problem = move |wall: &models::Wall| {
set_problem_uid.set(wall.random_problem()); set_problem_uid.set(wall.random_problem());
}; };
// Set a problem when wall is set (loaded) // Set a problem when wall is set (loaded)
Effect::new(move |_prev_value| match &*wall.read() { Effect::new(move |_prev_value| {
Some(Ok(wall)) => {
if problem_uid.get().is_none() { if problem_uid.get().is_none() {
tracing::debug!("Setting next problem"); tracing::debug!("Setting next problem");
fn_next_problem(wall); fn_next_problem(&wall.read());
} }
}
Some(Err(err)) => {
tracing::error!("Error getting wall: {err}");
}
None => {}
}); });
let (attempt_today, set_attempt_today) = signal(None); let (attempt_today, set_attempt_today) = signal(None);
@@ -112,24 +159,6 @@ pub fn Wall() -> impl IntoView {
} }
}); });
let header_items = move || HeaderItems {
left: vec![],
middle: vec![HeaderItem {
text: "ASCEND".to_string(),
link: None,
}],
right: vec![
HeaderItem {
text: "Routes".to_string(),
link: Some(format!("/wall/{}/routes", wall_uid.get())),
},
HeaderItem {
text: "Holds".to_string(),
link: Some(format!("/wall/{}/edit", wall_uid.get())),
},
],
};
// onclick handler helper // onclick handler helper
let set_or_deselect = |s: WriteSignal<Option<models::Attempt>>, v: models::Attempt| { let set_or_deselect = |s: WriteSignal<Option<models::Attempt>>, v: models::Attempt| {
s.update(|s| match s { s.update(|s| match s {
@@ -143,6 +172,11 @@ pub fn Wall() -> impl IntoView {
}; };
let onclick_flash = move |_| { let onclick_flash = move |_| {
set_or_deselect(set_attempt_today, models::Attempt::Flash); set_or_deselect(set_attempt_today, models::Attempt::Flash);
submit_attempt.dispatch(RonEncoded(server_functions::UpsertTodaysAttempt {
wall_uid: wall_uid.get(),
problem_uid: problem.get().uid,
attempt: models::Attempt::Flash,
}));
}; };
let onclick_send = move |_| { let onclick_send = move |_| {
set_or_deselect(set_attempt_today, models::Attempt::Send); set_or_deselect(set_attempt_today, models::Attempt::Send);
@@ -196,15 +230,9 @@ pub fn Wall() -> impl IntoView {
}) })
}; };
leptos::view! { // merge outer option (resource hasn't resolved yet) with inner option (there is no problem for the wall)
<div class="min-h-screen min-w-screen bg-neutral-950"> let problem_signal = Signal::derive(move || problem.get().transpose().map(Option::flatten));
<StyledHeader items=Signal::derive(header_items) />
<div class="m-2">
<Transition fallback=|| ()>
{move || Suspend::new(async move {
tracing::info!("executing main suspend");
let wall = wall.await?;
let grid = { let grid = {
let wall = wall.clone(); let wall = wall.clone();
view! { view! {
@@ -216,9 +244,7 @@ pub fn Wall() -> impl IntoView {
Suspend::new(async move { Suspend::new(async move {
let wall = wall.clone(); let wall = wall.clone();
tracing::info!("executing grid suspend"); tracing::info!("executing grid suspend");
let view = view! { let view = view! { <Grid wall=wall.get() problem=problem_signal /> };
<Grid wall=wall.clone() problem=problem_sig2 />
};
Ok::<_, ServerFnError>(view) Ok::<_, ServerFnError>(view)
}) })
} }
@@ -226,6 +252,7 @@ pub fn Wall() -> impl IntoView {
</Transition> </Transition>
} }
}; };
let v = view! { let v = view! {
<div class="inline-grid grid-cols-1 gap-8 md:grid-cols-[auto,1fr]"> <div class="inline-grid grid-cols-1 gap-8 md:grid-cols-[auto,1fr]">
<div>{grid}</div> <div>{grid}</div>
@@ -240,7 +267,7 @@ pub fn Wall() -> impl IntoView {
<Button <Button
icon=Icon::ArrowPath icon=Icon::ArrowPath
text="Next problem" text="Next problem"
onclick=move |_| fn_next_problem(&wall) onclick=move |_| fn_next_problem(&wall.read())
/> />
</div> </div>
</div> </div>
@@ -250,7 +277,7 @@ pub fn Wall() -> impl IntoView {
<Section title="Problem"> <Section title="Problem">
<Transition fallback=|| ()> <Transition fallback=|| ()>
{move || Suspend::new(async move { {move || Suspend::new(async move {
tracing::info!("executing probleminfo suspend"); tracing::info!("executing problem suspend");
let problem = problem.await?; let problem = problem.await?;
let view = problem let view = problem
.map(|problem| { .map(|problem| {
@@ -278,21 +305,8 @@ pub fn Wall() -> impl IntoView {
</div> </div>
</div> </div>
}; };
Ok::<_, ServerFnError>(v)
})}
</Transition>
</div>
</div>
}
} }
// TODO: refactor along these lines to limit suspend nesting in a single component?
// #[component]
// #[tracing::instrument(skip_all)]
// fn WithWall(#[prop(into)] wall: Signal<models::Wall>) -> impl IntoView {
// tracing::trace!("Enter");
// }
#[component] #[component]
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
fn AttemptRadio( fn AttemptRadio(

View File

@@ -16,7 +16,26 @@ pub fn wall_by_uid(wall_uid: Signal<models::WallUid>) -> RonResource<models::Wal
) )
} }
pub fn problem_by_uid(wall_uid: Signal<models::WallUid>, problem_uid: Signal<Option<models::ProblemUid>>) -> RonResource<Option<models::Problem>> { pub fn problem_by_uid(wall_uid: Signal<models::WallUid>, problem_uid: Signal<models::ProblemUid>) -> RonResource<models::Problem> {
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)
},
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<models::WallUid>,
problem_uid: Signal<Option<models::ProblemUid>>,
) -> RonResource<Option<models::Problem>> {
Resource::new_with_options( Resource::new_with_options(
move || (wall_uid.get(), problem_uid.get()), move || (wall_uid.get(), problem_uid.get()),
move |(wall_uid, problem_uid)| async move { move |(wall_uid, problem_uid)| async move {

View File

@@ -2,6 +2,10 @@ use crate::codec::ron::Ron;
use crate::codec::ron::RonEncoded; use crate::codec::ron::RonEncoded;
use crate::models; use crate::models;
use crate::models::UserInteraction; use crate::models::UserInteraction;
use derive_more::Display;
use derive_more::Error;
use derive_more::From;
use leptos::prelude::*;
use leptos::server; use leptos::server;
use server_fn::ServerFnError; use server_fn::ServerFnError;
@@ -202,3 +206,74 @@ pub(crate) async fn get_problem_by_uid(
Ok(RonEncoded::new(problem)) 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<RonEncoded<models::UserInteraction>, 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<UserInteraction, Error> {
let db = expect_context::<Database>();
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)
}