Recipe can now be scheduled

This commit is contained in:
Greg Burri 2025-02-04 22:29:56 +01:00
parent ae6da1a5ae
commit fbef990022
18 changed files with 233 additions and 51 deletions

7
Cargo.lock generated
View file

@ -356,6 +356,7 @@ dependencies = [
"iana-time-zone", "iana-time-zone",
"js-sys", "js-sys",
"num-traits", "num-traits",
"pure-rust-locales",
"serde", "serde",
"wasm-bindgen", "wasm-bindgen",
"windows-targets 0.52.6", "windows-targets 0.52.6",
@ -1853,6 +1854,12 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "pure-rust-locales"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1190fd18ae6ce9e137184f207593877e70f39b015040156b1e05081cdfe3733a"
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.38" version = "1.0.38"

View file

@ -15,7 +15,7 @@
#toast.show { #toast.show {
visibility: visible; visibility: visible;
animation: fadein 0.5s, fadeout 0.5s 3.6s; animation: fadein 0.5s, fadeout 0.5s 9.6s;
animation-iteration-count: 1; animation-iteration-count: 1;
} }

View file

@ -1,3 +1,6 @@
-- Datetimes are stored as 'ISO 8601' text format.
-- For example: '2025-01-07T10:41:05.697884837+00:00'.
-- Version 1 is the initial structure. -- Version 1 is the initial structure.
CREATE TABLE [Version] ( CREATE TABLE [Version] (
[id] INTEGER PRIMARY KEY, [id] INTEGER PRIMARY KEY,
@ -165,9 +168,11 @@ CREATE TABLE [RecipeScheduled] (
[id] INTEGER PRIMARY KEY, [id] INTEGER PRIMARY KEY,
[user_id] INTEGER NOT NULL, [user_id] INTEGER NOT NULL,
[recipe_id] INTEGER NOT NULL, [recipe_id] INTEGER NOT NULL,
[date] TEXT NOT NULL, [date] TEXT NOT NULL, -- In form of 'YYYY-MM-DD'.
[servings] INTEGER, -- If NULL use [recipe].[servings]. [servings] INTEGER, -- If NULL use [recipe].[servings].
UNIQUE([user_id], [recipe_id], [date]),
FOREIGN KEY([user_id]) REFERENCES [User]([id]) ON DELETE CASCADE, FOREIGN KEY([user_id]) REFERENCES [User]([id]) ON DELETE CASCADE,
FOREIGN KEY([recipe_id]) REFERENCES [Recipe]([id]) ON DELETE CASCADE FOREIGN KEY([recipe_id]) REFERENCES [Recipe]([id]) ON DELETE CASCADE
); );

View file

@ -752,7 +752,7 @@ VALUES ($1, $2)
Ok(()) Ok(())
} }
pub async fn add_schedule_recipe( pub async fn add_scheduled_recipe(
&self, &self,
user_id: i64, user_id: i64,
recipe_id: i64, recipe_id: i64,
@ -775,7 +775,7 @@ VALUES ($1, $2, $3, $4)
.map_err(DBError::from) .map_err(DBError::from)
} }
pub async fn remove_scheduled_recipe( pub async fn rm_scheduled_recipe(
&self, &self,
user_id: i64, user_id: i64,
recipe_id: i64, recipe_id: i64,
@ -964,13 +964,13 @@ VALUES
let tomorrow = today + Days::new(1); let tomorrow = today + Days::new(1);
connection connection
.add_schedule_recipe(user_id, recipe_id_1, today, 4) .add_scheduled_recipe(user_id, recipe_id_1, today, 4)
.await?; .await?;
connection connection
.add_schedule_recipe(user_id, recipe_id_2, yesterday, 4) .add_scheduled_recipe(user_id, recipe_id_2, yesterday, 4)
.await?; .await?;
connection connection
.add_schedule_recipe(user_id, recipe_id_1, tomorrow, 4) .add_scheduled_recipe(user_id, recipe_id_1, tomorrow, 4)
.await?; .await?;
assert_eq!( assert_eq!(
@ -1008,13 +1008,13 @@ VALUES
); );
connection connection
.remove_scheduled_recipe(user_id, recipe_id_1, today) .rm_scheduled_recipe(user_id, recipe_id_1, today)
.await?; .await?;
connection connection
.remove_scheduled_recipe(user_id, recipe_id_2, yesterday) .rm_scheduled_recipe(user_id, recipe_id_2, yesterday)
.await?; .await?;
connection connection
.remove_scheduled_recipe(user_id, recipe_id_1, tomorrow) .rm_scheduled_recipe(user_id, recipe_id_1, tomorrow)
.await?; .await?;
assert_eq!( assert_eq!(

View file

@ -181,6 +181,14 @@ async fn main() {
"/calendar/get_scheduled_recipes", "/calendar/get_scheduled_recipes",
get(services::ron::get_scheduled_recipes), get(services::ron::get_scheduled_recipes),
) )
.route(
"/calendar/schedule_recipe",
post(services::ron::schedule_recipe),
)
.route(
"/calendar/remove_scheduled_recipe",
delete(services::ron::rm_scheduled_recipe),
)
.fallback(services::ron::not_found); .fallback(services::ron::not_found);
let fragments_routes = Router::new().route( let fragments_routes = Router::new().route(

View file

@ -631,6 +631,36 @@ pub async fn get_scheduled_recipes(
} }
} }
#[debug_handler]
pub async fn schedule_recipe(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::ScheduleRecipe>,
) -> Result<impl IntoResponse> {
check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
if let Some(user) = user {
connection
.add_scheduled_recipe(user.id, ron.recipe_id, ron.date, ron.servings)
.await?;
}
Ok(StatusCode::OK)
}
#[debug_handler]
pub async fn rm_scheduled_recipe(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::ScheduledRecipe>,
) -> Result<impl IntoResponse> {
check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
if let Some(user) = user {
connection
.rm_scheduled_recipe(user.id, ron.recipe_id, ron.date)
.await?;
}
Ok(StatusCode::OK)
}
/// 404 /// /// 404 ///
#[debug_handler] #[debug_handler]

View file

@ -141,6 +141,9 @@ pub enum Sentence {
CalendarOctober, CalendarOctober,
CalendarNovember, CalendarNovember,
CalendarDecember, CalendarDecember,
CalendarAddToPlanner,
CalendarAddToPlannerSuccess,
CalendarDateFormat, // See https://docs.rs/chrono/latest/chrono/format/strftime/index.html.
} }
pub const DEFAULT_LANGUAGE_CODE: &str = "en"; pub const DEFAULT_LANGUAGE_CODE: &str = "en";
@ -181,6 +184,10 @@ impl Tr {
pub fn current_lang_code(&self) -> &str { pub fn current_lang_code(&self) -> &str {
&self.lang.code &self.lang.code
} }
pub fn current_lang_and_territory_code(&self) -> String {
format!("{}-{}", self.lang.code, self.lang.territory)
}
} }
// #[macro_export] // #[macro_export]
@ -200,6 +207,7 @@ impl Tr {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct StoredLanguage { struct StoredLanguage {
code: String, code: String,
territory: String,
name: String, name: String,
translation: Vec<(Sentence, String)>, translation: Vec<(Sentence, String)>,
} }
@ -207,6 +215,7 @@ struct StoredLanguage {
#[derive(Debug)] #[derive(Debug)]
struct Language { struct Language {
code: String, code: String,
territory: String,
name: String, name: String,
translation: Vec<String>, translation: Vec<String>,
} }
@ -215,6 +224,7 @@ impl Language {
pub fn from_stored_language(stored_language: StoredLanguage) -> Self { pub fn from_stored_language(stored_language: StoredLanguage) -> Self {
Self { Self {
code: stored_language.code, code: stored_language.code,
territory: stored_language.territory,
name: stored_language.name, name: stored_language.name,
translation: { translation: {
let mut translation = vec![String::new(); Sentence::COUNT]; let mut translation = vec![String::new(); Sentence::COUNT];

View file

@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="{{ tr.current_lang_and_territory_code() }}">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />

View file

@ -9,7 +9,7 @@
{% if crate::data::model::can_user_edit_recipe(user, recipe) %} {% if crate::data::model::can_user_edit_recipe(user, recipe) %}
<a class="edit-recipe" href="/recipe/edit/{{ recipe.id }}" >Edit</a> <a class="edit-recipe" href="/recipe/edit/{{ recipe.id }}" >Edit</a>
{% endif %} {% endif %}
<span class="add-to-planner">Add to planner</span> <span class="add-to-planner">{{ tr.t(Sentence::CalendarAddToPlanner) }}</span>
{% endif %} {% endif %}
<div class="tags"> <div class="tags">
@ -92,6 +92,9 @@
value="{{ user.default_servings }}"/> value="{{ user.default_servings }}"/>
</div> </div>
{% endif %} {% endif %}
<span class="calendar-add-to-planner-success">{{ tr.t(Sentence::CalendarAddToPlannerSuccess) }}</span>
<span class="calendar-date-format">{{ tr.t(Sentence::CalendarDateFormat) }}</span>
</div> </div>
</div> </div>

View file

@ -1,6 +1,7 @@
[ [
( (
code: "en", code: "en",
territory: "US",
name: "English", name: "English",
translation: [ translation: [
(MainTitle, "Cooking Recipes"), (MainTitle, "Cooking Recipes"),
@ -124,10 +125,14 @@
(CalendarOctober, "October"), (CalendarOctober, "October"),
(CalendarNovember, "November"), (CalendarNovember, "November"),
(CalendarDecember, "December"), (CalendarDecember, "December"),
(CalendarAddToPlanner, "Add to planner"),
(CalendarAddToPlannerSuccess, "Recipe {} has been scheduled for {}"),
(CalendarDateFormat, "%A, %-d %B, %C%y"),
] ]
), ),
( (
code: "fr", code: "fr",
territory: "FR",
name: "Français", name: "Français",
translation: [ translation: [
(MainTitle, "Recettes de Cuisine"), (MainTitle, "Recettes de Cuisine"),
@ -251,6 +256,9 @@
(CalendarOctober, "Octobre"), (CalendarOctober, "Octobre"),
(CalendarNovember, "Novembre"), (CalendarNovember, "Novembre"),
(CalendarDecember, "Décembre"), (CalendarDecember, "Décembre"),
(CalendarAddToPlanner, "Ajouter au planificateur"),
(CalendarAddToPlannerSuccess, "La recette {} a été agendée pour le {}"),
(CalendarDateFormat, "%A %-d %B %C%y"),
] ]
) )
] ]

View file

@ -17,7 +17,7 @@ pub struct Id {
pub id: i64, pub id: i64,
} }
/// RECIPE /// /*** RECIPE ***/
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone)]
pub struct SetRecipeTitle { pub struct SetRecipeTitle {
@ -159,7 +159,7 @@ pub struct Ingredient {
pub quantity_unit: String, pub quantity_unit: String,
} }
/// PROFILE /// /*** PROFILE ***/
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone)]
pub struct UpdateProfile { pub struct UpdateProfile {
@ -168,6 +168,29 @@ pub struct UpdateProfile {
pub password: Option<String>, pub password: Option<String>,
} }
/*** Calendar ***/
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ScheduledRecipes {
// (Scheduled date, recipe title, recipe id).
pub recipes: Vec<(NaiveDate, String, i64)>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ScheduleRecipe {
pub recipe_id: i64,
pub date: NaiveDate,
pub servings: u32,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ScheduledRecipe {
pub recipe_id: i64,
pub date: NaiveDate,
}
/*** Misc ***/
pub fn to_string<T>(ron: T) -> String pub fn to_string<T>(ron: T) -> String
where where
T: Serialize, T: Serialize,
@ -175,11 +198,3 @@ where
// TODO: handle'unwrap'. // TODO: handle'unwrap'.
to_string_pretty(&ron, PrettyConfig::new()).unwrap() to_string_pretty(&ron, PrettyConfig::new()).unwrap()
} }
/// Calendar ///
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ScheduledRecipes {
// (Scheduled date, recipe title, recipe id).
pub recipes: Vec<(NaiveDate, String, i64)>,
}

View file

@ -13,7 +13,7 @@ default = ["console_error_panic_hook"]
[dependencies] [dependencies]
common = { path = "../common" } common = { path = "../common" }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde", "unstable-locales"] }
ron = "0.8" ron = "0.8"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }

View file

@ -18,7 +18,7 @@ struct CalendarStateInternal {
} }
#[derive(Clone)] #[derive(Clone)]
struct CalendarState { pub struct CalendarState {
internal_state: Rc<RefCell<CalendarStateInternal>>, internal_state: Rc<RefCell<CalendarStateInternal>>,
} }
@ -56,7 +56,7 @@ impl CalendarState {
} }
} }
pub fn setup(calendar: Element) { pub fn setup(calendar: Element) -> CalendarState {
let prev: Element = calendar.selector(".prev"); let prev: Element = calendar.selector(".prev");
let next: Element = calendar.selector(".next"); let next: Element = calendar.selector(".next");
@ -98,6 +98,8 @@ pub fn setup(calendar: Element) {
} }
}) })
.forget(); .forget();
state
} }
const NB_CALENDAR_ROW: u64 = 5; const NB_CALENDAR_ROW: u64 = 5;

View file

@ -7,12 +7,26 @@ use crate::{
}; };
pub async fn show(element_selector: &str) -> bool { pub async fn show(element_selector: &str) -> bool {
show_and_initialize(element_selector, async |_| {}).await show_and_initialize(element_selector, async |_| Some(()))
.await
.is_some()
} }
pub async fn show_and_initialize<T>(element_selector: &str, initializer: T) -> bool pub async fn show_and_initialize<T, U>(element_selector: &str, initializer: T) -> Option<U>
where where
T: AsyncFn(Element), T: AsyncFn(Element) -> U,
{
show_and_initialize_with_ok(element_selector, initializer, |_, result| result).await
}
pub async fn show_and_initialize_with_ok<T, V, W, U>(
element_selector: &str,
initializer: T,
ok: V,
) -> Option<W>
where
T: AsyncFn(Element) -> U,
V: Fn(Element, U) -> W,
{ {
let dialog: HtmlDialogElement = by_id("modal-dialog"); let dialog: HtmlDialogElement = by_id("modal-dialog");
@ -24,7 +38,7 @@ where
let element: Element = selector_and_clone(element_selector); let element: Element = selector_and_clone(element_selector);
content_element.set_inner_html(""); content_element.set_inner_html("");
content_element.append_child(&element).unwrap(); content_element.append_child(&element).unwrap();
initializer(element).await; let init_result = initializer(element.clone()).await;
dialog.show_modal().unwrap(); dialog.show_modal().unwrap();
@ -34,8 +48,8 @@ where
pin_mut!(click_ok, click_cancel); pin_mut!(click_ok, click_cancel);
let result = select! { let result = select! {
() = click_ok => true, () = click_ok => Some(ok(element, init_result)),
() = click_cancel => false, () = click_cancel => None,
}; };
dialog.close(); dialog.close();

View file

@ -19,11 +19,6 @@ use crate::{
utils::{by_id, selector, selector_and_clone, SelectorExt}, utils::{by_id, selector, selector_and_clone, SelectorExt},
}; };
use futures::{
future::{FutureExt, Ready},
pin_mut, select, Future,
};
pub fn setup_page(recipe_id: i64) -> Result<(), JsValue> { pub fn setup_page(recipe_id: i64) -> Result<(), JsValue> {
// Title. // Title.
{ {
@ -265,6 +260,7 @@ pub fn setup_page(recipe_id: i64) -> Result<(), JsValue> {
}, },
) )
.await .await
.is_some()
{ {
let body = ron_api::Id { id: recipe_id }; let body = ron_api::Id { id: recipe_id };
let _ = request::delete::<(), _>("recipe/remove", body).await; let _ = request::delete::<(), _>("recipe/remove", body).await;
@ -400,6 +396,7 @@ fn create_group_element(group: &ron_api::Group) -> Element {
}, },
) )
.await .await
.is_some()
{ {
let body = ron_api::Id { id: group_id }; let body = ron_api::Id { id: group_id };
let _ = request::delete::<(), _>("recipe/remove_group", body).await; let _ = request::delete::<(), _>("recipe/remove_group", body).await;
@ -542,6 +539,7 @@ fn create_step_element(group_element: &Element, step: &ron_api::Step) -> Element
}, },
) )
.await .await
.is_some()
{ {
let body = ron_api::Id { id: step_id }; let body = ron_api::Id { id: step_id };
let _ = request::delete::<(), _>("recipe/remove_step", body).await; let _ = request::delete::<(), _>("recipe/remove_step", body).await;
@ -696,6 +694,7 @@ fn create_ingredient_element(step_element: &Element, ingredient: &ron_api::Ingre
}, },
) )
.await .await
.is_some()
{ {
let body = ron_api::Id { id: ingredient_id }; let body = ron_api::Id { id: ingredient_id };
let _ = request::delete::<(), _>("recipe/remove_ingredient", body).await; let _ = request::delete::<(), _>("recipe/remove_ingredient", body).await;
@ -717,7 +716,7 @@ async fn reload_recipes_list(current_recipe_id: i64) {
.await .await
{ {
Err(error) => { Err(error) => {
toast::show(Level::Info, &format!("Internal server error: {}", error)); toast::show_message(Level::Info, &format!("Internal server error: {}", error));
} }
Ok(response) => { Ok(response) => {
let list = document().get_element_by_id("recipes-list").unwrap(); let list = document().get_element_by_id("recipes-list").unwrap();

View file

@ -1,8 +1,9 @@
use std::future::Future; use std::{cell::RefCell, future::Future, rc::Rc, str::FromStr};
use common::ron_api; use chrono::Locale;
use common::{ron_api, utils::substitute};
use gloo::{ use gloo::{
console::console, console::log,
events::EventListener, events::EventListener,
net::http::Request, net::http::Request,
utils::{document, window}, utils::{document, window},
@ -24,13 +25,59 @@ pub fn setup_page(recipe_id: i64) -> Result<(), JsValue> {
let add_to_planner: Element = selector("#recipe-view .add-to-planner"); let add_to_planner: Element = selector("#recipe-view .add-to-planner");
EventListener::new(&add_to_planner, "click", move |_event| { EventListener::new(&add_to_planner, "click", move |_event| {
spawn_local(async move { spawn_local(async move {
modal_dialog::show_and_initialize( if let Some((date, servings)) = modal_dialog::show_and_initialize_with_ok(
"#hidden-templates .date-and-servings", "#hidden-templates .date-and-servings",
async |element| { async |element| calendar::setup(element.selector(".calendar")),
calendar::setup(element.selector(".calendar")); |element, calendar_state| {
let servings_element: HtmlInputElement = element.selector("#input-servings");
(
calendar_state.get_selected_date().date_naive(),
servings_element.value_as_number() as u32,
)
}, },
) )
.await; .await
{
if request::post::<(), _>(
"calendar/schedule_recipe",
ron_api::ScheduleRecipe {
recipe_id,
date,
servings,
},
)
.await
.is_ok()
{
toast::show_element_and_initialize(
Level::Success,
"#hidden-templates .calendar-add-to-planner-success",
|element| {
let title =
selector::<Element>("#recipe-view .recipe-title").inner_html();
let date_format =
selector::<Element>("#hidden-templates .calendar-date-format")
.inner_html();
let locale = {
let lang_and_territory = selector::<Element>("html")
.get_attribute("lang")
.unwrap()
.replace("-", "_");
Locale::from_str(&lang_and_territory).unwrap_or_default()
};
element.set_inner_html(&substitute(
&element.inner_html(),
"{}",
&[
&title,
&date.format_localized(&date_format, locale).to_string(),
],
));
},
);
}
}
}); });
}) })
.forget(); .forget();

View file

@ -1,5 +1,8 @@
use common::ron_api; use common::ron_api;
use gloo::net::http::{Request, RequestBuilder}; use gloo::{
console::log,
net::http::{Request, RequestBuilder},
};
use serde::{de::DeserializeOwned, Serialize}; use serde::{de::DeserializeOwned, Serialize};
use thiserror::Error; use thiserror::Error;
@ -62,19 +65,23 @@ where
{ {
match request.send().await { match request.send().await {
Err(error) => { Err(error) => {
toast::show(Level::Info, &format!("Internal server error: {}", error)); toast::show_message(Level::Info, &format!("Internal server error: {}", error));
Err(Error::Gloo(error)) Err(Error::Gloo(error))
} }
Ok(response) => { Ok(response) => {
if !response.ok() { if !response.ok() {
toast::show( toast::show_message(
Level::Info, Level::Info,
&format!("HTTP error: {}", response.status_text()), &format!("HTTP error: {}", response.status_text()),
); );
Err(Error::Http(response.status_text())) Err(Error::Http(response.status_text()))
} else { } else {
// Ok(()) let mut r = response.binary().await?;
Ok(ron::de::from_bytes::<T>(&response.binary().await?)?) // An empty response is considered to be an unit value.
if r.is_empty() {
r = b"()".to_vec();
}
Ok(ron::de::from_bytes::<T>(&r)?)
} }
} }
} }

View file

@ -1,4 +1,7 @@
use gloo::{timers::callback::Timeout, utils::document}; use gloo::{timers::callback::Timeout, utils::document};
use web_sys::Element;
use crate::utils::{by_id, selector_and_clone, SelectorExt};
pub enum Level { pub enum Level {
Success, Success,
@ -7,12 +10,36 @@ pub enum Level {
Warning, Warning,
} }
pub fn show(level: Level, message: &str) { const TIME_DISPLAYED: u32 = 10_000; // [ms].
pub fn show_message(level: Level, message: &str) {
let toast_element = document().get_element_by_id("toast").unwrap(); let toast_element = document().get_element_by_id("toast").unwrap();
toast_element.set_inner_html(message); toast_element.set_inner_html(message);
toast_element.set_class_name("show"); toast_element.set_class_name("show");
Timeout::new(4_000, move || { Timeout::new(TIME_DISPLAYED, move || {
toast_element.set_class_name("");
})
.forget();
}
pub fn show_element(level: Level, selector: &str) {
show_element_and_initialize(level, selector, |_| {})
}
pub fn show_element_and_initialize<T>(level: Level, selector: &str, initializer: T)
where
T: Fn(Element),
{
let toast_element = document().get_element_by_id("toast").unwrap();
let element: Element = selector_and_clone(selector);
toast_element.set_inner_html("");
toast_element.append_child(&element).unwrap();
initializer(element.clone());
toast_element.set_class_name("show");
Timeout::new(TIME_DISPLAYED, move || {
toast_element.set_class_name(""); toast_element.set_class_name("");
}) })
.forget(); .forget();