diff --git a/crates/ascend/Cargo.toml b/crates/ascend/Cargo.toml index 6d2c5e8..001f12b 100644 --- a/crates/ascend/Cargo.toml +++ b/crates/ascend/Cargo.toml @@ -37,7 +37,7 @@ type-toppings = { version = "0.2.1", features = ["result"] } wasm-bindgen = "=0.2.99" web-sys = { version = "0.3.76", features = ["File", "FileList"] } xdg = { version = "2.5", optional = true } -uuid = { version = "1.12", optional = true, features = ["serde", "v4"] } +uuid = { version = "1.12", features = ["serde", "v4"] } redb = { version = "2.4", optional = true } bincode = { version = "1.3", optional = true } @@ -48,7 +48,6 @@ version = "1" hydrate = ["leptos/hydrate"] ssr = [ "dep:axum", - "dep:uuid", "dep:redb", "dep:bincode", "dep:tokio", diff --git a/crates/ascend/src/models.rs b/crates/ascend/src/models.rs index 9cfbeba..e568a9c 100644 --- a/crates/ascend/src/models.rs +++ b/crates/ascend/src/models.rs @@ -1,71 +1,158 @@ //! Shared models between server and client code. -use serde::Deserialize; -use serde::Serialize; -use std::collections::BTreeMap; +pub use v1::Hold; +pub use v1::HoldPosition; +pub use v1::HoldRole; +pub use v1::Image; +pub use v2::Method; +pub use v2::Problem; +pub use v2::ProblemId; +pub use v2::Root; +pub use v2::Wall; +pub use v2::WallId; -#[derive(Serialize, Deserialize, Clone, Debug)] -pub struct Wall { - pub rows: u64, - pub cols: u64, - pub holds: BTreeMap, -} -impl Wall { - pub fn new(rows: u64, cols: u64) -> Self { - let mut holds = BTreeMap::new(); - for row in 0..rows { - for col in 0..cols { - let position = HoldPosition { row, col }; - let hold = Hold { position, image: None }; - holds.insert(position, hold); - } +pub mod v2 { + use super::v1; + use serde::Deserialize; + use serde::Serialize; + use std::collections::BTreeMap; + use std::collections::BTreeSet; + + #[derive(Serialize, Deserialize, Debug)] + pub struct Root { + pub walls: BTreeSet, + } + + #[derive(Serialize, Deserialize, Debug, Clone)] + pub struct Wall { + pub uid: WallId, + pub rows: u64, + pub cols: u64, + pub holds: BTreeMap, + pub problems: BTreeSet, + } + + #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] + pub struct WallId(pub uuid::Uuid); + impl WallId { + pub fn new() -> Self { + Self(uuid::Uuid::new_v4()) } - Self { rows, cols, holds } } -} -impl Default for Wall { - fn default() -> Self { - Self::new(12, 12) + + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] + pub struct Problem { + pub uid: ProblemId, + pub name: String, + pub set_by: String, + pub holds: BTreeMap, + pub method: Method, + pub date_added: chrono::DateTime, + } + + #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] + pub struct ProblemId(pub uuid::Uuid); + impl ProblemId { + pub fn new() -> Self { + Self(uuid::Uuid::new_v4()) + } + } + + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] + pub enum Method { + FeetFollowHands, + Footless, + FootlessPlusKickboard, } } -#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Copy)] -pub struct HoldPosition { - /// Starting from 0 - pub row: u64, +pub mod v1 { + use serde::Deserialize; + use serde::Serialize; + use smart_default::SmartDefault; + use std::collections::BTreeMap; + use std::collections::BTreeSet; - /// Starting from 0 - pub col: u64, -} - -#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct Problem { - pub holds: BTreeMap, -} - -/// The role of a hold on a route -#[derive(Serialize, Deserialize, Clone, Debug, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub enum HoldRole { - /// Start hold - Start, - - /// Any hold on the route without a specific role - Normal, - - /// Zone hold - Zone, - - /// End hold - End, -} - -#[derive(Serialize, Deserialize, Clone, Debug, Default)] -pub struct Hold { - pub position: HoldPosition, - pub image: Option, -} - -#[derive(Serialize, Deserialize, Clone, Debug, Default)] -pub struct Image { - pub filename: String, + const STATE_VERSION: u64 = 1; + + #[derive(Serialize, Deserialize, Clone, Debug, SmartDefault)] + pub struct PersistentState { + /// State schema version + #[default(STATE_VERSION)] + pub version: u64, + + pub wall: Wall, + pub problems: Problems, + } + + #[derive(Serialize, Deserialize, Clone, Debug, Default)] + pub struct Problems { + pub problems: BTreeSet, + } + + #[derive(Serialize, Deserialize, Clone, Debug)] + pub struct Wall { + pub rows: u64, + pub cols: u64, + pub holds: BTreeMap, + } + impl Wall { + pub fn new(rows: u64, cols: u64) -> Self { + let mut holds = BTreeMap::new(); + for row in 0..rows { + for col in 0..cols { + let position = HoldPosition { row, col }; + let hold = Hold { position, image: None }; + holds.insert(position, hold); + } + } + Self { rows, cols, holds } + } + } + impl Default for Wall { + fn default() -> Self { + Self::new(12, 12) + } + } + + #[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Copy)] + pub struct HoldPosition { + /// Starting from 0 + pub row: u64, + + /// Starting from 0 + pub col: u64, + } + + #[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] + pub struct Problem { + pub holds: BTreeMap, + } + + /// The role of a hold on a route + #[derive(Serialize, Deserialize, Clone, Debug, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] + pub enum HoldRole { + /// Start hold + Start, + + /// Any hold on the route without a specific role + Normal, + + /// Zone hold + Zone, + + /// End hold + End, + } + + #[derive(Serialize, Deserialize, Clone, Debug)] + pub struct Hold { + pub position: HoldPosition, + pub image: Option, + } + + #[derive(Serialize, Deserialize, Clone, Debug)] + pub struct Image { + pub filename: String, + } } diff --git a/crates/ascend/src/pages/wall.rs b/crates/ascend/src/pages/wall.rs index c302493..61d49e6 100644 --- a/crates/ascend/src/pages/wall.rs +++ b/crates/ascend/src/pages/wall.rs @@ -10,6 +10,7 @@ use leptos::reactive::graph::ReactiveNode; use serde::Deserialize; use serde::Serialize; use std::ops::Deref; +use std::sync::Arc; #[component] pub fn Wall() -> impl leptos::IntoView { @@ -125,15 +126,31 @@ pub struct InitialData { } #[server] -async fn load_initial_data() -> Result, ServerFnError> { - todo!() - // let state = expect_context::(); +#[tracing::instrument(skip_all, err)] +async fn load_initial_data(wall_id: models::WallId) -> Result, ServerFnError> { + let db = expect_context::>(); - // let wall = state.persistent.with(|s| s.wall.clone()).await; - // Ok(RonCodec::new(InitialData { wall })) + #[derive(Debug, derive_more::Error, derive_more::Display)] + enum Error { + #[display("Wall not found: {_0:?}")] + NotFound(#[error(not(source))] models::WallId), + } + + let wall = tokio::task::spawn_blocking(move || -> Result { + let read_txn = db.begin_read()?; + + let walls_table = read_txn.open_table(crate::server::db::current::TABLE_WALLS)?; + let wall = walls_table.get(wall_id)?.ok_or(Error::NotFound(wall_id))?.value(); + + Ok(wall) + }) + .await??; + + Ok(RonCodec::new(InitialData { wall })) } #[server] +#[tracing::instrument(skip_all, err)] async fn get_random_problem() -> Result>, ServerFnError> { todo!() // use rand::seq::IteratorRandom; diff --git a/crates/ascend/src/server.rs b/crates/ascend/src/server.rs index 8b618d8..ad2e699 100644 --- a/crates/ascend/src/server.rs +++ b/crates/ascend/src/server.rs @@ -4,7 +4,6 @@ use cli::Cli; use config::Config; use confik::Configuration; use confik::EnvSource; -use state::PersistentState; use std::path::Path; use std::sync::Arc; use tower_http::services::ServeDir; @@ -13,10 +12,9 @@ use type_toppings::ResultExt; mod cli; pub mod config; -mod db; +pub mod db; mod migrations; pub mod operations; -pub mod state; pub const STATE_FILE: &str = "datastore/private/state.ron"; @@ -128,5 +126,5 @@ pub enum Error { Confik(confik::Error), - Database(redb::DatabaseError), + Database(redb::Error), } diff --git a/crates/ascend/src/server/db.rs b/crates/ascend/src/server/db.rs index a82359f..6a37ff8 100644 --- a/crates/ascend/src/server/db.rs +++ b/crates/ascend/src/server/db.rs @@ -1,14 +1,16 @@ +use bincode::Bincode; use redb::Database; -use redb::DatabaseError; +use redb::TableDefinition; +use serde::Deserialize; +use serde::Serialize; use std::path::PathBuf; mod bincode; -pub mod db_models; pub const DB_FILE: &str = "datastore/private/ascend.redb"; -#[tracing::instrument(err)] -pub fn create() -> Result { +#[tracing::instrument(skip_all, err)] +pub fn create() -> Result { let file = PathBuf::from(DB_FILE); // Create parent dirs @@ -19,3 +21,45 @@ pub fn create() -> Result { let db = Database::create(file)?; Ok(db) } + +#[tracing::instrument(skip_all)] +pub fn get_version(db: &Database) -> Result, redb::Error> { + let txn = db.begin_read()?; + let version = txn.open_table(TABLE_VERSION)?.get(())?.map(|v| v.value()); + Ok(version) +} + +pub const TABLE_VERSION: TableDefinition<(), Bincode> = TableDefinition::new("version"); +#[derive(Serialize, Deserialize, Debug, derive_more::Display)] +#[display("{version}")] +pub struct Version { + pub version: u64, +} +impl Version { + pub fn current() -> Version { + Version { version: current::VERSION } + } +} + +pub use v2 as current; + +pub mod v2 { + use crate::models; + use crate::server::db::bincode::Bincode; + use redb::TableDefinition; + + pub const VERSION: u64 = 2; + + pub const TABLE_ROOT: TableDefinition<(), Bincode> = TableDefinition::new("root"); + pub const TABLE_WALLS: TableDefinition, Bincode> = TableDefinition::new("walls"); + pub const TABLE_PROBLEMS: TableDefinition, Bincode> = + TableDefinition::new("problems"); +} + +pub mod v1 { + use crate::models; + use crate::server::db::bincode::Bincode; + use redb::TableDefinition; + + pub const TABLE_ROOT: TableDefinition<(), Bincode> = TableDefinition::new("root"); +} diff --git a/crates/ascend/src/server/db/db_models.rs b/crates/ascend/src/server/db/db_models.rs deleted file mode 100644 index 8f9c879..0000000 --- a/crates/ascend/src/server/db/db_models.rs +++ /dev/null @@ -1,105 +0,0 @@ -use super::bincode::Bincode; -use redb::TableDefinition; -use serde::Deserialize; -use serde::Serialize; -pub use v2::Hold; -pub use v2::HoldPosition; -pub use v2::HoldRole; -pub use v2::Image; -pub use v2::Method; -pub use v2::Problem; -pub use v2::ProblemId; -pub use v2::Root; -pub use v2::TABLE_ROOT; -pub use v2::TABLE_WALLS; -pub use v2::Wall; -pub use v2::WallId; - -pub const TABLE_VERSION: TableDefinition<(), Bincode> = TableDefinition::new("version"); -#[derive(Serialize, Deserialize, Debug)] -pub struct Version { - pub version: u64, -} - -pub mod v2 { - use crate::server::db::bincode::Bincode; - use redb::TableDefinition; - use serde::Deserialize; - use serde::Serialize; - use std::collections::BTreeMap; - use std::collections::BTreeSet; - - pub const TABLE_ROOT: TableDefinition<(), Bincode> = TableDefinition::new("root"); - pub const TABLE_WALLS: TableDefinition, Bincode> = TableDefinition::new("walls"); - - #[derive(Serialize, Deserialize, Debug)] - pub struct Root { - pub walls: BTreeSet, - } - - #[derive(Serialize, Deserialize, Debug)] - pub struct Wall { - pub uid: WallId, - pub rows: u64, - pub cols: u64, - pub holds: BTreeMap, - } - - #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] - pub struct WallId(pub uuid::Uuid); - - #[derive(Serialize, Deserialize, Debug)] - pub struct Problem { - pub uid: ProblemId, - pub name: String, - pub set_by: String, - pub holds: BTreeMap, - pub method: Method, - pub date_added: chrono::NaiveDate, - } - - #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] - pub struct ProblemId(pub uuid::Uuid); - - #[derive(Serialize, Deserialize, Debug)] - pub enum Method { - FeetFollowHands, - Footless, - FootlessPlusKickboard, - } - - #[derive(Serialize, Deserialize, Debug)] - pub enum HoldRole { - Start, - Normal, - Zone, - End, - } - - #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, PartialOrd, Ord)] - pub struct HoldPosition { - pub row: u64, - pub col: u64, - } - - #[derive(Serialize, Deserialize, Debug)] - pub struct Hold { - pub position: HoldPosition, - pub image: Option, - } - - #[derive(Serialize, Deserialize, Debug)] - pub struct Image { - pub filename: String, - } -} - -pub mod v1 { - use crate::server::db::bincode::Bincode; - use crate::server::state::PersistentState; - use redb::TableDefinition; - - pub type Root = PersistentState; - - pub const TABLE_ROOT: TableDefinition<(), Bincode> = TableDefinition::new("root"); -} diff --git a/crates/ascend/src/server/migrations.rs b/crates/ascend/src/server/migrations.rs index dfc2e46..17d08c2 100644 --- a/crates/ascend/src/server/migrations.rs +++ b/crates/ascend/src/server/migrations.rs @@ -1,5 +1,5 @@ -use super::state::PersistentState; -use leptos::prelude::StorageAccess; +use super::db; +use crate::models; use redb::Database; use redb::ReadableTable; use redb::ReadableTableMetadata; @@ -7,34 +7,17 @@ use std::collections::BTreeSet; use std::path::PathBuf; use type_toppings::ResultExt; -#[tracing::instrument] +#[tracing::instrument(skip_all, err)] pub async fn run_migrations(db: &Database) -> Result<(), Box> { - migrate_state_file().await?; migrate_from_ron_to_redb(db).await?; + init_at_current_version(db).await?; migrate_to_v2(db).await?; Ok(()) } -/// State file moved to datastore/private -#[tracing::instrument(err)] -async fn migrate_state_file() -> Result<(), Box> { - let m = PathBuf::from("state.ron"); - if m.try_exists().expect_or_report_with(|| format!("Failed to read {}", m.display())) { - tracing::warn!("MIGRATING"); - let p = PathBuf::from(super::STATE_FILE); - tokio::fs::create_dir_all(p.parent().unwrap()).await?; - tokio::fs::rename(m, &p).await?; - } - Ok(()) -} - /// Use redb DB instead of Ron state file -#[tracing::instrument(err)] +#[tracing::instrument(skip_all, err)] async fn migrate_from_ron_to_redb(db: &Database) -> Result<(), Box> { - use super::db::db_models::TABLE_VERSION; - use super::db::db_models::Version; - use super::db::db_models::v1; - let ron_state_file_path = PathBuf::from(super::STATE_FILE); if ron_state_file_path @@ -43,27 +26,27 @@ async fn migrate_from_ron_to_redb(db: &Database) -> Result<(), Box Result<(), Box Result<(), Box> { + let txn = db.begin_write()?; + { + let mut version_table = txn.open_table(db::TABLE_VERSION)?; + let is_missing_version = version_table.get(())?.is_none(); + if is_missing_version { + let v = db::Version::current(); + tracing::warn!("INITIALIZING DATABASE AT VERSION {v}"); + version_table.insert((), v)?; + + // Root table + { + let mut table = txn.open_table(db::current::TABLE_ROOT)?; + assert!(table.is_empty()?); + table.insert((), models::Root { walls: BTreeSet::new() })?; + } + + // Walls table + { + // Opening the table creates the table + let table = txn.open_table(db::current::TABLE_WALLS)?; + assert!(table.is_empty()?); + } + + // Problems table + { + // Opening the table creates the table + let table = txn.open_table(db::current::TABLE_PROBLEMS)?; + assert!(table.is_empty()?); + } + } + } + txn.commit()?; + + Ok(()) +} + +#[tracing::instrument(skip_all, err)] async fn migrate_to_v2(db: &Database) -> Result<(), Box> { - use super::db::db_models::TABLE_VERSION; - use super::db::db_models::Version; - use super::db::db_models::v1; - use super::db::db_models::v2; + use super::db; let txn = db.begin_write()?; { - let mut version_table = txn.open_table(TABLE_VERSION)?; + let mut version_table = txn.open_table(db::TABLE_VERSION)?; let version = version_table.get(())?.unwrap().value().version; if version == 1 { tracing::warn!("MIGRATING"); - version_table.insert((), Version { version: 2 })?; + version_table.insert((), db::Version { version: 2 })?; - let root_table_v1 = txn.open_table(v1::TABLE_ROOT)?; + let root_table_v1 = txn.open_table(db::v1::TABLE_ROOT)?; let root_v1 = root_table_v1.get(())?.unwrap().value(); - txn.delete_table(v1::TABLE_ROOT)?; + txn.delete_table(db::v1::TABLE_ROOT)?; - let v1::Root { version: _, wall, problems } = root_v1; + let models::v1::PersistentState { version: _, wall, problems } = root_v1; + + // we'll reimport them instead of a lossy conversion. + drop(problems); let mut walls = BTreeSet::new(); - let wall_uid = v2::WallId(uuid::Uuid::new_v4()); + let wall_uid = models::v2::WallId(uuid::Uuid::new_v4()); let holds = wall .holds .into_iter() .map(|(hold_position, hold)| { ( - v2::HoldPosition { + models::v1::HoldPosition { row: hold_position.row, col: hold_position.col, }, - v2::Hold { - position: v2::HoldPosition { + models::v1::Hold { + position: models::v1::HoldPosition { row: hold.position.row, col: hold.position.col, }, - image: hold.image.map(|i| v2::Image { filename: i.filename }), + image: hold.image.map(|i| models::v1::Image { filename: i.filename }), }, ) }) .collect(); - let wall_v2 = v2::Wall { + let wall_v2 = models::v2::Wall { uid: wall_uid, rows: wall.rows, cols: wall.cols, holds, + problems: BTreeSet::new(), }; walls.insert(wall_v2.uid); - let root_v2 = v2::Root { walls }; + let root_v2 = models::v2::Root { walls }; - let mut root_table_v2 = txn.open_table(v2::TABLE_ROOT)?; + let mut root_table_v2 = txn.open_table(db::v2::TABLE_ROOT)?; root_table_v2.insert((), root_v2)?; - let mut walls_table = txn.open_table(v2::TABLE_WALLS)?; + let mut walls_table = txn.open_table(db::v2::TABLE_WALLS)?; walls_table.insert(wall_v2.uid, wall_v2)?; } } diff --git a/crates/ascend/src/server/operations.rs b/crates/ascend/src/server/operations.rs index 619374d..93ddeba 100644 --- a/crates/ascend/src/server/operations.rs +++ b/crates/ascend/src/server/operations.rs @@ -4,11 +4,14 @@ use crate::models; use crate::models::HoldPosition; use crate::models::HoldRole; use crate::server::config::Config; +use crate::server::db; use redb::Database; +use redb::ReadableTable; use std::collections::BTreeMap; +use std::sync::Arc; -#[tracing::instrument] -pub(crate) async fn import_mini_moonboard_problems(config: &Config, db: &Database) -> Result<(), Error> { +#[tracing::instrument(skip_all)] +pub(crate) async fn import_mini_moonboard_problems(config: &Config, db: Arc, wall_id: models::WallId) -> Result<(), Error> { use moonboard_parser::mini_moonboard; let mut problems = Vec::new(); @@ -21,9 +24,9 @@ pub(crate) async fn import_mini_moonboard_problems(config: &Config, db: &Databas let set_by = "mini-mb-2020-parser"; let mini_moonboard = mini_moonboard::parse(file_path.as_std_path()).await?; - for problem in mini_moonboard.problems { + for mini_mb_problem in mini_moonboard.problems { let mut holds = BTreeMap::::new(); - for mv in problem.moves { + for mv in mini_mb_problem.moves { let row = mv.description.row(); let col = mv.description.column(); let hold_position = HoldPosition { row, col }; @@ -37,29 +40,47 @@ pub(crate) async fn import_mini_moonboard_problems(config: &Config, db: &Databas holds.insert(hold_position, role); } - // TODO: + let name = mini_mb_problem.name; - // let name = problem.name; + let method = match mini_mb_problem.method { + mini_moonboard::Method::FeetFollowHands => models::Method::FeetFollowHands, + mini_moonboard::Method::Footless => models::Method::Footless, + mini_moonboard::Method::FootlessPlusKickboard => models::Method::FootlessPlusKickboard, + }; - // let method = match problem.method { - // mini_moonboard::Method::FeetFollowHands => models::Method::FeetFollowHands, - // mini_moonboard::Method::Footless => models::Method::Footless, - // mini_moonboard::Method::FootlessPlusKickboard => models::Method::FootlessPlusKickboard, - // }; + let problem_id = models::ProblemId::new(); - // let problem = models::Problem::new(name, set_by.to_owned(), holds, method); - // problems.push(problem); - - let problem = models::Problem { holds }; + let problem = models::Problem { + uid: problem_id, + name, + set_by: set_by.to_owned(), + holds, + method, + date_added: chrono::Utc::now(), + }; problems.push(problem); } - state - .persistent - .update(|s| { - s.problems.problems.extend(problems); - }) - .await?; + tokio::task::spawn_blocking(move || -> Result<(), redb::Error> { + let write_txn = db.begin_write()?; + { + let mut walls_table = write_txn.open_table(db::current::TABLE_WALLS)?; + let mut problems_table = write_txn.open_table(db::current::TABLE_PROBLEMS)?; + + let mut wall = walls_table.get(wall_id)?.unwrap().value(); + wall.problems.extend(problems.iter().map(|p| p.uid)); + walls_table.insert(wall_id, wall)?; + + for problem in problems { + let key = (wall_id, problem.uid); + problems_table.insert(key, problem)?; + } + } + write_txn.commit()?; + + Ok(()) + }) + .await??; Ok(()) } @@ -67,4 +88,6 @@ pub(crate) async fn import_mini_moonboard_problems(config: &Config, db: &Databas #[derive(Debug, derive_more::Error, derive_more::Display, derive_more::From)] pub enum Error { Parser(moonboard_parser::Error), + Redb(redb::Error), + Tokio(tokio::task::JoinError), } diff --git a/crates/ascend/src/server/state.rs b/crates/ascend/src/server/state.rs deleted file mode 100644 index eeb92b7..0000000 --- a/crates/ascend/src/server/state.rs +++ /dev/null @@ -1,25 +0,0 @@ -//! Server state - -const STATE_VERSION: u64 = 1; - -use crate::models; -use crate::models::Wall; -use serde::Deserialize; -use serde::Serialize; -use smart_default::SmartDefault; -use std::collections::BTreeSet; - -#[derive(Serialize, Deserialize, Clone, Debug, SmartDefault)] -pub struct PersistentState { - /// State schema version - #[default(STATE_VERSION)] - pub version: u64, - - pub wall: Wall, - pub problems: Problems, -} - -#[derive(Serialize, Deserialize, Clone, Debug, Default)] -pub struct Problems { - pub problems: BTreeSet, -} diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..fd819d7 --- /dev/null +++ b/todo.md @@ -0,0 +1,11 @@ +- save images with a uuid +- downscale images +- associate routes with wall +- group routes by pattern (pattern family has shift/mirror variations) +- generate pattern families of variations when importing problems +- implement pattern challenge (start an "adventure mode" based on a pattern family) +- Record problem success (enum: flash, send, no-send) +- implement routes page to show all routes for a given wall +- implement favorite routes feature +- use wall id in URL. +- decide on routes vs problems terminology