Compare commits

...

10 Commits

18 changed files with 881 additions and 464 deletions

844
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,7 @@ publish = false
crate-type = ["cdylib", "rlib"] crate-type = ["cdylib", "rlib"]
[dependencies] [dependencies]
axum = { version = "0.7", optional = true } axum = { version = "0.8", optional = true }
camino = { version = "1.1", optional = true } camino = { version = "1.1", optional = true }
chrono = { version = "0.4.39", features = ["now", "serde"] } chrono = { version = "0.4.39", features = ["now", "serde"] }
clap = { version = "4.5.7", features = ["derive"] } clap = { version = "4.5.7", features = ["derive"] }
@@ -23,18 +23,17 @@ derive_more = { version = "2", features = [
] } ] }
http = "1" http = "1"
image = { version = "0.25", optional = true } image = { version = "0.25", optional = true }
leptos = { version = "0.7.7", features = ["tracing"] } leptos = { version = "0.8", features = ["tracing"] }
leptos_axum = { version = "0.7", optional = true } leptos_axum = { version = "0.8", optional = true }
leptos_meta = { version = "0.7" } leptos_meta = { version = "0.8" }
leptos_router = { version = "0.7.0" } leptos_router = { version = "0.8" }
moonboard-parser = { workspace = true, optional = true } moonboard-parser = { workspace = true, optional = true }
rand = { version = "0.9", default-features = false, features = ["thread_rng"] } rand = { version = "0.9", default-features = false, features = ["thread_rng"] }
ron = { version = "0.8" } ron = { version = "0.8" }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
server_fn = { version = "0.7.4", features = ["cbor"] } server_fn = { version = "0.8", features = ["cbor"] }
smart-default = "0.7.1" smart-default = "0.7.1"
tokio = { version = "1", features = ["rt-multi-thread"], optional = true } tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
tower = { version = "0.4", optional = true }
tower-http = { version = "0.5", features = ["fs"], optional = true } tower-http = { version = "0.5", features = ["fs"], optional = true }
tracing = { version = "0.1" } tracing = { version = "0.1" }
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
@@ -46,7 +45,6 @@ xdg = { version = "2.5", optional = true }
uuid = { version = "1.12", features = ["serde", "v4"] } uuid = { version = "1.12", features = ["serde", "v4"] }
redb = { version = "2.4", optional = true } redb = { version = "2.4", optional = true }
bincode = { version = "1.3", optional = true } bincode = { version = "1.3", optional = true }
serde_json = { version = "1" }
codee = { version = "0.3" } codee = { version = "0.3" }
error_reporter = { version = "1" } error_reporter = { version = "1" }
getrandom = { version = "0.3.1" } getrandom = { version = "0.3.1" }
@@ -54,9 +52,6 @@ getrandom = { version = "0.3.1" }
[dev-dependencies] [dev-dependencies]
test-try = "0.1" test-try = "0.1"
[dev-dependencies.serde_json]
version = "1"
[features] [features]
hydrate = ["leptos/hydrate", "getrandom/wasm_js", "uuid/js"] hydrate = ["leptos/hydrate", "getrandom/wasm_js", "uuid/js"]
ssr = [ ssr = [
@@ -65,7 +60,6 @@ ssr = [
"dep:image", "dep:image",
"dep:bincode", "dep:bincode",
"dep:tokio", "dep:tokio",
"dep:tower",
"dep:tower-http", "dep:tower-http",
"dep:leptos_axum", "dep:leptos_axum",
"dep:confik", "dep:confik",
@@ -77,6 +71,11 @@ ssr = [
"leptos_router/ssr", "leptos_router/ssr",
] ]
[package.metadata.cargo-shear]
# getrandom is depended on in order to turn on its wasm feature indirectly
ignored = ["getrandom"]
[package.metadata.leptos] [package.metadata.leptos]
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name # The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
output-name = "ascend" output-name = "ascend"

View File

@@ -3,19 +3,22 @@ pub mod ron {
use codee::Decoder; use codee::Decoder;
use codee::Encoder; use codee::Encoder;
use leptos::prelude::FromServerFnError;
use leptos::prelude::ServerFnErrorErr;
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use server_fn::ServerFnError; use server_fn::ContentType;
use server_fn::codec::Encoding; use server_fn::codec::Encoding;
use server_fn::codec::FromReq; use server_fn::codec::FromReq;
use server_fn::codec::FromRes; use server_fn::codec::FromRes;
use server_fn::codec::IntoReq; use server_fn::codec::IntoReq;
use server_fn::codec::IntoRes; use server_fn::codec::IntoRes;
use server_fn::error::IntoAppError;
use server_fn::request::ClientReq; use server_fn::request::ClientReq;
use server_fn::request::Req; use server_fn::request::Req;
use server_fn::response::ClientRes; use server_fn::response::ClientRes;
use server_fn::response::Res; use server_fn::response::TryRes;
pub struct Ron; pub struct Ron;
@@ -44,10 +47,13 @@ pub mod ron {
} }
impl Encoding for Ron { impl Encoding for Ron {
const CONTENT_TYPE: &'static str = "application/ron";
const METHOD: http::Method = http::Method::POST; const METHOD: http::Method = http::Method::POST;
} }
impl ContentType for Ron {
const CONTENT_TYPE: &'static str = "application/ron";
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct RonEncoded<T>(pub T); pub struct RonEncoded<T>(pub T);
@@ -74,9 +80,10 @@ pub mod ron {
where where
Request: ClientReq<Err>, Request: ClientReq<Err>,
T: Serialize, T: Serialize,
Err: FromServerFnError,
{ {
fn into_req(self, path: &str, accepts: &str) -> Result<Request, ServerFnError<Err>> { fn into_req(self, path: &str, accepts: &str) -> Result<Request, Err> {
let data = Ron::encode(&self.0).map_err(|e| ServerFnError::Serialization(e.to_string()))?; let data = Ron::encode(&self.0).map_err(|e| ServerFnErrorErr::Serialization(e.to_string()).into_app_error())?;
Request::try_new_post(path, Ron::CONTENT_TYPE, accepts, data) Request::try_new_post(path, Ron::CONTENT_TYPE, accepts, data)
} }
} }
@@ -86,21 +93,25 @@ pub mod ron {
where where
Request: Req<Err> + Send, Request: Req<Err> + Send,
T: DeserializeOwned, T: DeserializeOwned,
Err: FromServerFnError,
{ {
async fn from_req(req: Request) -> Result<Self, ServerFnError<Err>> { async fn from_req(req: Request) -> Result<Self, Err> {
let data = req.try_into_string().await?; let data = req.try_into_string().await?;
Ron::decode(&data).map(RonEncoded).map_err(|e| ServerFnError::Args(e.to_string())) Ron::decode(&data)
.map(RonEncoded)
.map_err(|e| ServerFnErrorErr::Args(e.to_string()).into_app_error())
} }
} }
// IntoRes // IntoRes
impl<CustErr, T, Response> IntoRes<Ron, Response, CustErr> for RonEncoded<T> impl<Err, T, Response> IntoRes<Ron, Response, Err> for RonEncoded<T>
where where
Response: Res<CustErr>, Response: TryRes<Err>,
T: Serialize + Send, T: Serialize + Send,
Err: FromServerFnError,
{ {
async fn into_res(self) -> Result<Response, ServerFnError<CustErr>> { async fn into_res(self) -> Result<Response, Err> {
let data = Ron::encode(&self.0).map_err(|e| ServerFnError::Serialization(e.to_string()))?; let data = Ron::encode(&self.0).map_err(|e| ServerFnErrorErr::Serialization(e.to_string()).into_app_error())?;
Response::try_from_string(Ron::CONTENT_TYPE, data) Response::try_from_string(Ron::CONTENT_TYPE, data)
} }
} }
@@ -110,12 +121,13 @@ pub mod ron {
where where
Response: ClientRes<Err> + Send, Response: ClientRes<Err> + Send,
T: DeserializeOwned, T: DeserializeOwned,
Err: FromServerFnError,
{ {
async fn from_res(res: Response) -> Result<Self, ServerFnError<Err>> { async fn from_res(res: Response) -> Result<Self, Err> {
let data = res.try_into_string().await?; let data = res.try_into_string().await?;
Ron::decode(&data) Ron::decode(&data)
.map(RonEncoded) .map(RonEncoded)
.map_err(|e| ServerFnError::Deserialization(e.to_string())) .map_err(|e| ServerFnErrorErr::Deserialization(e.to_string()).into_app_error())
} }
} }

View File

@@ -7,7 +7,7 @@ use leptos::prelude::*;
#[component] #[component]
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
pub fn Attempt(#[prop(into)] date: Signal<DateTime<Utc>>, #[prop(into)] attempt: Signal<Option<models::Attempt>>) -> impl IntoView { pub fn Attempt(#[prop(into)] date: Signal<DateTime<Utc>>, #[prop(into)] attempt: Signal<Option<models::Attempt>>) -> impl IntoView {
tracing::trace!("Enter"); crate::tracing::on_enter!();
let s = time_ago(date.get()); let s = time_ago(date.get());
@@ -19,9 +19,7 @@ pub fn Attempt(#[prop(into)] date: Signal<DateTime<Utc>>, #[prop(into)] attempt:
}; };
let text_color = match attempt.get() { let text_color = match attempt.get() {
Some(models::Attempt::Flash) => "text-cyan-500", Some(attempt) => attempt.gradient().class_text(),
Some(models::Attempt::Send) => "text-teal-500",
Some(models::Attempt::Attempt) => "text-pink-500",
None => "", None => "",
}; };

View File

@@ -19,34 +19,40 @@ pub fn Button(
) -> impl IntoView { ) -> impl IntoView {
let margin = "mx-2 my-1 sm:mx-5 sm:my-2.5"; let margin = "mx-2 my-1 sm:mx-5 sm:my-2.5";
let icon_view = icon.get().map(|i| { let icon_view = move || {
let icon_view = i.into_view(); icon.get().map(|i| {
let mut classes = "self-center".to_string(); let icon_view = i.into_view();
classes.push(' '); let mut classes = "self-center".to_string();
classes.push_str(margin); classes.push(' ');
classes.push(' '); classes.push_str(margin);
classes.push_str(color.class_text()); classes.push(' ');
classes.push_str(color.class_text());
view! { <div class=classes>{icon_view}</div> } view! { <div class=classes>{icon_view}</div> }
}); })
};
let separator = (icon.read().is_some() && text.read().is_some()).then(|| { let separator = move || {
let mut classes = "w-0.5 bg-linear-to-br min-w-0.5".to_string(); (icon.read().is_some() && text.read().is_some()).then(|| {
classes.push(' '); let mut classes = "w-0.5 bg-linear-to-br min-w-0.5".to_string();
classes.push_str(color.class_from()); classes.push(' ');
classes.push(' '); classes.push_str(color.class_from());
classes.push_str(color.class_to()); classes.push(' ');
classes.push_str(color.class_to());
view! { <div class=classes /> } view! { <div class=classes /> }
}); })
};
let text_view = text.get().map(|text| { let text_view = move || {
let mut classes = "self-center uppercase w-full text-sm sm:text-base md:text-lg font-thin".to_string(); text.get().map(|text| {
classes.push(' '); let mut classes = "self-center uppercase w-full text-sm sm:text-base md:text-lg font-thin".to_string();
classes.push_str(margin); classes.push(' ');
classes.push_str(margin);
view! { <div class=classes>{text}</div> } view! { <div class=classes>{text}</div> }
}); })
};
let class = move || { let class = move || {
let mut classes = vec![]; let mut classes = vec![];

View File

@@ -7,6 +7,7 @@ pub enum Icon {
WrenchSolid, WrenchSolid,
ForwardSolid, ForwardSolid,
Check, Check,
Heart,
HeartOutline, HeartOutline,
ArrowPath, ArrowPath,
PaperAirplaneSolid, PaperAirplaneSolid,
@@ -26,6 +27,7 @@ impl Icon {
Icon::WrenchSolid => view! { <WrenchSolid /> }.into_any(), Icon::WrenchSolid => view! { <WrenchSolid /> }.into_any(),
Icon::ForwardSolid => view! { <ForwardSolid /> }.into_any(), Icon::ForwardSolid => view! { <ForwardSolid /> }.into_any(),
Icon::Check => view! { <Check /> }.into_any(), Icon::Check => view! { <Check /> }.into_any(),
Icon::Heart => view! { <Heart /> }.into_any(),
Icon::HeartOutline => view! { <HeartOutline /> }.into_any(), Icon::HeartOutline => view! { <HeartOutline /> }.into_any(),
Icon::ArrowPath => view! { <ArrowPath /> }.into_any(), Icon::ArrowPath => view! { <ArrowPath /> }.into_any(),
Icon::PaperAirplaneSolid => view! { <PaperAirplaneSolid /> }.into_any(), Icon::PaperAirplaneSolid => view! { <PaperAirplaneSolid /> }.into_any(),
@@ -123,6 +125,20 @@ pub fn Check() -> impl IntoView {
} }
} }
#[component]
pub fn Heart() -> impl IntoView {
view! {
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-6"
>
<path d="m11.645 20.91-.007-.003-.022-.012a15.247 15.247 0 0 1-.383-.218 25.18 25.18 0 0 1-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0 1 12 5.052 5.5 5.5 0 0 1 16.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 0 1-4.244 3.17 15.247 15.247 0 0 1-.383.219l-.022.012-.007.004-.003.001a.752.752 0 0 1-.704 0l-.003-.001Z" />
</svg>
}
}
#[component] #[component]
pub fn HeartOutline() -> impl IntoView { pub fn HeartOutline() -> impl IntoView {
view! { view! {

View File

@@ -22,12 +22,14 @@ pub fn OutlinedBox(children: Children, color: Gradient, #[prop(optional)] highli
let mut c = "py-1.5 rounded-md".to_string(); let mut c = "py-1.5 rounded-md".to_string();
if highlight() { if highlight() {
let bg = match color { let bg = match color {
Gradient::PinkOrange => "bg-pink-900", Gradient::PinkOrange => "bg-rose-900",
Gradient::CyanBlue => "bg-cyan-800", Gradient::CyanBlue => "bg-cyan-800",
Gradient::TealLime => "bg-teal-700", Gradient::TealLime => "bg-emerald-700",
Gradient::PurplePink => "bg-purple-900", Gradient::PurplePink => "bg-fuchsia-950",
Gradient::PurpleBlue => "bg-purple-900", Gradient::PurpleBlue => "bg-purple-900",
Gradient::Orange => "bg-orange-900", Gradient::Orange => "bg-orange-900",
Gradient::Pink => "bg-pink-900",
Gradient::PinkRed => "bg-fuchsia-950",
}; };
c.push(' '); c.push(' ');

View File

@@ -7,9 +7,11 @@ pub enum Gradient {
PurplePink, PurplePink,
#[default] #[default]
Orange, Orange,
Pink,
PinkRed,
} }
impl Gradient { impl Gradient {
pub fn class_from(&self) -> &str { pub fn class_from(&self) -> &'static str {
match self { match self {
Gradient::PinkOrange => "from-pink-500", Gradient::PinkOrange => "from-pink-500",
Gradient::CyanBlue => "from-cyan-500", Gradient::CyanBlue => "from-cyan-500",
@@ -17,10 +19,12 @@ impl Gradient {
Gradient::PurplePink => "from-purple-500", Gradient::PurplePink => "from-purple-500",
Gradient::PurpleBlue => "from-purple-600", Gradient::PurpleBlue => "from-purple-600",
Gradient::Orange => "from-orange-400", Gradient::Orange => "from-orange-400",
Gradient::Pink => "from-pink-400",
Gradient::PinkRed => "from-pink-400",
} }
} }
pub fn class_to(&self) -> &str { pub fn class_to(&self) -> &'static str {
match self { match self {
Gradient::PinkOrange => "to-orange-400", Gradient::PinkOrange => "to-orange-400",
Gradient::CyanBlue => "to-blue-500", Gradient::CyanBlue => "to-blue-500",
@@ -28,17 +32,21 @@ impl Gradient {
Gradient::PurplePink => "to-pink-500", Gradient::PurplePink => "to-pink-500",
Gradient::PurpleBlue => "to-blue-500", Gradient::PurpleBlue => "to-blue-500",
Gradient::Orange => "to-orange-500", Gradient::Orange => "to-orange-500",
Gradient::Pink => "to-pink-500",
Gradient::PinkRed => "to-red-500",
} }
} }
pub fn class_text(&self) -> &str { pub fn class_text(&self) -> &'static str {
match self { match self {
Gradient::PinkOrange => "text-pink-500", Gradient::PinkOrange => "text-rose-400",
Gradient::CyanBlue => "text-cyan-500", Gradient::CyanBlue => "text-cyan-500",
Gradient::TealLime => "text-teal-300", Gradient::TealLime => "text-emerald-300",
Gradient::PurplePink => "text-purple-500", Gradient::PurplePink => "text-fuchsia-500",
Gradient::PurpleBlue => "text-purple-600", Gradient::PurpleBlue => "text-purple-600",
Gradient::Orange => "text-orange-400", Gradient::Orange => "text-orange-400",
Gradient::Pink => "text-pink-400",
Gradient::PinkRed => "text-pink-400",
} }
} }
} }

View File

@@ -42,13 +42,13 @@ pub mod v4 {
pub problems: BTreeSet<Problem>, pub problems: BTreeSet<Problem>,
} }
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Problem { pub struct Problem {
pub pattern: Pattern, pub pattern: Pattern,
pub method: v2::Method, pub method: v2::Method,
} }
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Pattern { pub struct Pattern {
pub pattern: BTreeMap<v1::HoldPosition, v1::HoldRole>, pub pattern: BTreeMap<v1::HoldPosition, v1::HoldRole>,
} }
@@ -160,7 +160,7 @@ 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, Copy)] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Display, Copy, Hash)]
pub enum Method { pub enum Method {
#[display("Feet follow hands")] #[display("Feet follow hands")]
FeetFollowHands, FeetFollowHands,

View File

@@ -1,13 +1,30 @@
use super::*; use super::*;
use crate::gradient::Gradient;
use chrono::DateTime; use chrono::DateTime;
use chrono::Utc; use chrono::Utc;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::collections::HashSet;
impl Problem {
/// Returns all possible transformations for the pattern. Not for the method.
#[must_use]
pub fn transformations(&self, wall_dimensions: WallDimensions) -> HashSet<Self> {
self.pattern
.transformations(wall_dimensions)
.into_iter()
.map(|pattern| Self {
pattern,
method: self.method,
})
.collect()
}
}
impl Pattern { impl Pattern {
#[must_use] #[must_use]
pub fn canonicalize(&self) -> Self { pub fn canonicalize(&self) -> Self {
let mut pattern = self.clone(); let mut pattern = self.clone();
let min_col = pattern.pattern.iter().map(|(hold_position, _)| hold_position.col).min().unwrap_or(0); let min_col = pattern.pattern.keys().map(|hold_position| hold_position.col).min().unwrap_or(0);
pattern.pattern = pattern pattern.pattern = pattern
.pattern .pattern
.iter() .iter()
@@ -25,7 +42,7 @@ impl Pattern {
#[must_use] #[must_use]
pub fn shift_left(&self, shift: u64) -> Option<Self> { pub fn shift_left(&self, shift: u64) -> Option<Self> {
// Out of bounds check // Out of bounds check
if let Some(min_col) = self.pattern.iter().map(|(hold_position, _)| hold_position.col).min() { if let Some(min_col) = self.pattern.keys().map(|hold_position| hold_position.col).min() {
if shift > min_col { if shift > min_col {
return None; return None;
} }
@@ -35,7 +52,7 @@ impl Pattern {
.pattern .pattern
.iter() .iter()
.map(|(hold_position, hold_role)| { .map(|(hold_position, hold_role)| {
let mut hold_position = hold_position.clone(); let mut hold_position = *hold_position;
hold_position.col -= shift; hold_position.col -= shift;
(hold_position, *hold_role) (hold_position, *hold_role)
}) })
@@ -47,7 +64,7 @@ impl Pattern {
#[must_use] #[must_use]
pub fn shift_right(&self, wall_dimensions: WallDimensions, shift: u64) -> Option<Self> { pub fn shift_right(&self, wall_dimensions: WallDimensions, shift: u64) -> Option<Self> {
// Out of bounds check // Out of bounds check
if let Some(max_col) = self.pattern.iter().map(|(hold_position, _)| hold_position.col).max() { if let Some(max_col) = self.pattern.keys().map(|hold_position| hold_position.col).max() {
if max_col + shift >= wall_dimensions.cols { if max_col + shift >= wall_dimensions.cols {
return None; return None;
} }
@@ -57,7 +74,7 @@ impl Pattern {
.pattern .pattern
.iter() .iter()
.map(|(hold_position, hold_role)| { .map(|(hold_position, hold_role)| {
let mut hold_position = hold_position.clone(); let mut hold_position = *hold_position;
hold_position.col += shift; hold_position.col += shift;
(hold_position, *hold_role) (hold_position, *hold_role)
}) })
@@ -70,8 +87,8 @@ impl Pattern {
pub fn mirror(&self) -> Self { pub fn mirror(&self) -> Self {
let mut pattern = self.clone(); let mut pattern = self.clone();
let min_col = pattern.pattern.iter().map(|(hold_position, _)| hold_position.col).min().unwrap_or(0); let min_col = pattern.pattern.keys().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); let max_col = pattern.pattern.keys().map(|hold_position| hold_position.col).max().unwrap_or(0);
pattern.pattern = pattern pattern.pattern = pattern
.pattern .pattern
@@ -88,6 +105,23 @@ impl Pattern {
pattern pattern
} }
/// Returns all possible transformations for the pattern
#[must_use]
pub fn transformations(&self, wall_dimensions: WallDimensions) -> HashSet<Self> {
let mut transformations = HashSet::new();
let pattern = self.canonicalize();
for mut pat in [pattern.mirror(), pattern] {
transformations.insert(pat.clone());
while let Some(p) = pat.shift_right(wall_dimensions, 1) {
transformations.insert(p.clone());
pat = p;
}
}
transformations
}
} }
impl UserInteraction { impl UserInteraction {
@@ -184,6 +218,14 @@ impl Attempt {
Attempt::Flash => Icon::BoltSolid, Attempt::Flash => Icon::BoltSolid,
} }
} }
pub(crate) fn gradient(&self) -> Gradient {
match self {
Attempt::Attempt => Gradient::PinkOrange,
Attempt::Send => Gradient::TealLime,
Attempt::Flash => Gradient::CyanBlue,
}
}
} }
impl std::str::FromStr for Problem { impl std::str::FromStr for Problem {

View File

@@ -29,20 +29,18 @@ 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 = 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 }); // };
}; // view! {
// <p>"Import problems from"</p>
view! { // <button on:click=onclick>"Mini Moonboard"</button>
<p>"Import problems from"</p> // }
<button on:click=onclick>"Mini Moonboard"</button> // }
}
}
#[server] #[server]
#[tracing::instrument] #[tracing::instrument]

View File

@@ -8,11 +8,13 @@ use crate::gradient::Gradient;
use crate::models; use crate::models;
use crate::models::HoldRole; use crate::models::HoldRole;
use crate::server_functions; use crate::server_functions;
use crate::server_functions::SetIsFavorite;
use leptos::Params; use leptos::Params;
use leptos::prelude::*; 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::collections::HashSet;
use std::ops::Deref; use std::ops::Deref;
#[derive(Params, PartialEq, Clone)] #[derive(Params, PartialEq, Clone)]
@@ -60,7 +62,7 @@ struct Context {
wall: Signal<models::Wall>, wall: Signal<models::Wall>,
user_interactions: Signal<BTreeMap<models::Problem, models::UserInteraction>>, user_interactions: Signal<BTreeMap<models::Problem, models::UserInteraction>>,
problem: Signal<Option<models::Problem>>, problem: Signal<Option<models::Problem>>,
filtered_problems: Signal<BTreeSet<models::Problem>>, filtered_problem_transformations: Signal<Vec<HashSet<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>>,
@@ -70,6 +72,7 @@ struct Context {
cb_next_problem: Callback<()>, cb_next_problem: Callback<()>,
cb_set_problem: Callback<models::Problem>, cb_set_problem: Callback<models::Problem>,
cb_upsert_todays_attempt: Callback<server_functions::UpsertTodaysAttempt>, cb_upsert_todays_attempt: Callback<server_functions::UpsertTodaysAttempt>,
cb_set_is_favorite: Callback<bool>,
} }
#[component] #[component]
@@ -93,7 +96,9 @@ fn Controller(
// Derive signals // Derive signals
let user_interaction = signals::user_interaction(user_interactions.into(), problem.into()); let user_interaction = signals::user_interaction(user_interactions.into(), problem.into());
let filtered_problems = signals::filtered_problems(wall, filter_holds.into()); let problem_transformations = signals::problem_transformations(wall);
let filtered_problem_transformations = signals::filtered_problem_transformations(problem_transformations.into(), 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);
@@ -103,6 +108,20 @@ fn Controller(
upsert_todays_attempt.dispatch(RonEncoded(attempt)); upsert_todays_attempt.dispatch(RonEncoded(attempt));
}); });
// Set favorite
let set_is_favorite = ServerAction::<RonEncoded<server_functions::SetIsFavorite>>::new();
let cb_set_is_favorite = Callback::new(move |is_favorite| {
let wall_uid = wall.read().uid;
let Some(problem) = problem.get() else {
return;
};
set_is_favorite.dispatch(RonEncoded(SetIsFavorite {
wall_uid,
problem,
is_favorite,
}));
});
// Callback: Set specific problem // Callback: Set specific problem
let cb_set_problem: Callback<models::Problem> = Callback::new(move |problem| { let cb_set_problem: Callback<models::Problem> = Callback::new(move |problem| {
set_problem.set(Some(problem)); set_problem.set(Some(problem));
@@ -111,14 +130,23 @@ fn Controller(
// 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_problem_transformations.read();
let population = population.deref(); let population = population.deref();
use rand::seq::IteratorRandom; use rand::seq::IteratorRandom;
let mut rng = rand::rng(); let mut rng = rand::rng();
let problem = population.iter().choose(&mut rng);
set_problem.set(problem.cloned()); // Pick pattern
let Some(problem_set) = population.iter().choose(&mut rng) else {
return;
};
// Pick problem out of pattern transformations
let Some(problem) = problem_set.iter().choose(&mut rng) else {
return;
};
set_problem.set(Some(problem.clone()));
}); });
// Callback: On click hold, Add/Remove hold position to problem filter // Callback: On click hold, Add/Remove hold position to problem filter
@@ -148,6 +176,16 @@ fn Controller(
} }
}); });
// Update user interactions after setting favorite
Effect::new(move || {
if let Some(Ok(v)) = set_is_favorite.value().get() {
let v = v.into_inner();
user_interactions.update(|map| {
map.insert(v.problem.clone(), v);
});
}
});
provide_context(Context { provide_context(Context {
wall, wall,
problem: problem.into(), problem: problem.into(),
@@ -158,9 +196,10 @@ fn Controller(
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, cb_set_problem,
cb_set_is_favorite,
todays_attempt, todays_attempt,
filter_holds: filter_holds.into(), filter_holds: filter_holds.into(),
filtered_problems: filtered_problems.into(), filtered_problem_transformations: filtered_problem_transformations.into(),
user_interactions: user_interactions.into(), user_interactions: user_interactions.into(),
}); });
@@ -186,7 +225,7 @@ fn View() -> impl IntoView {
<Separator /> <Separator />
<div class="flex flex-row justify-around"> <div class="flex flex-row justify-between">
<NextProblemButton /> <NextProblemButton />
</div> </div>
</Section> </Section>
@@ -195,12 +234,14 @@ 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 /> <Transformations /> <Separator /><AttemptRadioGroup /> <Separator /> <div class="flex flex-row gap-2 justify-between">
<Separator /> <History /> <Transformations />
<FavoriteButton />
</div> <Separator /> <AttemptRadioGroup /> <Separator /> <History />
</Section> </Section>
</div> </div>
<div class="flex flex-row flex-auto justify-end items-start px-2 pt-3"> <div class="flex flex-col px-2 pt-3 gap-4">
<HoldsButton /> <HoldsButton />
</div> </div>
</div> </div>
@@ -261,6 +302,16 @@ fn Transformations() -> impl IntoView {
} }
} }
#[component]
#[tracing::instrument(skip_all)]
fn LikedButton() -> impl IntoView {
crate::tracing::on_enter!();
let _ctx = use_context::<Context>().unwrap();
view! { <Button text="Saved" icon=Icon::HeartOutline color=Gradient::PinkRed /> }
}
#[component] #[component]
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
fn HoldsButton() -> impl IntoView { fn HoldsButton() -> impl IntoView {
@@ -277,6 +328,25 @@ fn HoldsButton() -> impl IntoView {
} }
} }
#[component]
#[tracing::instrument(skip_all)]
fn FavoriteButton() -> impl IntoView {
crate::tracing::on_enter!();
let ctx = use_context::<Context>().unwrap();
let ui_toggle = Signal::derive(move || {
let guard = ctx.user_interaction.read();
guard.as_ref().map(|user_interaction| user_interaction.is_favorite).unwrap_or(false)
});
let on_click = Callback::new(move |_| {
ctx.cb_set_is_favorite.run(!ui_toggle.get());
});
let icon = Signal::derive(move || if ui_toggle.get() { Icon::Heart } else { Icon::HeartOutline });
let text = Signal::derive(move || if ui_toggle.get() { "Saved" } else { "Save" }.to_string());
view! { <Button text icon on_click color=Gradient::PinkRed /> }
}
#[component] #[component]
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
fn NextProblemButton() -> impl IntoView { fn NextProblemButton() -> impl IntoView {
@@ -315,9 +385,11 @@ fn Filter() -> impl IntoView {
} }
} }
let problems_count = ctx.filtered_problem_transformations.read().iter().map(|set| set.len()).sum::<usize>();
let problems_counter = { let problems_counter = {
let name = view! { <p class="mr-4 font-light 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">{problems_count}</p> };
view! { view! {
<div class="grid grid-rows-none gap-x-0.5 gap-y-1 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}
@@ -336,13 +408,15 @@ 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 in ctx.filtered_problems.read().iter() { for problem_set in ctx.filtered_problem_transformations.read().iter() {
if let Some(user_int) = user_ints.get(problem) { for problem in problem_set {
match user_int.best_attempt().map(|da| da.attempt) { if let Some(user_int) = user_ints.get(problem) {
Some(models::Attempt::Flash) => interaction_counters.flash += 1, match user_int.best_attempt().map(|da| da.attempt) {
Some(models::Attempt::Send) => interaction_counters.send += 1, Some(models::Attempt::Flash) => interaction_counters.flash += 1,
Some(models::Attempt::Attempt) => interaction_counters.attempt += 1, Some(models::Attempt::Send) => interaction_counters.send += 1,
None => {} Some(models::Attempt::Attempt) => interaction_counters.attempt += 1,
None => {}
}
} }
} }
} }
@@ -575,6 +649,7 @@ mod signals {
use leptos::prelude::*; use leptos::prelude::*;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::collections::BTreeSet; use std::collections::BTreeSet;
use std::collections::HashSet;
pub fn latest_attempt(user_interaction: Signal<Option<models::UserInteraction>>) -> Signal<Option<models::DatedAttempt>> { pub fn latest_attempt(user_interaction: Signal<Option<models::UserInteraction>>) -> Signal<Option<models::DatedAttempt>> {
Signal::derive(move || user_interaction.read().as_ref().and_then(models::UserInteraction::latest_attempt)) Signal::derive(move || user_interaction.read().as_ref().and_then(models::UserInteraction::latest_attempt))
@@ -600,6 +675,18 @@ mod signals {
}) })
} }
/// Maps each problem to a set of problems comprising all transformation of the problem pattern.
pub(crate) fn problem_transformations(wall: Signal<models::Wall>) -> Memo<Vec<HashSet<models::Problem>>> {
Memo::new(move |_prev_val| {
let wall = wall.read();
wall.problems
.iter()
.map(|problem| problem.transformations(wall.wall_dimensions))
.collect()
})
}
#[expect(dead_code)]
pub(crate) fn filtered_problems( pub(crate) fn filtered_problems(
wall: Signal<models::Wall>, wall: Signal<models::Wall>,
filter_holds: Signal<BTreeSet<models::HoldPosition>>, filter_holds: Signal<BTreeSet<models::HoldPosition>>,
@@ -610,12 +697,34 @@ mod signals {
wall.problems wall.problems
.iter() .iter()
.filter(|problem| filter_holds.iter().all(|hold_pos| problem.pattern.pattern.contains_key(hold_pos))) .filter(|problem| filter_holds.iter().all(|hold_pos| problem.pattern.pattern.contains_key(hold_pos)))
.map(|problem| problem.clone()) .cloned()
.collect::<BTreeSet<models::Problem>>() .collect::<BTreeSet<models::Problem>>()
}) })
}) })
} }
pub(crate) fn filtered_problem_transformations(
problem_transformations: Signal<Vec<HashSet<models::Problem>>>,
filter_holds: Signal<BTreeSet<models::HoldPosition>>,
) -> Memo<Vec<HashSet<models::Problem>>> {
Memo::new(move |_prev_val| {
let filter_holds = filter_holds.read();
let problem_transformations = problem_transformations.read();
problem_transformations
.iter()
.map(|problem_set| {
problem_set
.iter()
.filter(|problem| filter_holds.iter().all(|hold_pos| problem.pattern.pattern.contains_key(hold_pos)))
.cloned()
.collect::<HashSet<models::Problem>>()
})
.filter(|set| !set.is_empty())
.collect()
})
}
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.pattern.pattern.get(&hold_position).copied())) Signal::derive(move || problem.get().and_then(|p| p.pattern.pattern.get(&hold_position).copied()))
} }

View File

@@ -13,7 +13,6 @@ pub async fn run_migrations(db: &Database) -> Result<(), Box<dyn std::error::Err
if is_at_version(db, 3).await? { if is_at_version(db, 3).await? {
migrate_to_v4(db).await?; migrate_to_v4(db).await?;
} }
Ok(()) Ok(())
} }
@@ -71,13 +70,13 @@ pub async fn migrate_to_v4(db: &Database) -> Result<(), Box<dyn std::error::Erro
.map(|problem_uid| { .map(|problem_uid| {
let old_prob = &problems_dump[&(wall_uid, problem_uid)]; let old_prob = &problems_dump[&(wall_uid, problem_uid)];
let method = old_prob.method; let method = old_prob.method;
let problem = models::Problem {
models::Problem {
pattern: models::Pattern { pattern: models::Pattern {
pattern: old_prob.holds.clone(), pattern: old_prob.holds.clone(),
}, },
method, method,
}; }
problem
}) })
.collect(); .collect();
let wall = models::Wall { let wall = models::Wall {

View File

@@ -43,10 +43,8 @@ pub(crate) async fn import_mini_moonboard_problems(config: &Config, db: Database
mini_moonboard::Method::FootlessPlusKickboard => models::Method::FootlessPlusKickboard, mini_moonboard::Method::FootlessPlusKickboard => models::Method::FootlessPlusKickboard,
}; };
let problem = models::Problem { let pattern = models::Pattern { pattern }.canonicalize();
pattern: models::Pattern { pattern }, let problem = models::Problem { pattern, method };
method,
};
problems.push(problem); problems.push(problem);
} }

View File

@@ -20,7 +20,7 @@ pub async fn get_walls() -> Result<RonEncoded<Vec<models::Wall>>, ServerFnError>
use crate::server::db::Database; use crate::server::db::Database;
use leptos::prelude::expect_context; use leptos::prelude::expect_context;
use redb::ReadableTable; use redb::ReadableTable;
tracing::trace!("Enter"); crate::tracing::on_enter!();
let db = expect_context::<Database>(); let db = expect_context::<Database>();
@@ -45,7 +45,7 @@ pub(crate) async fn get_wall_by_uid(wall_uid: models::WallUid) -> Result<RonEnco
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;
tracing::trace!("Enter"); crate::tracing::on_enter!();
#[derive(Debug, derive_more::Error, derive_more::Display)] #[derive(Debug, derive_more::Error, derive_more::Display)]
enum Error { enum Error {
@@ -84,7 +84,7 @@ pub(crate) async fn get_user_interaction(
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;
tracing::trace!("Enter"); crate::tracing::on_enter!();
#[derive(Debug, derive_more::Error, derive_more::Display, derive_more::From)] #[derive(Debug, derive_more::Error, derive_more::Display, derive_more::From)]
enum Error { enum Error {
@@ -129,7 +129,7 @@ pub(crate) async fn get_user_interactions_for_wall(
use crate::server::db::DatabaseOperationError; use crate::server::db::DatabaseOperationError;
use leptos::prelude::expect_context; use leptos::prelude::expect_context;
use redb::ReadableTable; use redb::ReadableTable;
tracing::trace!("Enter"); crate::tracing::on_enter!();
#[derive(Debug, derive_more::Error, derive_more::Display, derive_more::From)] #[derive(Debug, derive_more::Error, derive_more::Display, derive_more::From)]
enum Error { enum Error {
@@ -188,7 +188,7 @@ pub(crate) async fn upsert_todays_attempt(
use crate::server::db::DatabaseOperationError; use crate::server::db::DatabaseOperationError;
use leptos::prelude::expect_context; use leptos::prelude::expect_context;
tracing::trace!("Enter"); crate::tracing::on_enter!();
#[derive(Debug, Error, Display, From)] #[derive(Debug, Error, Display, From)]
enum Error { enum Error {
@@ -244,3 +244,62 @@ pub(crate) async fn upsert_todays_attempt(
.map_err(ServerFnError::new) .map_err(ServerFnError::new)
.map(RonEncoded::new) .map(RonEncoded::new)
} }
/// Sets is_favorite field for a problem
#[server(
input = Ron,
output = Ron,
custom = RonEncoded
)]
#[tracing::instrument(err(Debug))]
pub(crate) async fn set_is_favorite(
wall_uid: models::WallUid,
problem: models::Problem,
is_favorite: bool,
) -> Result<RonEncoded<models::UserInteraction>, ServerFnError> {
use crate::server::db::Database;
use crate::server::db::DatabaseOperationError;
use leptos::prelude::expect_context;
crate::tracing::on_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: models::Problem, is_favorite: bool) -> 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.clone());
// Pop or default
let mut user_interaction = user_table
.remove(&key)?
.map(|guard| guard.value())
.unwrap_or_else(|| models::UserInteraction::new(wall_uid, problem));
user_interaction.is_favorite = is_favorite;
user_table.insert(key, user_interaction.clone())?;
Ok(user_interaction)
})
.await?;
Ok(user_interaction)
}
inner(wall_uid, problem, is_favorite)
.await
.map_err(error_reporter::Report::new)
.map_err(ServerFnError::new)
.map(RonEncoded::new)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 392 KiB

18
flake.lock generated
View File

@@ -10,11 +10,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1741078851, "lastModified": 1746696290,
"narHash": "sha256-1Qu/Uu+yPUDhHM2XjTbwQqpSrYhhHu7TpHHrT7UO/0o=", "narHash": "sha256-YokYinNgGIu80OErVMuFoIELhetzb45aWKTiKYNXvWA=",
"owner": "plul", "owner": "plul",
"repo": "basecamp", "repo": "basecamp",
"rev": "3e4579d8b4400506e5f53069448b3471608b5281", "rev": "108ef2874fd8f934602cda5bfdc0e58a541c6b4a",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -25,11 +25,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1742456341, "lastModified": 1746576598,
"narHash": "sha256-yvdnTnROddjHxoQqrakUQWDZSzVchczfsuuMOxg476c=", "narHash": "sha256-FshoQvr6Aor5SnORVvh/ZdJ1Sa2U4ZrIMwKBX5k2wu0=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "7344a3b78128f7b1765dba89060b015fb75431a7", "rev": "b3582c75c7f21ce0b429898980eddbbf05c68e55",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -53,11 +53,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1742524367, "lastModified": 1746671794,
"narHash": "sha256-KzTwk/5ETJavJZYV1DEWdCx05M4duFCxCpRbQSKWpng=", "narHash": "sha256-V+mpk2frYIEm85iYf+KPDmCGG3zBRAEhbv0E3lHdG2U=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "70bf752d176b2ce07417e346d85486acea9040ef", "rev": "ceec434b8741c66bb8df5db70d7e629a9d9c598f",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@@ -8,6 +8,9 @@ fmt:
fd --extension=rs --exec-batch leptosfmt fd --extension=rs --exec-batch leptosfmt
bc-fmt bc-fmt
fix:
bc-fix
serve: serve:
RUST_BACKTRACE=1 cargo leptos watch -- serve RUST_BACKTRACE=1 cargo leptos watch -- serve