From f808ca8585c79ab39e55fefae2610c177317146a Mon Sep 17 00:00:00 2001 From: Jeremy Wall Date: Tue, 17 Jan 2023 13:44:13 -0500 Subject: [PATCH] Handle messages for selecting a plan_date --- kitchen/sqlx-data.json | 120 +++++++++++++ kitchen/src/web/mod.rs | 53 ++++++ .../fetch_filtered_ingredients_for_date.sql | 8 + .../storage/fetch_modified_amts_for_date.sql | 9 + kitchen/src/web/storage/mod.rs | 163 +++++++++++++++++- .../save_filtered_ingredients_for_date.sql | 2 + .../storage/save_modified_amts_for_date.sql | 2 + .../storage/store_extra_items_for_date.sql | 3 + web/src/api.rs | 65 ++++++- web/src/app_state.rs | 50 +++++- web/src/pages/planning/mod.rs | 3 + web/src/pages/planning/select.rs | 26 +++ web/src/routing/mod.rs | 5 + 13 files changed, 505 insertions(+), 4 deletions(-) create mode 100644 kitchen/src/web/storage/fetch_filtered_ingredients_for_date.sql create mode 100644 kitchen/src/web/storage/fetch_modified_amts_for_date.sql create mode 100644 kitchen/src/web/storage/save_filtered_ingredients_for_date.sql create mode 100644 kitchen/src/web/storage/save_modified_amts_for_date.sql create mode 100644 kitchen/src/web/storage/store_extra_items_for_date.sql create mode 100644 web/src/pages/planning/select.rs diff --git a/kitchen/sqlx-data.json b/kitchen/sqlx-data.json index 4b81565..7f629cf 100644 --- a/kitchen/sqlx-data.json +++ b/kitchen/sqlx-data.json @@ -116,6 +116,16 @@ }, "query": "insert into staples (user_id, content) values (?, ?)\n on conflict(user_id) do update set content = excluded.content" }, + "1b6fd91460bef61cf02f210404a4ca57b520c969d1f9613e7101ee6aa7a9962a": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Right": 6 + } + }, + "query": "insert into modified_amts(user_id, name, form, measure_type, amt, plan_date)\n values (?, ?, ?, ?, ?, ?) on conflict (user_id, name, form, measure_type, plan_date) do update set amt=excluded.amt" + }, "2582522f8ca9f12eccc70a3b339d9030aee0f52e62d6674cfd3862de2a68a177": { "describe": { "columns": [], @@ -236,6 +246,40 @@ }, "query": "insert into recipes (user_id, recipe_id, recipe_text) values (?, ?, ?)\n on conflict(user_id, recipe_id) do update set recipe_text=excluded.recipe_text" }, + "4237ff804f254c122a36a14135b90434c6576f48d3a83245503d702552ea9f30": { + "describe": { + "columns": [ + { + "name": "name", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "amt", + "ordinal": 1, + "type_info": "Text" + } + ], + "nullable": [ + false, + false + ], + "parameters": { + "Right": 2 + } + }, + "query": "select\n name,\n amt\nfrom extra_items\nwhere\n user_id = ?\n and plan_date = ?" + }, + "5883c4a57def93cca45f8f9d81c8bba849547758217cd250e7ab28cc166ab42b": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Right": 5 + } + }, + "query": "insert into filtered_ingredients(user_id, name, form, measure_type, plan_date)\n values (?, ?, ?, ?, ?) on conflict(user_id, name, form, measure_type, plan_date) DO NOTHING" + }, "5d743897fb0d8fd54c3708f1b1c6e416346201faa9e28823c1ba5a421472b1fa": { "describe": { "columns": [], @@ -264,6 +308,42 @@ }, "query": "select content from staples where user_id = ?" }, + "699ff0f0d4d4c6e26a21c1922a5b5249d89ed1677680a2276899a7f8b26344ee": { + "describe": { + "columns": [ + { + "name": "name", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "form", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "measure_type", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "amt", + "ordinal": 3, + "type_info": "Text" + } + ], + "nullable": [ + false, + false, + false, + false + ], + "parameters": { + "Right": 2 + } + }, + "query": "select\n modified_amts.name,\n modified_amts.form,\n modified_amts.measure_type,\n modified_amts.amt\nfrom modified_amts\nwhere\n user_id = ?\n and plan_date = ?" + }, "6c43908d90f229b32ed8b1b076be9b452a995e1b42ba2554e947c515b031831a": { "describe": { "columns": [], @@ -294,6 +374,36 @@ }, "query": "delete from sessions where id = ?" }, + "7695a0602395006f9b76ecd4d0cb5ecd5dee419b71b3b0b9ea4f47a83f3df41a": { + "describe": { + "columns": [ + { + "name": "name", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "form", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "measure_type", + "ordinal": 2, + "type_info": "Text" + } + ], + "nullable": [ + false, + false, + false + ], + "parameters": { + "Right": 2 + } + }, + "query": "select\n filtered_ingredients.name,\n filtered_ingredients.form,\n filtered_ingredients.measure_type\nfrom filtered_ingredients\nwhere\n user_id = ?\n and plan_date = ?" + }, "7f4abc448b16e8b6b2bb74f8e810e245e81b38e1407085a20d28bfddfc06891f": { "describe": { "columns": [ @@ -414,6 +524,16 @@ }, "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" }, + "ba07658eb11f9d6cfdb5dbee4496b2573f1e51f4b4d9ae760eca3b977649b5c7": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Right": 4 + } + }, + "query": "insert into extra_items (user_id, name, amt, plan_date)\nvalues (?, ?, ?, ?)\non conflict (user_id, name, plan_date) do update set amt=excluded.amt" + }, "c988364f9f83f4fa8bd0e594bab432ee7c9ec47ca40f4d16e5e2a8763653f377": { "describe": { "columns": [ diff --git a/kitchen/src/web/mod.rs b/kitchen/src/web/mod.rs index a79aca7..c7fc01d 100644 --- a/kitchen/src/web/mod.rs +++ b/kitchen/src/web/mod.rs @@ -293,6 +293,26 @@ async fn api_inventory_v2( } } +async fn api_inventory_for_date( + Extension(app_store): Extension>, + session: storage::UserIdFromSession, + Path(date): Path, +) -> api::InventoryResponse { + use storage::{UserId, UserIdFromSession::FoundUserId}; + if let FoundUserId(UserId(id)) = session { + app_store + .fetch_inventory_for_date(id, date) + .await + .map(|d| { + let data: api::InventoryData = d.into(); + data + }) + .into() + } else { + api::Response::Unauthorized + } +} + async fn api_inventory( Extension(app_store): Extension>, session: storage::UserIdFromSession, @@ -309,6 +329,35 @@ async fn api_inventory( } } +async fn api_save_inventory_for_date( + Extension(app_store): Extension>, + session: storage::UserIdFromSession, + Path(date): Path, + Json((filtered_ingredients, modified_amts, extra_items)): Json<( + Vec, + Vec<(IngredientKey, String)>, + Vec<(String, String)>, + )>, +) -> api::EmptyResponse { + use storage::{UserId, UserIdFromSession::FoundUserId}; + if let FoundUserId(UserId(id)) = session { + let filtered_ingredients = filtered_ingredients.into_iter().collect(); + let modified_amts = modified_amts.into_iter().collect(); + app_store + .save_inventory_data_for_date( + id, + &date, + filtered_ingredients, + modified_amts, + extra_items, + ) + .await + .into() + } else { + api::EmptyResponse::Unauthorized + } +} + async fn save_inventory_data( app_store: Arc, id: String, @@ -441,6 +490,10 @@ fn mk_v2_routes() -> Router { "/inventory", get(api_inventory_v2).post(api_save_inventory_v2), ) + .route( + "/inventory/at/:date", + get(api_inventory_for_date).post(api_save_inventory_for_date), + ) // TODO(jwall): This is now deprecated but will still work .route("/categories", get(api_categories).post(api_save_categories)) .route( diff --git a/kitchen/src/web/storage/fetch_filtered_ingredients_for_date.sql b/kitchen/src/web/storage/fetch_filtered_ingredients_for_date.sql new file mode 100644 index 0000000..7c81089 --- /dev/null +++ b/kitchen/src/web/storage/fetch_filtered_ingredients_for_date.sql @@ -0,0 +1,8 @@ +select + filtered_ingredients.name, + filtered_ingredients.form, + filtered_ingredients.measure_type +from filtered_ingredients +where + user_id = ? + and plan_date = ? \ No newline at end of file diff --git a/kitchen/src/web/storage/fetch_modified_amts_for_date.sql b/kitchen/src/web/storage/fetch_modified_amts_for_date.sql new file mode 100644 index 0000000..873b67e --- /dev/null +++ b/kitchen/src/web/storage/fetch_modified_amts_for_date.sql @@ -0,0 +1,9 @@ +select + modified_amts.name, + modified_amts.form, + modified_amts.measure_type, + modified_amts.amt +from modified_amts +where + user_id = ? + and 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 4be6c47..3d00b54 100644 --- a/kitchen/src/web/storage/mod.rs +++ b/kitchen/src/web/storage/mod.rs @@ -145,6 +145,16 @@ pub trait APIStore { date: NaiveDate, ) -> Result<()>; + async fn fetch_inventory_for_date + Send>( + &self, + user_id: S, + date: NaiveDate, + ) -> Result<( + Vec, + Vec<(IngredientKey, String)>, + Vec<(String, String)>, + )>; + async fn fetch_latest_inventory_data + Send>( &self, user_id: S, @@ -154,6 +164,15 @@ pub trait APIStore { Vec<(String, String)>, )>; + async fn save_inventory_data_for_date + Send>( + &self, + user_id: S, + date: &NaiveDate, + filtered_ingredients: BTreeSet, + modified_amts: BTreeMap, + extra_items: Vec<(String, String)>, + ) -> Result<()>; + async fn save_inventory_data + Send>( &self, user_id: S, @@ -652,7 +671,89 @@ impl APIStore for SqliteStore { Ok(Some(result)) } - // TODO(jwall): Do we need fetch for date variants of this. + async fn fetch_inventory_for_date + Send>( + &self, + user_id: S, + date: NaiveDate, + ) -> Result<( + Vec, + Vec<(IngredientKey, String)>, + Vec<(String, String)>, + )> { + let user_id = user_id.as_ref(); + struct FilteredIngredientRow { + name: String, + form: String, + measure_type: String, + } + let filtered_ingredient_rows: Vec = sqlx::query_file_as!( + FilteredIngredientRow, + "src/web/storage/fetch_filtered_ingredients_for_date.sql", + user_id, + date, + ) + .fetch_all(self.pool.as_ref()) + .await?; + let mut filtered_ingredients = Vec::new(); + for row in filtered_ingredient_rows { + filtered_ingredients.push(IngredientKey::new( + row.name, + if row.form.is_empty() { + None + } else { + Some(row.form) + }, + row.measure_type, + )); + } + struct ModifiedAmtRow { + name: String, + form: String, + measure_type: String, + amt: String, + } + let modified_amt_rows = sqlx::query_file_as!( + ModifiedAmtRow, + "src/web/storage/fetch_modified_amts_for_date.sql", + user_id, + date, + ) + .fetch_all(self.pool.as_ref()) + .await?; + let mut modified_amts = Vec::new(); + for row in modified_amt_rows { + modified_amts.push(( + IngredientKey::new( + row.name, + if row.form.is_empty() { + None + } else { + Some(row.form) + }, + row.measure_type, + ), + row.amt, + )); + } + pub struct ExtraItemRow { + name: String, + amt: String, + } + let extra_items_rows = sqlx::query_file_as!( + ExtraItemRow, + "src/web/storage/fetch_extra_items_for_date.sql", + user_id, + date, + ) + .fetch_all(self.pool.as_ref()) + .await?; + let mut extra_items = Vec::new(); + for row in extra_items_rows { + extra_items.push((row.name, row.amt)); + } + Ok((filtered_ingredients, modified_amts, extra_items)) + } + async fn fetch_latest_inventory_data + Send>( &self, user_id: S, @@ -732,6 +833,66 @@ impl APIStore for SqliteStore { Ok((filtered_ingredients, modified_amts, extra_items)) } + async fn save_inventory_data_for_date + Send>( + &self, + user_id: S, + date: &NaiveDate, + filtered_ingredients: BTreeSet, + modified_amts: BTreeMap, + extra_items: Vec<(String, String)>, + ) -> Result<()> { + let user_id = user_id.as_ref(); + let mut transaction = self.pool.as_ref().begin().await?; + // store the filtered_ingredients + for key in filtered_ingredients { + let name = key.name(); + let form = key.form(); + let measure_type = key.measure_type(); + sqlx::query_file!( + "src/web/storage/save_filtered_ingredients_for_date.sql", + user_id, + name, + form, + measure_type, + date, + ) + .execute(&mut transaction) + .await?; + } + // store the modified amts + for (key, amt) in modified_amts { + let name = key.name(); + let form = key.form(); + let measure_type = key.measure_type(); + let amt = &amt; + sqlx::query_file!( + "src/web/storage/save_modified_amts_for_date.sql", + user_id, + name, + form, + measure_type, + amt, + date, + ) + .execute(&mut transaction) + .await?; + } + // Store the extra items + for (name, amt) in extra_items { + sqlx::query_file!( + "src/web/storage/store_extra_items_for_date.sql", + user_id, + name, + amt, + date + ) + .execute(&mut transaction) + .await?; + } + transaction.commit().await?; + Ok(()) + } + async fn save_inventory_data + Send>( &self, user_id: S, diff --git a/kitchen/src/web/storage/save_filtered_ingredients_for_date.sql b/kitchen/src/web/storage/save_filtered_ingredients_for_date.sql new file mode 100644 index 0000000..4f282fe --- /dev/null +++ b/kitchen/src/web/storage/save_filtered_ingredients_for_date.sql @@ -0,0 +1,2 @@ +insert into filtered_ingredients(user_id, name, form, measure_type, plan_date) + values (?, ?, ?, ?, ?) on conflict(user_id, name, form, measure_type, plan_date) DO NOTHING \ No newline at end of file diff --git a/kitchen/src/web/storage/save_modified_amts_for_date.sql b/kitchen/src/web/storage/save_modified_amts_for_date.sql new file mode 100644 index 0000000..74f3335 --- /dev/null +++ b/kitchen/src/web/storage/save_modified_amts_for_date.sql @@ -0,0 +1,2 @@ +insert into modified_amts(user_id, name, form, measure_type, amt, plan_date) + values (?, ?, ?, ?, ?, ?) on conflict (user_id, name, form, measure_type, plan_date) do update set amt=excluded.amt \ No newline at end of file diff --git a/kitchen/src/web/storage/store_extra_items_for_date.sql b/kitchen/src/web/storage/store_extra_items_for_date.sql new file mode 100644 index 0000000..13b5f80 --- /dev/null +++ b/kitchen/src/web/storage/store_extra_items_for_date.sql @@ -0,0 +1,3 @@ +insert into extra_items (user_id, name, amt, plan_date) +values (?, ?, ?, ?) +on conflict (user_id, name, plan_date) do update set amt=excluded.amt \ No newline at end of file diff --git a/web/src/api.rs b/web/src/api.rs index ff6bd79..8e2b426 100644 --- a/web/src/api.rs +++ b/web/src/api.rs @@ -257,6 +257,27 @@ impl LocalStore { } } + pub fn set_plan_date(&self, date: &NaiveDate) { + self.store + .set( + "plan:date", + &to_string(&date).expect("Failed to serialize plan:date"), + ) + .expect("Failed to store plan:date"); + } + + pub fn get_plan_date(&self) -> Option { + if let Some(date) = self + .store + .get("plan:date") + .expect("Failed to get plan date") + { + Some(from_str(&date).expect("Failed to deserialize plan_date")) + } else { + None + } + } + pub fn get_inventory_data( &self, ) -> Option<( @@ -606,7 +627,7 @@ impl HttpStore { pub async fn fetch_plan_for_date( &self, - date: NaiveDate, + date: &NaiveDate, ) -> Result>, Error> { let mut path = self.v2_path(); path.push_str("/plan"); @@ -643,6 +664,48 @@ impl HttpStore { } } + pub async fn fetch_inventory_for_date( + &self, + date: &NaiveDate, + ) -> Result< + ( + BTreeSet, + BTreeMap, + Vec<(String, String)>, + ), + Error, + > { + let mut path = self.v2_path(); + path.push_str("/inventory"); + path.push_str("/at"); + path.push_str(&format!("/{}", date)); + let resp = reqwasm::http::Request::get(&path).send().await?; + if resp.status() != 200 { + let err = Err(format!("Status: {}", resp.status()).into()); + Ok(match self.local_store.get_inventory_data() { + Some(val) => val, + None => return err, + }) + } else { + debug!("We got a valid response back"); + let InventoryData { + filtered_ingredients, + modified_amts, + extra_items, + } = resp + .json::() + .await + .map_err(|e| format!("{}", e))? + .as_success() + .unwrap(); + Ok(( + filtered_ingredients.into_iter().collect(), + modified_amts.into_iter().collect(), + extra_items, + )) + } + } + pub async fn fetch_inventory_data( &self, ) -> Result< diff --git a/web/src/app_state.rs b/web/src/app_state.rs index f2df26e..17daf80 100644 --- a/web/src/app_state.rs +++ b/web/src/app_state.rs @@ -38,6 +38,7 @@ pub struct AppState { pub modified_amts: BTreeMap, pub auth: Option, pub plan_dates: BTreeSet, + pub selected_plan_date: Option, } impl AppState { @@ -52,6 +53,7 @@ impl AppState { modified_amts: BTreeMap::new(), auth: None, plan_dates: BTreeSet::new(), + selected_plan_date: None, } } } @@ -73,6 +75,7 @@ pub enum Message { SaveState(Option>), LoadState(Option>), UpdateStaples(String, Option>), + SelectPlanDate(NaiveDate), } impl Debug for Message { @@ -113,6 +116,7 @@ impl Debug for Message { Self::SaveState(_) => write!(f, "SaveState"), Self::LoadState(_) => write!(f, "LoadState"), Self::UpdateStaples(arg, _) => f.debug_tuple("UpdateStaples").field(arg).finish(), + Self::SelectPlanDate(arg) => f.debug_tuple("SelectPlanDate").field(arg).finish(), } } } @@ -189,8 +193,15 @@ impl StateMachine { debug!(?plan_dates, "meal plan list"); state.plan_dates = BTreeSet::from_iter(plan_dates.drain(0..)); } + info!("Synchronizing meal plan"); - let plan = store.fetch_plan().await?; + let plan = if let Some(cached_plan_date) = local_store.get_plan_date() { + let plan = store.fetch_plan_for_date(&cached_plan_date).await?; + state.selected_plan_date = Some(cached_plan_date); + plan + } else { + store.fetch_plan().await? + }; if let Some(plan) = plan { // set the counts. let mut plan_map = BTreeMap::new(); @@ -242,8 +253,13 @@ impl StateMachine { error!("{:?}", e); } } + let inventory_data = if let Some(cached_plan_date) = &state.selected_plan_date { + store.fetch_inventory_for_date(cached_plan_date).await + } else { + store.fetch_inventory_data().await + }; info!("Synchronizing inventory data"); - match store.fetch_inventory_data().await { + match inventory_data { Ok((filtered_ingredients, modified_amts, extra_items)) => { local_store.set_inventory_data(( &filtered_ingredients, @@ -436,6 +452,36 @@ impl MessageMapper for StateMachine { }); return; } + Message::SelectPlanDate(date) => { + let store = self.store.clone(); + let local_store = self.local_store.clone(); + spawn_local_scoped(cx, async move { + if let Some(mut plan) = store + .fetch_plan_for_date(&date) + .await + .expect("Failed to fetch plan for date") + { + // Note(jwall): This is a little unusual but because this + // is async code we can't rely on the set below. + original_copy.recipe_counts = + BTreeMap::from_iter(plan.drain(0..).map(|(k, v)| (k, v as usize))); + } + let (filtered, modified, extras) = store + .fetch_inventory_for_date(&date) + .await + .expect("Failed to fetch inventory_data for date"); + original_copy.modified_amts = modified; + original_copy.filtered_ingredients = filtered; + original_copy.extras = extras; + local_store.set_plan_date(&date); + + original.set(original_copy); + }); + // NOTE(jwall): Because we do our signal set above in the async block + // we have to return here to avoid lifetime issues and double setting + // the original signal. + return; + } } original.set(original_copy); } diff --git a/web/src/pages/planning/mod.rs b/web/src/pages/planning/mod.rs index 682e4a2..940e35b 100644 --- a/web/src/pages/planning/mod.rs +++ b/web/src/pages/planning/mod.rs @@ -17,10 +17,12 @@ use sycamore::prelude::*; pub mod cook; pub mod inventory; pub mod plan; +pub mod select; pub use cook::*; pub use inventory::*; pub use plan::*; +pub use select::*; #[derive(Props)] pub struct PageState<'a, G: Html> { @@ -33,6 +35,7 @@ pub fn PlanningPage<'a, G: Html>(cx: Scope<'a>, state: PageState<'a, G>) -> View let PageState { children, selected } = state; let children = children.call(cx); let planning_tabs: Vec<(String, &'static str)> = vec![ + ("/ui/planning/select".to_owned(), "Select"), ("/ui/planning/plan".to_owned(), "Plan"), ("/ui/planning/inventory".to_owned(), "Inventory"), ("/ui/planning/cook".to_owned(), "Cook"), diff --git a/web/src/pages/planning/select.rs b/web/src/pages/planning/select.rs new file mode 100644 index 0000000..09908ce --- /dev/null +++ b/web/src/pages/planning/select.rs @@ -0,0 +1,26 @@ +// Copyright 2023 Jeremy Wall (Jeremy@marzhilsltudios.com) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +use super::PlanningPage; +use crate::app_state::StateHandler; + +use sycamore::prelude::*; + +#[component] +pub fn SelectPage<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> View { + view! {cx, + PlanningPage( + selected=Some("Select".to_owned()), + ) { "TODO(jwall)" } + } +} diff --git a/web/src/routing/mod.rs b/web/src/routing/mod.rs index d96d72e..5108c27 100644 --- a/web/src/routing/mod.rs +++ b/web/src/routing/mod.rs @@ -62,6 +62,8 @@ pub enum ManageRoutes { #[derive(Route, Debug)] pub enum PlanningRoutes { + #[to("/select")] + Select, #[to("/plan")] Plan, #[to("/inventory")] @@ -83,6 +85,9 @@ fn route_switch<'ctx, G: Html>(route: &Routes, cx: Scope<'ctx>, sh: StateHandler use ManageRoutes::*; use PlanningRoutes::*; match route { + Routes::Planning(Select) => view! {cx, + SelectPage(sh) + }, Routes::Planning(Plan) => view! {cx, PlanPage(sh) },