Compare commits

..

6 Commits

Author SHA1 Message Date
bd8b0fecf1 feat: transformation buttons 2025-04-01 21:00:31 +02:00
c15db2847d wip 2025-04-01 17:48:19 +02:00
0a95aca872 wip 2025-04-01 15:02:49 +02:00
91bea767d0 wip 2025-04-01 13:46:28 +02:00
ed6aa4b9c9 wip 2025-03-31 22:43:19 +02:00
d11f8510b4 wip 2025-03-31 16:25:20 +02:00
18 changed files with 501 additions and 399 deletions

View File

@@ -51,12 +51,12 @@ codee = { version = "0.3" }
error_reporter = { version = "1" } error_reporter = { version = "1" }
getrandom = { version = "0.3.1" } getrandom = { version = "0.3.1" }
[dev-dependencies]
test-try = "0.1"
[dev-dependencies.serde_json] [dev-dependencies.serde_json]
version = "1" version = "1"
[dev-dependencies.test-try]
version = "0.1"
[features] [features]
hydrate = ["leptos/hydrate", "getrandom/wasm_js", "uuid/js"] hydrate = ["leptos/hydrate", "getrandom/wasm_js", "uuid/js"]
ssr = [ ssr = [

View File

@@ -43,8 +43,7 @@ pub fn App() -> impl IntoView {
<Routes fallback=|| "Not found"> <Routes fallback=|| "Not found">
<Route path=path!("/") view=Home /> <Route path=path!("/") view=Home />
<Route path=path!("/wall/:wall_uid") view=pages::wall::Page /> <Route path=path!("/wall/:wall_uid") view=pages::wall::Page />
<Route path=path!("/wall/:wall_uid/edit") view=pages::edit_wall::EditWall /> <Route path=path!("/wall/:wall_uid/holds") view=pages::holds::Page />
<Route path=path!("/wall/:wall_uid/routes") view=pages::routes::Routes />
</Routes> </Routes>
</Router> </Router>
} }

View File

@@ -19,9 +19,9 @@ use leptos::prelude::*;
#[component] #[component]
pub fn OnHoverRed(children: Children) -> impl IntoView { pub fn OnHoverRed(children: Children) -> impl IntoView {
view! { view! {
<div class="group relative"> <div class="relative group">
<div>{children()}</div> <div>{children()}</div>
<div class="absolute inset-0 bg-red-500 opacity-0 group-hover:opacity-50"></div> <div class="absolute inset-0 bg-red-500 opacity-0 group-hover:opacity-50" />
</div> </div>
} }
} }

View File

@@ -8,7 +8,7 @@ pub fn Checkbox(checked: RwSignal<bool>, #[prop(into)] text: Signal<String>, #[p
let unique_id = Oco::from(format!("checkbox-{}", uuid::Uuid::new_v4())); let unique_id = Oco::from(format!("checkbox-{}", uuid::Uuid::new_v4()));
let checkbox_view = view! { let checkbox_view = view! {
<div class="self-center text-white bg-white rounded-xs aspect-square mx-5 my-2.5"> <div class="self-center my-2.5 mx-5 text-white bg-white rounded-xs aspect-square">
<span class=("text-gray-950", move || checked.get())> <span class=("text-gray-950", move || checked.get())>
<icons::Check /> <icons::Check />
</span> </span>
@@ -25,7 +25,7 @@ pub fn Checkbox(checked: RwSignal<bool>, #[prop(into)] text: Signal<String>, #[p
}; };
let text_view = view! { let text_view = view! {
<div class="self-center mx-5 my-2.5 uppercase w-full text-lg font-thin"> <div class="self-center my-2.5 mx-5 w-full text-lg font-thin uppercase">
{move || text.get()} {move || text.get()}
</div> </div>
}; };

View File

@@ -19,7 +19,7 @@ pub fn Problem(
for row in 0..dim.get().rows { for row in 0..dim.get().rows {
for col in 0..dim.get().cols { for col in 0..dim.get().cols {
let hold_position = models::HoldPosition { row, col }; let hold_position = models::HoldPosition { row, col };
let role = move || problem.get().holds.get(&hold_position).copied(); let role = move || problem.read().pattern.pattern.get(&hold_position).copied();
let role = Signal::derive(role); let role = Signal::derive(role);
let hold = view! { <Hold role /> }; let hold = view! { <Hold role /> };
holds.push(hold); holds.push(hold);

View File

@@ -6,15 +6,15 @@ use leptos::prelude::*;
pub fn ProblemInfo(#[prop(into)] problem: Signal<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 = Signal::derive(move || problem.read().name.clone());
let set_by = Signal::derive(move || problem.read().set_by.clone());
let method = Signal::derive(move || problem.read().method.to_string()); let method = Signal::derive(move || problem.read().method.to_string());
// let name = Signal::derive(move || problem.read().name.clone());
// let set_by = Signal::derive(move || problem.read().set_by.clone());
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-x-0.5 gap-y-1 grid-cols-[auto_1fr]">
<NameValue name="Name:" value=name />
<NameValue name="Method:" value=method /> <NameValue name="Method:" value=method />
<NameValue name="Set By:" value=set_by /> // <NameValue name="Name:" value=name />
// <NameValue name="Set By:" value=set_by />
</div> </div>
} }
} }
@@ -23,7 +23,7 @@ pub fn ProblemInfo(#[prop(into)] problem: Signal<models::Problem>) -> impl IntoV
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
fn NameValue(#[prop(into)] name: Signal<String>, #[prop(into)] value: Signal<String>) -> impl IntoView { fn NameValue(#[prop(into)] name: Signal<String>, #[prop(into)] value: Signal<String>) -> impl IntoView {
view! { view! {
<p class="font-light mr-4 text-right text-orange-300">{name.get()}</p> <p class="mr-4 font-light text-right text-orange-300">{name.get()}</p>
<p class="text-white">{value.get()}</p> <p class="text-white">{value.get()}</p>
} }
} }

View File

@@ -8,22 +8,68 @@ pub use v2::ImageFilename;
pub use v2::ImageResolution; pub use v2::ImageResolution;
pub use v2::ImageUid; pub use v2::ImageUid;
pub use v2::Method; pub use v2::Method;
pub use v2::Problem;
pub use v2::ProblemUid;
pub use v2::Root; pub use v2::Root;
pub use v2::Wall;
pub use v2::WallDimensions; pub use v2::WallDimensions;
pub use v2::WallUid; pub use v2::WallUid;
pub use v3::Attempt; pub use v3::Attempt;
pub use v3::UserInteraction;
pub use v4::DatedAttempt; pub use v4::DatedAttempt;
pub use v4::Pattern;
pub use v4::Problem;
pub use v4::Transformation;
pub use v4::UserInteraction;
pub use v4::Wall;
mod semantics; mod semantics;
pub mod v4 { pub mod v4 {
use super::v1;
use super::v2;
use super::v3; use super::v3;
use chrono::DateTime; use chrono::DateTime;
use chrono::Utc; use chrono::Utc;
use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Wall {
pub uid: v2::WallUid,
pub wall_dimensions: v2::WallDimensions,
pub holds: BTreeMap<v1::HoldPosition, v2::Hold>,
/// Canonicalized.
pub problems: BTreeSet<Problem>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Problem {
pub pattern: Pattern,
pub method: v2::Method,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Pattern {
pub pattern: BTreeMap<v1::HoldPosition, v1::HoldRole>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Copy, Default)]
pub struct Transformation {
pub shift_right: u64,
pub mirror: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct UserInteraction {
pub wall_uid: v2::WallUid,
pub problem: Problem,
/// Dates on which this problem was attempted, and how it went
pub attempted_on: BTreeMap<chrono::DateTime<chrono::Utc>, v3::Attempt>,
/// Is among favorite problems
pub is_favorite: bool,
}
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub struct DatedAttempt { pub struct DatedAttempt {
@@ -85,7 +131,6 @@ pub mod v2 {
pub struct Wall { pub struct Wall {
pub uid: WallUid, pub uid: WallUid,
// TODO: Replace by walldimensions
pub rows: u64, pub rows: u64,
pub cols: u64, pub cols: u64,
@@ -93,7 +138,7 @@ pub mod v2 {
pub problems: BTreeSet<ProblemUid>, pub problems: BTreeSet<ProblemUid>,
} }
#[derive(Debug, Copy, Clone, PartialEq, Eq)] #[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WallDimensions { pub struct WallDimensions {
pub rows: u64, pub rows: u64,
pub cols: u64, pub cols: u64,
@@ -115,16 +160,16 @@ pub mod v2 {
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash, derive_more::FromStr, derive_more::Display)] #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash, derive_more::FromStr, derive_more::Display)]
pub struct ProblemUid(pub uuid::Uuid); pub struct ProblemUid(pub uuid::Uuid);
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Display)] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Display, Copy)]
pub enum Method { pub enum Method {
#[display("Feet follow hands")] #[display("Feet follow hands")]
FeetFollowHands, FeetFollowHands,
#[display("Footless")]
Footless,
#[display("Footless plus kickboard")] #[display("Footless plus kickboard")]
FootlessPlusKickboard, FootlessPlusKickboard,
#[display("Footless")]
Footless,
} }
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]

View File

@@ -3,14 +3,100 @@ use chrono::DateTime;
use chrono::Utc; use chrono::Utc;
use std::collections::BTreeMap; use std::collections::BTreeMap;
impl Pattern {
#[must_use]
pub fn canonicalize(&self) -> Self {
let mut pattern = self.clone();
let min_col = pattern.pattern.iter().map(|(hold_position, _)| hold_position.col).min().unwrap_or(0);
pattern.pattern = pattern
.pattern
.iter()
.map(|(hold_position, hold_role)| {
let hold_position = HoldPosition {
row: hold_position.row,
col: hold_position.col - min_col,
};
(hold_position, *hold_role)
})
.collect();
std::cmp::min(pattern.mirror(), pattern)
}
#[must_use]
pub fn shift_left(&self, shift: u64) -> Option<Self> {
// Out of bounds check
if let Some(min_col) = self.pattern.iter().map(|(hold_position, _)| hold_position.col).min() {
if shift > min_col {
return None;
}
}
let pattern: BTreeMap<HoldPosition, HoldRole> = self
.pattern
.iter()
.map(|(hold_position, hold_role)| {
let mut hold_position = hold_position.clone();
hold_position.col -= shift;
(hold_position, *hold_role)
})
.collect();
Some(Self { pattern })
}
#[must_use]
pub fn shift_right(&self, wall_dimensions: WallDimensions, shift: u64) -> Option<Self> {
// Out of bounds check
if let Some(max_col) = self.pattern.iter().map(|(hold_position, _)| hold_position.col).max() {
if max_col + shift >= wall_dimensions.cols {
return None;
}
}
let pattern: BTreeMap<HoldPosition, HoldRole> = self
.pattern
.iter()
.map(|(hold_position, hold_role)| {
let mut hold_position = hold_position.clone();
hold_position.col += shift;
(hold_position, *hold_role)
})
.collect();
Some(Self { pattern })
}
#[must_use]
pub fn mirror(&self) -> Self {
let mut pattern = self.clone();
let min_col = pattern.pattern.iter().map(|(hold_position, _)| hold_position.col).min().unwrap_or(0);
let max_col = pattern.pattern.iter().map(|(hold_position, _)| hold_position.col).max().unwrap_or(0);
pattern.pattern = pattern
.pattern
.iter()
.map(|(hold_position, hold_role)| {
let HoldPosition { row, col } = *hold_position;
let mut mirrored_col = col;
mirrored_col += 2 * (max_col - col);
mirrored_col -= max_col - min_col;
let hold_position = HoldPosition { row, col: mirrored_col };
(hold_position, *hold_role)
})
.collect();
pattern
}
}
impl UserInteraction { impl UserInteraction {
pub(crate) fn new(wall_uid: WallUid, problem_uid: ProblemUid) -> Self { pub(crate) fn new(wall_uid: WallUid, problem: Problem) -> Self {
Self { Self {
wall_uid, wall_uid,
problem_uid, problem,
is_favorite: false, is_favorite: false,
attempted_on: BTreeMap::new(), attempted_on: BTreeMap::new(),
is_saved: false,
} }
} }
@@ -63,15 +149,11 @@ impl WallUid {
pub(crate) fn create() -> Self { pub(crate) fn create() -> Self {
Self(uuid::Uuid::new_v4()) Self(uuid::Uuid::new_v4())
} }
} #[expect(dead_code)]
impl ProblemUid {
pub(crate) fn create() -> Self {
Self(uuid::Uuid::new_v4())
}
pub(crate) fn min() -> Self { pub(crate) fn min() -> Self {
Self(uuid::Uuid::nil()) Self(uuid::Uuid::nil())
} }
#[expect(dead_code)]
pub(crate) fn max() -> Self { pub(crate) fn max() -> Self {
Self(uuid::Uuid::max()) Self(uuid::Uuid::max())
} }
@@ -103,3 +185,69 @@ impl Attempt {
} }
} }
} }
impl std::str::FromStr for Problem {
type Err = ron::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let problem = ron::from_str(s)?;
Ok(problem)
}
}
impl std::fmt::Display for Problem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = ron::to_string(self).unwrap();
write!(f, "{s}")?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test_try::test_try]
fn canonicalize_empty_pattern() {
let pattern = Pattern {
pattern: [].into_iter().collect(),
};
let canonicalized = pattern.canonicalize();
assert_eq!(pattern, canonicalized);
let mirrored = pattern.mirror();
assert_eq!(pattern, mirrored);
}
#[test_try::test_try]
fn canonicalize_pattern() {
let pattern = Pattern {
pattern: [
(HoldPosition { row: 0, col: 1 }, HoldRole::End),
(HoldPosition { row: 7, col: 6 }, HoldRole::Start),
]
.into_iter()
.collect(),
};
let canonicalized = pattern.canonicalize();
assert_eq!(canonicalized.pattern[&HoldPosition { row: 0, col: 0 }], HoldRole::End);
assert_eq!(canonicalized.pattern[&HoldPosition { row: 7, col: 5 }], HoldRole::Start);
}
#[test_try::test_try]
fn mirror_pattern() {
let pattern = Pattern {
pattern: [
(HoldPosition { row: 0, col: 1 }, HoldRole::End),
(HoldPosition { row: 7, col: 6 }, HoldRole::Start),
]
.into_iter()
.collect(),
};
let mirrored = pattern.mirror();
assert_eq!(mirrored.pattern[&HoldPosition { row: 0, col: 6 }], HoldRole::End);
assert_eq!(mirrored.pattern[&HoldPosition { row: 7, col: 1 }], HoldRole::Start);
}
}

View File

@@ -1,4 +1,3 @@
pub mod edit_wall; pub mod holds;
pub mod routes;
pub mod settings; pub mod settings;
pub mod wall; pub mod wall;

View File

@@ -24,7 +24,7 @@ struct RouteParams {
} }
#[component] #[component]
pub fn EditWall() -> impl IntoView { pub fn Page() -> impl IntoView {
let params = leptos_router::hooks::use_params::<RouteParams>(); let params = leptos_router::hooks::use_params::<RouteParams>();
let wall_uid = Signal::derive(move || { let wall_uid = Signal::derive(move || {
params params
@@ -85,8 +85,8 @@ fn Ready(wall: models::Wall) -> impl IntoView {
} }
let style = { let style = {
let grid_rows = crate::css::grid_rows_n(wall.rows); let grid_rows = crate::css::grid_rows_n(wall.wall_dimensions.rows);
let grid_cols = crate::css::grid_cols_n(wall.cols); let grid_cols = crate::css::grid_cols_n(wall.wall_dimensions.cols);
[grid_rows, grid_cols].join(" ") [grid_rows, grid_cols].join(" ")
}; };

View File

@@ -1,102 +0,0 @@
use crate::components;
use crate::components::header::HeaderItem;
use crate::components::header::HeaderItems;
use crate::components::header::StyledHeader;
use crate::models;
use leptos::Params;
use leptos::prelude::*;
use leptos_router::params::Params;
#[derive(Params, PartialEq, Clone)]
struct RouteParams {
// Is never None
wall_uid: Option<models::WallUid>,
}
#[component]
#[tracing::instrument(skip_all)]
pub fn Routes() -> impl IntoView {
tracing::debug!("Enter");
let params = leptos_router::hooks::use_params::<RouteParams>();
let wall_uid = Signal::derive(move || {
params
.get()
.expect("gets wall_uid from URL")
.wall_uid
.expect("wall_uid param is never None")
});
let wall = crate::resources::wall_by_uid(wall_uid);
let problems = crate::resources::problems_for_wall(wall_uid);
let header_items = move || HeaderItems {
left: vec![HeaderItem {
text: "← Ascend".to_string(),
link: Some(format!("/wall/{}", wall_uid.get())),
}],
middle: vec![HeaderItem {
text: "Routes".to_string(),
link: None,
}],
right: vec![],
};
let suspend = move || {
Suspend::new(async move {
let wall = wall.await;
let problems = problems.await;
let v = move || -> Result<_, ServerFnError> {
let wall = wall.clone()?;
let problems = problems.clone()?;
let wall_dimensions = models::WallDimensions {
rows: wall.rows,
cols: wall.cols,
};
let problems_sample = move || problems.values().take(10).cloned().collect::<Vec<_>>();
Ok(view! {
<div>
<For
each=problems_sample
key=|problem| problem.uid
children=move |problem: models::Problem| {
view! {
<Problem dim=wall_dimensions problem />
<hr class="my-8 h-px bg-gray-700 border-0" />
}
}
/>
</div>
})
};
view! { <ErrorBoundary fallback=|_errors| "error">{v}</ErrorBoundary> }
})
};
view! {
<div class="min-h-screen min-w-screen bg-neutral-950">
<StyledHeader items=Signal::derive(header_items) />
<div class="container mx-auto mt-6">
<Suspense fallback=|| view! { <p>"loading"</p> }>{suspend}</Suspense>
</div>
</div>
}
}
#[component]
#[tracing::instrument(skip_all)]
fn Problem(#[prop(into)] dim: Signal<models::WallDimensions>, #[prop(into)] problem: Signal<models::Problem>) -> impl IntoView {
view! {
<div class="flex items-start">
<div class="flex-none">
<components::Problem dim problem />
</div>
<components::ProblemInfo problem=problem.get() />
</div>
}
}

View File

@@ -13,6 +13,7 @@ use leptos::prelude::*;
use leptos_router::params::Params; use leptos_router::params::Params;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::collections::BTreeSet; use std::collections::BTreeSet;
use std::ops::Deref;
#[derive(Params, PartialEq, Clone)] #[derive(Params, PartialEq, Clone)]
struct RouteParams { struct RouteParams {
@@ -35,8 +36,7 @@ pub fn Page() -> impl IntoView {
}); });
let wall = crate::resources::wall_by_uid(wall_uid); let wall = crate::resources::wall_by_uid(wall_uid);
let problems = crate::resources::problems_for_wall(wall_uid); let user_interactions = crate::resources::user_interactions_for_wall(wall_uid);
let user_interactions = crate::resources::user_interactions(wall_uid);
leptos::view! { leptos::view! {
<div class="min-h-screen min-w-screen bg-neutral-950"> <div class="min-h-screen min-w-screen bg-neutral-950">
@@ -46,10 +46,9 @@ pub fn Page() -> impl IntoView {
{move || Suspend::new(async move { {move || Suspend::new(async move {
tracing::debug!("executing main suspend"); tracing::debug!("executing main suspend");
let wall = wall.await?; let wall = wall.await?;
let problems = problems.await?;
let user_interactions = user_interactions.await?; let user_interactions = user_interactions.await?;
let user_interactions = RwSignal::new(user_interactions); let user_interactions = RwSignal::new(user_interactions);
Ok::<_, ServerFnError>(view! { <Controller wall problems user_interactions /> }) Ok::<_, ServerFnError>(view! { <Controller wall user_interactions /> })
})} })}
</Suspense> </Suspense>
</div> </div>
@@ -59,9 +58,9 @@ pub fn Page() -> impl IntoView {
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
struct Context { struct Context {
wall: Signal<models::Wall>, wall: Signal<models::Wall>,
user_interactions: Signal<BTreeMap<models::ProblemUid, models::UserInteraction>>, user_interactions: Signal<BTreeMap<models::Problem, models::UserInteraction>>,
problem: Signal<Option<models::Problem>>, problem: Signal<Option<models::Problem>>,
filtered_problems: Signal<BTreeMap<models::ProblemUid, models::Problem>>, filtered_problems: Signal<BTreeSet<models::Problem>>,
user_interaction: Signal<Option<models::UserInteraction>>, user_interaction: Signal<Option<models::UserInteraction>>,
todays_attempt: Signal<Option<models::Attempt>>, todays_attempt: Signal<Option<models::Attempt>>,
latest_attempt: Signal<Option<models::DatedAttempt>>, latest_attempt: Signal<Option<models::DatedAttempt>>,
@@ -69,6 +68,7 @@ struct Context {
cb_click_hold: Callback<models::HoldPosition>, cb_click_hold: Callback<models::HoldPosition>,
cb_remove_hold_from_filter: Callback<models::HoldPosition>, cb_remove_hold_from_filter: Callback<models::HoldPosition>,
cb_next_problem: Callback<()>, cb_next_problem: Callback<()>,
cb_set_problem: Callback<models::Problem>,
cb_upsert_todays_attempt: Callback<server_functions::UpsertTodaysAttempt>, cb_upsert_todays_attempt: Callback<server_functions::UpsertTodaysAttempt>,
} }
@@ -76,13 +76,12 @@ struct Context {
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
fn Controller( fn Controller(
#[prop(into)] wall: Signal<models::Wall>, #[prop(into)] wall: Signal<models::Wall>,
#[prop(into)] problems: Signal<BTreeMap<models::ProblemUid, models::Problem>>, #[prop(into)] user_interactions: RwSignal<BTreeMap<models::Problem, models::UserInteraction>>,
#[prop(into)] user_interactions: RwSignal<BTreeMap<models::ProblemUid, models::UserInteraction>>,
) -> impl IntoView { ) -> impl IntoView {
crate::tracing::on_enter!(); crate::tracing::on_enter!();
// Extract data from URL // Extract data from URL
let (problem_uid, set_problem_uid) = leptos_router::hooks::query_signal::<models::ProblemUid>("problem"); let (problem, set_problem) = leptos_router::hooks::query_signal::<models::Problem>("problem");
// Filter // Filter
let (filter_holds, set_filter_holds) = signal(BTreeSet::new()); let (filter_holds, set_filter_holds) = signal(BTreeSet::new());
@@ -93,10 +92,8 @@ fn Controller(
}); });
// Derive signals // Derive signals
let wall_uid = signals::wall_uid(wall); let user_interaction = signals::user_interaction(user_interactions.into(), problem.into());
let problem = signals::problem(problems, problem_uid.into()); let filtered_problems = signals::filtered_problems(wall, filter_holds.into());
let user_interaction = signals::user_interaction(user_interactions.into(), problem_uid.into());
let filtered_problems = signals::filtered_problems(problems, filter_holds.into());
let todays_attempt = signals::todays_attempt(user_interaction); let todays_attempt = signals::todays_attempt(user_interaction);
let latest_attempt = signals::latest_attempt(user_interaction); let latest_attempt = signals::latest_attempt(user_interaction);
@@ -106,17 +103,22 @@ fn Controller(
upsert_todays_attempt.dispatch(RonEncoded(attempt)); upsert_todays_attempt.dispatch(RonEncoded(attempt));
}); });
// Callback: Set specific problem
let cb_set_problem: Callback<models::Problem> = Callback::new(move |problem| {
set_problem.set(Some(problem));
});
// Callback: Set next problem to a random problem // Callback: Set next problem to a random problem
let cb_set_random_problem: Callback<()> = Callback::new(move |_| { let cb_set_random_problem: Callback<()> = Callback::new(move |_| {
// TODO: remove current problem from population // TODO: remove current problem from population
let population = filtered_problems.read(); let population = filtered_problems.read();
let population = population.keys().copied(); let population = population.deref();
use rand::seq::IteratorRandom; use rand::seq::IteratorRandom;
let mut rng = rand::rng(); let mut rng = rand::rng();
let problem_uid = population.choose(&mut rng); let problem = population.iter().choose(&mut rng);
set_problem_uid.set(problem_uid); set_problem.set(problem.cloned());
}); });
// Callback: On click hold, Add/Remove hold position to problem filter // Callback: On click hold, Add/Remove hold position to problem filter
@@ -130,7 +132,7 @@ fn Controller(
// Set a problem when wall is set (loaded) // Set a problem when wall is set (loaded)
Effect::new(move |_prev_value| { Effect::new(move |_prev_value| {
if problem_uid.get().is_none() { if problem.read().is_none() {
tracing::debug!("Setting initial problem"); tracing::debug!("Setting initial problem");
cb_set_random_problem.run(()); cb_set_random_problem.run(());
} }
@@ -141,20 +143,21 @@ fn Controller(
if let Some(Ok(v)) = upsert_todays_attempt.value().get() { if let Some(Ok(v)) = upsert_todays_attempt.value().get() {
let v = v.into_inner(); let v = v.into_inner();
user_interactions.update(|map| { user_interactions.update(|map| {
map.insert(v.problem_uid, v); map.insert(v.problem.clone(), v);
}); });
} }
}); });
provide_context(Context { provide_context(Context {
wall, wall,
problem, problem: problem.into(),
cb_click_hold, cb_click_hold,
user_interaction, user_interaction,
latest_attempt, latest_attempt,
cb_upsert_todays_attempt, cb_upsert_todays_attempt,
cb_remove_hold_from_filter, cb_remove_hold_from_filter,
cb_next_problem: cb_set_random_problem, cb_next_problem: cb_set_random_problem,
cb_set_problem,
todays_attempt, todays_attempt,
filter_holds: filter_holds.into(), filter_holds: filter_holds.into(),
filtered_problems: filtered_problems.into(), filtered_problems: filtered_problems.into(),
@@ -184,7 +187,6 @@ fn View() -> impl IntoView {
<Separator /> <Separator />
<div class="flex flex-row justify-around"> <div class="flex flex-row justify-around">
<Transformations />
<NextProblemButton /> <NextProblemButton />
</div> </div>
</Section> </Section>
@@ -193,11 +195,12 @@ fn View() -> impl IntoView {
<Section title="Current problem"> <Section title="Current problem">
{move || ctx.problem.get().map(|problem| view! { <ProblemInfo problem /> })} {move || ctx.problem.get().map(|problem| view! { <ProblemInfo problem /> })}
<Separator /> <AttemptRadioGroup /> <Separator /> <History /> <Separator /> <Transformations /> <Separator /><AttemptRadioGroup />
<Separator /> <History />
</Section> </Section>
</div> </div>
<div class="flex-auto flex flex-row justify-end items-start px-2 pt-3"> <div class="flex flex-row flex-auto justify-end items-start px-2 pt-3">
<HoldsButton /> <HoldsButton />
</div> </div>
</div> </div>
@@ -211,21 +214,49 @@ fn Transformations() -> impl IntoView {
let ctx = use_context::<Context>().unwrap(); let ctx = use_context::<Context>().unwrap();
let on_left = Callback::new(move |()| { let left = Signal::derive(move || {
tracing::info!("left"); let mut problem = ctx.problem.get()?;
}); let new_pattern = problem.pattern.shift_left(1)?;
let on_mirror = Callback::new(move |()| { problem.pattern = new_pattern;
tracing::info!("mirror"); Some(problem)
});
let on_right = Callback::new(move |()| {
tracing::info!("right");
}); });
let right = Signal::derive(move || {
let mut problem = ctx.problem.get()?;
let wall_dimensions = ctx.wall.read().wall_dimensions;
let new_pattern = problem.pattern.shift_right(wall_dimensions, 1)?;
problem.pattern = new_pattern;
Some(problem)
});
let on_click_left = Callback::new(move |()| {
tracing::debug!("left");
if let Some(problem) = left.get() {
ctx.cb_set_problem.run(problem);
}
});
let on_click_mirror = Callback::new(move |()| {
tracing::debug!("mirror");
if let Some(mut problem) = ctx.problem.get() {
problem.pattern = problem.pattern.mirror();
ctx.cb_set_problem.run(problem);
}
});
let on_click_right = Callback::new(move |()| {
tracing::debug!("right");
if let Some(problem) = right.get() {
ctx.cb_set_problem.run(problem);
}
});
let left_disabled = Signal::derive(move || left.read().is_none());
let right_disabled = Signal::derive(move || right.read().is_none());
view! { view! {
<div class="flex flex-row justify-center gap-2"> <div class="flex flex-row gap-2 justify-center">
<Button icon=Icon::ChevronLeft disabled=true on_click=on_left /> <Button icon=Icon::ChevronLeft disabled=left_disabled on_click=on_click_left />
<Button icon=Icon::CodeBracketSquare on_click=on_mirror /> <Button icon=Icon::CodeBracketSquare on_click=on_click_mirror />
<Button icon=Icon::ChevronRight on_click=on_right /> <Button icon=Icon::ChevronRight disabled=right_disabled on_click=on_click_right />
</div> </div>
} }
} }
@@ -254,7 +285,7 @@ fn NextProblemButton() -> impl IntoView {
let ctx = use_context::<Context>().unwrap(); let ctx = use_context::<Context>().unwrap();
let on_click = Callback::new(move |_| ctx.cb_next_problem.run(())); let on_click = Callback::new(move |_| ctx.cb_next_problem.run(()));
view! { <Button icon=Icon::ArrowPath text="Next problem" on_click color=Gradient::PurpleBlue /> } view! { <Button icon=Icon::ArrowPath text="Randomize" on_click color=Gradient::PurpleBlue /> }
} }
#[component] #[component]
@@ -285,10 +316,10 @@ fn Filter() -> impl IntoView {
} }
let problems_counter = { let problems_counter = {
let name = view! { <p class="font-light mr-4 text-right text-orange-300">{"Problems:"}</p> }; let name = view! { <p class="mr-4 font-light text-right text-orange-300">{"Problems:"}</p> };
let value = view! { <p class="text-white">{ctx.filtered_problems.read().len()}</p> }; let value = view! { <p class="text-white">{ctx.filtered_problems.read().len()}</p> };
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-x-0.5 gap-y-1 grid-cols-[auto_1fr]">
{name} {value} {name} {value}
</div> </div>
} }
@@ -305,8 +336,8 @@ fn Filter() -> impl IntoView {
let mut interaction_counters = InteractionCounters::default(); let mut interaction_counters = InteractionCounters::default();
let interaction_counters_view = { let interaction_counters_view = {
let user_ints = ctx.user_interactions.read(); let user_ints = ctx.user_interactions.read();
for problem_uid in ctx.filtered_problems.read().keys() { for problem in ctx.filtered_problems.read().iter() {
if let Some(user_int) = user_ints.get(problem_uid) { if let Some(user_int) = user_ints.get(problem) {
match user_int.best_attempt().map(|da| da.attempt) { match user_int.best_attempt().map(|da| da.attempt) {
Some(models::Attempt::Flash) => interaction_counters.flash += 1, Some(models::Attempt::Flash) => interaction_counters.flash += 1,
Some(models::Attempt::Send) => interaction_counters.send += 1, Some(models::Attempt::Send) => interaction_counters.send += 1,
@@ -370,7 +401,7 @@ fn AttemptRadioGroup() -> impl IntoView {
let ctx = use_context::<Context>().unwrap(); let ctx = use_context::<Context>().unwrap();
let problem_uid = Signal::derive(move || ctx.problem.read().as_ref().map(|p| p.uid)); let problem = ctx.problem;
let mut attempt_radio_buttons = vec![]; let mut attempt_radio_buttons = vec![];
for variant in [models::Attempt::Flash, models::Attempt::Send, models::Attempt::Attempt] { for variant in [models::Attempt::Flash, models::Attempt::Send, models::Attempt::Attempt] {
@@ -379,10 +410,10 @@ fn AttemptRadioGroup() -> impl IntoView {
let onclick = move |_| { let onclick = move |_| {
let attempt = if ui_toggle.get() { None } else { Some(variant) }; let attempt = if ui_toggle.get() { None } else { Some(variant) };
if let Some(problem_uid) = problem_uid.get() { if let Some(problem) = problem.get() {
ctx.cb_upsert_todays_attempt.run(server_functions::UpsertTodaysAttempt { ctx.cb_upsert_todays_attempt.run(server_functions::UpsertTodaysAttempt {
wall_uid: ctx.wall.read().uid, wall_uid: ctx.wall.read().uid,
problem_uid, problem,
attempt, attempt,
}); });
} }
@@ -390,7 +421,7 @@ fn AttemptRadioGroup() -> impl IntoView {
attempt_radio_buttons.push(view! { <AttemptRadioButton on:click=onclick variant selected=ui_toggle /> }); attempt_radio_buttons.push(view! { <AttemptRadioButton on:click=onclick variant selected=ui_toggle /> });
} }
view! { <div class="gap-2 flex flex-col justify-evenly 2xl:flex-row">{attempt_radio_buttons}</div> } view! { <div class="flex flex-col gap-2 justify-evenly 2xl:flex-row">{attempt_radio_buttons}</div> }
} }
#[component] #[component]
@@ -469,14 +500,14 @@ fn Wall() -> impl IntoView {
} }
let style = { let style = {
let grid_rows = crate::css::grid_rows_n(wall.rows); let grid_rows = crate::css::grid_rows_n(wall.wall_dimensions.rows);
let grid_cols = crate::css::grid_cols_n(wall.cols); let grid_cols = crate::css::grid_cols_n(wall.wall_dimensions.cols);
let max_width = format!("{}vh", wall.cols as f64 / wall.rows as f64 * 100.); let max_width = format!("{}vh", wall.wall_dimensions.cols as f64 / wall.wall_dimensions.rows as f64 * 100.);
format!("max-height: 100vh; max-width: {max_width}; {}", [grid_rows, grid_cols].join(" ")) format!("max-height: 100vh; max-width: {max_width}; {}", [grid_rows, grid_cols].join(" "))
}; };
view! { view! {
<div style=style class="p-1 grid gap-1"> <div style=style class="grid gap-1 p-1">
{cells} {cells}
</div> </div>
} }
@@ -524,13 +555,13 @@ fn Hold(
#[component] #[component]
fn Separator() -> impl IntoView { fn Separator() -> impl IntoView {
view! { <div class="m-2 sm:m-3 md:m-4 h-4" /> } view! { <div class="m-2 h-4 sm:m-3 md:m-4" /> }
} }
#[component] #[component]
fn Section(children: Children, #[prop(into)] title: MaybeProp<String>) -> impl IntoView { fn Section(children: Children, #[prop(into)] title: MaybeProp<String>) -> impl IntoView {
view! { view! {
<div class="bg-neutral-900 px-5 pt-3 pb-8 rounded-lg"> <div class="px-5 pt-3 pb-8 rounded-lg bg-neutral-900">
<div class="py-3 text-lg text-center text-orange-400 border-t border-orange-400"> <div class="py-3 text-lg text-center text-orange-400 border-t border-orange-400">
{move || title.get()} {move || title.get()}
</div> </div>
@@ -553,49 +584,39 @@ mod signals {
Signal::derive(move || latest_attempt.read().as_ref().and_then(models::UserInteraction::todays_attempt)) Signal::derive(move || latest_attempt.read().as_ref().and_then(models::UserInteraction::todays_attempt))
} }
#[expect(dead_code)]
pub fn wall_uid(wall: Signal<models::Wall>) -> Signal<models::WallUid> { pub fn wall_uid(wall: Signal<models::Wall>) -> Signal<models::WallUid> {
Signal::derive(move || wall.read().uid) Signal::derive(move || wall.read().uid)
} }
pub fn user_interaction( pub fn user_interaction(
user_interactions: Signal<BTreeMap<models::ProblemUid, models::UserInteraction>>, user_interactions: Signal<BTreeMap<models::Problem, models::UserInteraction>>,
problem_uid: Signal<Option<models::ProblemUid>>, problem: Signal<Option<models::Problem>>,
) -> Signal<Option<models::UserInteraction>> { ) -> Signal<Option<models::UserInteraction>> {
Signal::derive(move || { Signal::derive(move || {
let problem_uid = problem_uid.get()?; let problem = problem.get()?;
let user_interactions = user_interactions.read(); let user_interactions = user_interactions.read();
user_interactions.get(&problem_uid).cloned() user_interactions.get(&problem).cloned()
})
}
pub(crate) fn problem(
problems: Signal<BTreeMap<models::ProblemUid, models::Problem>>,
problem_uid: Signal<Option<models::ProblemUid>>,
) -> Signal<Option<models::Problem>> {
Signal::derive(move || {
let problem_uid = problem_uid.get()?;
let problems = problems.read();
problems.get(&problem_uid).cloned()
}) })
} }
pub(crate) fn filtered_problems( pub(crate) fn filtered_problems(
problems: Signal<BTreeMap<models::ProblemUid, models::Problem>>, wall: Signal<models::Wall>,
filter_holds: Signal<BTreeSet<models::HoldPosition>>, filter_holds: Signal<BTreeSet<models::HoldPosition>>,
) -> Memo<BTreeMap<models::ProblemUid, models::Problem>> { ) -> Memo<BTreeSet<models::Problem>> {
Memo::new(move |_prev_val| { Memo::new(move |_prev_val| {
let filter_holds = filter_holds.read(); let filter_holds = filter_holds.read();
problems.with(|problems| { wall.with(|wall| {
problems wall.problems
.iter() .iter()
.filter(|(_, problem)| filter_holds.iter().all(|hold_pos| problem.holds.contains_key(hold_pos))) .filter(|problem| filter_holds.iter().all(|hold_pos| problem.pattern.pattern.contains_key(hold_pos)))
.map(|(problem_uid, problem)| (*problem_uid, problem.clone())) .map(|problem| problem.clone())
.collect::<BTreeMap<models::ProblemUid, models::Problem>>() .collect::<BTreeSet<models::Problem>>()
}) })
}) })
} }
pub(crate) fn hold_role(problem: Signal<Option<models::Problem>>, hold_position: models::HoldPosition) -> Signal<Option<models::HoldRole>> { pub(crate) fn hold_role(problem: Signal<Option<models::Problem>>, hold_position: models::HoldPosition) -> Signal<Option<models::HoldRole>> {
Signal::derive(move || problem.get().and_then(|p| p.holds.get(&hold_position).copied())) Signal::derive(move || problem.get().and_then(|p| p.pattern.pattern.get(&hold_position).copied()))
} }
} }

View File

@@ -17,47 +17,15 @@ pub fn wall_by_uid(wall_uid: Signal<models::WallUid>) -> RonResource<models::Wal
) )
} }
/// 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(
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,
)
}
/// Returns all problems for a wall
pub fn problems_for_wall(wall_uid: Signal<models::WallUid>) -> RonResource<BTreeMap<models::ProblemUid, models::Problem>> {
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,
)
}
/// Returns user interaction for a single problem /// Returns user interaction for a single problem
pub fn user_interaction( pub fn user_interaction(wall_uid: Signal<models::WallUid>, problem: Signal<Option<models::Problem>>) -> RonResource<Option<models::UserInteraction>> {
wall_uid: Signal<models::WallUid>,
problem_uid: Signal<Option<models::ProblemUid>>,
) -> RonResource<Option<models::UserInteraction>> {
Resource::new_with_options( Resource::new_with_options(
move || (wall_uid.get(), problem_uid.get()), move || (wall_uid.get(), problem.get()),
move |(wall_uid, problem_uid)| async move { move |(wall_uid, problem)| async move {
let Some(problem_uid) = problem_uid else { let Some(problem) = problem else {
return Ok(None); return Ok(None);
}; };
crate::server_functions::get_user_interaction(wall_uid, problem_uid) crate::server_functions::get_user_interaction(wall_uid, problem)
.await .await
.map(RonEncoded::into_inner) .map(RonEncoded::into_inner)
}, },
@@ -66,10 +34,14 @@ pub fn user_interaction(
} }
/// Returns all user interactions for a wall /// Returns all user interactions for a wall
pub fn user_interactions(wall_uid: Signal<models::WallUid>) -> RonResource<BTreeMap<models::ProblemUid, models::UserInteraction>> { pub fn user_interactions_for_wall(wall_uid: Signal<models::WallUid>) -> RonResource<BTreeMap<models::Problem, models::UserInteraction>> {
Resource::new_with_options( Resource::new_with_options(
move || wall_uid.get(), move || wall_uid.get(),
move |wall_uid| async move { crate::server_functions::get_user_interactions(wall_uid).await.map(RonEncoded::into_inner) }, move |wall_uid| async move {
crate::server_functions::get_user_interactions_for_wall(wall_uid)
.await
.map(RonEncoded::into_inner)
},
false, false,
) )
} }

View File

@@ -121,13 +121,6 @@ pub async fn init_at_current_version(db: &Database) -> Result<(), DatabaseOperat
assert!(table.is_empty()?); assert!(table.is_empty()?);
} }
// Problems table
{
// Opening the table creates the table
let table = txn.open_table(current::TABLE_PROBLEMS)?;
assert!(table.is_empty()?);
}
// User table // User table
{ {
// Opening the table creates the table // Opening the table creates the table
@@ -147,11 +140,23 @@ use crate::models;
pub mod current { pub mod current {
use super::v2; use super::v2;
use super::v3; use super::v3;
pub use v2::TABLE_PROBLEMS; use super::v4;
pub use v2::TABLE_ROOT; pub use v2::TABLE_ROOT;
pub use v2::TABLE_WALLS;
pub use v3::TABLE_USER;
pub use v3::VERSION; pub use v3::VERSION;
pub use v4::TABLE_USER;
pub use v4::TABLE_WALLS;
}
pub mod v4 {
use crate::models;
use crate::server::db::bincode::Bincode;
use redb::TableDefinition;
pub const VERSION: u64 = 4;
pub const TABLE_WALLS: TableDefinition<Bincode<models::v2::WallUid>, Bincode<models::v4::Wall>> = TableDefinition::new("walls");
pub const TABLE_USER: TableDefinition<Bincode<(models::v2::WallUid, models::v4::Problem)>, Bincode<models::v4::UserInteraction>> =
TableDefinition::new("user");
} }
pub mod v3 { pub mod v3 {

View File

@@ -3,12 +3,116 @@ use super::db::DatabaseOperationError;
use super::db::{self}; use super::db::{self};
use crate::models; use crate::models;
use redb::ReadableTable; use redb::ReadableTable;
use std::collections::BTreeMap;
#[tracing::instrument(skip_all, err)] #[tracing::instrument(skip_all, err)]
pub async fn run_migrations(db: &Database) -> Result<(), Box<dyn std::error::Error>> { pub async fn run_migrations(db: &Database) -> Result<(), Box<dyn std::error::Error>> {
if is_at_version(db, 2).await? { if is_at_version(db, 2).await? {
migrate_to_v3(db).await?; migrate_to_v3(db).await?;
} }
if is_at_version(db, 3).await? {
migrate_to_v4(db).await?;
}
Ok(())
}
/// migrate: walls table
/// migrate: user table
/// remove: problems table
#[tracing::instrument(skip_all, err)]
pub async fn migrate_to_v4(db: &Database) -> Result<(), Box<dyn std::error::Error>> {
tracing::warn!("MIGRATING TO VERSION 4");
db.write(|txn| {
let walls_dump = txn
.open_table(db::v2::TABLE_WALLS)?
.iter()?
.map(|el| {
let (k, v) = el.unwrap();
(k.value(), v.value())
})
.collect::<BTreeMap<_, _>>();
let problems_dump = txn
.open_table(db::v2::TABLE_PROBLEMS)?
.iter()?
.map(|el| {
let (k, v) = el.unwrap();
(k.value(), v.value())
})
.collect::<BTreeMap<_, _>>();
let user_dump = txn
.open_table(db::v3::TABLE_USER)?
.iter()?
.map(|el| {
let (k, v) = el.unwrap();
(k.value(), v.value())
})
.collect::<BTreeMap<_, _>>();
txn.delete_table(db::v2::TABLE_WALLS)?;
txn.delete_table(db::v2::TABLE_PROBLEMS)?;
txn.delete_table(db::v3::TABLE_USER)?;
let mut new_walls_table = txn.open_table(db::current::TABLE_WALLS)?;
let mut new_user_table = txn.open_table(db::current::TABLE_USER)?;
for (wall_uid, wall) in walls_dump.into_iter() {
let models::v2::Wall {
uid: _,
rows,
cols,
holds,
problems,
} = wall;
let problems = problems
.into_iter()
.map(|problem_uid| {
let old_prob = &problems_dump[&(wall_uid, problem_uid)];
let method = old_prob.method;
let problem = models::Problem {
pattern: models::Pattern {
pattern: old_prob.holds.clone(),
},
method,
};
problem
})
.collect();
let wall = models::Wall {
uid: wall_uid,
wall_dimensions: models::WallDimensions { rows, cols },
holds,
problems,
};
new_walls_table.insert(wall_uid, wall)?;
}
for ((wall_uid, problem_uid), user_interaction) in user_dump.into_iter() {
let old_prob = &problems_dump[&(wall_uid, problem_uid)];
let problem = models::Problem {
pattern: models::Pattern {
pattern: old_prob.holds.clone(),
},
method: old_prob.method,
};
let key = (wall_uid, problem.clone());
let value = models::UserInteraction {
wall_uid,
problem,
attempted_on: user_interaction.attempted_on,
is_favorite: user_interaction.is_favorite,
};
new_user_table.insert(key, value)?;
}
Ok(())
})
.await?;
db.set_version(db::Version { version: db::v4::VERSION }).await?;
Ok(()) Ok(())
} }

View File

@@ -20,11 +20,9 @@ pub(crate) async fn import_mini_moonboard_problems(config: &Config, db: Database
tracing::info!("Parsing mini moonboard problems from {file_path}"); tracing::info!("Parsing mini moonboard problems from {file_path}");
let set_by = "mini-mb-2020-parser";
let mini_moonboard = mini_moonboard::parse(file_path.as_std_path()).await?; let mini_moonboard = mini_moonboard::parse(file_path.as_std_path()).await?;
for mini_mb_problem in mini_moonboard.problems { for mini_mb_problem in mini_moonboard.problems {
let mut holds = BTreeMap::<HoldPosition, HoldRole>::new(); let mut pattern = BTreeMap::<HoldPosition, HoldRole>::new();
for mv in mini_mb_problem.moves { for mv in mini_mb_problem.moves {
let row = mv.description.row(); let row = mv.description.row();
let col = mv.description.column(); let col = mv.description.column();
@@ -36,43 +34,29 @@ pub(crate) async fn import_mini_moonboard_problems(config: &Config, db: Database
(false, true) => HoldRole::End, (false, true) => HoldRole::End,
(false, false) => HoldRole::Normal, (false, false) => HoldRole::Normal,
}; };
holds.insert(hold_position, role); pattern.insert(hold_position, role);
} }
let name = mini_mb_problem.name;
let method = match mini_mb_problem.method { let method = match mini_mb_problem.method {
mini_moonboard::Method::FeetFollowHands => models::Method::FeetFollowHands, mini_moonboard::Method::FeetFollowHands => models::Method::FeetFollowHands,
mini_moonboard::Method::Footless => models::Method::Footless, mini_moonboard::Method::Footless => models::Method::Footless,
mini_moonboard::Method::FootlessPlusKickboard => models::Method::FootlessPlusKickboard, mini_moonboard::Method::FootlessPlusKickboard => models::Method::FootlessPlusKickboard,
}; };
let problem_id = models::ProblemUid::create();
let problem = models::Problem { let problem = models::Problem {
uid: problem_id, pattern: models::Pattern { pattern },
name,
set_by: set_by.to_owned(),
holds,
method, method,
date_added: chrono::Utc::now(),
}; };
problems.push(problem); problems.push(problem);
} }
db.write(|txn| { db.write(|txn| {
let mut walls_table = txn.open_table(db::current::TABLE_WALLS)?; let mut walls_table = txn.open_table(db::current::TABLE_WALLS)?;
let mut problems_table = txn.open_table(db::current::TABLE_PROBLEMS)?;
let mut wall = walls_table.get(wall_uid)?.unwrap().value(); let mut wall = walls_table.get(wall_uid)?.unwrap().value();
wall.problems.extend(problems.iter().map(|p| p.uid)); wall.problems.extend(problems);
walls_table.insert(wall_uid, wall)?; walls_table.insert(wall_uid, wall)?;
for problem in problems {
let key = (wall_uid, problem.uid);
problems_table.insert(key, problem)?;
}
Ok(()) Ok(())
}) })
.await?; .await?;

View File

@@ -9,7 +9,6 @@ use leptos::prelude::*;
use leptos::server; use leptos::server;
use server_fn::ServerFnError; use server_fn::ServerFnError;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use type_toppings::IteratorExt;
#[server( #[server(
input = Ron, input = Ron,
@@ -71,68 +70,6 @@ pub(crate) async fn get_wall_by_uid(wall_uid: models::WallUid) -> Result<RonEnco
Ok(RonEncoded::new(wall)) Ok(RonEncoded::new(wall))
} }
#[server(
input = Ron,
output = Ron,
custom = RonEncoded
)]
#[tracing::instrument(err(Debug))]
pub(crate) async fn get_problems_for_wall(
wall_uid: models::WallUid,
) -> Result<RonEncoded<BTreeMap<models::ProblemUid, models::Problem>>, 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, 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) -> Result<BTreeMap<models::ProblemUid, models::Problem>, Error> {
let db = expect_context::<Database>();
let problems = db
.read(|txn| {
let walls_table = txn.open_table(crate::server::db::current::TABLE_WALLS)?;
tracing::debug!("getting wall");
let wall = walls_table
.get(wall_uid)?
.ok_or(Error::WallNotFound(wall_uid))
.map_err(DatabaseOperationError::custom)?
.value();
tracing::debug!("got wall");
drop(walls_table);
tracing::debug!("open problems table");
let problems_table = txn.open_table(crate::server::db::current::TABLE_PROBLEMS)?;
tracing::debug!("opened problems table");
let problems = wall
.problems
.iter()
.map(|problem_uid| problems_table.get(&(wall_uid, *problem_uid)))
.filter_map(|res| res.transpose())
.map_res(|guard| guard.value())
.map_res(|problem| (problem.uid, problem))
.collect::<Result<BTreeMap<models::ProblemUid, models::Problem>, _>>()?;
Ok(problems)
})
.await?;
Ok(problems)
}
let problems = inner(wall_uid).await.map_err(error_reporter::Report::new).map_err(ServerFnError::new)?;
Ok(RonEncoded::new(problems))
}
/// Returns user interaction for a single wall problem /// Returns user interaction for a single wall problem
#[server( #[server(
input = Ron, input = Ron,
@@ -142,7 +79,7 @@ pub(crate) async fn get_problems_for_wall(
#[tracing::instrument(err(Debug))] #[tracing::instrument(err(Debug))]
pub(crate) async fn get_user_interaction( pub(crate) async fn get_user_interaction(
wall_uid: models::WallUid, wall_uid: models::WallUid,
problem_uid: models::ProblemUid, problem: models::Problem,
) -> Result<RonEncoded<Option<models::UserInteraction>>, ServerFnError> { ) -> Result<RonEncoded<Option<models::UserInteraction>>, ServerFnError> {
use crate::server::db::Database; use crate::server::db::Database;
use crate::server::db::DatabaseOperationError; use crate::server::db::DatabaseOperationError;
@@ -157,13 +94,13 @@ pub(crate) async fn get_user_interaction(
DatabaseOperation(DatabaseOperationError), DatabaseOperation(DatabaseOperationError),
} }
async fn inner(wall_uid: models::WallUid, problem_uid: models::ProblemUid) -> Result<Option<UserInteraction>, Error> { async fn inner(wall_uid: models::WallUid, problem: models::Problem) -> Result<Option<UserInteraction>, Error> {
let db = expect_context::<Database>(); let db = expect_context::<Database>();
let user_interaction = db let user_interaction = db
.read(|txn| { .read(|txn| {
let user_table = txn.open_table(crate::server::db::current::TABLE_USER)?; 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()); let user_interaction = user_table.get(&(wall_uid, problem))?.map(|guard| guard.value());
Ok(user_interaction) Ok(user_interaction)
}) })
.await?; .await?;
@@ -171,7 +108,7 @@ pub(crate) async fn get_user_interaction(
Ok(user_interaction) Ok(user_interaction)
} }
let user_interaction = inner(wall_uid, problem_uid) let user_interaction = inner(wall_uid, problem)
.await .await
.map_err(error_reporter::Report::new) .map_err(error_reporter::Report::new)
.map_err(ServerFnError::new)?; .map_err(ServerFnError::new)?;
@@ -185,12 +122,13 @@ pub(crate) async fn get_user_interaction(
custom = RonEncoded custom = RonEncoded
)] )]
#[tracing::instrument(err(Debug))] #[tracing::instrument(err(Debug))]
pub(crate) async fn get_user_interactions( pub(crate) async fn get_user_interactions_for_wall(
wall_uid: models::WallUid, wall_uid: models::WallUid,
) -> Result<RonEncoded<BTreeMap<models::ProblemUid, models::UserInteraction>>, ServerFnError> { ) -> Result<RonEncoded<BTreeMap<models::Problem, models::UserInteraction>>, ServerFnError> {
use crate::server::db::Database; use crate::server::db::Database;
use crate::server::db::DatabaseOperationError; use crate::server::db::DatabaseOperationError;
use leptos::prelude::expect_context; use leptos::prelude::expect_context;
use redb::ReadableTable;
tracing::trace!("Enter"); tracing::trace!("Enter");
#[derive(Debug, derive_more::Error, derive_more::Display, derive_more::From)] #[derive(Debug, derive_more::Error, derive_more::Display, derive_more::From)]
@@ -198,18 +136,28 @@ pub(crate) async fn get_user_interactions(
DatabaseOperation(DatabaseOperationError), DatabaseOperation(DatabaseOperationError),
} }
async fn inner(wall_uid: models::WallUid) -> Result<BTreeMap<models::ProblemUid, models::UserInteraction>, Error> { async fn inner(wall_uid: models::WallUid) -> Result<BTreeMap<models::Problem, models::UserInteraction>, Error> {
let db = expect_context::<Database>(); let db = expect_context::<Database>();
let user_interactions = db let user_interactions = db
.read(|txn| { .read(|txn| {
let user_table = txn.open_table(crate::server::db::current::TABLE_USER)?; let user_table = txn.open_table(crate::server::db::current::TABLE_USER)?;
let range = user_table.range((wall_uid, models::ProblemUid::min())..=(wall_uid, models::ProblemUid::max()))?; let user_interactions = user_table
let user_interactions = range .iter()?
.filter(|guard| {
guard
.as_ref()
.map(|(key, _val)| {
let (wall_uid, _problem) = key.value();
wall_uid
})
.map(|wall_uid_| wall_uid_ == wall_uid)
.unwrap_or(false)
})
.map(|guard| { .map(|guard| {
guard.map(|(_key, val)| { guard.map(|(_key, val)| {
let val = val.value(); let val = val.value();
(val.problem_uid, val) (val.problem.clone(), val)
}) })
}) })
.collect::<Result<_, _>>()?; .collect::<Result<_, _>>()?;
@@ -224,43 +172,6 @@ pub(crate) async fn get_user_interactions(
Ok(RonEncoded::new(user_interaction)) 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<RonEncoded<models::Problem>, 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 {
#[display("Problem not found: {_0:?}")]
NotFound(#[error(not(source))] models::ProblemUid),
}
let db = expect_context::<Database>();
let problem = db
.read(|txn| {
let table = txn.open_table(crate::server::db::current::TABLE_PROBLEMS)?;
let problem = table
.get((wall_uid, problem_uid))?
.ok_or(Error::NotFound(problem_uid))
.map_err(DatabaseOperationError::custom)?
.value();
Ok(problem)
})
.await?;
Ok(RonEncoded::new(problem))
}
/// Inserts or updates today's attempt. /// Inserts or updates today's attempt.
#[server( #[server(
input = Ron, input = Ron,
@@ -270,7 +181,7 @@ pub(crate) async fn get_problem_by_uid(
#[tracing::instrument(err(Debug))] #[tracing::instrument(err(Debug))]
pub(crate) async fn upsert_todays_attempt( pub(crate) async fn upsert_todays_attempt(
wall_uid: models::WallUid, wall_uid: models::WallUid,
problem_uid: models::ProblemUid, problem: models::Problem,
attempt: Option<models::Attempt>, attempt: Option<models::Attempt>,
) -> Result<RonEncoded<models::UserInteraction>, ServerFnError> { ) -> Result<RonEncoded<models::UserInteraction>, ServerFnError> {
use crate::server::db::Database; use crate::server::db::Database;
@@ -287,20 +198,20 @@ pub(crate) async fn upsert_todays_attempt(
DatabaseOperation(DatabaseOperationError), DatabaseOperation(DatabaseOperationError),
} }
async fn inner(wall_uid: models::WallUid, problem_uid: models::ProblemUid, attempt: Option<models::Attempt>) -> Result<UserInteraction, Error> { async fn inner(wall_uid: models::WallUid, problem: models::Problem, attempt: Option<models::Attempt>) -> Result<UserInteraction, Error> {
let db = expect_context::<Database>(); let db = expect_context::<Database>();
let user_interaction = db let user_interaction = db
.write(|txn| { .write(|txn| {
let mut user_table = txn.open_table(crate::server::db::current::TABLE_USER)?; let mut user_table = txn.open_table(crate::server::db::current::TABLE_USER)?;
let key = (wall_uid, problem_uid); let key = (wall_uid, problem.clone());
// Pop or default // Pop or default
let mut user_interaction = user_table let mut user_interaction = user_table
.remove(key)? .remove(&key)?
.map(|guard| guard.value()) .map(|guard| guard.value())
.unwrap_or_else(|| models::UserInteraction::new(wall_uid, problem_uid)); .unwrap_or_else(|| models::UserInteraction::new(wall_uid, problem));
// If the last entry is from today, remove it // If the last entry is from today, remove it
if let Some(entry) = user_interaction.attempted_on.last_entry() { if let Some(entry) = user_interaction.attempted_on.last_entry() {
@@ -327,7 +238,7 @@ pub(crate) async fn upsert_todays_attempt(
Ok(user_interaction) Ok(user_interaction)
} }
inner(wall_uid, problem_uid, attempt) inner(wall_uid, problem, attempt)
.await .await
.map_err(error_reporter::Report::new) .map_err(error_reporter::Report::new)
.map_err(ServerFnError::new) .map_err(ServerFnError::new)

16
notes.txt Normal file
View File

@@ -0,0 +1,16 @@
# Random selection: Sample from filtered
[patterns with variations that satisfy filter]
[variations]
# Random selection no filter: Sample equally weighted patterns
[patterns]
[random variation within pattern]
Normalize: shift left, and use minimum of mirrored pattern pair
# Filter stats:
patterns: X
pattern variations: Y