Meal Plan Storage API and UI

Closes #20
This commit is contained in:
Jeremy Wall 2022-11-11 16:45:14 -05:00
parent 3ebf61e77e
commit a5e8575ef9
18 changed files with 533 additions and 35 deletions

125
Cargo.lock generated
View File

@ -38,6 +38,15 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "ansi_term" name = "ansi_term"
version = "0.12.1" version = "0.12.1"
@ -441,15 +450,17 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.19" version = "0.4.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" checksum = "bfd4d1b31faaa3a89d7934dbded3111da0d2ef28e3ebccdb4f0179f5929d1ef1"
dependencies = [ dependencies = [
"libc", "iana-time-zone",
"js-sys",
"num-integer", "num-integer",
"num-traits", "num-traits",
"serde", "serde",
"time 0.1.44", "time 0.1.44",
"wasm-bindgen",
"winapi", "winapi",
] ]
@ -505,6 +516,16 @@ dependencies = [
"os_str_bytes", "os_str_bytes",
] ]
[[package]]
name = "codespan-reporting"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e"
dependencies = [
"termcolor",
"unicode-width",
]
[[package]] [[package]]
name = "concurrent-queue" name = "concurrent-queue"
version = "1.2.2" version = "1.2.2"
@ -540,6 +561,12 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "core-foundation-sys"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc"
[[package]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.2" version = "0.2.2"
@ -646,6 +673,50 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "cxx"
version = "1.0.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97abf9f0eca9e52b7f81b945524e76710e6cb2366aead23b7d4fbf72e281f888"
dependencies = [
"cc",
"cxxbridge-flags",
"cxxbridge-macro",
"link-cplusplus",
]
[[package]]
name = "cxx-build"
version = "1.0.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cc32cc5fea1d894b77d269ddb9f192110069a8a9c1f1d441195fba90553dea3"
dependencies = [
"cc",
"codespan-reporting",
"once_cell",
"proc-macro2",
"quote",
"scratch",
"syn",
]
[[package]]
name = "cxxbridge-flags"
version = "1.0.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ca220e4794c934dc6b1207c3b42856ad4c302f2df1712e9f8d2eec5afaacf1f"
[[package]]
name = "cxxbridge-macro"
version = "1.0.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b846f081361125bfc8dc9d3940c84e1fd83ba54bbca7b17cd29483c828be0704"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.9.0" version = "0.9.0"
@ -1112,6 +1183,30 @@ dependencies = [
"want", "want",
] ]
[[package]]
name = "iana-time-zone"
version = "0.1.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64c122667b287044802d6ce17ee2ddf13207ed924c712de9a66a5814d5b64765"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"wasm-bindgen",
"winapi",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0703ae284fc167426161c2e3f1da3ea71d94b21bedbcc9494e92b28e334e3dca"
dependencies = [
"cxx",
"cxx-build",
]
[[package]] [[package]]
name = "idna" name = "idna"
version = "0.2.3" version = "0.2.3"
@ -1182,6 +1277,7 @@ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
"axum-auth", "axum-auth",
"chrono",
"ciborium", "ciborium",
"clap", "clap",
"cookie", "cookie",
@ -1205,6 +1301,7 @@ version = "0.2.9"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64", "base64",
"chrono",
"console_error_panic_hook", "console_error_panic_hook",
"recipes", "recipes",
"reqwasm", "reqwasm",
@ -1249,6 +1346,15 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "link-cplusplus"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9272ab7b96c9046fbc5bc56c06c117cb639fe2d509df0c421cad82d2915cf369"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.8" version = "0.4.8"
@ -1723,6 +1829,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
[[package]]
name = "scratch"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8132065adcfd6e02db789d9285a0deb2f3fcb04002865ab67d5fb103533898"
[[package]] [[package]]
name = "sct" name = "sct"
version = "0.7.0" version = "0.7.0"
@ -1909,6 +2021,7 @@ dependencies = [
"bitflags", "bitflags",
"byteorder", "byteorder",
"bytes", "bytes",
"chrono",
"crc", "crc",
"crossbeam-queue", "crossbeam-queue",
"dotenvy", "dotenvy",
@ -2402,6 +2515,12 @@ version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e8820f5d777f6224dc4be3632222971ac30164d4a258d595640799554ebfd99" checksum = "7e8820f5d777f6224dc4be3632222971ac30164d4a258d595640799554ebfd99"
[[package]]
name = "unicode-width"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b"
[[package]] [[package]]
name = "unicode-xid" name = "unicode-xid"
version = "0.2.4" version = "0.2.4"

View File

@ -43,5 +43,8 @@ clean:
sqlx-migrate: sqlx-migrate:
cd kitchen; cargo sqlx migrate run --database-url $(sqlite_url) cd kitchen; cargo sqlx migrate run --database-url $(sqlite_url)
sqlx-revert:
cd kitchen; cargo sqlx migrate revert --database-url $(sqlite_url)
sqlx-prepare: sqlx-prepare:
cd kitchen; cargo sqlx prepare --database-url $(sqlite_url) cd kitchen; cargo sqlx prepare --database-url $(sqlite_url)

View File

@ -19,6 +19,7 @@ ciborium = "0.2.0"
tower = "0.4.13" tower = "0.4.13"
serde = "1.0.144" serde = "1.0.144"
cookie = "0.16.0" cookie = "0.16.0"
chrono = "0.4.22"
[dependencies.argon2] [dependencies.argon2]
version = "0.4.1" version = "0.4.1"
@ -53,4 +54,4 @@ features = ["tokio1"]
[dependencies.sqlx] [dependencies.sqlx]
version = "0.6.2" version = "0.6.2"
features = ["sqlite", "runtime-async-std-rustls", "offline"] features = ["sqlite", "runtime-async-std-rustls", "offline", "chrono"]

View File

@ -1,3 +1,2 @@
-- Add down migration script here -- Add down migration script here
drop table plans;
drop table plan_recipes; drop table plan_recipes;

View File

@ -1,3 +1,2 @@
-- Add up migration script here -- Add up migration script here
CREATE TABLE plans(id NUMBER, user_id TEXT, date TEXT); CREATE table plan_recipes(user_id TEXT NOT NULL, plan_date DATE NOT NULL, recipe_id TEXT NOT NULL, count integer NOT NULL);
CREATE table plan_recipes(plan_id NUMBER, recipe_id TEXT);

View File

@ -42,6 +42,36 @@
}, },
"query": "select recipe_id, recipe_text from recipes where user_id = ? and recipe_id = ?" "query": "select recipe_id, recipe_text from recipes where user_id = ? and recipe_id = ?"
}, },
"19832e3582c05ed49c676fde33cde64274379a83a8dd130f6eec96c1d7250909": {
"describe": {
"columns": [
{
"name": "plan_date: NaiveDate",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "recipe_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "count",
"ordinal": 2,
"type_info": "Int64"
}
],
"nullable": [
false,
false,
false
],
"parameters": {
"Right": 2
}
},
"query": "select plan_date as \"plan_date: NaiveDate\", recipe_id, count\nfrom plan_recipes\nwhere\n user_id = ?\n and date(plan_date) > ?\norder by user_id, plan_date"
},
"3fd4017569dca4fe73e97e0e2bd612027a8c1b17b0b10faabd6459f56ca1c0bb": { "3fd4017569dca4fe73e97e0e2bd612027a8c1b17b0b10faabd6459f56ca1c0bb": {
"describe": { "describe": {
"columns": [], "columns": [],
@ -134,6 +164,46 @@
}, },
"query": "insert into sessions (id, session_value) values (?, ?)" "query": "insert into sessions (id, session_value) values (?, ?)"
}, },
"ad3408cd773dd8f9308255ec2800171638a1aeda9817c57fb8360f97115f8e97": {
"describe": {
"columns": [
{
"name": "plan_date: NaiveDate",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "recipe_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "count",
"ordinal": 2,
"type_info": "Int64"
}
],
"nullable": [
false,
false,
false
],
"parameters": {
"Right": 1
}
},
"query": "with max_date as (\n select user_id, max(date(plan_date)) as plan_date from plan_recipes group by user_id\n)\n\nselect plan_recipes.plan_date as \"plan_date: NaiveDate\", plan_recipes.recipe_id, plan_recipes.count\n from plan_recipes\n inner join max_date on plan_recipes.user_id = max_date.user_id\nwhere\n plan_recipes.user_id = ?\n and plan_recipes.plan_date = max_date.plan_date"
},
"c889e3621cb2977204b847c03930cde394cc16eaa63741f8ca07484a41f1aa87": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 4
}
},
"query": "insert into plan_recipes (user_id, plan_date, recipe_id, count) values (?, ?, ?, ?)"
},
"c988364f9f83f4fa8bd0e594bab432ee7c9ec47ca40f4d16e5e2a8763653f377": { "c988364f9f83f4fa8bd0e594bab432ee7c9ec47ca40f4d16e5e2a8763653f377": {
"describe": { "describe": {
"columns": [ "columns": [

View File

@ -181,13 +181,80 @@ async fn api_save_recipes(
) -> impl IntoResponse { ) -> impl IntoResponse {
use storage::{UserId, UserIdFromSession::FoundUserId}; use storage::{UserId, UserIdFromSession::FoundUserId};
if let FoundUserId(UserId(id)) = session { if let FoundUserId(UserId(id)) = session {
if let Err(e) = app_store let result = app_store
.store_recipes_for_user(id.as_str(), &recipes) .store_recipes_for_user(id.as_str(), &recipes)
.await;
match result.map_err(|e| format!("Error: {:?}", e)) {
Ok(val) => Ok(axum::Json::from(val)),
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
}
} else {
Err((
StatusCode::UNAUTHORIZED,
"You must be authorized to use this API call".to_owned(),
))
}
}
async fn api_plan(
Extension(app_store): Extension<Arc<storage::SqliteStore>>,
session: storage::UserIdFromSession,
) -> impl IntoResponse {
use storage::{UserId, UserIdFromSession::FoundUserId};
if let FoundUserId(UserId(id)) = session {
match app_store
.fetch_latest_meal_plan(&id)
.await
.map_err(|e| format!("Error: {:?}", e))
{
Ok(val) => Ok(axum::Json::from(val)),
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
}
} else {
Err((
StatusCode::UNAUTHORIZED,
"You must be authorized to use this API call".to_owned(),
))
}
}
async fn api_plan_since(
Extension(app_store): Extension<Arc<storage::SqliteStore>>,
session: storage::UserIdFromSession,
Path(date): Path<chrono::NaiveDate>,
) -> impl IntoResponse {
use storage::{UserId, UserIdFromSession::FoundUserId};
if let FoundUserId(UserId(id)) = session {
match app_store
.fetch_meal_plans_since(&id, date)
.await
.map_err(|e| format!("Error: {:?}", e))
{
Ok(val) => Ok(axum::Json::from(val)),
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
}
} else {
Err((
StatusCode::UNAUTHORIZED,
"You must be authorized to use this API call".to_owned(),
))
}
}
async fn api_save_plan(
Extension(app_store): Extension<Arc<storage::SqliteStore>>,
session: storage::UserIdFromSession,
Json(meal_plan): Json<Vec<(String, i32)>>,
) -> impl IntoResponse {
use storage::{UserId, UserIdFromSession::FoundUserId};
if let FoundUserId(UserId(id)) = session {
if let Err(e) = app_store
.save_meal_plan(id.as_str(), &meal_plan, chrono::Local::now().date_naive())
.await .await
{ {
return (StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)); return (StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e));
} }
(StatusCode::OK, "Successfully saved categories".to_owned()) (StatusCode::OK, "Successfully saved mealPlan".to_owned())
} else { } else {
( (
StatusCode::UNAUTHORIZED, StatusCode::UNAUTHORIZED,
@ -214,6 +281,9 @@ pub async fn ui_main(recipe_dir_path: PathBuf, store_path: PathBuf, listen_socke
.route("/api/v1/recipes", get(api_recipes).post(api_save_recipes)) .route("/api/v1/recipes", get(api_recipes).post(api_save_recipes))
// recipe entry api path route // recipe entry api path route
.route("/api/v1/recipe/:recipe_id", get(api_recipe_entry)) .route("/api/v1/recipe/:recipe_id", get(api_recipe_entry))
// mealplan api path routes
.route("/api/v1/plan", get(api_plan).post(api_save_plan))
.route("/api/v1/plan/:date", get(api_plan_since))
// categories api path route // categories api path route
.route( .route(
"/api/v1/categories", "/api/v1/categories",

View File

@ -0,0 +1,10 @@
with max_date as (
select user_id, max(date(plan_date)) as plan_date from plan_recipes group by user_id
)
select plan_recipes.plan_date as "plan_date: NaiveDate", plan_recipes.recipe_id, plan_recipes.count
from plan_recipes
inner join max_date on plan_recipes.user_id = max_date.user_id
where
plan_recipes.user_id = ?
and plan_recipes.plan_date = max_date.plan_date

View File

@ -0,0 +1,6 @@
select plan_date as "plan_date: NaiveDate", recipe_id, count
from plan_recipes
where
user_id = ?
and date(plan_date) > ?
order by user_id, plan_date

View File

@ -12,8 +12,8 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use async_std::sync::Arc; use async_std::sync::Arc;
use std::path::Path;
use std::str::FromStr; use std::str::FromStr;
use std::{collections::BTreeMap, path::Path};
use argon2::{ use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
@ -26,6 +26,7 @@ use axum::{
headers::Cookie, headers::Cookie,
http::StatusCode, http::StatusCode,
}; };
use chrono::NaiveDate;
use ciborium; use ciborium;
use recipes::RecipeEntry; use recipes::RecipeEntry;
use secrecy::{ExposeSecret, Secret}; use secrecy::{ExposeSecret, Secret};
@ -100,6 +101,24 @@ pub trait APIStore {
user_id: S, user_id: S,
id: S, id: S,
) -> Result<Option<RecipeEntry>>; ) -> Result<Option<RecipeEntry>>;
async fn fetch_latest_meal_plan<S: AsRef<str> + Send>(
&self,
user_id: S,
) -> Result<Option<Vec<(String, i32)>>>;
async fn fetch_meal_plans_since<S: AsRef<str> + Send>(
&self,
user_id: S,
date: NaiveDate,
) -> Result<Option<BTreeMap<NaiveDate, Vec<(String, i32)>>>>;
async fn save_meal_plan<S: AsRef<str> + Send>(
&self,
user_id: S,
recipe_counts: &Vec<(String, i32)>,
date: NaiveDate,
) -> Result<()>;
} }
#[async_trait] #[async_trait]
@ -371,4 +390,93 @@ impl APIStore for SqliteStore {
.await?; .await?;
Ok(()) Ok(())
} }
async fn save_meal_plan<S: AsRef<str> + Send>(
&self,
user_id: S,
recipe_counts: &Vec<(String, i32)>,
date: NaiveDate,
) -> Result<()> {
let user_id = user_id.as_ref();
let mut transaction = self.pool.as_ref().begin().await?;
for (id, count) in recipe_counts {
sqlx::query_file!(
"src/web/storage/save_meal_plan.sql",
user_id,
date,
id,
count
)
.execute(&mut transaction)
.await?;
}
transaction.commit().await?;
Ok(())
}
async fn fetch_meal_plans_since<S: AsRef<str> + Send>(
&self,
user_id: S,
date: NaiveDate,
) -> Result<Option<BTreeMap<NaiveDate, Vec<(String, i32)>>>> {
let user_id = user_id.as_ref();
struct Row {
pub plan_date: NaiveDate,
pub recipe_id: String,
pub count: i64,
}
// NOTE(jwall): It feels like I shouldn't have to use an override here
// but I do because of the way sqlite does types and how that interacts
// with sqlx's type inference machinery.
let rows = sqlx::query_file_as!(
Row,
r#"src/web/storage/fetch_meal_plans_since.sql"#,
user_id,
date
)
.fetch_all(self.pool.as_ref())
.await?;
if rows.is_empty() {
return Ok(None);
}
let mut result = BTreeMap::new();
for row in rows {
let (date, recipe_id, count): (NaiveDate, String, i64) =
(row.plan_date, row.recipe_id, row.count);
result
.entry(date.clone())
.or_insert_with(|| Vec::new())
.push((recipe_id, count as i32));
}
Ok(Some(result))
}
async fn fetch_latest_meal_plan<S: AsRef<str> + Send>(
&self,
user_id: S,
) -> Result<Option<Vec<(String, i32)>>> {
let user_id = user_id.as_ref();
struct Row {
pub plan_date: NaiveDate,
pub recipe_id: String,
pub count: i64,
}
// NOTE(jwall): It feels like I shouldn't have to use an override here
// but I do because of the way sqlite does types and how that interacts
// with sqlx's type inference machinery.
let rows =
sqlx::query_file_as!(Row, "src/web/storage/fetch_latest_meal_plan.sql", user_id,)
.fetch_all(self.pool.as_ref())
.await?;
if rows.is_empty() {
return Ok(None);
}
let mut result = Vec::new();
for row in rows {
let (_, recipe_id, count): (NaiveDate, String, i64) =
(row.plan_date, row.recipe_id, row.count);
result.push((recipe_id, count as i32));
}
Ok(Some(result))
}
} }

View File

@ -0,0 +1 @@
insert into plan_recipes (user_id, plan_date, recipe_id, count) values (?, ?, ?, ?)

View File

@ -23,6 +23,10 @@ async-trait = "0.1.57"
base64 = "0.13.0" base64 = "0.13.0"
sycamore-router = "0.8" sycamore-router = "0.8"
[dependencies.chrono]
version = "0.4.22"
features = ["serde"]
[dependencies.reqwasm] [dependencies.reqwasm]
version = "0.5.0" version = "0.5.0"

View File

@ -62,10 +62,19 @@ pub async fn init_page_state(store: &HttpStore, state: &app_state::State) -> Res
state.recipes.set(recipes); state.recipes.set(recipes);
} }
} }
if let Some(rs) = recipes {
for r in rs { if let Ok(Some(plan)) = store.get_plan().await {
if !state.recipe_counts.get().contains_key(r.recipe_id()) { // set the counts.
state.set_recipe_count_by_index(&r.recipe_id().to_owned(), 0); for (id, count) in plan {
state.set_recipe_count_by_index(&id, count as usize);
}
} else {
// Initialize things to zero
if let Some(rs) = recipes {
for r in rs {
if !state.recipe_counts.get().contains_key(r.recipe_id()) {
state.set_recipe_count_by_index(&r.recipe_id().to_owned(), 0);
}
} }
} }
} }
@ -307,4 +316,59 @@ impl HttpStore {
Ok(()) Ok(())
} }
} }
pub async fn save_plan(&self, plan: Vec<(String, i32)>) -> Result<(), Error> {
let mut path = self.root.clone();
path.push_str("/plan");
let storage = js_lib::get_storage();
let serialized_plan = to_string(&plan).expect("Unable to encode plan as json");
storage.set("plan", &serialized_plan)?;
let resp = reqwasm::http::Request::post(&path)
.body(to_string(&plan).expect("Unable to encode plan as json"))
.header("content-type", "application/json")
.send()
.await?;
if resp.status() != 200 {
Err(format!("Status: {}", resp.status()).into())
} else {
debug!("We got a valid response back!");
Ok(())
}
}
pub async fn get_plan(&self) -> Result<Option<Vec<(String, i32)>>, Error> {
let mut path = self.root.clone();
path.push_str("/plan");
let resp = reqwasm::http::Request::get(&path).send().await?;
let storage = js_lib::get_storage();
if resp.status() != 200 {
Err(format!("Status: {}", resp.status()).into())
} else {
debug!("We got a valid response back");
let plan: Option<Vec<(String, i32)>> =
resp.json().await.map_err(|e| format!("{}", e))?;
if let Some(ref entry) = plan {
let serialized: String = to_string(entry).map_err(|e| format!("{}", e))?;
storage.set("plan", &serialized)?
}
Ok(plan)
}
}
pub async fn get_plans_since(
&self,
date: chrono::NaiveDate,
) -> Result<BTreeMap<chrono::NaiveDate, Vec<(String, i32)>>, Error> {
let mut path = self.root.clone();
path.push_str("/plan");
path.push_str(&format!("/{}", date));
// TODO(jwall): How does this play with the cache?
let resp = reqwasm::http::Request::get(&path).send().await?;
if resp.status() != 200 {
Err(format!("Status: {}", resp.status()).into())
} else {
debug!("We got a valid response back");
Ok(resp.json().await?)
}
}
} }

View File

@ -19,7 +19,7 @@ use tracing::{debug, instrument, warn};
use recipes::{Ingredient, IngredientAccumulator, Recipe}; use recipes::{Ingredient, IngredientAccumulator, Recipe};
pub struct State { pub struct State {
pub recipe_counts: RcSignal<BTreeMap<String, usize>>, pub recipe_counts: RcSignal<BTreeMap<String, RcSignal<usize>>>,
pub extras: RcSignal<Vec<(usize, (RcSignal<String>, RcSignal<String>))>>, pub extras: RcSignal<Vec<(usize, (RcSignal<String>, RcSignal<String>))>>,
pub staples: RcSignal<Option<Recipe>>, pub staples: RcSignal<Option<Recipe>>,
pub recipes: RcSignal<BTreeMap<String, Recipe>>, pub recipes: RcSignal<BTreeMap<String, Recipe>>,
@ -45,12 +45,12 @@ impl State {
use_context::<std::rc::Rc<Self>>(cx).clone() use_context::<std::rc::Rc<Self>>(cx).clone()
} }
pub fn get_menu_list(&self) -> Vec<(String, usize)> { pub fn get_menu_list(&self) -> Vec<(String, RcSignal<usize>)> {
self.recipe_counts self.recipe_counts
.get() .get()
.iter() .iter()
.map(|(k, v)| (k.clone(), *v)) .map(|(k, v)| (k.clone(), v.clone()))
.filter(|(_, v)| *v != 0) .filter(|(_, v)| *(v.get_untracked()) != 0)
.collect() .collect()
} }
@ -62,7 +62,7 @@ impl State {
let mut acc = IngredientAccumulator::new(); let mut acc = IngredientAccumulator::new();
let recipe_counts = self.get_menu_list(); let recipe_counts = self.get_menu_list();
for (idx, count) in recipe_counts.iter() { for (idx, count) in recipe_counts.iter() {
for _ in 0..*count { for _ in 0..*count.get_untracked() {
acc.accumulate_from( acc.accumulate_from(
self.recipes self.recipes
.get() .get()
@ -96,14 +96,27 @@ impl State {
groups groups
} }
pub fn get_recipe_count_by_index(&self, key: &String) -> Option<usize> { /// Retrieves the count for a recipe without triggering subscribers to the entire
self.recipe_counts.get().get(key).cloned() /// recipe count set.
pub fn get_recipe_count_by_index(&self, key: &String) -> Option<RcSignal<usize>> {
self.recipe_counts.get_untracked().get(key).cloned()
} }
pub fn set_recipe_count_by_index(&self, key: &String, count: usize) -> usize { pub fn reset_recipe_counts(&self) {
let mut counts = self.recipe_counts.get().as_ref().clone(); for (key, count) in self.recipe_counts.get_untracked().iter() {
counts.insert(key.clone(), count); count.set(0);
}
}
/// Set the recipe_count by index. Does not trigger subscribers to the entire set of recipe_counts.
/// This does trigger subscribers of the specific recipe you are updating though.
pub fn set_recipe_count_by_index(&self, key: &String, count: usize) -> RcSignal<usize> {
let mut counts = self.recipe_counts.get_untracked().as_ref().clone();
counts
.entry(key.clone())
.and_modify(|e| e.set(count))
.or_insert_with(|| create_rc_signal(count));
self.recipe_counts.set(counts); self.recipe_counts.set(counts);
count self.recipe_counts.get_untracked().get(key).unwrap().clone()
} }
} }

View File

@ -16,8 +16,8 @@ pub mod categories;
pub mod header; pub mod header;
pub mod recipe; pub mod recipe;
pub mod recipe_list; pub mod recipe_list;
pub mod recipe_plan;
pub mod recipe_selection; pub mod recipe_selection;
pub mod recipe_selector;
pub mod shopping_list; pub mod shopping_list;
pub mod tabs; pub mod tabs;
@ -26,7 +26,7 @@ pub use categories::*;
pub use header::*; pub use header::*;
pub use recipe::*; pub use recipe::*;
pub use recipe_list::*; pub use recipe_list::*;
pub use recipe_plan::*;
pub use recipe_selection::*; pub use recipe_selection::*;
pub use recipe_selector::*;
pub use shopping_list::*; pub use shopping_list::*;
pub use tabs::*; pub use tabs::*;

View File

@ -20,7 +20,7 @@ use crate::{api::*, app_state};
#[allow(non_snake_case)] #[allow(non_snake_case)]
#[instrument] #[instrument]
pub fn RecipeSelector<G: Html>(cx: Scope) -> View<G> { pub fn RecipePlan<G: Html>(cx: Scope) -> View<G> {
let rows = create_memo(cx, move || { let rows = create_memo(cx, move || {
let state = app_state::State::get_from_context(cx); let state = app_state::State::get_from_context(cx);
let mut rows = Vec::new(); let mut rows = Vec::new();
@ -37,9 +37,10 @@ pub fn RecipeSelector<G: Html>(cx: Scope) -> View<G> {
} }
rows rows
}); });
let clicked = create_signal(cx, false); let refresh_click = create_signal(cx, false);
let save_click = create_signal(cx, false);
create_effect(cx, move || { create_effect(cx, move || {
clicked.track(); refresh_click.track();
let store = HttpStore::get_from_context(cx); let store = HttpStore::get_from_context(cx);
let state = app_state::State::get_from_context(cx); let state = app_state::State::get_from_context(cx);
spawn_local_scoped(cx, { spawn_local_scoped(cx, {
@ -47,9 +48,24 @@ pub fn RecipeSelector<G: Html>(cx: Scope) -> View<G> {
if let Err(err) = init_page_state(store.as_ref(), state.as_ref()).await { if let Err(err) = init_page_state(store.as_ref(), state.as_ref()).await {
error!(?err); error!(?err);
}; };
state.reset_recipe_counts();
} }
}); });
}); });
create_effect(cx, move || {
save_click.track();
let store = HttpStore::get_from_context(cx);
let state = app_state::State::get_from_context(cx);
spawn_local_scoped(cx, {
let mut plan = Vec::new();
for (key, count) in state.recipe_counts.get_untracked().iter() {
plan.push((key.clone(), *count.get_untracked() as i32));
}
async move {
store.save_plan(plan).await.expect("Failed to save plan");
}
})
});
view! {cx, view! {cx,
table(class="recipe_selector no-print") { table(class="recipe_selector no-print") {
(View::new_fragment( (View::new_fragment(
@ -69,10 +85,15 @@ pub fn RecipeSelector<G: Html>(cx: Scope) -> View<G> {
}).collect() }).collect()
)) ))
} }
input(type="button", value="Refresh Recipes", on:click=move |_| { input(type="button", value="Reset Recipes", on:click=move |_| {
// Poor man's click event signaling. // Poor man's click event signaling.
let toggle = !*clicked.get(); let toggle = !*refresh_click.get();
clicked.set(toggle); refresh_click.set(toggle);
})
input(type="button", value="Save Plan", on:click=move |_| {
// Poor man's click event signaling.
let toggle = !*save_click.get();
save_click.set(toggle);
}) })
} }
} }

View File

@ -43,6 +43,15 @@ pub fn RecipeSelection<G: Html>(cx: Scope, props: RecipeCheckBoxProps) -> View<G
.unwrap_or_else(|| state.set_recipe_count_by_index(id.as_ref(), 0)) .unwrap_or_else(|| state.set_recipe_count_by_index(id.as_ref(), 0))
), ),
); );
create_effect(cx, {
let id = id.clone();
let state = app_state::State::get_from_context(cx);
move || {
if let Some(usize_count) = state.get_recipe_count_by_index(id.as_ref()) {
count.set(format!("{}", *usize_count.get()));
}
}
});
let title = props.title.get().clone(); let title = props.title.get().clone();
let for_id = id.clone(); let for_id = id.clone();
let href = format!("/ui/recipe/view/{}", id); let href = format!("/ui/recipe/view/{}", id);
@ -51,6 +60,7 @@ pub fn RecipeSelection<G: Html>(cx: Scope, props: RecipeCheckBoxProps) -> View<G
div() { div() {
label(for=for_id) { a(href=href) { (*title) } } label(for=for_id) { a(href=href) { (*title) } }
input(type="number", class="item-count-sel", min="0", bind:value=count, name=name, on:change=move |_| { input(type="number", class="item-count-sel", min="0", bind:value=count, name=name, on:change=move |_| {
let state = app_state::State::get_from_context(cx);
debug!(idx=%id, count=%(*count.get()), "setting recipe count"); debug!(idx=%id, count=%(*count.get()), "setting recipe count");
state.set_recipe_count_by_index(id.as_ref(), count.get().parse().expect("recipe count isn't a valid usize number")); state.set_recipe_count_by_index(id.as_ref(), count.get().parse().expect("recipe count isn't a valid usize number"));
}) })

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use super::PlanningPage; use super::PlanningPage;
use crate::components::recipe_selector::*; use crate::components::recipe_plan::*;
use sycamore::prelude::*; use sycamore::prelude::*;
@ -21,6 +21,6 @@ pub fn PlanPage<G: Html>(cx: Scope) -> View<G> {
view! {cx, view! {cx,
PlanningPage( PlanningPage(
selected=Some("Plan".to_owned()), selected=Some("Plan".to_owned()),
) { RecipeSelector() } ) { RecipePlan() }
} }
} }