Recipe can now be scheduled
authorGreg Burri <greg.burri@gmail.com>
Tue, 4 Feb 2025 21:29:56 +0000 (22:29 +0100)
committerGreg Burri <greg.burri@gmail.com>
Tue, 4 Feb 2025 21:29:56 +0000 (22:29 +0100)
18 files changed:
Cargo.lock
backend/scss/toast.scss
backend/sql/version_1.sql
backend/src/data/db/recipe.rs
backend/src/main.rs
backend/src/services/ron.rs
backend/src/translation.rs
backend/templates/base.html
backend/templates/recipe_view.html
backend/translation.ron
common/src/ron_api.rs
frontend/Cargo.toml
frontend/src/calendar.rs
frontend/src/modal_dialog.rs
frontend/src/recipe_edit.rs
frontend/src/recipe_view.rs
frontend/src/request.rs
frontend/src/toast.rs

index 01a5811..e250eba 100644 (file)
@@ -356,6 +356,7 @@ dependencies = [
  "iana-time-zone",
  "js-sys",
  "num-traits",
+ "pure-rust-locales",
  "serde",
  "wasm-bindgen",
  "windows-targets 0.52.6",
@@ -1853,6 +1854,12 @@ dependencies = [
  "cc",
 ]
 
+[[package]]
+name = "pure-rust-locales"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1190fd18ae6ce9e137184f207593877e70f39b015040156b1e05081cdfe3733a"
+
 [[package]]
 name = "quote"
 version = "1.0.38"
index e6b4582..a0eeb34 100644 (file)
@@ -15,7 +15,7 @@
 
 #toast.show {
     visibility: visible;
-    animation: fadein 0.5s, fadeout 0.5s 3.6s;
+    animation: fadein 0.5s, fadeout 0.5s 9.6s;
     animation-iteration-count: 1;
 }
 
index 07d7d60..122a014 100644 (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.
 CREATE TABLE [Version] (
     [id] INTEGER PRIMARY KEY,
@@ -165,9 +168,11 @@ CREATE TABLE [RecipeScheduled] (
     [id] INTEGER PRIMARY KEY,
     [user_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].
 
+    UNIQUE([user_id], [recipe_id], [date]),
+
     FOREIGN KEY([user_id]) REFERENCES [User]([id]) ON DELETE CASCADE,
     FOREIGN KEY([recipe_id]) REFERENCES [Recipe]([id]) ON DELETE CASCADE
 );
index d7b8978..4db47da 100644 (file)
@@ -752,7 +752,7 @@ VALUES ($1, $2)
         Ok(())
     }
 
-    pub async fn add_schedule_recipe(
+    pub async fn add_scheduled_recipe(
         &self,
         user_id: i64,
         recipe_id: i64,
@@ -775,7 +775,7 @@ VALUES ($1, $2, $3, $4)
         .map_err(DBError::from)
     }
 
-    pub async fn remove_scheduled_recipe(
+    pub async fn rm_scheduled_recipe(
         &self,
         user_id: i64,
         recipe_id: i64,
@@ -964,13 +964,13 @@ VALUES
         let tomorrow = today + Days::new(1);
 
         connection
-            .add_schedule_recipe(user_id, recipe_id_1, today, 4)
+            .add_scheduled_recipe(user_id, recipe_id_1, today, 4)
             .await?;
         connection
-            .add_schedule_recipe(user_id, recipe_id_2, yesterday, 4)
+            .add_scheduled_recipe(user_id, recipe_id_2, yesterday, 4)
             .await?;
         connection
-            .add_schedule_recipe(user_id, recipe_id_1, tomorrow, 4)
+            .add_scheduled_recipe(user_id, recipe_id_1, tomorrow, 4)
             .await?;
 
         assert_eq!(
@@ -1008,13 +1008,13 @@ VALUES
         );
 
         connection
-            .remove_scheduled_recipe(user_id, recipe_id_1, today)
+            .rm_scheduled_recipe(user_id, recipe_id_1, today)
             .await?;
         connection
-            .remove_scheduled_recipe(user_id, recipe_id_2, yesterday)
+            .rm_scheduled_recipe(user_id, recipe_id_2, yesterday)
             .await?;
         connection
-            .remove_scheduled_recipe(user_id, recipe_id_1, tomorrow)
+            .rm_scheduled_recipe(user_id, recipe_id_1, tomorrow)
             .await?;
 
         assert_eq!(
index 5ac03eb..8a98761 100644 (file)
@@ -181,6 +181,14 @@ async fn main() {
             "/calendar/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);
 
     let fragments_routes = Router::new().route(
index bedccb1..cfc4650 100644 (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 ///
 
 #[debug_handler]
index 1e272cb..189cf8c 100644 (file)
@@ -141,6 +141,9 @@ pub enum Sentence {
     CalendarOctober,
     CalendarNovember,
     CalendarDecember,
+    CalendarAddToPlanner,
+    CalendarAddToPlannerSuccess,
+    CalendarDateFormat, // See https://docs.rs/chrono/latest/chrono/format/strftime/index.html.
 }
 
 pub const DEFAULT_LANGUAGE_CODE: &str = "en";
@@ -181,6 +184,10 @@ impl Tr {
     pub fn current_lang_code(&self) -> &str {
         &self.lang.code
     }
+
+    pub fn current_lang_and_territory_code(&self) -> String {
+        format!("{}-{}", self.lang.code, self.lang.territory)
+    }
 }
 
 // #[macro_export]
@@ -200,6 +207,7 @@ impl Tr {
 #[derive(Debug, Deserialize)]
 struct StoredLanguage {
     code: String,
+    territory: String,
     name: String,
     translation: Vec<(Sentence, String)>,
 }
@@ -207,6 +215,7 @@ struct StoredLanguage {
 #[derive(Debug)]
 struct Language {
     code: String,
+    territory: String,
     name: String,
     translation: Vec<String>,
 }
@@ -215,6 +224,7 @@ impl Language {
     pub fn from_stored_language(stored_language: StoredLanguage) -> Self {
         Self {
             code: stored_language.code,
+            territory: stored_language.territory,
             name: stored_language.name,
             translation: {
                 let mut translation = vec![String::new(); Sentence::COUNT];
index 8da3d5d..4991b56 100644 (file)
@@ -1,5 +1,5 @@
 <!DOCTYPE html>
-<html lang="en">
+<html lang="{{ tr.current_lang_and_territory_code() }}">
     <head>
         <meta charset="utf-8" />
         <meta name="viewport" content="width=device-width, initial-scale=1.0" />
index 231f85c..f32c8a3 100644 (file)
@@ -9,7 +9,7 @@
         {% if crate::data::model::can_user_edit_recipe(user, recipe) %}
             <a class="edit-recipe" href="/recipe/edit/{{ recipe.id }}" >Edit</a>
         {% endif %}
-        <span class="add-to-planner">Add to planner</span>
+        <span class="add-to-planner">{{ tr.t(Sentence::CalendarAddToPlanner) }}</span>
     {% endif %}
 
     <div class="tags">
@@ -92,6 +92,9 @@
                     value="{{ user.default_servings }}"/>
             </div>
         {% 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>
 
index f99b3a3..6b4d939 100644 (file)
@@ -1,6 +1,7 @@
 [
     (
         code: "en",
+        territory: "US",
         name: "English",
         translation: [
             (MainTitle, "Cooking Recipes"),
             (CalendarOctober, "October"),
             (CalendarNovember, "November"),
             (CalendarDecember, "December"),
+            (CalendarAddToPlanner, "Add to planner"),
+            (CalendarAddToPlannerSuccess, "Recipe {} has been scheduled for {}"),
+            (CalendarDateFormat, "%A, %-d %B, %C%y"),
         ]
     ),
     (
         code: "fr",
+        territory: "FR",
         name: "Français",
         translation: [
             (MainTitle, "Recettes de Cuisine"),
             (CalendarOctober, "Octobre"),
             (CalendarNovember, "Novembre"),
             (CalendarDecember, "Décembre"),
+            (CalendarAddToPlanner, "Ajouter au planificateur"),
+            (CalendarAddToPlannerSuccess, "La recette {} a été agendée pour le {}"),
+            (CalendarDateFormat, "%A %-d %B %C%y"),
         ]
     )
 ]
\ No newline at end of file
index 5ca697f..189cabd 100644 (file)
@@ -17,7 +17,7 @@ pub struct Id {
     pub id: i64,
 }
 
-/// RECIPE ///
+/*** RECIPE ***/
 
 #[derive(Serialize, Deserialize, Clone)]
 pub struct SetRecipeTitle {
@@ -159,7 +159,7 @@ pub struct Ingredient {
     pub quantity_unit: String,
 }
 
-/// PROFILE ///
+/*** PROFILE ***/
 
 #[derive(Serialize, Deserialize, Clone)]
 pub struct UpdateProfile {
@@ -168,6 +168,29 @@ pub struct UpdateProfile {
     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
 where
     T: Serialize,
@@ -175,11 +198,3 @@ where
     // TODO: handle'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)>,
-}
index adc06e1..d07892d 100644 (file)
@@ -13,7 +13,7 @@ default = ["console_error_panic_hook"]
 [dependencies]
 common = { path = "../common" }
 
-chrono = { version = "0.4", features = ["serde"] }
+chrono = { version = "0.4", features = ["serde", "unstable-locales"] }
 
 ron = "0.8"
 serde = { version = "1.0", features = ["derive"] }
index 64c16e5..58e1d4e 100644 (file)
@@ -18,7 +18,7 @@ struct CalendarStateInternal {
 }
 
 #[derive(Clone)]
-struct CalendarState {
+pub struct CalendarState {
     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 next: Element = calendar.selector(".next");
 
@@ -98,6 +98,8 @@ pub fn setup(calendar: Element) {
         }
     })
     .forget();
+
+    state
 }
 
 const NB_CALENDAR_ROW: u64 = 5;
index 477df12..9095c9b 100644 (file)
@@ -7,12 +7,26 @@ use crate::{
 };
 
 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
-    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");
 
@@ -24,7 +38,7 @@ where
     let element: Element = selector_and_clone(element_selector);
     content_element.set_inner_html("");
     content_element.append_child(&element).unwrap();
-    initializer(element).await;
+    let init_result = initializer(element.clone()).await;
 
     dialog.show_modal().unwrap();
 
@@ -34,8 +48,8 @@ where
     pin_mut!(click_ok, click_cancel);
 
     let result = select! {
-        () = click_ok => true,
-        () = click_cancel => false,
+        () = click_ok => Some(ok(element, init_result)),
+        () = click_cancel => None,
     };
 
     dialog.close();
index d9c2c5a..5a8dab5 100644 (file)
@@ -19,11 +19,6 @@ use crate::{
     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> {
     // Title.
     {
@@ -265,6 +260,7 @@ pub fn setup_page(recipe_id: i64) -> Result<(), JsValue> {
                 },
             )
             .await
+            .is_some()
             {
                 let body = ron_api::Id { id: recipe_id };
                 let _ = request::delete::<(), _>("recipe/remove", body).await;
@@ -400,6 +396,7 @@ fn create_group_element(group: &ron_api::Group) -> Element {
                 },
             )
             .await
+            .is_some()
             {
                 let body = ron_api::Id { id: group_id };
                 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
+            .is_some()
             {
                 let body = ron_api::Id { id: step_id };
                 let _ = request::delete::<(), _>("recipe/remove_step", body).await;
@@ -696,6 +694,7 @@ fn create_ingredient_element(step_element: &Element, ingredient: &ron_api::Ingre
                 },
             )
             .await
+            .is_some()
             {
                 let body = ron_api::Id { id: ingredient_id };
                 let _ = request::delete::<(), _>("recipe/remove_ingredient", body).await;
@@ -717,7 +716,7 @@ async fn reload_recipes_list(current_recipe_id: i64) {
         .await
     {
         Err(error) => {
-            toast::show(Level::Info, &format!("Internal server error: {}", error));
+            toast::show_message(Level::Info, &format!("Internal server error: {}", error));
         }
         Ok(response) => {
             let list = document().get_element_by_id("recipes-list").unwrap();
index bee05c6..26b9699 100644 (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::{
-    console::console,
+    console::log,
     events::EventListener,
     net::http::Request,
     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");
     EventListener::new(&add_to_planner, "click", move |_event| {
         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",
-                async |element| {
-                    calendar::setup(element.selector(".calendar"));
+                async |element| 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();
index 306f96b..bb3b2c3 100644 (file)
@@ -1,5 +1,8 @@
 use common::ron_api;
-use gloo::net::http::{Request, RequestBuilder};
+use gloo::{
+    console::log,
+    net::http::{Request, RequestBuilder},
+};
 use serde::{de::DeserializeOwned, Serialize};
 use thiserror::Error;
 
@@ -62,19 +65,23 @@ where
 {
     match request.send().await {
         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))
         }
         Ok(response) => {
             if !response.ok() {
-                toast::show(
+                toast::show_message(
                     Level::Info,
                     &format!("HTTP error: {}", response.status_text()),
                 );
                 Err(Error::Http(response.status_text()))
             } else {
-                // Ok(())
-                Ok(ron::de::from_bytes::<T>(&response.binary().await?)?)
+                let mut r = 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)?)
             }
         }
     }
index 1c30a71..b58cbbd 100644 (file)
@@ -1,4 +1,7 @@
 use gloo::{timers::callback::Timeout, utils::document};
+use web_sys::Element;
+
+use crate::utils::{by_id, selector_and_clone, SelectorExt};
 
 pub enum Level {
     Success,
@@ -7,12 +10,36 @@ pub enum Level {
     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();
     toast_element.set_inner_html(message);
     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("");
     })
     .forget();