diff --git a/Cargo.lock b/Cargo.lock index 97bfce6..4387b2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,6 +38,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "ansi_term" version = "0.12.1" @@ -441,15 +450,17 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.19" +version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" +checksum = "bfd4d1b31faaa3a89d7934dbded3111da0d2ef28e3ebccdb4f0179f5929d1ef1" dependencies = [ - "libc", + "iana-time-zone", + "js-sys", "num-integer", "num-traits", "serde", "time 0.1.44", + "wasm-bindgen", "winapi", ] @@ -505,6 +516,16 @@ dependencies = [ "os_str_bytes", ] +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + [[package]] name = "concurrent-queue" version = "1.2.2" @@ -540,6 +561,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" + [[package]] name = "cpufeatures" version = "0.2.2" @@ -646,6 +673,50 @@ dependencies = [ "syn", ] +[[package]] +name = "cxx" +version = "1.0.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97abf9f0eca9e52b7f81b945524e76710e6cb2366aead23b7d4fbf72e281f888" +dependencies = [ + "cc", + "cxxbridge-flags", + "cxxbridge-macro", + "link-cplusplus", +] + +[[package]] +name = "cxx-build" +version = "1.0.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc32cc5fea1d894b77d269ddb9f192110069a8a9c1f1d441195fba90553dea3" +dependencies = [ + "cc", + "codespan-reporting", + "once_cell", + "proc-macro2", + "quote", + "scratch", + "syn", +] + +[[package]] +name = "cxxbridge-flags" +version = "1.0.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca220e4794c934dc6b1207c3b42856ad4c302f2df1712e9f8d2eec5afaacf1f" + +[[package]] +name = "cxxbridge-macro" +version = "1.0.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b846f081361125bfc8dc9d3940c84e1fd83ba54bbca7b17cd29483c828be0704" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "digest" version = "0.9.0" @@ -1112,6 +1183,30 @@ dependencies = [ "want", ] +[[package]] +name = "iana-time-zone" +version = "0.1.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64c122667b287044802d6ce17ee2ddf13207ed924c712de9a66a5814d5b64765" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "winapi", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0703ae284fc167426161c2e3f1da3ea71d94b21bedbcc9494e92b28e334e3dca" +dependencies = [ + "cxx", + "cxx-build", +] + [[package]] name = "idna" version = "0.2.3" @@ -1182,6 +1277,7 @@ dependencies = [ "async-trait", "axum", "axum-auth", + "chrono", "ciborium", "clap", "cookie", @@ -1205,6 +1301,7 @@ version = "0.2.9" dependencies = [ "async-trait", "base64", + "chrono", "console_error_panic_hook", "recipes", "reqwasm", @@ -1249,6 +1346,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "link-cplusplus" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9272ab7b96c9046fbc5bc56c06c117cb639fe2d509df0c421cad82d2915cf369" +dependencies = [ + "cc", +] + [[package]] name = "lock_api" version = "0.4.8" @@ -1723,6 +1829,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" +[[package]] +name = "scratch" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8132065adcfd6e02db789d9285a0deb2f3fcb04002865ab67d5fb103533898" + [[package]] name = "sct" version = "0.7.0" @@ -1909,6 +2021,7 @@ dependencies = [ "bitflags", "byteorder", "bytes", + "chrono", "crc", "crossbeam-queue", "dotenvy", @@ -2402,6 +2515,12 @@ version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e8820f5d777f6224dc4be3632222971ac30164d4a258d595640799554ebfd99" +[[package]] +name = "unicode-width" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" + [[package]] name = "unicode-xid" version = "0.2.4" diff --git a/Makefile b/Makefile index 4604483..8b1b631 100644 --- a/Makefile +++ b/Makefile @@ -43,5 +43,8 @@ clean: sqlx-migrate: cd kitchen; cargo sqlx migrate run --database-url $(sqlite_url) +sqlx-revert: + cd kitchen; cargo sqlx migrate revert --database-url $(sqlite_url) + sqlx-prepare: cd kitchen; cargo sqlx prepare --database-url $(sqlite_url) diff --git a/kitchen/Cargo.toml b/kitchen/Cargo.toml index f640b71..c37bb71 100644 --- a/kitchen/Cargo.toml +++ b/kitchen/Cargo.toml @@ -19,6 +19,7 @@ ciborium = "0.2.0" tower = "0.4.13" serde = "1.0.144" cookie = "0.16.0" +chrono = "0.4.22" [dependencies.argon2] version = "0.4.1" @@ -53,4 +54,4 @@ features = ["tokio1"] [dependencies.sqlx] version = "0.6.2" -features = ["sqlite", "runtime-async-std-rustls", "offline"] \ No newline at end of file +features = ["sqlite", "runtime-async-std-rustls", "offline", "chrono"] \ No newline at end of file diff --git a/kitchen/migrations/20221030222458_mealplans_v0.down.sql b/kitchen/migrations/20221030222458_mealplans_v0.down.sql index c32f8de..b4559dd 100644 --- a/kitchen/migrations/20221030222458_mealplans_v0.down.sql +++ b/kitchen/migrations/20221030222458_mealplans_v0.down.sql @@ -1,3 +1,2 @@ -- Add down migration script here -drop table plans; drop table plan_recipes; \ No newline at end of file diff --git a/kitchen/migrations/20221030222458_mealplans_v0.up.sql b/kitchen/migrations/20221030222458_mealplans_v0.up.sql index e06d773..11c919f 100644 --- a/kitchen/migrations/20221030222458_mealplans_v0.up.sql +++ b/kitchen/migrations/20221030222458_mealplans_v0.up.sql @@ -1,3 +1,2 @@ -- Add up migration script here -CREATE TABLE plans(id NUMBER, user_id TEXT, date TEXT); -CREATE table plan_recipes(plan_id NUMBER, recipe_id TEXT); \ No newline at end of file +CREATE table plan_recipes(user_id TEXT NOT NULL, plan_date DATE NOT NULL, recipe_id TEXT NOT NULL, count integer NOT NULL); \ No newline at end of file diff --git a/kitchen/sqlx-data.json b/kitchen/sqlx-data.json index 4cfe822..2343e07 100644 --- a/kitchen/sqlx-data.json +++ b/kitchen/sqlx-data.json @@ -42,6 +42,36 @@ }, "query": "select recipe_id, recipe_text from recipes where user_id = ? and recipe_id = ?" }, + "19832e3582c05ed49c676fde33cde64274379a83a8dd130f6eec96c1d7250909": { + "describe": { + "columns": [ + { + "name": "plan_date: NaiveDate", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "recipe_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "count", + "ordinal": 2, + "type_info": "Int64" + } + ], + "nullable": [ + false, + false, + false + ], + "parameters": { + "Right": 2 + } + }, + "query": "select plan_date as \"plan_date: NaiveDate\", recipe_id, count\nfrom plan_recipes\nwhere\n user_id = ?\n and date(plan_date) > ?\norder by user_id, plan_date" + }, "3fd4017569dca4fe73e97e0e2bd612027a8c1b17b0b10faabd6459f56ca1c0bb": { "describe": { "columns": [], @@ -134,6 +164,46 @@ }, "query": "insert into sessions (id, session_value) values (?, ?)" }, + "ad3408cd773dd8f9308255ec2800171638a1aeda9817c57fb8360f97115f8e97": { + "describe": { + "columns": [ + { + "name": "plan_date: NaiveDate", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "recipe_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "count", + "ordinal": 2, + "type_info": "Int64" + } + ], + "nullable": [ + false, + false, + false + ], + "parameters": { + "Right": 1 + } + }, + "query": "with max_date as (\n select user_id, max(date(plan_date)) as plan_date from plan_recipes group by user_id\n)\n\nselect plan_recipes.plan_date as \"plan_date: NaiveDate\", plan_recipes.recipe_id, plan_recipes.count\n from plan_recipes\n inner join max_date on plan_recipes.user_id = max_date.user_id\nwhere\n plan_recipes.user_id = ?\n and plan_recipes.plan_date = max_date.plan_date" + }, + "c889e3621cb2977204b847c03930cde394cc16eaa63741f8ca07484a41f1aa87": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Right": 4 + } + }, + "query": "insert into plan_recipes (user_id, plan_date, recipe_id, count) values (?, ?, ?, ?)" + }, "c988364f9f83f4fa8bd0e594bab432ee7c9ec47ca40f4d16e5e2a8763653f377": { "describe": { "columns": [ diff --git a/kitchen/src/web/mod.rs b/kitchen/src/web/mod.rs index 942f117..959024e 100644 --- a/kitchen/src/web/mod.rs +++ b/kitchen/src/web/mod.rs @@ -181,13 +181,80 @@ async fn api_save_recipes( ) -> impl IntoResponse { use storage::{UserId, UserIdFromSession::FoundUserId}; if let FoundUserId(UserId(id)) = session { - if let Err(e) = app_store + let result = app_store .store_recipes_for_user(id.as_str(), &recipes) + .await; + match result.map_err(|e| format!("Error: {:?}", e)) { + Ok(val) => Ok(axum::Json::from(val)), + Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)), + } + } else { + Err(( + StatusCode::UNAUTHORIZED, + "You must be authorized to use this API call".to_owned(), + )) + } +} + +async fn api_plan( + Extension(app_store): Extension>, + session: storage::UserIdFromSession, +) -> impl IntoResponse { + use storage::{UserId, UserIdFromSession::FoundUserId}; + if let FoundUserId(UserId(id)) = session { + match app_store + .fetch_latest_meal_plan(&id) + .await + .map_err(|e| format!("Error: {:?}", e)) + { + Ok(val) => Ok(axum::Json::from(val)), + Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)), + } + } else { + Err(( + StatusCode::UNAUTHORIZED, + "You must be authorized to use this API call".to_owned(), + )) + } +} + +async fn api_plan_since( + Extension(app_store): Extension>, + session: storage::UserIdFromSession, + Path(date): Path, +) -> impl IntoResponse { + use storage::{UserId, UserIdFromSession::FoundUserId}; + if let FoundUserId(UserId(id)) = session { + match app_store + .fetch_meal_plans_since(&id, date) + .await + .map_err(|e| format!("Error: {:?}", e)) + { + Ok(val) => Ok(axum::Json::from(val)), + Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)), + } + } else { + Err(( + StatusCode::UNAUTHORIZED, + "You must be authorized to use this API call".to_owned(), + )) + } +} + +async fn api_save_plan( + Extension(app_store): Extension>, + session: storage::UserIdFromSession, + Json(meal_plan): Json>, +) -> impl IntoResponse { + use storage::{UserId, UserIdFromSession::FoundUserId}; + if let FoundUserId(UserId(id)) = session { + if let Err(e) = app_store + .save_meal_plan(id.as_str(), &meal_plan, chrono::Local::now().date_naive()) .await { return (StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)); } - (StatusCode::OK, "Successfully saved categories".to_owned()) + (StatusCode::OK, "Successfully saved mealPlan".to_owned()) } else { ( StatusCode::UNAUTHORIZED, @@ -214,6 +281,9 @@ pub async fn ui_main(recipe_dir_path: PathBuf, store_path: PathBuf, listen_socke .route("/api/v1/recipes", get(api_recipes).post(api_save_recipes)) // recipe entry api path route .route("/api/v1/recipe/:recipe_id", get(api_recipe_entry)) + // mealplan api path routes + .route("/api/v1/plan", get(api_plan).post(api_save_plan)) + .route("/api/v1/plan/:date", get(api_plan_since)) // categories api path route .route( "/api/v1/categories", diff --git a/kitchen/src/web/storage/fetch_latest_meal_plan.sql b/kitchen/src/web/storage/fetch_latest_meal_plan.sql new file mode 100644 index 0000000..40ace29 --- /dev/null +++ b/kitchen/src/web/storage/fetch_latest_meal_plan.sql @@ -0,0 +1,10 @@ +with max_date as ( + select user_id, max(date(plan_date)) as plan_date from plan_recipes group by user_id +) + +select plan_recipes.plan_date as "plan_date: NaiveDate", plan_recipes.recipe_id, plan_recipes.count + from plan_recipes + inner join max_date on plan_recipes.user_id = max_date.user_id +where + plan_recipes.user_id = ? + and plan_recipes.plan_date = max_date.plan_date \ No newline at end of file diff --git a/kitchen/src/web/storage/fetch_meal_plans_since.sql b/kitchen/src/web/storage/fetch_meal_plans_since.sql new file mode 100644 index 0000000..8013fef --- /dev/null +++ b/kitchen/src/web/storage/fetch_meal_plans_since.sql @@ -0,0 +1,6 @@ +select plan_date as "plan_date: NaiveDate", recipe_id, count +from plan_recipes +where + user_id = ? + and date(plan_date) > ? +order by user_id, plan_date \ No newline at end of file diff --git a/kitchen/src/web/storage/mod.rs b/kitchen/src/web/storage/mod.rs index e6c634b..e06e995 100644 --- a/kitchen/src/web/storage/mod.rs +++ b/kitchen/src/web/storage/mod.rs @@ -12,8 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. use async_std::sync::Arc; -use std::path::Path; use std::str::FromStr; +use std::{collections::BTreeMap, path::Path}; use argon2::{ password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, @@ -26,6 +26,7 @@ use axum::{ headers::Cookie, http::StatusCode, }; +use chrono::NaiveDate; use ciborium; use recipes::RecipeEntry; use secrecy::{ExposeSecret, Secret}; @@ -100,6 +101,24 @@ pub trait APIStore { user_id: S, id: S, ) -> Result>; + + async fn fetch_latest_meal_plan + Send>( + &self, + user_id: S, + ) -> Result>>; + + async fn fetch_meal_plans_since + Send>( + &self, + user_id: S, + date: NaiveDate, + ) -> Result>>>; + + async fn save_meal_plan + Send>( + &self, + user_id: S, + recipe_counts: &Vec<(String, i32)>, + date: NaiveDate, + ) -> Result<()>; } #[async_trait] @@ -371,4 +390,93 @@ impl APIStore for SqliteStore { .await?; Ok(()) } + + async fn save_meal_plan + Send>( + &self, + user_id: S, + recipe_counts: &Vec<(String, i32)>, + date: NaiveDate, + ) -> Result<()> { + let user_id = user_id.as_ref(); + let mut transaction = self.pool.as_ref().begin().await?; + for (id, count) in recipe_counts { + sqlx::query_file!( + "src/web/storage/save_meal_plan.sql", + user_id, + date, + id, + count + ) + .execute(&mut transaction) + .await?; + } + transaction.commit().await?; + Ok(()) + } + + async fn fetch_meal_plans_since + Send>( + &self, + user_id: S, + date: NaiveDate, + ) -> Result>>> { + let user_id = user_id.as_ref(); + struct Row { + pub plan_date: NaiveDate, + pub recipe_id: String, + pub count: i64, + } + // NOTE(jwall): It feels like I shouldn't have to use an override here + // but I do because of the way sqlite does types and how that interacts + // with sqlx's type inference machinery. + let rows = sqlx::query_file_as!( + Row, + r#"src/web/storage/fetch_meal_plans_since.sql"#, + user_id, + date + ) + .fetch_all(self.pool.as_ref()) + .await?; + if rows.is_empty() { + return Ok(None); + } + let mut result = BTreeMap::new(); + for row in rows { + let (date, recipe_id, count): (NaiveDate, String, i64) = + (row.plan_date, row.recipe_id, row.count); + result + .entry(date.clone()) + .or_insert_with(|| Vec::new()) + .push((recipe_id, count as i32)); + } + Ok(Some(result)) + } + + async fn fetch_latest_meal_plan + Send>( + &self, + user_id: S, + ) -> Result>> { + let user_id = user_id.as_ref(); + struct Row { + pub plan_date: NaiveDate, + pub recipe_id: String, + pub count: i64, + } + // NOTE(jwall): It feels like I shouldn't have to use an override here + // but I do because of the way sqlite does types and how that interacts + // with sqlx's type inference machinery. + let rows = + sqlx::query_file_as!(Row, "src/web/storage/fetch_latest_meal_plan.sql", user_id,) + .fetch_all(self.pool.as_ref()) + .await?; + if rows.is_empty() { + return Ok(None); + } + let mut result = Vec::new(); + for row in rows { + let (_, recipe_id, count): (NaiveDate, String, i64) = + (row.plan_date, row.recipe_id, row.count); + result.push((recipe_id, count as i32)); + } + Ok(Some(result)) + } } diff --git a/kitchen/src/web/storage/save_meal_plan.sql b/kitchen/src/web/storage/save_meal_plan.sql new file mode 100644 index 0000000..a632bd5 --- /dev/null +++ b/kitchen/src/web/storage/save_meal_plan.sql @@ -0,0 +1 @@ +insert into plan_recipes (user_id, plan_date, recipe_id, count) values (?, ?, ?, ?) \ No newline at end of file diff --git a/web/Cargo.toml b/web/Cargo.toml index 0051bf7..09ca669 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -23,6 +23,10 @@ async-trait = "0.1.57" base64 = "0.13.0" sycamore-router = "0.8" +[dependencies.chrono] +version = "0.4.22" +features = ["serde"] + [dependencies.reqwasm] version = "0.5.0" diff --git a/web/src/api.rs b/web/src/api.rs index 84bb9ac..8ec42c1 100644 --- a/web/src/api.rs +++ b/web/src/api.rs @@ -62,10 +62,19 @@ pub async fn init_page_state(store: &HttpStore, state: &app_state::State) -> Res state.recipes.set(recipes); } } - if let Some(rs) = recipes { - for r in rs { - if !state.recipe_counts.get().contains_key(r.recipe_id()) { - state.set_recipe_count_by_index(&r.recipe_id().to_owned(), 0); + + if let Ok(Some(plan)) = store.get_plan().await { + // set the counts. + for (id, count) in plan { + state.set_recipe_count_by_index(&id, count as usize); + } + } else { + // Initialize things to zero + if let Some(rs) = recipes { + for r in rs { + if !state.recipe_counts.get().contains_key(r.recipe_id()) { + state.set_recipe_count_by_index(&r.recipe_id().to_owned(), 0); + } } } } @@ -307,4 +316,59 @@ impl HttpStore { Ok(()) } } + + pub async fn save_plan(&self, plan: Vec<(String, i32)>) -> Result<(), Error> { + let mut path = self.root.clone(); + path.push_str("/plan"); + let storage = js_lib::get_storage(); + let serialized_plan = to_string(&plan).expect("Unable to encode plan as json"); + storage.set("plan", &serialized_plan)?; + let resp = reqwasm::http::Request::post(&path) + .body(to_string(&plan).expect("Unable to encode plan as json")) + .header("content-type", "application/json") + .send() + .await?; + if resp.status() != 200 { + Err(format!("Status: {}", resp.status()).into()) + } else { + debug!("We got a valid response back!"); + Ok(()) + } + } + + pub async fn get_plan(&self) -> Result>, Error> { + let mut path = self.root.clone(); + path.push_str("/plan"); + let resp = reqwasm::http::Request::get(&path).send().await?; + let storage = js_lib::get_storage(); + if resp.status() != 200 { + Err(format!("Status: {}", resp.status()).into()) + } else { + debug!("We got a valid response back"); + let plan: Option> = + resp.json().await.map_err(|e| format!("{}", e))?; + if let Some(ref entry) = plan { + let serialized: String = to_string(entry).map_err(|e| format!("{}", e))?; + storage.set("plan", &serialized)? + } + Ok(plan) + } + } + + pub async fn get_plans_since( + &self, + date: chrono::NaiveDate, + ) -> Result>, Error> { + let mut path = self.root.clone(); + path.push_str("/plan"); + path.push_str(&format!("/{}", date)); + // TODO(jwall): How does this play with the cache? + let resp = reqwasm::http::Request::get(&path).send().await?; + if resp.status() != 200 { + Err(format!("Status: {}", resp.status()).into()) + } else { + debug!("We got a valid response back"); + Ok(resp.json().await?) + } + } } diff --git a/web/src/app_state.rs b/web/src/app_state.rs index 0f2b4c6..7329a27 100644 --- a/web/src/app_state.rs +++ b/web/src/app_state.rs @@ -19,7 +19,7 @@ use tracing::{debug, instrument, warn}; use recipes::{Ingredient, IngredientAccumulator, Recipe}; pub struct State { - pub recipe_counts: RcSignal>, + pub recipe_counts: RcSignal>>, pub extras: RcSignal, RcSignal))>>, pub staples: RcSignal>, pub recipes: RcSignal>, @@ -45,12 +45,12 @@ impl State { use_context::>(cx).clone() } - pub fn get_menu_list(&self) -> Vec<(String, usize)> { + pub fn get_menu_list(&self) -> Vec<(String, RcSignal)> { self.recipe_counts .get() .iter() - .map(|(k, v)| (k.clone(), *v)) - .filter(|(_, v)| *v != 0) + .map(|(k, v)| (k.clone(), v.clone())) + .filter(|(_, v)| *(v.get_untracked()) != 0) .collect() } @@ -62,7 +62,7 @@ impl State { let mut acc = IngredientAccumulator::new(); let recipe_counts = self.get_menu_list(); for (idx, count) in recipe_counts.iter() { - for _ in 0..*count { + for _ in 0..*count.get_untracked() { acc.accumulate_from( self.recipes .get() @@ -96,14 +96,27 @@ impl State { groups } - pub fn get_recipe_count_by_index(&self, key: &String) -> Option { - self.recipe_counts.get().get(key).cloned() + /// Retrieves the count for a recipe without triggering subscribers to the entire + /// recipe count set. + pub fn get_recipe_count_by_index(&self, key: &String) -> Option> { + self.recipe_counts.get_untracked().get(key).cloned() } - pub fn set_recipe_count_by_index(&self, key: &String, count: usize) -> usize { - let mut counts = self.recipe_counts.get().as_ref().clone(); - counts.insert(key.clone(), count); + pub fn reset_recipe_counts(&self) { + for (key, count) in self.recipe_counts.get_untracked().iter() { + count.set(0); + } + } + + /// Set the recipe_count by index. Does not trigger subscribers to the entire set of recipe_counts. + /// This does trigger subscribers of the specific recipe you are updating though. + pub fn set_recipe_count_by_index(&self, key: &String, count: usize) -> RcSignal { + let mut counts = self.recipe_counts.get_untracked().as_ref().clone(); + counts + .entry(key.clone()) + .and_modify(|e| e.set(count)) + .or_insert_with(|| create_rc_signal(count)); self.recipe_counts.set(counts); - count + self.recipe_counts.get_untracked().get(key).unwrap().clone() } } diff --git a/web/src/components/mod.rs b/web/src/components/mod.rs index b867c53..f5c58d6 100644 --- a/web/src/components/mod.rs +++ b/web/src/components/mod.rs @@ -16,8 +16,8 @@ pub mod categories; pub mod header; pub mod recipe; pub mod recipe_list; +pub mod recipe_plan; pub mod recipe_selection; -pub mod recipe_selector; pub mod shopping_list; pub mod tabs; @@ -26,7 +26,7 @@ pub use categories::*; pub use header::*; pub use recipe::*; pub use recipe_list::*; +pub use recipe_plan::*; pub use recipe_selection::*; -pub use recipe_selector::*; pub use shopping_list::*; pub use tabs::*; diff --git a/web/src/components/recipe_selector.rs b/web/src/components/recipe_plan.rs similarity index 69% rename from web/src/components/recipe_selector.rs rename to web/src/components/recipe_plan.rs index b066fc2..f3c781d 100644 --- a/web/src/components/recipe_selector.rs +++ b/web/src/components/recipe_plan.rs @@ -20,7 +20,7 @@ use crate::{api::*, app_state}; #[allow(non_snake_case)] #[instrument] -pub fn RecipeSelector(cx: Scope) -> View { +pub fn RecipePlan(cx: Scope) -> View { let rows = create_memo(cx, move || { let state = app_state::State::get_from_context(cx); let mut rows = Vec::new(); @@ -37,9 +37,10 @@ pub fn RecipeSelector(cx: Scope) -> View { } rows }); - let clicked = create_signal(cx, false); + let refresh_click = create_signal(cx, false); + let save_click = create_signal(cx, false); create_effect(cx, move || { - clicked.track(); + refresh_click.track(); let store = HttpStore::get_from_context(cx); let state = app_state::State::get_from_context(cx); spawn_local_scoped(cx, { @@ -47,9 +48,24 @@ pub fn RecipeSelector(cx: Scope) -> View { if let Err(err) = init_page_state(store.as_ref(), state.as_ref()).await { error!(?err); }; + state.reset_recipe_counts(); } }); }); + create_effect(cx, move || { + save_click.track(); + let store = HttpStore::get_from_context(cx); + let state = app_state::State::get_from_context(cx); + spawn_local_scoped(cx, { + let mut plan = Vec::new(); + for (key, count) in state.recipe_counts.get_untracked().iter() { + plan.push((key.clone(), *count.get_untracked() as i32)); + } + async move { + store.save_plan(plan).await.expect("Failed to save plan"); + } + }) + }); view! {cx, table(class="recipe_selector no-print") { (View::new_fragment( @@ -69,10 +85,15 @@ pub fn RecipeSelector(cx: Scope) -> View { }).collect() )) } - input(type="button", value="Refresh Recipes", on:click=move |_| { + input(type="button", value="Reset Recipes", on:click=move |_| { // Poor man's click event signaling. - let toggle = !*clicked.get(); - clicked.set(toggle); + let toggle = !*refresh_click.get(); + refresh_click.set(toggle); + }) + input(type="button", value="Save Plan", on:click=move |_| { + // Poor man's click event signaling. + let toggle = !*save_click.get(); + save_click.set(toggle); }) } } diff --git a/web/src/components/recipe_selection.rs b/web/src/components/recipe_selection.rs index ade45ec..7a2e0ad 100644 --- a/web/src/components/recipe_selection.rs +++ b/web/src/components/recipe_selection.rs @@ -43,6 +43,15 @@ pub fn RecipeSelection(cx: Scope, props: RecipeCheckBoxProps) -> View(cx: Scope, props: RecipeCheckBoxProps) -> View(cx: Scope) -> View { view! {cx, PlanningPage( selected=Some("Plan".to_owned()), - ) { RecipeSelector() } + ) { RecipePlan() } } }