From: Greg Burri Date: Tue, 11 Feb 2025 18:39:13 +0000 (+0100) Subject: Shopping list (WIP) X-Git-Url: https://git.euphorik.ch/?a=commitdiff_plain;h=084be9fb000231d7042bb7df393e055288a93e4d;p=recipes.git Shopping list (WIP) --- diff --git a/backend/scss/style.scss b/backend/scss/style.scss index a1efb64..ab748cd 100644 --- a/backend/scss/style.scss +++ b/backend/scss/style.scss @@ -124,10 +124,10 @@ body { h1 { text-align: center; } + } - #hidden-templates { - display: none; - } + #hidden-templates { + display: none; } #recipe-edit { diff --git a/backend/sql/version_1.sql b/backend/sql/version_1.sql index d4adac9..c3edc21 100644 --- a/backend/sql/version_1.sql +++ b/backend/sql/version_1.sql @@ -177,6 +177,7 @@ CREATE TABLE [RecipeScheduled] ( FOREIGN KEY([recipe_id]) REFERENCES [Recipe]([id]) ON DELETE CASCADE ); +CREATE INDEX [RecipeScheduled_user_id_index] ON [RecipeScheduled]([user_id]); CREATE INDEX [RecipeScheduled_date_index] ON [RecipeScheduled]([date]); CREATE TABLE [ShoppingEntry] ( @@ -200,6 +201,8 @@ CREATE TABLE [ShoppingEntry] ( FOREIGN KEY([recipe_scheduled_id]) REFERENCES [RecipeScheduled]([id]) ON DELETE SET NULL ); +CREATE INDEX [ShoppingEntry_user_id_index] ON [ShoppingEntry]([user_id]); + -- When an ingredient is deleted, its values are copied to any shopping entry -- that referenced it. CREATE TRIGGER [Ingredient_trigger_delete] diff --git a/backend/src/data/db/mod.rs b/backend/src/data/db/mod.rs index 23b7ccb..f0a6382 100644 --- a/backend/src/data/db/mod.rs +++ b/backend/src/data/db/mod.rs @@ -17,6 +17,7 @@ use crate::consts; pub mod recipe; pub mod settings; +pub mod shopping_list; pub mod user; const CURRENT_DB_VERSION: u32 = 1; diff --git a/backend/src/data/db/shopping_list.rs b/backend/src/data/db/shopping_list.rs new file mode 100644 index 0000000..ddb914e --- /dev/null +++ b/backend/src/data/db/shopping_list.rs @@ -0,0 +1,38 @@ +use sqlx; + +use super::{Connection, DBError, Result}; +use crate::data::model; + +impl Connection { + pub async fn get_shopping_list(&self, user_id: i64) -> Result> { + sqlx::query_as( + r#" +SELECT [ShoppingEntry].[id], + CASE [ShoppingEntry].[name] + WHEN '' THEN [Ingredient].[name] + ELSE [ShoppingEntry].[name] + END AS [name], + CASE WHEN [ShoppingEntry].[quantity_value] IS NOT NULL THEN [ShoppingEntry].[quantity_value] + ELSE [Ingredient].[quantity_value] + END AS [quantity_value], + CASE [ShoppingEntry].[quantity_unit] WHEN '' THEN [Ingredient].[quantity_unit] + ELSE [ShoppingEntry].[quantity_unit] + END AS [quantity_unit], + [Recipe].[id] AS [recipe_id], + [Recipe].[title] AS [recipe_title], + [RecipeScheduled].[date], + [is_checked] +FROM [ShoppingEntry] + LEFT JOIN [Ingredient] ON [Ingredient].[id] = [ShoppingEntry].[ingredient_id] + LEFT JOIN [RecipeScheduled] ON [RecipeScheduled].[id] = [ShoppingEntry].[recipe_scheduled_id] + LEFT JOIN [Recipe] ON [Recipe].[id] = [RecipeScheduled].[recipe_id] +WHERE [ShoppingEntry].[user_id] = $1 +ORDER BY [is_checked], [recipe_id], [name] + "#, + ) + .bind(user_id) + .fetch_all(&self.pool) + .await + .map_err(DBError::from) + } +} diff --git a/backend/src/data/model.rs b/backend/src/data/model.rs index a6834c8..a1285f2 100644 --- a/backend/src/data/model.rs +++ b/backend/src/data/model.rs @@ -72,3 +72,15 @@ pub struct Ingredient { pub quantity_value: Option, pub quantity_unit: String, } + +#[derive(Debug, FromRow)] +pub struct ShoppingListItem { + pub id: i64, + pub name: String, + pub quantity_value: Option, + pub quantity_unit: String, + pub recipe_id: Option, + pub recipe_title: Option, + pub date: Option, + pub is_checked: bool, +} diff --git a/backend/src/main.rs b/backend/src/main.rs index e4c597f..f6c9659 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -190,6 +190,10 @@ async fn main() { "/calendar/remove_scheduled_recipe", delete(services::ron::rm_scheduled_recipe), ) + .route( + "/shopping_list/get_list", + get(services::ron::get_shopping_list), + ) .fallback(services::ron::not_found); let fragments_routes = Router::new().route( diff --git a/backend/src/services/ron.rs b/backend/src/services/ron.rs index d5be0b9..55f2266 100644 --- a/backend/src/services/ron.rs +++ b/backend/src/services/ron.rs @@ -618,7 +618,7 @@ pub async fn set_ingredients_order( Ok(StatusCode::OK) } -/// Calendar /// +/*** Calendar ***/ #[debug_handler] pub async fn get_scheduled_recipes( @@ -692,7 +692,46 @@ pub async fn rm_scheduled_recipe( Ok(StatusCode::OK) } -/// 404 /// +/*** Shopping list ***/ + +impl From for common::ron_api::ShoppingListItem { + fn from(item: model::ShoppingListItem) -> Self { + Self { + id: item.id, + name: item.name, + quantity_value: item.quantity_value, + quantity_unit: item.quantity_unit, + recipe_id: item.recipe_id, + recipe_title: item.recipe_title, + date: item.date, + is_checked: item.is_checked, + } + } +} + +#[debug_handler] +pub async fn get_shopping_list( + State(connection): State, + Extension(user): Extension>, +) -> Result { + if let Some(user) = user { + Ok(ron_response_ok( + connection + .get_shopping_list(user.id) + .await? + .into_iter() + .map(common::ron_api::ShoppingListItem::from) + .collect::>(), + )) + } else { + Err(ErrorResponse::from(ron_error( + StatusCode::UNAUTHORIZED, + NOT_AUTHORIZED_MESSAGE, + ))) + } +} + +/*** 404 ***/ #[debug_handler] pub async fn not_found(Extension(_user): Extension>) -> impl IntoResponse { diff --git a/backend/templates/home.html b/backend/templates/home.html index a94dfe7..ebe80fb 100644 --- a/backend/templates/home.html +++ b/backend/templates/home.html @@ -4,6 +4,20 @@
{% include "calendar.html" %} + +
+
+
+ +
+
+ +
+
+
+
+
+ {{ tr.t(Sentence::CalendarDateFormat) }}
{% endblock %} \ No newline at end of file diff --git a/backend/templates/recipe_edit.html b/backend/templates/recipe_edit.html index 502b37e..5648a04 100644 --- a/backend/templates/recipe_edit.html +++ b/backend/templates/recipe_edit.html @@ -74,7 +74,7 @@ {%+ if recipe.is_published %} checked {% endif %} - > + /> @@ -83,63 +83,63 @@ + -
-
- - - - +
+
+ - - + + - + + -
-
+ - +
-
- - - - + +
- +
+ -
+ + - -
+ -
- +
- - + +
- - +
+ - - + + - - + + - -
+ + -
+ + - {{ tr.t(Sentence::RecipeDeleteConfirmation) }} - {{ tr.t(Sentence::RecipeGroupDeleteConfirmation) }} - {{ tr.t(Sentence::RecipeStepDeleteConfirmation) }} - {{ tr.t(Sentence::RecipeIngredientDeleteConfirmation) }} +
+ +
+ + {{ tr.t(Sentence::RecipeDeleteConfirmation) }} + {{ tr.t(Sentence::RecipeGroupDeleteConfirmation) }} + {{ tr.t(Sentence::RecipeStepDeleteConfirmation) }} + {{ tr.t(Sentence::RecipeIngredientDeleteConfirmation) }}
{% endblock %} \ No newline at end of file diff --git a/backend/templates/recipe_view.html b/backend/templates/recipe_view.html index 3afa746..c514d83 100644 --- a/backend/templates/recipe_view.html +++ b/backend/templates/recipe_view.html @@ -79,39 +79,39 @@
{% endfor %} + -
- {# To create a modal dialog to choose a date and and servings #} -
- {% include "calendar.html" %} - - - - - - -
- - {{ tr.t(Sentence::CalendarAddToPlannerSuccess) }} - {{ tr.t(Sentence::CalendarAddToPlannerAlreadyExists) }} - {{ tr.t(Sentence::CalendarDateFormat) }} +
+ {# To create a modal dialog to choose a date and and servings #} +
+ {% include "calendar.html" %} + + + + + +
+ + {{ tr.t(Sentence::CalendarAddToPlannerSuccess) }} + {{ tr.t(Sentence::CalendarAddToPlannerAlreadyExists) }} + {{ tr.t(Sentence::CalendarDateFormat) }}
{% endblock %} \ No newline at end of file diff --git a/common/src/ron_api.rs b/common/src/ron_api.rs index d86b7cb..379073c 100644 --- a/common/src/ron_api.rs +++ b/common/src/ron_api.rs @@ -209,6 +209,20 @@ pub struct ScheduledRecipe { pub date: NaiveDate, } +/*** Shopping list ***/ + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct ShoppingListItem { + pub id: i64, + pub name: String, + pub quantity_value: Option, + pub quantity_unit: String, + pub recipe_id: Option, + pub recipe_title: Option, + pub date: Option, + pub is_checked: bool, +} + /*** Misc ***/ pub fn to_string(ron: T) -> String diff --git a/frontend/src/calendar.rs b/frontend/src/calendar.rs index 3d5ef74..a0a4e29 100644 --- a/frontend/src/calendar.rs +++ b/frontend/src/calendar.rs @@ -222,11 +222,7 @@ fn display_month( let scheduled_recipes_element: Element = selector(&format!("#day-grid-{}{} .scheduled-recipes", i, j)); - let recipe_element = recipe_template - .clone_node_with_deep(true) - .unwrap() - .dyn_into::() - .unwrap(); + let recipe_element = recipe_template.deep_clone(); recipe_element.set_id(&id); scheduled_recipes_element diff --git a/frontend/src/home.rs b/frontend/src/home.rs index 942eb43..3a4f660 100644 --- a/frontend/src/home.rs +++ b/frontend/src/home.rs @@ -11,8 +11,9 @@ use crate::{ calendar, modal_dialog, recipe_scheduler::RecipeScheduler, request, + shopping_list::ShoppingList, toast::{self, Level}, - utils::{get_locale, selector, SelectorExt}, + utils::{by_id, get_locale, selector, SelectorExt}, }; pub fn setup_page(is_user_logged: bool) -> Result<(), JsValue> { @@ -26,5 +27,44 @@ pub fn setup_page(is_user_logged: bool) -> Result<(), JsValue> { }, recipe_scheduler, ); + + let shopping_list = ShoppingList::new(!is_user_logged); + + spawn_local(async move { + let item_template: Element = selector("#hidden-templates .shopping-item"); + let container: Element = by_id("shopping-list"); + let date_format = + selector::("#hidden-templates .calendar-date-format").inner_html(); + for item in shopping_list.get_items().await.unwrap() { + let item_element = item_template.deep_clone(); + item_element + .selector::(".item-name") + .set_inner_html(&item.name); + + if let Some(quantity_value) = item.quantity_value { + item_element + .selector::(".item-quantity") + .set_inner_html(&format!("{} {}", quantity_value, item.quantity_unit)); + } + + // Display associated sheduled recipe information if it exists. + if let (Some(recipe_id), Some(recipe_title), Some(date)) = + (item.recipe_id, item.recipe_title, item.date) + { + let recipe_element = item_element.selector::(".item-scheduled-recipe a"); + recipe_element.set_inner_html(&format!( + "{} @ {}", + recipe_title, + date.format_localized(&date_format, get_locale()), + )); + recipe_element + .set_attribute("href", &format!("/recipe/view/{}", recipe_id)) + .unwrap(); + } + + container.append_child(&item_element).unwrap(); + } + }); + Ok(()) } diff --git a/frontend/src/lib.rs b/frontend/src/lib.rs index d9d2365..949eb30 100644 --- a/frontend/src/lib.rs +++ b/frontend/src/lib.rs @@ -15,6 +15,7 @@ mod recipe_edit; mod recipe_scheduler; mod recipe_view; mod request; +mod shopping_list; mod toast; mod utils; diff --git a/frontend/src/shopping_list.rs b/frontend/src/shopping_list.rs new file mode 100644 index 0000000..155a1d9 --- /dev/null +++ b/frontend/src/shopping_list.rs @@ -0,0 +1,35 @@ +use chrono::{Datelike, Days, Months, NaiveDate}; +use common::ron_api; +use gloo::storage::{LocalStorage, Storage}; +use ron::ser::{to_string_pretty, PrettyConfig}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{calendar, request}; + +#[derive(Error, Debug)] +pub enum Error { + #[error("Request error: {0}")] + Request(#[from] request::Error), +} + +type Result = std::result::Result; + +#[derive(Clone, Copy)] +pub struct ShoppingList { + is_local: bool, +} + +impl ShoppingList { + pub fn new(is_local: bool) -> Self { + Self { is_local } + } + + pub async fn get_items(&self) -> Result> { + if self.is_local { + Ok(vec![]) // TODO + } else { + Ok(request::get("shopping_list/get_list", ()).await?) + } + } +} diff --git a/frontend/src/utils.rs b/frontend/src/utils.rs index 4732ad3..ac347a2 100644 --- a/frontend/src/utils.rs +++ b/frontend/src/utils.rs @@ -13,6 +13,8 @@ pub trait SelectorExt { fn selector_all(&self, selectors: &str) -> Vec where T: JsCast; + + fn deep_clone(&self) -> Self; } impl SelectorExt for Element { @@ -38,6 +40,13 @@ impl SelectorExt for Element { .map(|e| e.unwrap().dyn_into::().unwrap()) .collect() } + + fn deep_clone(&self) -> Self { + self.clone_node_with_deep(true) + .unwrap() + .dyn_into::() + .unwrap() + } } pub fn selector(selectors: &str) -> T