Shopping list (WIP)

This commit is contained in:
Greg Burri 2025-02-11 19:39:13 +01:00
parent ce3821b94e
commit 084be9fb00
16 changed files with 296 additions and 90 deletions

View file

@ -124,11 +124,11 @@ body {
h1 { h1 {
text-align: center; text-align: center;
} }
}
#hidden-templates { #hidden-templates {
display: none; display: none;
} }
}
#recipe-edit { #recipe-edit {
.drag-handle { .drag-handle {

View file

@ -177,6 +177,7 @@ CREATE TABLE [RecipeScheduled] (
FOREIGN KEY([recipe_id]) REFERENCES [Recipe]([id]) ON DELETE CASCADE 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 INDEX [RecipeScheduled_date_index] ON [RecipeScheduled]([date]);
CREATE TABLE [ShoppingEntry] ( CREATE TABLE [ShoppingEntry] (
@ -200,6 +201,8 @@ CREATE TABLE [ShoppingEntry] (
FOREIGN KEY([recipe_scheduled_id]) REFERENCES [RecipeScheduled]([id]) ON DELETE SET NULL 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 -- When an ingredient is deleted, its values are copied to any shopping entry
-- that referenced it. -- that referenced it.
CREATE TRIGGER [Ingredient_trigger_delete] CREATE TRIGGER [Ingredient_trigger_delete]

View file

@ -17,6 +17,7 @@ use crate::consts;
pub mod recipe; pub mod recipe;
pub mod settings; pub mod settings;
pub mod shopping_list;
pub mod user; pub mod user;
const CURRENT_DB_VERSION: u32 = 1; const CURRENT_DB_VERSION: u32 = 1;

View file

@ -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)
}
}

View file

@ -72,3 +72,15 @@ pub struct Ingredient {
pub quantity_value: Option<f64>, pub quantity_value: Option<f64>,
pub quantity_unit: String, 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,
}

View file

@ -190,6 +190,10 @@ async fn main() {
"/calendar/remove_scheduled_recipe", "/calendar/remove_scheduled_recipe",
delete(services::ron::rm_scheduled_recipe), delete(services::ron::rm_scheduled_recipe),
) )
.route(
"/shopping_list/get_list",
get(services::ron::get_shopping_list),
)
.fallback(services::ron::not_found); .fallback(services::ron::not_found);
let fragments_routes = Router::new().route( let fragments_routes = Router::new().route(

View file

@ -618,7 +618,7 @@ pub async fn set_ingredients_order(
Ok(StatusCode::OK) Ok(StatusCode::OK)
} }
/// Calendar /// /*** Calendar ***/
#[debug_handler] #[debug_handler]
pub async fn get_scheduled_recipes( pub async fn get_scheduled_recipes(
@ -692,7 +692,46 @@ pub async fn rm_scheduled_recipe(
Ok(StatusCode::OK) 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] #[debug_handler]
pub async fn not_found(Extension(_user): Extension<Option<model::User>>) -> impl IntoResponse { pub async fn not_found(Extension(_user): Extension<Option<model::User>>) -> impl IntoResponse {

View file

@ -4,6 +4,20 @@
<div class="content" id="home"> <div class="content" id="home">
{% include "calendar.html" %} {% 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> </div>
{% endblock %} {% endblock %}

View file

@ -74,7 +74,7 @@
{%+ if recipe.is_published %} {%+ if recipe.is_published %}
checked checked
{% endif %} {% endif %}
> />
<label for="input-is-published">{{ tr.t(Sentence::RecipeIsPublished) }}</label> <label for="input-is-published">{{ tr.t(Sentence::RecipeIsPublished) }}</label>
<input id="input-delete" type="button" value="{{ tr.t(Sentence::RecipeDelete) }}" /> <input id="input-delete" type="button" value="{{ tr.t(Sentence::RecipeDelete) }}" />
@ -83,6 +83,7 @@
</div> </div>
<input id="input-add-group" type="button" value="{{ tr.t(Sentence::RecipeAddAGroup) }}" /> <input id="input-add-group" type="button" value="{{ tr.t(Sentence::RecipeAddAGroup) }}" />
</div>
<div id="hidden-templates"> <div id="hidden-templates">
<div class="group"> <div class="group">
@ -140,6 +141,5 @@
<span class="recipe-step-delete-confirmation">{{ tr.t(Sentence::RecipeStepDeleteConfirmation) }}</span> <span class="recipe-step-delete-confirmation">{{ tr.t(Sentence::RecipeStepDeleteConfirmation) }}</span>
<span class="recipe-ingredient-delete-confirmation">{{ tr.t(Sentence::RecipeIngredientDeleteConfirmation) }}</span> <span class="recipe-ingredient-delete-confirmation">{{ tr.t(Sentence::RecipeIngredientDeleteConfirmation) }}</span>
</div> </div>
</div>
{% endblock %} {% endblock %}

View file

@ -79,6 +79,7 @@
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
</div>
<div id="hidden-templates"> <div id="hidden-templates">
{# To create a modal dialog to choose a date and and servings #} {# To create a modal dialog to choose a date and and servings #}
@ -112,6 +113,5 @@
<span class="calendar-add-to-planner-already-exists">{{ tr.t(Sentence::CalendarAddToPlannerAlreadyExists) }}</span> <span class="calendar-add-to-planner-already-exists">{{ tr.t(Sentence::CalendarAddToPlannerAlreadyExists) }}</span>
<span class="calendar-date-format">{{ tr.t(Sentence::CalendarDateFormat) }}</span> <span class="calendar-date-format">{{ tr.t(Sentence::CalendarDateFormat) }}</span>
</div> </div>
</div>
{% endblock %} {% endblock %}

View file

@ -209,6 +209,20 @@ pub struct ScheduledRecipe {
pub date: NaiveDate, 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 ***/ /*** Misc ***/
pub fn to_string<T>(ron: T) -> String pub fn to_string<T>(ron: T) -> String

View file

@ -222,11 +222,7 @@ fn display_month(
let scheduled_recipes_element: Element = let scheduled_recipes_element: Element =
selector(&format!("#day-grid-{}{} .scheduled-recipes", i, j)); selector(&format!("#day-grid-{}{} .scheduled-recipes", i, j));
let recipe_element = recipe_template let recipe_element = recipe_template.deep_clone();
.clone_node_with_deep(true)
.unwrap()
.dyn_into::<Element>()
.unwrap();
recipe_element.set_id(&id); recipe_element.set_id(&id);
scheduled_recipes_element scheduled_recipes_element

View file

@ -11,8 +11,9 @@ use crate::{
calendar, modal_dialog, calendar, modal_dialog,
recipe_scheduler::RecipeScheduler, recipe_scheduler::RecipeScheduler,
request, request,
shopping_list::ShoppingList,
toast::{self, Level}, 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> { 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, 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(()) Ok(())
} }

View file

@ -15,6 +15,7 @@ mod recipe_edit;
mod recipe_scheduler; mod recipe_scheduler;
mod recipe_view; mod recipe_view;
mod request; mod request;
mod shopping_list;
mod toast; mod toast;
mod utils; mod utils;

View file

@ -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?)
}
}
}

View file

@ -13,6 +13,8 @@ pub trait SelectorExt {
fn selector_all<T>(&self, selectors: &str) -> Vec<T> fn selector_all<T>(&self, selectors: &str) -> Vec<T>
where where
T: JsCast; T: JsCast;
fn deep_clone(&self) -> Self;
} }
impl SelectorExt for Element { impl SelectorExt for Element {
@ -38,6 +40,13 @@ impl SelectorExt for Element {
.map(|e| e.unwrap().dyn_into::<T>().unwrap()) .map(|e| e.unwrap().dyn_into::<T>().unwrap())
.collect() .collect()
} }
fn deep_clone(&self) -> Self {
self.clone_node_with_deep(true)
.unwrap()
.dyn_into::<Element>()
.unwrap()
}
} }
pub fn selector<T>(selectors: &str) -> T pub fn selector<T>(selectors: &str) -> T