Calendar (WIP)

This commit is contained in:
Greg Burri 2025-01-29 14:37:25 +01:00
parent 9d3f9e9c60
commit 79a0aeb1b8
24 changed files with 613 additions and 231 deletions

159
Cargo.lock generated
View file

@ -26,7 +26,7 @@ dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
"zerocopy 0.7.35",
]
[[package]]
@ -315,9 +315,9 @@ dependencies = [
[[package]]
name = "bumpalo"
version = "3.16.0"
version = "3.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf"
[[package]]
name = "byteorder"
@ -356,6 +356,7 @@ dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-targets 0.52.6",
]
@ -420,6 +421,7 @@ checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
name = "common"
version = "0.1.0"
dependencies = [
"chrono",
"ron",
"serde",
]
@ -477,9 +479,9 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.16"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16b80225097f2e5ae4e7179dd2266824648f3e2f49d9134d584b76389d31c4c3"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
@ -832,10 +834,22 @@ dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasi 0.11.0+wasi-snapshot-preview1",
"wasm-bindgen",
]
[[package]]
name = "getrandom"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8"
dependencies = [
"cfg-if",
"libc",
"wasi 0.13.3+wasi-0.2.2",
"windows-targets 0.52.6",
]
[[package]]
name = "gimli"
version = "0.31.1"
@ -912,7 +926,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "903f432be5ba34427eac5e16048ef65604a82061fe93789f2212afc73d8617d6"
dependencies = [
"getrandom",
"getrandom 0.2.15",
"gloo-events",
"gloo-utils",
"serde",
@ -1156,9 +1170,9 @@ checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "httparse"
version = "1.9.5"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d71d3574edd2771538b901e6549113b4006ece66150fb69c0fb6d9a2adae946"
checksum = "f2d708df4e7140240a16cd6ab0ab65c972d7433ab77819ea693fde9c43811e2a"
[[package]]
name = "httpdate"
@ -1177,9 +1191,9 @@ dependencies = [
[[package]]
name = "hyper"
version = "1.5.2"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "256fb8d4bd6413123cc9d91832d78325c48ff41677595be797d90f42969beae0"
checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80"
dependencies = [
"bytes",
"futures-channel",
@ -1573,7 +1587,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd"
dependencies = [
"libc",
"wasi",
"wasi 0.11.0+wasi-snapshot-preview1",
"windows-sys 0.52.0",
]
@ -1609,7 +1623,7 @@ dependencies = [
"num-integer",
"num-iter",
"num-traits",
"rand",
"rand 0.8.5",
"smallvec",
"zeroize",
]
@ -1707,7 +1721,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core",
"rand_core 0.6.4",
"subtle",
]
@ -1808,7 +1822,7 @@ version = "0.2.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04"
dependencies = [
"zerocopy",
"zerocopy 0.7.35",
]
[[package]]
@ -1861,8 +1875,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.0",
"zerocopy 0.8.14",
]
[[package]]
@ -1872,7 +1897,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.0",
]
[[package]]
@ -1881,7 +1916,17 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
"getrandom 0.2.15",
]
[[package]]
name = "rand_core"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b08f3c9802962f7e1b25113931d94f43ed9725bebc59db9d0c3e9a23b67e15ff"
dependencies = [
"getrandom 0.3.1",
"zerocopy 0.8.14",
]
[[package]]
@ -1897,8 +1942,8 @@ dependencies = [
"derive_more",
"itertools",
"lettre",
"rand",
"rand_core",
"rand 0.9.0",
"rand_core 0.9.0",
"rinja",
"ron",
"serde",
@ -1974,7 +2019,7 @@ checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d"
dependencies = [
"cc",
"cfg-if",
"getrandom",
"getrandom 0.2.15",
"libc",
"spin",
"untrusted",
@ -2047,7 +2092,7 @@ dependencies = [
"num-traits",
"pkcs1",
"pkcs8",
"rand_core",
"rand_core 0.6.4",
"signature",
"spki",
"subtle",
@ -2105,9 +2150,9 @@ dependencies = [
[[package]]
name = "rustls-pki-types"
version = "1.10.1"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2bf47e6ff922db3825eb750c4e2ff784c6ff8fb9e13046ef6a1d1c5401b0b37"
checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c"
[[package]]
name = "rustls-webpki"
@ -2128,9 +2173,9 @@ checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4"
[[package]]
name = "ryu"
version = "1.0.18"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
checksum = "6ea1a2d0a644769cc99faa24c3ad26b379b786fe7c36fd3c546254801650e6dd"
[[package]]
name = "scopeguard"
@ -2171,9 +2216,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.137"
version = "1.0.138"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "930cfb6e6abf99298aaad7d29abbef7a9999a9a8806a40088f55f0dcec03146b"
checksum = "d434192e7da787e94a6ea7e9670b26a036d0ca41e0b7efb2676dd32bae872949"
dependencies = [
"itoa",
"memchr",
@ -2256,7 +2301,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
@ -2422,7 +2467,7 @@ dependencies = [
"memchr",
"once_cell",
"percent-encoding",
"rand",
"rand 0.8.5",
"rsa",
"serde",
"sha1",
@ -2461,7 +2506,7 @@ dependencies = [
"md-5",
"memchr",
"once_cell",
"rand",
"rand 0.8.5",
"serde",
"serde_json",
"sha2",
@ -2588,13 +2633,13 @@ dependencies = [
[[package]]
name = "tempfile"
version = "3.15.0"
version = "3.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8a559c81686f576e8cd0290cd2a24a2a9ad80c98b3478856500fcbd7acd704"
checksum = "38c246215d7d24f48ae091a2902398798e05d978b24315d6efbc00ede9a8bb91"
dependencies = [
"cfg-if",
"fastrand",
"getrandom",
"getrandom 0.3.1",
"once_cell",
"rustix",
"windows-sys 0.59.0",
@ -2921,9 +2966,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-ident"
version = "1.0.14"
version = "1.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83"
checksum = "a210d160f08b701c8721ba1c726c11662f877ea6b7094007e1ca9a1041945034"
[[package]]
name = "unicode-normalization"
@ -3011,6 +3056,15 @@ version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "wasi"
version = "0.13.3+wasi-0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2"
dependencies = [
"wit-bindgen-rt",
]
[[package]]
name = "wasite"
version = "0.1.0"
@ -3315,6 +3369,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen-rt"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c"
dependencies = [
"bitflags",
]
[[package]]
name = "write16"
version = "1.0.0"
@ -3358,7 +3421,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
dependencies = [
"byteorder",
"zerocopy-derive",
"zerocopy-derive 0.7.35",
]
[[package]]
name = "zerocopy"
version = "0.8.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a367f292d93d4eab890745e75a778da40909cab4d6ff8173693812f79c4a2468"
dependencies = [
"zerocopy-derive 0.8.14",
]
[[package]]
@ -3372,6 +3444,17 @@ dependencies = [
"syn",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3931cb58c62c13adec22e38686b559c86a30565e16ad6e8510a337cedc611e1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zerofrom"
version = "0.1.5"

View file

@ -1,9 +1,14 @@
* FIX: when the event blur is triggered when changing page, the async process doesn't finish all the time
* User can change default_servings in profile
* Can choose servings number in recipe view
* Default number is the user setting user.default_servings
* A symbol show the native recipe servings number
* Check position of message error in profile/sign in/sign up with flex grid layout
* Define the UI (mockups).
* Two CSS: one for desktop and one for mobile
* Use CSS flex/grid to define a good design/layout
* CSS for toast and modal dialog
* Calendar: Choose the first day of the week
* Make a search page
Use FTS5:
https://sqlite.org/fts5.html
@ -13,6 +18,10 @@
* Make the home page: Define what to display to the user
* Show existing tags when editing a recipe
[ok] Add a table for website global settings with two column: name + value
* Add a boolean settings to enable/disable new inscription
[ok] Add a [is_admin] flag to [User] table
[ok] Test when there is an SQL error (syntax error for sample)
[ok] Drag and drop of steps and groups to define their order
[ok] Force tags in lowercase
[ok] Remove the given language to recipe_edit and replace it by tr (like the header)

View file

@ -16,7 +16,7 @@ tower-http = { version = "0.6", features = ["fs", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
chrono = "0.4"
chrono = { version = "0.4", features = ["serde"] }
# Rust object notation, to load configuration files.
ron = "0.8"
@ -30,8 +30,8 @@ sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio", "chrono"] }
rinja = { version = "0.3" }
argon2 = { version = "0.5", features = ["default", "std"] }
rand_core = { version = "0.6", features = ["std"] }
rand = "0.8"
rand_core = { version = "0.9", features = ["std"] }
rand = "0.9"
strum = "0.26"
strum_macros = "0.26"

View file

@ -41,6 +41,10 @@
width: 14%;
text-align: center;
margin: 0;
&.current-month {
background-color: blue;
}
}
}
}

View file

@ -172,6 +172,8 @@ CREATE TABLE [RecipeScheduled] (
FOREIGN KEY([recipe_id]) REFERENCES [Recipe]([id]) ON DELETE CASCADE
);
CREATE INDEX [RecipeScheduled_date_index] ON [RecipeScheduled]([date]);
CREATE TABLE [ShoppingEntry] (
[id] INTEGER PRIMARY KEY,
[user_id] INTEGER NOT NULL,

View file

@ -1,10 +1,9 @@
use chrono::prelude::*;
use chrono::{prelude::*, Days};
use common::ron_api::Difficulty;
use itertools::Itertools;
use super::{Connection, DBError, Result};
use crate::data::model;
use common::ron_api::Difficulty;
use crate::{data::model, user_authentication};
impl Connection {
/// Returns all the recipe titles where recipe is written in the given language.
@ -106,11 +105,10 @@ SELECT COUNT(*)
FROM [Recipe]
INNER JOIN [User] ON [User].[id] = [Recipe].[user_id]
INNER JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id]
WHERE [Group].[id] IN ({}) AND ([user_id] = $2 OR (SELECT [is_admin] FROM [User] WHERE [id] = $2))
WHERE [Group].[id] IN ({}) AND ([user_id] = $1 OR (SELECT [is_admin] FROM [User] WHERE [id] = $1))
"#,
params
);
let mut query = sqlx::query_scalar::<_, u64>(&query_str).bind(user_id);
for id in group_ids {
query = query.bind(id);
@ -147,11 +145,10 @@ FROM [Recipe]
INNER JOIN [User] ON [User].[id] = [Recipe].[user_id]
INNER JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id]
INNER JOIN [Step] ON [Step].[group_id] = [Group].[id]
WHERE [Step].[id] IN ({}) AND ([user_id] = $2 OR (SELECT [is_admin] FROM [User] WHERE [id] = $2))
WHERE [Step].[id] IN ({}) AND ([user_id] = $1 OR (SELECT [is_admin] FROM [User] WHERE [id] = $1))
"#,
params
);
let mut query = sqlx::query_scalar::<_, u64>(&query_str).bind(user_id);
for id in steps_ids {
query = query.bind(id);
@ -199,11 +196,10 @@ INNER JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id]
INNER JOIN [Step] ON [Step].[group_id] = [Group].[id]
INNER JOIN [Ingredient] ON [Ingredient].[step_id] = [Step].[id]
WHERE [Ingredient].[id] IN ({}) AND
([user_id] = $2 OR (SELECT [is_admin] FROM [User] WHERE [id] = $2))
([user_id] = $1 OR (SELECT [is_admin] FROM [User] WHERE [id] = $1))
"#,
params
);
let mut query = sqlx::query_scalar::<_, u64>(&query_str).bind(user_id);
for id in ingredients_ids {
query = query.bind(id);
@ -755,6 +751,73 @@ VALUES ($1, $2)
Ok(())
}
pub async fn add_schedule_recipe(
&self,
user_id: i64,
recipe_id: i64,
date: NaiveDate,
servings: u32,
) -> Result<()> {
sqlx::query(
r#"
INSERT INTO [RecipeScheduled] (user_id, recipe_id, date, servings)
VALUES ($1, $2, $3, $4)
"#,
)
.bind(user_id)
.bind(recipe_id)
.bind(date)
.bind(servings)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(DBError::from)
}
pub async fn remove_scheduled_recipe(
&self,
user_id: i64,
recipe_id: i64,
date: NaiveDate,
) -> Result<()> {
sqlx::query(
r#"
DELETE FROM [RecipeScheduled]
WHERE [user_id] = $1 AND [recipe_id] = $2 AND [date] = $3
"#,
)
.bind(user_id)
.bind(recipe_id)
.bind(date)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(DBError::from)
}
pub async fn get_scheduled_recipes(
&self,
user_id: i64,
start_date: NaiveDate,
end_date: NaiveDate,
) -> Result<Vec<(NaiveDate, String, i64)>> {
sqlx::query_as(
r#"
SELECT [date], [Recipe].[title], [Recipe].[id], [RecipeScheduled].[date]
FROM [RecipeScheduled]
INNER JOIN [Recipe] ON [Recipe].[id] = [RecipeScheduled].[recipe_id]
WHERE [RecipeScheduled].[user_id] = $1 AND [date] >= $2 AND [date] <= $3
ORDER BY [date]
"#,
)
.bind(user_id)
.bind(start_date)
.bind(end_date)
.fetch_all(&self.pool)
.await
.map_err(DBError::from)
}
}
#[cfg(test)]
@ -884,4 +947,83 @@ VALUES
Ok(())
}
#[tokio::test]
async fn schedule_recipe() -> Result<()> {
let connection = Connection::new_in_memory().await?;
let user_id = create_a_user(&connection).await?;
let recipe_id_1 = connection.create_recipe(user_id).await?;
connection.set_recipe_title(recipe_id_1, "recipe 1").await?;
let recipe_id_2 = connection.create_recipe(user_id).await?;
connection.set_recipe_title(recipe_id_2, "recipe 2").await?;
let today = NaiveDate::from_ymd_opt(2025, 1, 23).unwrap();
let yesterday = today - Days::new(1);
let tomorrow = today + Days::new(1);
connection
.add_schedule_recipe(user_id, recipe_id_1, today, 4)
.await?;
connection
.add_schedule_recipe(user_id, recipe_id_2, yesterday, 4)
.await?;
connection
.add_schedule_recipe(user_id, recipe_id_1, tomorrow, 4)
.await?;
assert_eq!(
connection
.get_scheduled_recipes(user_id, today, today)
.await?,
vec![(
NaiveDate::from_ymd_opt(2025, 1, 23).unwrap(),
"recipe 1".to_string(),
1
)]
);
assert_eq!(
connection
.get_scheduled_recipes(user_id, yesterday, tomorrow)
.await?,
vec![
(
NaiveDate::from_ymd_opt(2025, 1, 22).unwrap(),
"recipe 2".to_string(),
2
),
(
NaiveDate::from_ymd_opt(2025, 1, 23).unwrap(),
"recipe 1".to_string(),
1
),
(
NaiveDate::from_ymd_opt(2025, 1, 24).unwrap(),
"recipe 1".to_string(),
1
)
]
);
connection
.remove_scheduled_recipe(user_id, recipe_id_1, today)
.await?;
connection
.remove_scheduled_recipe(user_id, recipe_id_2, yesterday)
.await?;
connection
.remove_scheduled_recipe(user_id, recipe_id_1, tomorrow)
.await?;
assert_eq!(
connection
.get_scheduled_recipes(user_id, yesterday, tomorrow)
.await?,
vec![]
);
Ok(())
}
}

View file

@ -1,5 +1,5 @@
use chrono::{prelude::*, Duration};
use rand::distributions::{Alphanumeric, DistString};
use rand::distr::{Alphanumeric, SampleString};
use sqlx::Sqlite;
use super::{Connection, DBError, Result};
@ -57,7 +57,7 @@ pub enum ResetPasswordResult {
}
fn generate_token() -> String {
Alphanumeric.sample_string(&mut rand::thread_rng(), consts::TOKEN_SIZE)
Alphanumeric.sample_string(&mut rand::rng(), consts::TOKEN_SIZE)
}
impl Connection {

View file

@ -177,6 +177,10 @@ async fn main() {
"/recipe/set_ingredients_order",
put(services::ron::set_ingredients_order),
)
.route(
"/calendar/get_scheduled_recipes",
get(services::ron::get_scheduled_recipes),
)
.fallback(services::ron::not_found);
let fragments_routes = Router::new().route(

View file

@ -5,6 +5,7 @@ use axum::{
response::{ErrorResponse, IntoResponse, Result},
};
use axum_extra::extract::cookie::{Cookie, CookieJar};
use chrono::NaiveDate;
use serde::Deserialize;
// use tracing::{event, Level};
@ -183,11 +184,11 @@ async fn check_user_rights_recipe_ingredient(
async fn check_user_rights_recipe_ingredients(
connection: &db::Connection,
user: &Option<model::User>,
step_ids: &[i64],
ingredient_ids: &[i64],
) -> Result<()> {
if user.is_none()
|| !connection
.can_edit_recipe_all_ingredients(user.as_ref().unwrap().id, step_ids)
.can_edit_recipe_all_ingredients(user.as_ref().unwrap().id, ingredient_ids)
.await?
{
Err(ErrorResponse::from(ron_error(
@ -599,7 +600,39 @@ pub async fn set_ingredients_order(
Ok(StatusCode::OK)
}
///// 404 /////
/// Calendar ///
#[derive(Deserialize)]
pub struct DateRange {
start_date: NaiveDate,
end_date: NaiveDate,
}
#[debug_handler]
pub async fn get_scheduled_recipes(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
date_range: Query<DateRange>,
) -> Result<impl IntoResponse> {
if let Some(user) = user {
Ok(ron_response(
StatusCode::OK,
common::ron_api::ScheduledRecipes {
recipes: connection
.get_scheduled_recipes(user.id, date_range.start_date, date_range.end_date)
.await?,
},
))
} 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 {
ron_error(StatusCode::NOT_FOUND, "Not found")

View file

@ -1,12 +1,13 @@
use std::{borrow::Borrow, fs::File, sync::LazyLock};
use common::utils;
use ron::de::from_reader;
use serde::Deserialize;
use strum::EnumCount;
use strum_macros::EnumCount;
use tracing::{event, Level};
use crate::{consts, utils};
use crate::consts;
#[derive(Debug, Clone, EnumCount, Deserialize)]
pub enum Sentence {
@ -109,6 +110,10 @@ pub enum Sentence {
RecipeIngredientQuantity,
RecipeIngredientUnit,
RecipeIngredientComment,
RecipeDeleteConfirmation,
RecipeGroupDeleteConfirmation,
RecipeStepDeleteConfirmation,
RecipeIngredientDeleteConfirmation,
// View Recipe.
RecipeOneServing,

View file

@ -39,44 +39,3 @@ pub fn get_url_from_host(host: &str) -> String {
host
)
}
pub fn substitute(str: &str, pattern: &str, replacements: &[&str]) -> String {
let mut result = String::with_capacity(
(str.len() + replacements.iter().map(|s| s.len()).sum::<usize>())
.saturating_sub(pattern.len() * replacements.len()),
);
let mut i = 0;
for s in str.split(pattern) {
result.push_str(s);
if i < replacements.len() {
result.push_str(replacements[i]);
}
i += 1;
}
if i == 1 {
return str.to_string();
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_substitute() {
assert_eq!(substitute("", "", &[]), "");
assert_eq!(substitute("", "", &[""]), "");
assert_eq!(substitute("", "{}", &["a"]), "");
assert_eq!(substitute("a", "{}", &["b"]), "a");
assert_eq!(substitute("a{}", "{}", &["b"]), "ab");
assert_eq!(substitute("{}c", "{}", &["b"]), "bc");
assert_eq!(substitute("a{}c", "{}", &["b"]), "abc");
assert_eq!(substitute("{}b{}", "{}", &["a", "c"]), "abc");
assert_eq!(substitute("{}{}{}", "{}", &["a", "bc", "def"]), "abcdef");
assert_eq!(substitute("{}{}{}", "{}", &["a"]), "a");
}
}

View file

@ -38,7 +38,7 @@
<ul class="days">
{% for i in 0..7 %}
{% for j in 0..5 %}
<li id="day-{{i}}{{j}}"></li>
<li id="day-{{i}}{{j}}"><div class="number"></div><div class="scheduled-recipes"></div></li>
{% endfor %}
{% endfor %}
</ul>

View file

@ -134,6 +134,11 @@
</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>
</div>

View file

@ -95,6 +95,10 @@
(RecipeIngredientQuantity, "Quantity"),
(RecipeIngredientUnit, "Unit"),
(RecipeIngredientComment, "Comment"),
(RecipeDeleteConfirmation, "Are you sure to delete the recipe: '{}'?"),
(RecipeGroupDeleteConfirmation, "Are you sure to delete the group: '{}'?"),
(RecipeStepDeleteConfirmation, "Are you sure to delete the step: '{}'?"),
(RecipeIngredientDeleteConfirmation, "Are you sure to delete the ingredient: '{}'?"),
(RecipeOneServing, "1 serving"),
(RecipeSomeServings, "{} servings"),
@ -217,6 +221,10 @@
(RecipeIngredientQuantity, "Quantité"),
(RecipeIngredientUnit, "Unité"),
(RecipeIngredientComment, "Commentaire"),
(RecipeDeleteConfirmation, "Êtes-vous sûr de vouloir supprimer la recette : '{}' ?"),
(RecipeGroupDeleteConfirmation, "Êtes-vous sûr de vouloir supprimer le groupe : '{}' ?"),
(RecipeStepDeleteConfirmation, "Êtes-vous sûr de vouloir supprimer l'étape : '{}' ?"),
(RecipeIngredientDeleteConfirmation, "Êtes-vous sûr de vouloir supprimer 'ingrédient : '{}' ?"),
(RecipeOneServing, "pour 1 personne"),
(RecipeSomeServings, "pour {} personnes"),

View file

@ -7,3 +7,4 @@ edition = "2021"
[dependencies]
ron = "0.8"
serde = { version = "1.0", features = ["derive"] }
chrono = { version = "0.4", features = ["serde"] }

View file

@ -1,3 +1,4 @@
use chrono::NaiveDate;
use ron::ser::{to_string_pretty, PrettyConfig};
use serde::{Deserialize, Serialize};
@ -16,7 +17,7 @@ pub struct Id {
pub id: i64,
}
///// RECIPE /////
/// RECIPE ///
#[derive(Serialize, Deserialize, Clone)]
pub struct SetRecipeTitle {
@ -158,7 +159,7 @@ pub struct Ingredient {
pub quantity_unit: String,
}
///// PROFILE /////
/// PROFILE ///
#[derive(Serialize, Deserialize, Clone)]
pub struct UpdateProfile {
@ -174,3 +175,11 @@ 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)>,
}

View file

@ -12,3 +12,44 @@ pub fn validate_password(password: &str) -> PasswordValidation {
PasswordValidation::Ok
}
}
pub fn substitute(str: &str, pattern: &str, replacements: &[&str]) -> String {
let mut result = String::with_capacity(
(str.len() + replacements.iter().map(|s| s.len()).sum::<usize>())
.saturating_sub(pattern.len() * replacements.len()),
);
let mut i = 0;
for s in str.split(pattern) {
result.push_str(s);
if i < replacements.len() {
result.push_str(replacements[i]);
}
i += 1;
}
if i == 1 {
return str.to_string();
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_substitute() {
assert_eq!(substitute("", "", &[]), "");
assert_eq!(substitute("", "", &[""]), "");
assert_eq!(substitute("", "{}", &["a"]), "");
assert_eq!(substitute("a", "{}", &["b"]), "a");
assert_eq!(substitute("a{}", "{}", &["b"]), "ab");
assert_eq!(substitute("{}c", "{}", &["b"]), "bc");
assert_eq!(substitute("a{}c", "{}", &["b"]), "abc");
assert_eq!(substitute("{}b{}", "{}", &["a", "c"]), "abc");
assert_eq!(substitute("{}{}{}", "{}", &["a", "bc", "def"]), "abcdef");
assert_eq!(substitute("{}{}{}", "{}", &["a"]), "a");
}
}

View file

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

View file

@ -1,100 +1,144 @@
use std::{
ops::{AddAssign, SubAssign},
sync::{
use std::sync::{
atomic::{AtomicI32, AtomicU32, Ordering},
Arc,
},
Arc, Mutex,
};
use chrono::{offset::Local, Datelike, Days, NaiveDate, Weekday};
use chrono::{offset::Local, DateTime, Datelike, Days, Months, NaiveDate, Weekday};
use common::ron_api;
use gloo::{console::log, events::EventListener};
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::spawn_local;
use web_sys::Element;
use crate::utils::{by_id, SelectorExt};
use crate::{
request,
utils::{by_id, selector, SelectorExt},
};
pub fn setup(calendar: &Element) {
struct CalendarStateInternal {
current_date: DateTime<Local>,
selected_date: DateTime<Local>,
}
#[derive(Clone)]
struct CalendarState {
internal_state: Arc<Mutex<CalendarStateInternal>>,
}
impl CalendarState {
pub fn new() -> Self {
let current_date = Local::now();
Self {
internal_state: Arc::new(Mutex::new(CalendarStateInternal {
current_date,
selected_date: current_date,
})),
}
}
pub fn to_next_month(&self) -> DateTime<Local> {
let mut locker = self.internal_state.lock().unwrap();
let new_date = locker
.current_date
.checked_add_months(Months::new(1))
.unwrap();
locker.current_date = new_date;
new_date
}
pub fn to_previous_month(&self) -> DateTime<Local> {
let mut locker = self.internal_state.lock().unwrap();
let new_date = locker
.current_date
.checked_sub_months(Months::new(1))
.unwrap();
locker.current_date = new_date;
new_date
}
pub fn get_current_date(&self) -> DateTime<Local> {
self.internal_state.lock().unwrap().current_date
}
pub fn get_selected_date(&self) -> DateTime<Local> {
self.internal_state.lock().unwrap().selected_date
}
}
pub fn setup(calendar: Element) {
let prev: Element = calendar.selector(".prev");
let next: Element = calendar.selector(".next");
let current_month = Arc::new(AtomicU32::new(Local::now().month()));
let current_year = Arc::new(AtomicI32::new(Local::now().year()));
let state = CalendarState::new();
display_month(calendar, Local::now().year(), Local::now().month());
display_month(&calendar, state.get_current_date());
let calendar_clone = calendar.clone();
let current_month_clone = current_month.clone();
let current_year_clone = current_year.clone();
let state_clone = state.clone();
EventListener::new(&prev, "click", move |_event| {
let mut m = current_month_clone.load(Ordering::Relaxed) - 1;
if m == 0 {
current_year_clone.fetch_sub(1, Ordering::Relaxed);
m = 12
}
current_month_clone.store(m, Ordering::Relaxed);
display_month(
&calendar_clone,
current_year_clone.load(Ordering::Relaxed),
m,
);
let m = state_clone.to_previous_month();
display_month(&calendar_clone, m);
})
.forget();
let calendar_clone = calendar.clone();
let current_month_clone = current_month.clone();
let current_year_clone = current_year.clone();
let state_clone = state.clone();
EventListener::new(&next, "click", move |_event| {
let mut m = current_month_clone.load(Ordering::Relaxed) + 1;
if m == 13 {
current_year_clone.fetch_add(1, Ordering::Relaxed);
m = 1
}
current_month_clone.store(m, Ordering::Relaxed);
display_month(
&calendar_clone,
current_year_clone.load(Ordering::Relaxed),
m,
);
let m = state_clone.to_next_month();
display_month(&calendar_clone, m);
})
.forget();
// now.weekday()
// let days: Element = calendar.selector(".days");
// let state_clone = state.clone();
// EventListener::new(&days, "click", move |event| {
// log!(event);
// let target: Element = event.target().unwrap().dyn_into().unwrap();
// if
// })
// .forget();
// console!(now.to_string());
// let calendar_clone = calendar.clone();
// let current_month_clone = current_month.clone();
// let current_year_clone = current_year.clone();
// EventListener::new(&next, "click", move |_event| {
// let mut m = current_month_clone.load(Ordering::Relaxed) + 1;
// if m == 13 {
// current_year_clone.fetch_add(1, Ordering::Relaxed);
// m = 1
// }
// current_month_clone.store(m, Ordering::Relaxed);
// display_month(
// &calendar_clone,
// current_year_clone.load(Ordering::Relaxed),
// m,
// );
// })
// .forget();
}
// fn translate_month(month: u32) -> &'static str {
// match
// }
fn display_month(calendar: &Element, year: i32, month: u32) {
log!(year, month);
const NB_CALENDAR_ROW: u64 = 5;
fn display_month(calendar: &Element, date: DateTime<Local>) {
calendar
.selector::<Element>(".year")
.set_inner_html(&year.to_string());
.set_inner_html(&date.year().to_string());
for (i, m) in calendar
.selector_all::<Element>(".month")
.into_iter()
.enumerate()
{
if i as u32 + 1 == month {
if i as u32 + 1 == date.month() {
m.set_class_name("month current");
} else {
m.set_class_name("month");
}
}
// calendar
// .selector::<Element>(".month")
// .set_inner_html(&month.to_string());
let mut current = date;
let mut current = NaiveDate::from_ymd_opt(year, month, 1).unwrap();
// let mut day = Local:: ;
while (current - Days::new(1)).month() == month {
while (current - Days::new(1)).month() == date.month() {
current = current - Days::new(1);
}
@ -102,20 +146,46 @@ fn display_month(calendar: &Element, year: i32, month: u32) {
current = current - Days::new(1);
}
for i in 0..7 {
for j in 0..5 {
let li: Element = by_id(&format!("day-{}{}", i, j));
li.set_inner_html(&current.day().to_string());
let first_day = current;
if current == Local::now().date_naive() {
li.set_class_name("current-month today");
} else if current.month() == month {
li.set_class_name("current-month");
for i in 0..7 {
for j in 0..NB_CALENDAR_ROW {
let day_element: Element = by_id(&format!("day-{}{}", i, j));
let day_content: Element = day_element.selector(".number");
day_content.set_inner_html(&current.day().to_string());
if current == Local::now() {
day_element.set_class_name("current-month today");
} else if current.month() == date.month() {
day_element.set_class_name("current-month");
} else {
li.set_class_name("");
day_element.set_class_name("");
}
current = current + Days::new(1);
}
}
spawn_local(async move {
let scheduled_recipes: ron_api::ScheduledRecipes = request::get(
"calendar/get_scheduled_recipes",
[
("start_date", first_day.date_naive().to_string()),
(
"end_date",
(first_day + Days::new(NB_CALENDAR_ROW * 7))
.date_naive()
.to_string(),
),
],
)
.await
.unwrap();
for recipe in scheduled_recipes.recipes {
log!(recipe.1);
}
// create_tag_elements(recipe_id, &tags.tags);
});
}

View file

@ -1,3 +1,10 @@
use common::ron_api;
use gloo::{console::log, events::EventListener, utils::window};
use utils::by_id;
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::spawn_local;
use web_sys::HtmlSelectElement;
mod calendar;
mod modal_dialog;
mod on_click;
@ -7,14 +14,6 @@ mod request;
mod toast;
mod utils;
use gloo::{console::log, events::EventListener, utils::window};
use utils::by_id;
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::spawn_local;
use web_sys::HtmlSelectElement;
use common::ron_api;
#[wasm_bindgen(start)]
pub fn main() -> Result<(), JsValue> {
console_error_panic_hook::set_once();

View file

@ -6,17 +6,13 @@ use crate::{
utils::{by_id, selector_and_clone, SelectorExt},
};
pub enum DialogContent<'a, T>
where
T: Fn(&Element),
{
Text(&'a str),
CloneFromElement(&'a str, T),
pub async fn show(element_selector: &str) -> bool {
show_and_initialize(element_selector, async |_| {}).await
}
pub async fn show<T>(content: DialogContent<'_, T>) -> bool
pub async fn show_and_initialize<T>(element_selector: &str, initializer: T) -> bool
where
T: Fn(&Element),
T: AsyncFn(Element),
{
let dialog: HtmlDialogElement = by_id("modal-dialog");
@ -25,15 +21,10 @@ where
let content_element = dialog.selector::<Element>(".content");
match content {
DialogContent::Text(message) => content_element.set_inner_html(message),
DialogContent::CloneFromElement(element_selector, initilizer) => {
let element: Element = selector_and_clone(element_selector);
content_element.set_inner_html("");
content_element.append_child(&element).unwrap();
initilizer(&element);
}
}
initializer(element).await;
dialog.show_modal().unwrap();

View file

@ -1,5 +1,6 @@
use std::{cell::RefCell, rc, sync::Mutex};
use common::{ron_api, utils::substitute};
use gloo::{
events::{EventListener, EventListenerOptions},
net::http::Request,
@ -12,14 +13,17 @@ use web_sys::{
KeyboardEvent,
};
use common::ron_api;
use crate::{
modal_dialog, request,
toast::{self, Level},
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.
{
@ -248,12 +252,18 @@ pub fn setup_page(recipe_id: i64) -> Result<(), JsValue> {
// Delete recipe button.
let delete_button: HtmlInputElement = by_id("input-delete");
EventListener::new(&delete_button, "click", move |_event| {
let title: HtmlInputElement = by_id("input-title");
spawn_local(async move {
if modal_dialog::show(modal_dialog::DialogContent::<fn(&Element)>::Text(&format!(
"Are you sure to delete the recipe '{}'",
title.value()
)))
if modal_dialog::show_and_initialize(
"#hidden-templates .recipe-delete-confirmation",
async |element| {
let title: HtmlInputElement = by_id("input-title");
element.set_inner_html(&substitute(
&element.inner_html(),
"{}",
&[&title.value()],
));
},
)
.await
{
let body = ron_api::Id { id: recipe_id };
@ -377,14 +387,18 @@ fn create_group_element(group: &ron_api::Group) -> Element {
let group_element_cloned = group_element.clone();
let delete_button: HtmlInputElement = group_element.selector(".input-group-delete");
EventListener::new(&delete_button, "click", move |_event| {
// FIXME: How to avoid cloning twice?
let group_element_cloned = group_element_cloned.clone();
spawn_local(async move {
if modal_dialog::show_and_initialize(
"#hidden-templates .recipe-group-delete-confirmation",
async move |element| {
let name = group_element_cloned
.selector::<HtmlInputElement>(".input-group-name")
.value();
spawn_local(async move {
if modal_dialog::show(modal_dialog::DialogContent::<fn(&Element)>::Text(&format!(
"Are you sure to delete the group '{}'",
name
)))
element.set_inner_html(&substitute(&element.inner_html(), "{}", &[&name]));
},
)
.await
{
let body = ron_api::Id { id: group_id };
@ -515,14 +529,18 @@ fn create_step_element(group_element: &Element, step: &ron_api::Step) -> Element
let step_element_cloned = step_element.clone();
let delete_button: HtmlInputElement = step_element.selector(".input-step-delete");
EventListener::new(&delete_button, "click", move |_event| {
// FIXME: How to avoid cloning twice?
let step_element_cloned = step_element_cloned.clone();
spawn_local(async move {
if modal_dialog::show_and_initialize(
"#hidden-templates .recipe-step-delete-confirmation",
async move |element| {
let action = step_element_cloned
.selector::<HtmlTextAreaElement>(".text-area-step-action")
.value();
spawn_local(async move {
if modal_dialog::show(modal_dialog::DialogContent::<fn(&Element)>::Text(&format!(
"Are you sure to delete the step '{}'",
action
)))
element.set_inner_html(&substitute(&element.inner_html(), "{}", &[&action]));
},
)
.await
{
let body = ron_api::Id { id: step_id };
@ -665,14 +683,18 @@ fn create_ingredient_element(step_element: &Element, ingredient: &ron_api::Ingre
let ingredient_element_cloned = ingredient_element.clone();
let delete_button: HtmlInputElement = ingredient_element.selector(".input-ingredient-delete");
EventListener::new(&delete_button, "click", move |_event| {
// FIXME: How to avoid cloning twice?
let ingredient_element_cloned = ingredient_element_cloned.clone();
spawn_local(async move {
if modal_dialog::show_and_initialize(
"#hidden-templates .recipe-ingredient-delete-confirmation",
async move |element| {
let name = ingredient_element_cloned
.selector::<HtmlInputElement>(".input-ingredient-name")
.value();
spawn_local(async move {
if modal_dialog::show(modal_dialog::DialogContent::<fn(&Element)>::Text(&format!(
"Are you sure to delete the ingredient '{}'",
name
)))
element.set_inner_html(&substitute(&element.inner_html(), "{}", &[&name]));
},
)
.await
{
let body = ron_api::Id { id: ingredient_id };

View file

@ -1,3 +1,6 @@
use std::future::Future;
use common::ron_api;
use gloo::{
console::console,
events::EventListener,
@ -11,8 +14,6 @@ use web_sys::{
KeyboardEvent,
};
use common::ron_api;
use crate::{
calendar, modal_dialog, request,
toast::{self, Level},
@ -22,15 +23,10 @@ use crate::{
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| {
// console!("CLICK".to_string());
spawn_local(async move {
modal_dialog::show(modal_dialog::DialogContent::CloneFromElement(
"#hidden-templates .calendar",
|element| {
// console!("SETUP...".to_string());
modal_dialog::show_and_initialize("#hidden-templates .calendar", async |element| {
calendar::setup(element);
},
))
})
.await;
});
})

View file

@ -1,9 +1,8 @@
use common::ron_api;
use gloo::net::http::{Request, RequestBuilder};
use serde::{de::DeserializeOwned, Serialize};
use thiserror::Error;
use common::ron_api;
use crate::toast::{self, Level};
#[derive(Error, Debug)]