Shopping list (WIP)
authorGreg Burri <greg.burri@gmail.com>
Tue, 11 Feb 2025 18:39:13 +0000 (19:39 +0100)
committerGreg Burri <greg.burri@gmail.com>
Tue, 11 Feb 2025 18:39:13 +0000 (19:39 +0100)
16 files changed:
backend/scss/style.scss
backend/sql/version_1.sql
backend/src/data/db/mod.rs
backend/src/data/db/shopping_list.rs [new file with mode: 0644]
backend/src/data/model.rs
backend/src/main.rs
backend/src/services/ron.rs
backend/templates/home.html
backend/templates/recipe_edit.html
backend/templates/recipe_view.html
common/src/ron_api.rs
frontend/src/calendar.rs
frontend/src/home.rs
frontend/src/lib.rs
frontend/src/shopping_list.rs [new file with mode: 0644]
frontend/src/utils.rs

index a1efb64..ab748cd 100644 (file)
@@ -124,10 +124,10 @@ body {
             h1 {
                 text-align: center;
             }
+        }
 
-            #hidden-templates {
-                display: none;
-            }
+        #hidden-templates {
+            display: none;
         }
 
         #recipe-edit {
index d4adac9..c3edc21 100644 (file)
@@ -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]
index 23b7ccb..f0a6382 100644 (file)
@@ -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 (file)
index 0000000..ddb914e
--- /dev/null
@@ -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<Vec<model::ShoppingListItem>> {
+        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)
+    }
+}
index a6834c8..a1285f2 100644 (file)
@@ -72,3 +72,15 @@ pub struct Ingredient {
     pub quantity_value: Option<f64>,
     pub quantity_unit: String,
 }
+
+#[derive(Debug, FromRow)]
+pub struct ShoppingListItem {
+    pub id: i64,
+    pub name: String,
+    pub quantity_value: Option<f64>,
+    pub quantity_unit: String,
+    pub recipe_id: Option<i64>,
+    pub recipe_title: Option<String>,
+    pub date: Option<NaiveDate>,
+    pub is_checked: bool,
+}
index e4c597f..f6c9659 100644 (file)
@@ -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(
index d5be0b9..55f2266 100644 (file)
@@ -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<model::ShoppingListItem> 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<db::Connection>,
+    Extension(user): Extension<Option<model::User>>,
+) -> Result<impl IntoResponse> {
+    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::<Vec<_>>(),
+        ))
+    } else {
+        Err(ErrorResponse::from(ron_error(
+            StatusCode::UNAUTHORIZED,
+            NOT_AUTHORIZED_MESSAGE,
+        )))
+    }
+}
+
+/*** 404 ***/
 
 #[debug_handler]
 pub async fn not_found(Extension(_user): Extension<Option<model::User>>) -> impl IntoResponse {
index a94dfe7..ebe80fb 100644 (file)
@@ -4,6 +4,20 @@
 
 <div class="content" id="home">
     {% include "calendar.html" %}
+
+    <div id="shopping-list">
+    </div>
+</div>
+
+<div id="hidden-templates">
+    <div class="shopping-item">
+        <input class="item-is-checked" type="checkbox"/>
+        <div class="item-quantity"></div>
+        <div class="item-name"></div>
+        <div class="item-scheduled-recipe"><a></a></div>
+        <div class="item-delete"></div>
+    </div>
+    <span class="calendar-date-format">{{ tr.t(Sentence::CalendarDateFormat) }}</span>
 </div>
 
 {% endblock %}
\ No newline at end of file
index 502b37e..5648a04 100644 (file)
@@ -74,7 +74,7 @@
         {%+ if recipe.is_published %}
             checked
         {% endif %}
-    >
+    />
     <label for="input-is-published">{{ tr.t(Sentence::RecipeIsPublished) }}</label>
 
     <input id="input-delete" type="button" value="{{ tr.t(Sentence::RecipeDelete) }}" />
     </div>
 
     <input id="input-add-group" type="button" value="{{ tr.t(Sentence::RecipeAddAGroup) }}" />
+</div>
 
-    <div id="hidden-templates">
-        <div class="group">
-            <span class="drag-handle"></span>
-
-            <label for="input-group-name">{{ tr.t(Sentence::RecipeGroupName) }}</label>
-            <input class="input-group-name" type="text" />
+<div id="hidden-templates">
+    <div class="group">
+        <span class="drag-handle"></span>
 
-            <label for="input-group-comment">{{ tr.t(Sentence::RecipeGroupComment) }}</label>
-            <input class="input-group-comment" type="text" />
+        <label for="input-group-name">{{ tr.t(Sentence::RecipeGroupName) }}</label>
+        <input class="input-group-name" type="text" />
 
-            <input class="input-group-delete" type="button" value="{{ tr.t(Sentence::RecipeRemoveGroup) }}" />
+        <label for="input-group-comment">{{ tr.t(Sentence::RecipeGroupComment) }}</label>
+        <input class="input-group-comment" type="text" />
 
-            <div class="steps">
-            </div>
+        <input class="input-group-delete" type="button" value="{{ tr.t(Sentence::RecipeRemoveGroup) }}" />
 
-            <input class="input-add-step" type="button" value="{{ tr.t(Sentence::RecipeAddAStep) }}" />
+        <div class="steps">
         </div>
 
-        <div class="step">
-            <span class="drag-handle"></span>
-
-            <label for="text-area-step-action">{{ tr.t(Sentence::RecipeStepAction) }}</label>
-            <textarea class="text-area-step-action"></textarea>
+        <input class="input-add-step" type="button" value="{{ tr.t(Sentence::RecipeAddAStep) }}" />
+    </div>
 
-            <input class="input-step-delete" type="button" value="{{ tr.t(Sentence::RecipeRemoveStep) }}" />
+    <div class="step">
+        <span class="drag-handle"></span>
 
-            <div class="ingredients"></div>
+        <label for="text-area-step-action">{{ tr.t(Sentence::RecipeStepAction) }}</label>
+        <textarea class="text-area-step-action"></textarea>
 
-            <input class="input-add-ingredient" type="button" value="{{ tr.t(Sentence::RecipeAddAnIngredient) }}"/>
-        </div>
+        <input class="input-step-delete" type="button" value="{{ tr.t(Sentence::RecipeRemoveStep) }}" />
 
-        <div class="ingredient">
-            <span class="drag-handle"></span>
+        <div class="ingredients"></div>
 
-            <label for="input-ingredient-name">{{ tr.t(Sentence::RecipeIngredientName) }}</label>
-            <input class="input-ingredient-name" type="text" />
+        <input class="input-add-ingredient" type="button" value="{{ tr.t(Sentence::RecipeAddAnIngredient) }}"/>
+    </div>
 
-            <label for="input-ingredient-quantity">{{ tr.t(Sentence::RecipeIngredientQuantity) }}</label>
-            <input class="input-ingredient-quantity" type="number" step="0.1" min="0" max="10000" />
+    <div class="ingredient">
+        <span class="drag-handle"></span>
 
-            <label for="input-ingredient-unit">{{ tr.t(Sentence::RecipeIngredientUnit) }}</label>
-            <input class="input-ingredient-unit" type="text" />
+        <label for="input-ingredient-name">{{ tr.t(Sentence::RecipeIngredientName) }}</label>
+        <input class="input-ingredient-name" type="text" />
 
-            <label for="input-ingredient-comment">{{ tr.t(Sentence::RecipeIngredientComment) }}</label>
-            <input class="input-ingredient-comment" type="text" />
+        <label for="input-ingredient-quantity">{{ tr.t(Sentence::RecipeIngredientQuantity) }}</label>
+        <input class="input-ingredient-quantity" type="number" step="0.1" min="0" max="10000" />
 
-            <input class="input-ingredient-delete" type="button" value="{{ tr.t(Sentence::RecipeRemoveIngredient) }}" />
-        </div>
+        <label for="input-ingredient-unit">{{ tr.t(Sentence::RecipeIngredientUnit) }}</label>
+        <input class="input-ingredient-unit" type="text" />
 
-        <div class="dropzone"></div>
+        <label for="input-ingredient-comment">{{ tr.t(Sentence::RecipeIngredientComment) }}</label>
+        <input class="input-ingredient-comment" type="text" />
 
-        <span class="recipe-delete-confirmation">{{ tr.t(Sentence::RecipeDeleteConfirmation) }}</span>
-        <span class="recipe-group-delete-confirmation">{{ tr.t(Sentence::RecipeGroupDeleteConfirmation) }}</span>
-        <span class="recipe-step-delete-confirmation">{{ tr.t(Sentence::RecipeStepDeleteConfirmation) }}</span>
-        <span class="recipe-ingredient-delete-confirmation">{{ tr.t(Sentence::RecipeIngredientDeleteConfirmation) }}</span>
+        <input class="input-ingredient-delete" type="button" value="{{ tr.t(Sentence::RecipeRemoveIngredient) }}" />
     </div>
+
+    <div class="dropzone"></div>
+
+    <span class="recipe-delete-confirmation">{{ tr.t(Sentence::RecipeDeleteConfirmation) }}</span>
+    <span class="recipe-group-delete-confirmation">{{ tr.t(Sentence::RecipeGroupDeleteConfirmation) }}</span>
+    <span class="recipe-step-delete-confirmation">{{ tr.t(Sentence::RecipeStepDeleteConfirmation) }}</span>
+    <span class="recipe-ingredient-delete-confirmation">{{ tr.t(Sentence::RecipeIngredientDeleteConfirmation) }}</span>
 </div>
 
 {% endblock %}
\ No newline at end of file
index 3afa746..c514d83 100644 (file)
         </div>
     </div>
     {% endfor %}
+</div>
 
-    <div id="hidden-templates">
-        {# To create a modal dialog to choose a date and and servings #}
-        <div class="date-and-servings" >
-            {% include "calendar.html" %}
-
-            <label for="input-servings">{{ tr.t(Sentence::RecipeServings) }}</label>
-            <input
-                id="input-servings"
-                type="number"
-                step="1" min="1" max="100"
-                value="
-                {% if let Some(user) = user %}
-                    {{ user.default_servings }}
-                {% else %}
-                    4
-                {% endif %}
-                "/>
-
-            <input
-                id="input-add-ingredients-to-shopping-list"
-                type="checkbox"
-                checked
-            >
-            <label for="input-add-ingredients-to-shopping-list">
-                {{ tr.t(Sentence::CalendarAddIngredientsToShoppingList) }}
-            </label>
-        </div>
-
-        <span class="calendar-add-to-planner-success">{{ tr.t(Sentence::CalendarAddToPlannerSuccess) }}</span>
-        <span class="calendar-add-to-planner-already-exists">{{ tr.t(Sentence::CalendarAddToPlannerAlreadyExists) }}</span>
-        <span class="calendar-date-format">{{ tr.t(Sentence::CalendarDateFormat) }}</span>
+<div id="hidden-templates">
+    {# To create a modal dialog to choose a date and and servings #}
+    <div class="date-and-servings" >
+        {% include "calendar.html" %}
+
+        <label for="input-servings">{{ tr.t(Sentence::RecipeServings) }}</label>
+        <input
+            id="input-servings"
+            type="number"
+            step="1" min="1" max="100"
+            value="
+            {% if let Some(user) = user %}
+                {{ user.default_servings }}
+            {% else %}
+                4
+            {% endif %}
+            "/>
+
+        <input
+            id="input-add-ingredients-to-shopping-list"
+            type="checkbox"
+            checked
+        >
+        <label for="input-add-ingredients-to-shopping-list">
+            {{ tr.t(Sentence::CalendarAddIngredientsToShoppingList) }}
+        </label>
     </div>
+
+    <span class="calendar-add-to-planner-success">{{ tr.t(Sentence::CalendarAddToPlannerSuccess) }}</span>
+    <span class="calendar-add-to-planner-already-exists">{{ tr.t(Sentence::CalendarAddToPlannerAlreadyExists) }}</span>
+    <span class="calendar-date-format">{{ tr.t(Sentence::CalendarDateFormat) }}</span>
 </div>
 
 {% endblock %}
\ No newline at end of file
index d86b7cb..379073c 100644 (file)
@@ -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<f64>,
+    pub quantity_unit: String,
+    pub recipe_id: Option<i64>,
+    pub recipe_title: Option<String>,
+    pub date: Option<NaiveDate>,
+    pub is_checked: bool,
+}
+
 /*** Misc ***/
 
 pub fn to_string<T>(ron: T) -> String
index 3d5ef74..a0a4e29 100644 (file)
@@ -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::<Element>()
-                    .unwrap();
+                let recipe_element = recipe_template.deep_clone();
                 recipe_element.set_id(&id);
 
                 scheduled_recipes_element
index 942eb43..3a4f660 100644 (file)
@@ -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::<Element>("#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::<Element>(".item-name")
+                .set_inner_html(&item.name);
+
+            if let Some(quantity_value) = item.quantity_value {
+                item_element
+                    .selector::<Element>(".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::<Element>(".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(())
 }
index d9d2365..949eb30 100644 (file)
@@ -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 (file)
index 0000000..155a1d9
--- /dev/null
@@ -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<T> = std::result::Result<T, Error>;
+
+#[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<Vec<ron_api::ShoppingListItem>> {
+        if self.is_local {
+            Ok(vec![]) // TODO
+        } else {
+            Ok(request::get("shopping_list/get_list", ()).await?)
+        }
+    }
+}
index 4732ad3..ac347a2 100644 (file)
@@ -13,6 +13,8 @@ pub trait SelectorExt {
     fn selector_all<T>(&self, selectors: &str) -> Vec<T>
     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::<T>().unwrap())
             .collect()
     }
+
+    fn deep_clone(&self) -> Self {
+        self.clone_node_with_deep(true)
+            .unwrap()
+            .dyn_into::<Element>()
+            .unwrap()
+    }
 }
 
 pub fn selector<T>(selectors: &str) -> T