Add support for meals (#1)

Reviewed-on: #1
Co-authored-by: Simon Bernier St-Pierre <git.sbstp.ca@gmail.com>
This commit was merged in pull request #1.
This commit is contained in:
2026-08-01 20:27:38 -04:00
committed by sbstp
parent 6a2cef2003
commit 9b32fd23a7
11 changed files with 1858 additions and 135 deletions
Generated
+36
View File
@@ -417,6 +417,15 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "getopts"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
dependencies = [
"unicode-width",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.17" version = "0.2.17"
@@ -918,6 +927,25 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "pulldown-cmark"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
dependencies = [
"bitflags",
"getopts",
"memchr",
"pulldown-cmark-escape",
"unicase",
]
[[package]]
name = "pulldown-cmark-escape"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.47" version = "1.0.47"
@@ -1350,6 +1378,7 @@ dependencies = [
"futures-util", "futures-util",
"hex", "hex",
"maud", "maud",
"pulldown-cmark",
"rand 0.8.7", "rand 0.8.7",
"serde", "serde",
"serde_json", "serde_json",
@@ -1357,6 +1386,7 @@ dependencies = [
"sqlx", "sqlx",
"thiserror", "thiserror",
"tokio", "tokio",
"tower",
"tower-http", "tower-http",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
@@ -1654,6 +1684,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"
+3 -1
View File
@@ -10,6 +10,7 @@ axum = { version = "0.8", features = ["ws"] }
futures-util = "0.3" futures-util = "0.3"
hex = "0.4" hex = "0.4"
maud = "0.27" maud = "0.27"
pulldown-cmark = "0.13"
rand = "0.8" rand = "0.8"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
@@ -17,6 +18,7 @@ sha2 = "0.10"
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite", "macros", "tls-rustls"] } sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite", "macros", "tls-rustls"] }
thiserror = "2" thiserror = "2"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.6", features = ["fs", "trace"] } tower = "0.5"
tower-http = { version = "0.6", features = ["fs", "trace", "set-header"] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+2 -1
View File
@@ -45,8 +45,9 @@ The file is optional — if it is missing or invalid, seeding is silently skippe
- Shared lists with one-time, seven-day invitation links - Shared lists with one-time, seven-day invitation links
- Invite-only registration by default after the first account - Invite-only registration by default after the first account
- Add, edit, check, and delete grocery items - Add, edit, check, and delete grocery items
- List-scoped categories with common defaults and custom category creation - Global categories with common defaults seeded at startup and custom category creation
- Items grouped by category and assigned from the add/edit forms - Items grouped by category and assigned from the add/edit forms
- Meals with ingredients, markdown descriptions, and one-click "add meal to list"
- Server-authoritative last-write-wins updates - Server-authoritative last-write-wins updates
- Per-list WebSocket updates with server-rendered htmx fragments - Per-list WebSocket updates with server-rendered htmx fragments
- In-memory presence for members currently viewing a list - In-memory presence for members currently viewing a list
+17
View File
@@ -52,6 +52,23 @@ pub struct Category {
pub name: String, pub name: String,
} }
#[derive(Clone, Debug)]
pub struct Meal {
pub id: i64,
pub name: String,
pub description: String,
pub ingredients: Vec<MealIngredient>,
}
#[derive(Clone, Debug)]
pub struct MealIngredient {
pub id: i64,
pub name: String,
pub quantity: String,
pub note: String,
pub category_id: Option<i64>,
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct PresenceUser { pub struct PresenceUser {
pub user_id: i64, pub user_id: i64,
+231 -17
View File
@@ -16,18 +16,23 @@ use axum::{
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, de::DeserializeOwned}; use serde::{Deserialize, de::DeserializeOwned};
use thiserror::Error; use thiserror::Error;
use tower_http::{services::ServeDir, trace::TraceLayer}; use tower_http::{
services::ServeDir,
set_header::SetResponseHeaderLayer,
trace::TraceLayer,
};
use tracing::{error, warn}; use tracing::{error, warn};
use crate::domain::{DomainError, SessionUser}; use crate::domain::{DomainError, SessionUser};
use crate::ports::{HubEvent, RealtimeNotifier}; use crate::ports::{HubEvent, RealtimeNotifier};
use crate::services::{AuthService, InvitationService, ListService}; use crate::services::{AuthService, InvitationService, ListService, MealService};
use crate::views; use crate::views;
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub auth: Arc<AuthService>, pub auth: Arc<AuthService>,
pub lists: Arc<ListService>, pub lists: Arc<ListService>,
pub meals: Arc<MealService>,
pub invitations: Arc<InvitationService>, pub invitations: Arc<InvitationService>,
pub realtime: Arc<dyn RealtimeNotifier>, pub realtime: Arc<dyn RealtimeNotifier>,
pub cookie_secure: bool, pub cookie_secure: bool,
@@ -74,12 +79,29 @@ pub fn build_router(state: AppState) -> Router {
.route("/lists/{list_id}/items/{item_id}/check", post(check_item)) .route("/lists/{list_id}/items/{item_id}/check", post(check_item))
.route("/lists/{list_id}/items/{item_id}/edit", post(edit_item)) .route("/lists/{list_id}/items/{item_id}/edit", post(edit_item))
.route("/lists/{list_id}/items/{item_id}/delete", post(delete_item)) .route("/lists/{list_id}/items/{item_id}/delete", post(delete_item))
.route("/lists/{list_id}/categories", post(create_category)) .route("/categories", post(create_category))
.route("/invitations", post(create_invitation)) .route("/invitations", post(create_invitation))
.route("/meals", get(meals_page).post(create_meal))
.route("/meals/new", get(new_meal_page))
.route("/meals/{meal_id}", get(meal_page))
.route("/meals/{meal_id}/edit", post(edit_meal))
.route("/meals/{meal_id}/delete", post(delete_meal))
.route("/meals/{meal_id}/ingredients", post(add_ingredient))
.route("/meals/{meal_id}/ingredients/{ingredient_id}/edit", post(edit_ingredient))
.route("/meals/{meal_id}/ingredients/{ingredient_id}/delete", post(delete_ingredient))
.route("/lists/{list_id}/add-meal", post(add_meal_to_list))
.route("/lists/{list_id}/stream", get(list_stream)) .route("/lists/{list_id}/stream", get(list_stream))
.route("/invite/{token}", get(invitation_page)) .route("/invite/{token}", get(invitation_page))
.route("/invite/{token}/accept", post(accept_invitation)) .route("/invite/{token}/accept", post(accept_invitation))
.nest_service("/static", ServeDir::new("static")) .nest_service(
"/static",
tower::ServiceBuilder::new()
.layer(SetResponseHeaderLayer::overriding(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=0, must-revalidate"),
))
.service(ServeDir::new("static")),
)
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.layer(middleware::from_fn(log_response_status)) .layer(middleware::from_fn(log_response_status))
.with_state(state) .with_state(state)
@@ -203,6 +225,37 @@ struct CategoryForm {
csrf: String, csrf: String,
} }
#[derive(Debug, Deserialize)]
struct MealForm {
name: String,
#[serde(default)]
description: String,
csrf: String,
}
#[derive(Debug, Deserialize)]
struct IngredientForm {
name: String,
#[serde(default)]
quantity: String,
#[serde(default)]
note: String,
#[serde(default)]
category_id: Option<String>,
csrf: String,
}
#[derive(Debug, Deserialize)]
struct AddMealForm {
meal_id: i64,
csrf: String,
}
#[derive(Debug, Deserialize)]
struct MealPickerQuery {
picker: Option<i64>,
}
async fn home() -> Redirect { async fn home() -> Redirect {
Redirect::to("/lists") Redirect::to("/lists")
} }
@@ -361,7 +414,7 @@ async fn list_page(
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let access = require_list(&state, list_id).await?; let access = require_list(&state, list_id).await?;
let items = state.lists.items(list_id).await?; let items = state.lists.items(list_id).await?;
let categories = state.lists.categories(list_id).await?; let categories = state.lists.categories().await?;
let presence = state.realtime.presence(list_id).await; let presence = state.realtime.presence(list_id).await;
Ok(html_response(views::list_page( Ok(html_response(views::list_page(
&user.session.user, &user.session.user,
@@ -460,30 +513,191 @@ async fn delete_item(
async fn create_category( async fn create_category(
State(state): State<AppState>, State(state): State<AppState>,
user: CurrentUser, user: CurrentUser,
Path(list_id): Path<i64>,
LoggedForm(form): LoggedForm<CategoryForm>, LoggedForm(form): LoggedForm<CategoryForm>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?; verify_csrf(&user, &form.csrf)?;
require_list(&state, list_id).await?;
let name = form.name.trim().to_owned(); let name = form.name.trim().to_owned();
if name.is_empty() || name.chars().count() > 60 { if name.is_empty() || name.chars().count() > 60 {
return Err(AppError::BadRequest( return Err(AppError::BadRequest(
"Category names must be between 1 and 60 characters.".into(), "Category names must be between 1 and 60 characters.".into(),
)); ));
} }
state.lists.create_category(list_id, name).await?; state.lists.create_category(name).await?;
Ok(Redirect::to("/lists").into_response())
}
let access = require_list(&state, list_id).await?; async fn meals_page(
let items = state.lists.items(list_id).await?; State(state): State<AppState>,
let categories = state.lists.categories(list_id).await?; user: CurrentUser,
Ok(html_response(views::category_created( Query(query): Query<MealPickerQuery>,
&access, ) -> Result<Response, AppError> {
&items, let meals = state.meals.list_meals().await?;
if let Some(list_id) = query.picker {
return Ok(html_response(views::meal_picker(
&meals,
list_id,
&user.session.csrf_token,
)));
}
Ok(html_response(views::meals_page(&user.session.user, &meals)))
}
async fn new_meal_page(user: CurrentUser) -> Result<Response, AppError> {
Ok(html_response(views::meal_form_page(
&user.session.user,
None,
&user.session.csrf_token,
)))
}
async fn create_meal(
State(state): State<AppState>,
user: CurrentUser,
LoggedForm(form): LoggedForm<MealForm>,
) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?;
let name = form.name.trim().to_owned();
if name.is_empty() || name.chars().count() > 120 {
return Err(AppError::BadRequest(
"Meal names must be between 1 and 120 characters.".into(),
));
}
let meal = state
.meals
.create_meal(name, form.description.trim().to_owned())
.await?;
Ok(Redirect::to(&format!("/meals/{}", meal.id)).into_response())
}
async fn meal_page(
State(state): State<AppState>,
user: CurrentUser,
Path(meal_id): Path<i64>,
) -> Result<Response, AppError> {
let meal = state
.meals
.get_meal(meal_id)
.await?
.ok_or(AppError::NotFound)?;
let categories = state.lists.categories().await?;
Ok(html_response(views::meal_page(
&user.session.user,
&meal,
&categories, &categories,
&user.session.csrf_token, &user.session.csrf_token,
))) )))
} }
async fn edit_meal(
State(state): State<AppState>,
user: CurrentUser,
Path(meal_id): Path<i64>,
LoggedForm(form): LoggedForm<MealForm>,
) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?;
let name = form.name.trim().to_owned();
if name.is_empty() || name.chars().count() > 120 {
return Err(AppError::BadRequest(
"Meal names must be between 1 and 120 characters.".into(),
));
}
state
.meals
.update_meal(meal_id, name, form.description.trim().to_owned())
.await?;
Ok(Redirect::to(&format!("/meals/{meal_id}")).into_response())
}
async fn delete_meal(
State(state): State<AppState>,
user: CurrentUser,
Path(meal_id): Path<i64>,
LoggedForm(form): LoggedForm<CsrfForm>,
) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?;
state.meals.delete_meal(meal_id).await?;
Ok(Redirect::to("/meals").into_response())
}
async fn add_ingredient(
State(state): State<AppState>,
user: CurrentUser,
Path(meal_id): Path<i64>,
LoggedForm(form): LoggedForm<IngredientForm>,
) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?;
let name = form.name.trim().to_owned();
if name.is_empty() || name.chars().count() > 120 {
return Err(AppError::BadRequest(
"Ingredient names must be between 1 and 120 characters.".into(),
));
}
state
.meals
.add_ingredient(
meal_id,
name,
form.quantity.trim().to_owned(),
form.note.trim().to_owned(),
parse_category_id(form.category_id),
)
.await?;
Ok(Redirect::to(&format!("/meals/{meal_id}")).into_response())
}
async fn edit_ingredient(
State(state): State<AppState>,
user: CurrentUser,
Path((meal_id, ingredient_id)): Path<(i64, i64)>,
LoggedForm(form): LoggedForm<IngredientForm>,
) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?;
let name = form.name.trim().to_owned();
if name.is_empty() || name.chars().count() > 120 {
return Err(AppError::BadRequest(
"Ingredient names must be between 1 and 120 characters.".into(),
));
}
state
.meals
.update_ingredient(
meal_id,
ingredient_id,
name,
form.quantity.trim().to_owned(),
form.note.trim().to_owned(),
parse_category_id(form.category_id),
)
.await?;
Ok(Redirect::to(&format!("/meals/{meal_id}")).into_response())
}
async fn delete_ingredient(
State(state): State<AppState>,
user: CurrentUser,
Path((meal_id, ingredient_id)): Path<(i64, i64)>,
LoggedForm(form): LoggedForm<CsrfForm>,
) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?;
state
.meals
.delete_ingredient(meal_id, ingredient_id)
.await?;
Ok(Redirect::to(&format!("/meals/{meal_id}")).into_response())
}
async fn add_meal_to_list(
State(state): State<AppState>,
user: CurrentUser,
Path(list_id): Path<i64>,
LoggedForm(form): LoggedForm<AddMealForm>,
) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?;
require_list(&state, list_id).await?;
state.meals.add_meal_to_list(form.meal_id, list_id).await?;
list_fragment_response(&state, &user, list_id).await
}
async fn create_invitation( async fn create_invitation(
State(state): State<AppState>, State(state): State<AppState>,
user: CurrentUser, user: CurrentUser,
@@ -647,7 +861,7 @@ async fn websocket_snapshot(
) -> Result<String, AppError> { ) -> Result<String, AppError> {
let access = require_list(state, list_id).await?; let access = require_list(state, list_id).await?;
let items = state.lists.items(list_id).await?; let items = state.lists.items(list_id).await?;
let categories = state.lists.categories(list_id).await?; let categories = state.lists.categories().await?;
Ok( Ok(
views::live_list_fragments(&access, &items, &categories, &user.session.csrf_token) views::live_list_fragments(&access, &items, &categories, &user.session.csrf_token)
.into_string() .into_string()
@@ -662,7 +876,7 @@ async fn websocket_list_update(
) -> Result<String, AppError> { ) -> Result<String, AppError> {
let access = require_list(state, list_id).await?; let access = require_list(state, list_id).await?;
let items = state.lists.items(list_id).await?; let items = state.lists.items(list_id).await?;
let categories = state.lists.categories(list_id).await?; let categories = state.lists.categories().await?;
Ok( Ok(
views::live_list_fragments(&access, &items, &categories, &user.session.csrf_token) views::live_list_fragments(&access, &items, &categories, &user.session.csrf_token)
.into_string(), .into_string(),
@@ -676,7 +890,7 @@ async fn list_fragment_response(
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let access = require_list(state, list_id).await?; let access = require_list(state, list_id).await?;
let items = state.lists.items(list_id).await?; let items = state.lists.items(list_id).await?;
let categories = state.lists.categories(list_id).await?; let categories = state.lists.categories().await?;
Ok(html_response(views::list_items_fragment( Ok(html_response(views::list_items_fragment(
&access, &access,
&items, &items,
+18 -4
View File
@@ -18,14 +18,16 @@ use tracing::{info, warn};
use crate::http::{AppState, build_router}; use crate::http::{AppState, build_router};
use crate::hub::InMemoryHub; use crate::hub::InMemoryHub;
use crate::ports::{ use crate::ports::{
CategoryRepository, InvitationRepository, ItemRepository, ListRepository, PasswordHasher, CategoryRepository, InvitationRepository, ItemRepository, ListRepository,
RealtimeNotifier, SessionRepository, TokenGenerator, UserRepository, MealIngredientRepository, MealRepository, PasswordHasher, RealtimeNotifier, SessionRepository,
TokenGenerator, UserRepository,
}; };
use crate::security::{Argon2PasswordHasher, RandomTokenGenerator}; use crate::security::{Argon2PasswordHasher, RandomTokenGenerator};
use crate::services::{AuthService, InvitationService, ListService, RegistrationMode}; use crate::services::{AuthService, InvitationService, ListService, MealService, RegistrationMode};
use crate::sqlite::{ use crate::sqlite::{
SqliteCategoryRepository, SqliteInvitationRepository, SqliteItemRepository, SqliteCategoryRepository, SqliteInvitationRepository, SqliteItemRepository,
SqliteListRepository, SqliteSessionRepository, SqliteDatabase, SqliteUserRepository, SqliteListRepository, SqliteMealIngredientRepository, SqliteMealRepository,
SqliteSessionRepository, SqliteDatabase, SqliteUserRepository,
}; };
#[tokio::main] #[tokio::main]
@@ -63,6 +65,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let lists: Arc<dyn ListRepository> = Arc::new(SqliteListRepository); let lists: Arc<dyn ListRepository> = Arc::new(SqliteListRepository);
let categories: Arc<dyn CategoryRepository> = Arc::new(SqliteCategoryRepository); let categories: Arc<dyn CategoryRepository> = Arc::new(SqliteCategoryRepository);
let items: Arc<dyn ItemRepository> = Arc::new(SqliteItemRepository); let items: Arc<dyn ItemRepository> = Arc::new(SqliteItemRepository);
let meals: Arc<dyn MealRepository> = Arc::new(SqliteMealRepository);
let meal_ingredients: Arc<dyn MealIngredientRepository> =
Arc::new(SqliteMealIngredientRepository);
let invitations: Arc<dyn InvitationRepository> = Arc::new(SqliteInvitationRepository); let invitations: Arc<dyn InvitationRepository> = Arc::new(SqliteInvitationRepository);
let hasher: Arc<dyn PasswordHasher> = Arc::new(Argon2PasswordHasher); let hasher: Arc<dyn PasswordHasher> = Arc::new(Argon2PasswordHasher);
let tokens: Arc<dyn TokenGenerator> = Arc::new(RandomTokenGenerator); let tokens: Arc<dyn TokenGenerator> = Arc::new(RandomTokenGenerator);
@@ -88,6 +93,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Arc::clone(&invitations), Arc::clone(&invitations),
Arc::clone(&tokens), Arc::clone(&tokens),
)); ));
let meals_service = Arc::new(MealService::new(
db.clone(),
Arc::clone(&meals),
Arc::clone(&meal_ingredients),
Arc::clone(&lists),
Arc::clone(&items),
Arc::clone(&realtime),
));
let seed_path = env::var("SEED_CONFIG").unwrap_or_else(|_| "seed.json".into()); let seed_path = env::var("SEED_CONFIG").unwrap_or_else(|_| "seed.json".into());
seed::seed_if_needed(&db, &users, &hasher, FilePath::new(&seed_path)).await; seed::seed_if_needed(&db, &users, &hasher, FilePath::new(&seed_path)).await;
@@ -95,6 +108,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let state = AppState { let state = AppState {
auth, auth,
lists: lists_service, lists: lists_service,
meals: meals_service,
invitations: invitations_service, invitations: invitations_service,
realtime, realtime,
cookie_secure, cookie_secure,
+83 -7
View File
@@ -1,7 +1,10 @@
use async_trait::async_trait; use async_trait::async_trait;
use sqlx::SqliteConnection; use sqlx::SqliteConnection;
use crate::domain::{Category, DomainResult, GroceryList, Item, PresenceUser, SessionUser, User}; use crate::domain::{
Category, DomainResult, GroceryList, Item, Meal, MealIngredient, PresenceUser, SessionUser,
User,
};
/// Repositories take `&mut SqliteConnection` (which a `Transaction` derefs to), /// Repositories take `&mut SqliteConnection` (which a `Transaction` derefs to),
/// so several repositories can commit together atomically within a single /// so several repositories can commit together atomically within a single
@@ -60,17 +63,26 @@ pub trait ListRepository: Send + Sync {
#[async_trait] #[async_trait]
pub trait CategoryRepository: Send + Sync { pub trait CategoryRepository: Send + Sync {
async fn categories( async fn categories(&self, txn: &mut SqliteConnection) -> DomainResult<Vec<Category>>;
&self,
txn: &mut SqliteConnection,
list_id: i64,
) -> DomainResult<Vec<Category>>;
async fn create_category( async fn create_category(
&self, &self,
txn: &mut SqliteConnection, txn: &mut SqliteConnection,
list_id: i64,
name: String, name: String,
) -> DomainResult<i64>; ) -> DomainResult<i64>;
async fn category_by_name(
&self,
txn: &mut SqliteConnection,
name: String,
) -> DomainResult<Option<Category>>;
}
/// A single item to insert in bulk, without a per-item revision bump.
#[derive(Clone, Debug)]
pub struct NewItem {
pub name: String,
pub quantity: String,
pub note: String,
pub category_id: Option<i64>,
} }
#[async_trait] #[async_trait]
@@ -85,6 +97,12 @@ pub trait ItemRepository: Send + Sync {
note: String, note: String,
category_id: Option<i64>, category_id: Option<i64>,
) -> DomainResult<i64>; ) -> DomainResult<i64>;
async fn add_items_bulk(
&self,
txn: &mut SqliteConnection,
list_id: i64,
items: Vec<NewItem>,
) -> DomainResult<i64>;
async fn set_item_checked( async fn set_item_checked(
&self, &self,
txn: &mut SqliteConnection, txn: &mut SqliteConnection,
@@ -126,6 +144,64 @@ pub trait InvitationRepository: Send + Sync {
) -> DomainResult<()>; ) -> DomainResult<()>;
} }
#[async_trait]
pub trait MealRepository: Send + Sync {
async fn create_meal(
&self,
txn: &mut SqliteConnection,
name: String,
description: String,
) -> DomainResult<Meal>;
async fn get_meal(
&self,
txn: &mut SqliteConnection,
meal_id: i64,
) -> DomainResult<Option<Meal>>;
async fn list_meals(&self, txn: &mut SqliteConnection) -> DomainResult<Vec<Meal>>;
async fn update_meal(
&self,
txn: &mut SqliteConnection,
meal_id: i64,
name: String,
description: String,
) -> DomainResult<()>;
async fn delete_meal(&self, txn: &mut SqliteConnection, meal_id: i64) -> DomainResult<()>;
}
#[async_trait]
pub trait MealIngredientRepository: Send + Sync {
async fn ingredients_for_meal(
&self,
txn: &mut SqliteConnection,
meal_id: i64,
) -> DomainResult<Vec<MealIngredient>>;
async fn add_ingredient(
&self,
txn: &mut SqliteConnection,
meal_id: i64,
name: String,
quantity: String,
note: String,
category_id: Option<i64>,
) -> DomainResult<i64>;
async fn update_ingredient(
&self,
txn: &mut SqliteConnection,
meal_id: i64,
ingredient_id: i64,
name: String,
quantity: String,
note: String,
category_id: Option<i64>,
) -> DomainResult<()>;
async fn delete_ingredient(
&self,
txn: &mut SqliteConnection,
meal_id: i64,
ingredient_id: i64,
) -> DomainResult<()>;
}
#[async_trait] #[async_trait]
pub trait PasswordHasher: Send + Sync { pub trait PasswordHasher: Send + Sync {
fn hash(&self, password: &str) -> DomainResult<String>; fn hash(&self, password: &str) -> DomainResult<String>;
+173 -7
View File
@@ -1,9 +1,12 @@
use std::sync::Arc; use std::sync::Arc;
use crate::domain::{DomainError, DomainResult, GroceryList, Item, SessionUser, User}; use crate::domain::{
DomainError, DomainResult, GroceryList, Item, Meal, SessionUser, User,
};
use crate::ports::{ use crate::ports::{
CategoryRepository, InvitationRepository, ItemRepository, ListRepository, PasswordHasher, CategoryRepository, InvitationRepository, ItemRepository, ListRepository, MealIngredientRepository,
RealtimeNotifier, SessionRepository, TokenGenerator, UserRepository, MealRepository, NewItem, PasswordHasher, RealtimeNotifier, SessionRepository, TokenGenerator,
UserRepository,
}; };
use crate::sqlite::SqliteDatabase; use crate::sqlite::SqliteDatabase;
@@ -187,10 +190,10 @@ impl ListService {
.await .await
} }
pub async fn categories(&self, list_id: i64) -> DomainResult<Vec<crate::domain::Category>> { pub async fn categories(&self) -> DomainResult<Vec<crate::domain::Category>> {
let categories = Arc::clone(&self.categories); let categories = Arc::clone(&self.categories);
self.db self.db
.run(move |txn| Box::pin(async move { categories.categories(txn, list_id).await })) .run(move |txn| Box::pin(async move { categories.categories(txn).await }))
.await .await
} }
@@ -270,12 +273,175 @@ impl ListService {
Ok(revision) Ok(revision)
} }
pub async fn create_category(&self, list_id: i64, name: String) -> DomainResult<i64> { pub async fn create_category(&self, name: String) -> DomainResult<i64> {
let categories = Arc::clone(&self.categories); let categories = Arc::clone(&self.categories);
self.db
.run(move |txn| Box::pin(async move { categories.create_category(txn, name).await }))
.await
}
}
pub struct MealService {
db: SqliteDatabase,
meals: Arc<dyn MealRepository>,
ingredients: Arc<dyn MealIngredientRepository>,
lists: Arc<dyn ListRepository>,
items: Arc<dyn ItemRepository>,
realtime: Arc<dyn RealtimeNotifier>,
}
impl MealService {
pub fn new(
db: SqliteDatabase,
meals: Arc<dyn MealRepository>,
ingredients: Arc<dyn MealIngredientRepository>,
lists: Arc<dyn ListRepository>,
items: Arc<dyn ItemRepository>,
realtime: Arc<dyn RealtimeNotifier>,
) -> Self {
Self {
db,
meals,
ingredients,
lists,
items,
realtime,
}
}
pub async fn create_meal(&self, name: String, description: String) -> DomainResult<Meal> {
let meals = Arc::clone(&self.meals);
self.db
.run(move |txn| Box::pin(async move { meals.create_meal(txn, name, description).await }))
.await
}
pub async fn get_meal(&self, meal_id: i64) -> DomainResult<Option<Meal>> {
let meals = Arc::clone(&self.meals);
self.db
.run(move |txn| Box::pin(async move { meals.get_meal(txn, meal_id).await }))
.await
}
pub async fn list_meals(&self) -> DomainResult<Vec<Meal>> {
let meals = Arc::clone(&self.meals);
self.db
.run(move |txn| Box::pin(async move { meals.list_meals(txn).await }))
.await
}
pub async fn update_meal(
&self,
meal_id: i64,
name: String,
description: String,
) -> DomainResult<()> {
let meals = Arc::clone(&self.meals);
self.db
.run(move |txn| {
Box::pin(async move { meals.update_meal(txn, meal_id, name, description).await })
})
.await
}
pub async fn delete_meal(&self, meal_id: i64) -> DomainResult<()> {
let meals = Arc::clone(&self.meals);
self.db
.run(move |txn| Box::pin(async move { meals.delete_meal(txn, meal_id).await }))
.await
}
pub async fn add_ingredient(
&self,
meal_id: i64,
name: String,
quantity: String,
note: String,
category_id: Option<i64>,
) -> DomainResult<i64> {
let ingredients = Arc::clone(&self.ingredients);
self.db
.run(move |txn| {
Box::pin(async move {
ingredients
.add_ingredient(txn, meal_id, name, quantity, note, category_id)
.await
})
})
.await
}
pub async fn update_ingredient(
&self,
meal_id: i64,
ingredient_id: i64,
name: String,
quantity: String,
note: String,
category_id: Option<i64>,
) -> DomainResult<()> {
let ingredients = Arc::clone(&self.ingredients);
self.db
.run(move |txn| {
Box::pin(async move {
ingredients
.update_ingredient(
txn,
meal_id,
ingredient_id,
name,
quantity,
note,
category_id,
)
.await
})
})
.await
}
pub async fn delete_ingredient(&self, meal_id: i64, ingredient_id: i64) -> DomainResult<()> {
let ingredients = Arc::clone(&self.ingredients);
self.db
.run(move |txn| {
Box::pin(async move {
ingredients
.delete_ingredient(txn, meal_id, ingredient_id)
.await
})
})
.await
}
/// Expands a meal's ingredients into items on a list in one unit of work,
/// bumping the list revision exactly once.
pub async fn add_meal_to_list(&self, meal_id: i64, list_id: i64) -> DomainResult<i64> {
let meals = Arc::clone(&self.meals);
let lists = Arc::clone(&self.lists);
let items = Arc::clone(&self.items);
let revision = self let revision = self
.db .db
.run(move |txn| { .run(move |txn| {
Box::pin(async move { categories.create_category(txn, list_id, name).await }) Box::pin(async move {
let meal = meals
.get_meal(txn, meal_id)
.await?
.ok_or(DomainError::NotFound)?;
if lists.get_list(txn, list_id).await?.is_none() {
return Err(DomainError::NotFound);
}
let new_items = meal
.ingredients
.into_iter()
.map(|ingredient| NewItem {
name: ingredient.name,
quantity: ingredient.quantity,
note: ingredient.note,
category_id: ingredient.category_id,
})
.collect();
items.add_items_bulk(txn, list_id, new_items).await
})
}) })
.await?; .await?;
self.realtime.publish_list_changed(list_id, revision).await; self.realtime.publish_list_changed(list_id, revision).await;
+738 -60
View File
@@ -6,10 +6,13 @@ use async_trait::async_trait;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use sqlx::{Connection, Row, SqliteConnection, SqlitePool, sqlite::SqliteConnectOptions}; use sqlx::{Connection, Row, SqliteConnection, SqlitePool, sqlite::SqliteConnectOptions};
use crate::domain::{Category, DomainError, DomainResult, GroceryList, Item, SessionUser, User}; use crate::domain::{
Category, DomainError, DomainResult, GroceryList, Item, Meal, MealIngredient, SessionUser,
User,
};
use crate::ports::{ use crate::ports::{
CategoryRepository, InvitationRepository, ItemRepository, ListRepository, SessionRepository, CategoryRepository, InvitationRepository, ItemRepository, ListRepository, MealIngredientRepository,
UserRepository, MealRepository, NewItem, SessionRepository, UserRepository,
}; };
#[derive(Clone)] #[derive(Clone)]
@@ -27,6 +30,7 @@ impl SqliteDatabase {
.create_if_missing(true); .create_if_missing(true);
let pool = SqlitePool::connect_with(options).await.map_err(db_error)?; let pool = SqlitePool::connect_with(options).await.map_err(db_error)?;
migrate(&pool).await?; migrate(&pool).await?;
seed_default_categories(&pool).await?;
Ok(Self { pool }) Ok(Self { pool })
} }
@@ -52,6 +56,7 @@ impl SqliteDatabase {
.create_if_missing(true); .create_if_missing(true);
let pool = SqlitePool::connect_with(options).await.map_err(db_error)?; let pool = SqlitePool::connect_with(options).await.map_err(db_error)?;
migrate(&pool).await?; migrate(&pool).await?;
seed_default_categories(&pool).await?;
Ok(Self { pool }) Ok(Self { pool })
} }
} }
@@ -109,11 +114,9 @@ async fn migrate(pool: &SqlitePool) -> DomainResult<()> {
); );
CREATE TABLE IF NOT EXISTS categories ( CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
list_id INTEGER NOT NULL REFERENCES lists(id) ON DELETE CASCADE, name TEXT NOT NULL UNIQUE COLLATE NOCASE,
name TEXT NOT NULL COLLATE NOCASE,
position INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL
UNIQUE (list_id, name)
); );
CREATE TABLE IF NOT EXISTS invitations ( CREATE TABLE IF NOT EXISTS invitations (
token_hash TEXT PRIMARY KEY, token_hash TEXT PRIMARY KEY,
@@ -133,8 +136,24 @@ async fn migrate(pool: &SqlitePool) -> DomainResult<()> {
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL
); );
CREATE TABLE IF NOT EXISTS meals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS meal_ingredients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
meal_id INTEGER NOT NULL REFERENCES meals(id) ON DELETE CASCADE,
name TEXT NOT NULL,
quantity TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
position INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS items_list_idx ON items(list_id); CREATE INDEX IF NOT EXISTS items_list_idx ON items(list_id);
CREATE INDEX IF NOT EXISTS categories_list_idx ON categories(list_id); CREATE INDEX IF NOT EXISTS meal_ingredients_meal_idx ON meal_ingredients(meal_id);
CREATE INDEX IF NOT EXISTS sessions_user_idx ON sessions(user_id);", CREATE INDEX IF NOT EXISTS sessions_user_idx ON sessions(user_id);",
) )
.execute(pool) .execute(pool)
@@ -143,6 +162,28 @@ async fn migrate(pool: &SqlitePool) -> DomainResult<()> {
Ok(()) Ok(())
} }
/// Inserts the default global categories once, if the categories table is empty.
async fn seed_default_categories(pool: &SqlitePool) -> DomainResult<()> {
let count: i64 = sqlx::query("SELECT COUNT(*) FROM categories")
.fetch_one(pool)
.await
.map_err(db_error)?
.get(0);
if count > 0 {
return Ok(());
}
for (position, category_name) in DEFAULT_CATEGORIES.iter().enumerate() {
sqlx::query("INSERT INTO categories (name, position, created_at) VALUES (?1, ?2, ?3)")
.bind(category_name)
.bind(position as i64)
.bind(now())
.execute(pool)
.await
.map_err(db_error)?;
}
Ok(())
}
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub struct SqliteUserRepository; pub struct SqliteUserRepository;
@@ -323,19 +364,6 @@ impl ListRepository for SqliteListRepository {
.await .await
.map_err(db_error)? .map_err(db_error)?
.get::<i64, _>(0); .get::<i64, _>(0);
for (position, category_name) in DEFAULT_CATEGORIES.iter().enumerate() {
sqlx::query(
"INSERT INTO categories (list_id, name, position, created_at)
VALUES (?1, ?2, ?3, ?4)",
)
.bind(list_id)
.bind(category_name)
.bind(position as i64)
.bind(now())
.execute(&mut *txn)
.await
.map_err(db_error)?;
}
Ok(GroceryList { Ok(GroceryList {
id: list_id, id: list_id,
name, name,
@@ -370,18 +398,12 @@ pub struct SqliteCategoryRepository;
#[async_trait] #[async_trait]
impl CategoryRepository for SqliteCategoryRepository { impl CategoryRepository for SqliteCategoryRepository {
async fn categories( async fn categories(&self, txn: &mut SqliteConnection) -> DomainResult<Vec<Category>> {
&self,
txn: &mut SqliteConnection,
list_id: i64,
) -> DomainResult<Vec<Category>> {
let rows = sqlx::query( let rows = sqlx::query(
"SELECT id, name "SELECT id, name
FROM categories FROM categories
WHERE list_id = ?1
ORDER BY position ASC, name COLLATE NOCASE ASC", ORDER BY position ASC, name COLLATE NOCASE ASC",
) )
.bind(list_id)
.fetch_all(&mut *txn) .fetch_all(&mut *txn)
.await .await
.map_err(db_error)?; .map_err(db_error)?;
@@ -397,23 +419,17 @@ impl CategoryRepository for SqliteCategoryRepository {
async fn create_category( async fn create_category(
&self, &self,
txn: &mut SqliteConnection, txn: &mut SqliteConnection,
list_id: i64,
name: String, name: String,
) -> DomainResult<i64> { ) -> DomainResult<i64> {
let position: i64 = sqlx::query( let position: i64 = sqlx::query("SELECT COALESCE(MAX(position), -1) + 1 FROM categories")
"SELECT COALESCE(MAX(position), -1) + 1 .fetch_one(&mut *txn)
FROM categories WHERE list_id = ?1", .await
) .map_err(db_error)?
.bind(list_id) .get(0);
.fetch_one(&mut *txn)
.await
.map_err(db_error)?
.get(0);
let result = sqlx::query( let result = sqlx::query(
"INSERT INTO categories (list_id, name, position, created_at) "INSERT INTO categories (name, position, created_at)
VALUES (?1, ?2, ?3, ?4)", VALUES (?1, ?2, ?3)",
) )
.bind(list_id)
.bind(&name) .bind(&name)
.bind(position) .bind(position)
.bind(now()) .bind(now())
@@ -424,7 +440,32 @@ impl CategoryRepository for SqliteCategoryRepository {
Err(error) if is_unique_violation(&error) => return Err(DomainError::Conflict), Err(error) if is_unique_violation(&error) => return Err(DomainError::Conflict),
Err(error) => return Err(db_error(error)), Err(error) => return Err(db_error(error)),
} }
bump_revision(txn, list_id).await let id = sqlx::query("SELECT last_insert_rowid()")
.fetch_one(&mut *txn)
.await
.map_err(db_error)?
.get::<i64, _>(0);
Ok(id)
}
async fn category_by_name(
&self,
txn: &mut SqliteConnection,
name: String,
) -> DomainResult<Option<Category>> {
let row = sqlx::query(
"SELECT id, name
FROM categories
WHERE name = ?1 COLLATE NOCASE",
)
.bind(&name)
.fetch_optional(&mut *txn)
.await
.map_err(db_error)?;
Ok(row.map(|row| Category {
id: row.get(0),
name: row.get(1),
}))
} }
} }
@@ -468,7 +509,7 @@ impl ItemRepository for SqliteItemRepository {
note: String, note: String,
category_id: Option<i64>, category_id: Option<i64>,
) -> DomainResult<i64> { ) -> DomainResult<i64> {
ensure_category(txn, list_id, category_id).await?; ensure_category(txn, category_id).await?;
let position: i64 = let position: i64 =
sqlx::query("SELECT COALESCE(MAX(position), -1) + 1 FROM items WHERE list_id = ?1") sqlx::query("SELECT COALESCE(MAX(position), -1) + 1 FROM items WHERE list_id = ?1")
.bind(list_id) .bind(list_id)
@@ -494,6 +535,42 @@ impl ItemRepository for SqliteItemRepository {
bump_revision(txn, list_id).await bump_revision(txn, list_id).await
} }
async fn add_items_bulk(
&self,
txn: &mut SqliteConnection,
list_id: i64,
items: Vec<NewItem>,
) -> DomainResult<i64> {
let mut position: i64 =
sqlx::query("SELECT COALESCE(MAX(position), -1) + 1 FROM items WHERE list_id = ?1")
.bind(list_id)
.fetch_one(&mut *txn)
.await
.map_err(db_error)?
.get(0);
let now = now();
for item in items {
ensure_category(txn, item.category_id).await?;
sqlx::query(
"INSERT INTO items
(list_id, name, quantity, note, category_id, checked, version, position, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, 0, 1, ?6, ?7, ?7)",
)
.bind(list_id)
.bind(&item.name)
.bind(&item.quantity)
.bind(&item.note)
.bind(item.category_id)
.bind(position)
.bind(now)
.execute(&mut *txn)
.await
.map_err(db_error)?;
position += 1;
}
bump_revision(txn, list_id).await
}
async fn set_item_checked( async fn set_item_checked(
&self, &self,
txn: &mut SqliteConnection, txn: &mut SqliteConnection,
@@ -530,7 +607,7 @@ impl ItemRepository for SqliteItemRepository {
note: String, note: String,
category_id: Option<i64>, category_id: Option<i64>,
) -> DomainResult<i64> { ) -> DomainResult<i64> {
ensure_category(txn, list_id, category_id).await?; ensure_category(txn, category_id).await?;
let changed = sqlx::query( let changed = sqlx::query(
"UPDATE items "UPDATE items
SET name = ?1, quantity = ?2, note = ?3, category_id = ?4, SET name = ?1, quantity = ?2, note = ?3, category_id = ?4,
@@ -639,17 +716,274 @@ impl InvitationRepository for SqliteInvitationRepository {
} }
} }
#[derive(Clone, Copy)]
pub struct SqliteMealRepository;
#[async_trait]
impl MealRepository for SqliteMealRepository {
async fn create_meal(
&self,
txn: &mut SqliteConnection,
name: String,
description: String,
) -> DomainResult<Meal> {
let now = now();
sqlx::query(
"INSERT INTO meals (name, description, created_at, updated_at)
VALUES (?1, ?2, ?3, ?3)",
)
.bind(&name)
.bind(&description)
.bind(now)
.execute(&mut *txn)
.await
.map_err(db_error)?;
let id = sqlx::query("SELECT last_insert_rowid()")
.fetch_one(&mut *txn)
.await
.map_err(db_error)?
.get::<i64, _>(0);
Ok(Meal {
id,
name,
description,
ingredients: Vec::new(),
})
}
async fn get_meal(
&self,
txn: &mut SqliteConnection,
meal_id: i64,
) -> DomainResult<Option<Meal>> {
let row = sqlx::query(
"SELECT id, name, description
FROM meals
WHERE id = ?1",
)
.bind(meal_id)
.fetch_optional(&mut *txn)
.await
.map_err(db_error)?;
let Some(row) = row else {
return Ok(None);
};
let meal = Meal {
id: row.get(0),
name: row.get(1),
description: row.get(2),
ingredients: Vec::new(),
};
let ingredients = SqliteMealIngredientRepository
.ingredients_for_meal(txn, meal.id)
.await?;
Ok(Some(Meal {
ingredients,
..meal
}))
}
async fn list_meals(&self, txn: &mut SqliteConnection) -> DomainResult<Vec<Meal>> {
let rows = sqlx::query(
"SELECT id, name, description
FROM meals
ORDER BY name COLLATE NOCASE ASC",
)
.fetch_all(&mut *txn)
.await
.map_err(db_error)?;
let mut meals = Vec::new();
for row in rows {
let meal = Meal {
id: row.get(0),
name: row.get(1),
description: row.get(2),
ingredients: Vec::new(),
};
let ingredients = SqliteMealIngredientRepository
.ingredients_for_meal(txn, meal.id)
.await?;
meals.push(Meal {
ingredients,
..meal
});
}
Ok(meals)
}
async fn update_meal(
&self,
txn: &mut SqliteConnection,
meal_id: i64,
name: String,
description: String,
) -> DomainResult<()> {
let changed = sqlx::query(
"UPDATE meals
SET name = ?1, description = ?2, updated_at = ?3
WHERE id = ?4",
)
.bind(&name)
.bind(&description)
.bind(now())
.bind(meal_id)
.execute(&mut *txn)
.await
.map_err(db_error)?
.rows_affected();
if changed == 0 {
return Err(DomainError::NotFound);
}
Ok(())
}
async fn delete_meal(&self, txn: &mut SqliteConnection, meal_id: i64) -> DomainResult<()> {
let changed = sqlx::query("DELETE FROM meals WHERE id = ?1")
.bind(meal_id)
.execute(&mut *txn)
.await
.map_err(db_error)?
.rows_affected();
if changed == 0 {
return Err(DomainError::NotFound);
}
Ok(())
}
}
#[derive(Clone, Copy)]
pub struct SqliteMealIngredientRepository;
#[async_trait]
impl MealIngredientRepository for SqliteMealIngredientRepository {
async fn ingredients_for_meal(
&self,
txn: &mut SqliteConnection,
meal_id: i64,
) -> DomainResult<Vec<MealIngredient>> {
let rows = sqlx::query(
"SELECT id, name, quantity, note, category_id
FROM meal_ingredients
WHERE meal_id = ?1
ORDER BY position ASC, id ASC",
)
.bind(meal_id)
.fetch_all(&mut *txn)
.await
.map_err(db_error)?;
Ok(rows
.into_iter()
.map(|row| MealIngredient {
id: row.get(0),
name: row.get(1),
quantity: row.get(2),
note: row.get(3),
category_id: row.get(4),
})
.collect())
}
async fn add_ingredient(
&self,
txn: &mut SqliteConnection,
meal_id: i64,
name: String,
quantity: String,
note: String,
category_id: Option<i64>,
) -> DomainResult<i64> {
ensure_category(txn, category_id).await?;
let position: i64 = sqlx::query(
"SELECT COALESCE(MAX(position), -1) + 1
FROM meal_ingredients WHERE meal_id = ?1",
)
.bind(meal_id)
.fetch_one(&mut *txn)
.await
.map_err(db_error)?
.get(0);
sqlx::query(
"INSERT INTO meal_ingredients (meal_id, name, quantity, note, category_id, position)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
)
.bind(meal_id)
.bind(&name)
.bind(&quantity)
.bind(&note)
.bind(category_id)
.bind(position)
.execute(&mut *txn)
.await
.map_err(db_error)?;
let id = sqlx::query("SELECT last_insert_rowid()")
.fetch_one(&mut *txn)
.await
.map_err(db_error)?
.get::<i64, _>(0);
Ok(id)
}
async fn update_ingredient(
&self,
txn: &mut SqliteConnection,
meal_id: i64,
ingredient_id: i64,
name: String,
quantity: String,
note: String,
category_id: Option<i64>,
) -> DomainResult<()> {
ensure_category(txn, category_id).await?;
let changed = sqlx::query(
"UPDATE meal_ingredients
SET name = ?1, quantity = ?2, note = ?3, category_id = ?4
WHERE id = ?5 AND meal_id = ?6",
)
.bind(&name)
.bind(&quantity)
.bind(&note)
.bind(category_id)
.bind(ingredient_id)
.bind(meal_id)
.execute(&mut *txn)
.await
.map_err(db_error)?
.rows_affected();
if changed == 0 {
return Err(DomainError::NotFound);
}
Ok(())
}
async fn delete_ingredient(
&self,
txn: &mut SqliteConnection,
meal_id: i64,
ingredient_id: i64,
) -> DomainResult<()> {
let changed = sqlx::query("DELETE FROM meal_ingredients WHERE id = ?1 AND meal_id = ?2")
.bind(ingredient_id)
.bind(meal_id)
.execute(&mut *txn)
.await
.map_err(db_error)?
.rows_affected();
if changed == 0 {
return Err(DomainError::NotFound);
}
Ok(())
}
}
async fn ensure_category( async fn ensure_category(
txn: &mut SqliteConnection, txn: &mut SqliteConnection,
list_id: i64,
category_id: Option<i64>, category_id: Option<i64>,
) -> DomainResult<()> { ) -> DomainResult<()> {
let Some(category_id) = category_id else { let Some(category_id) = category_id else {
return Ok(()); return Ok(());
}; };
let row = sqlx::query("SELECT 1 FROM categories WHERE id = ?1 AND list_id = ?2") let row = sqlx::query("SELECT 1 FROM categories WHERE id = ?1")
.bind(category_id) .bind(category_id)
.bind(list_id)
.fetch_optional(&mut *txn) .fetch_optional(&mut *txn)
.await .await
.map_err(db_error)?; .map_err(db_error)?;
@@ -782,11 +1116,11 @@ mod tests {
.unwrap() .unwrap()
} }
async fn get_categories(db: &SqliteDatabase, list_id: i64) -> Vec<Category> { async fn get_categories(db: &SqliteDatabase) -> Vec<Category> {
let categories = SqliteCategoryRepository; let categories = SqliteCategoryRepository;
db.run(move |txn| { db.run(move |txn| {
let categories = categories.clone(); let categories = categories.clone();
Box::pin(async move { categories.categories(txn, list_id).await }) Box::pin(async move { categories.categories(txn).await })
}) })
.await .await
.unwrap() .unwrap()
@@ -985,12 +1319,15 @@ mod tests {
// ---- ListRepository ---- // ---- ListRepository ----
#[tokio::test] #[tokio::test]
async fn create_list_seeds_default_categories() { async fn create_list_does_not_seed_categories() {
let db = setup().await; let db = setup().await;
// Default categories are seeded globally at startup.
let before = get_categories(&db).await.len();
let list = create_list(&db, "Weekly shop").await; let list = create_list(&db, "Weekly shop").await;
assert!(list.id > 0); assert!(list.id > 0);
assert_eq!(list.revision, 0); assert_eq!(list.revision, 0);
assert_eq!(get_categories(&db, list.id).await.len(), 6); // Categories are global now; creating a list must not add any.
assert_eq!(get_categories(&db).await.len(), before);
} }
#[tokio::test] #[tokio::test]
@@ -1038,39 +1375,36 @@ mod tests {
// ---- CategoryRepository ---- // ---- CategoryRepository ----
#[tokio::test] #[tokio::test]
async fn create_category_adds_and_bumps_revision() { async fn create_category_is_global_and_returns_id() {
let db = setup().await; let db = setup().await;
let list = create_list(&db, "Weekly shop").await;
let categories = SqliteCategoryRepository; let categories = SqliteCategoryRepository;
let revision = db let id = db
.run(move |txn| { .run(move |txn| {
let categories = categories.clone(); let categories = categories.clone();
Box::pin(async move { Box::pin(async move {
categories categories
.create_category(txn, list.id, "Bakery".into()) .create_category(txn, "Bakery".into())
.await .await
}) })
}) })
.await .await
.unwrap(); .unwrap();
assert_eq!(revision, 1); assert!(id > 0);
let cats = get_categories(&db, list.id).await; let cats = get_categories(&db).await;
assert_eq!(cats.len(), 7);
assert!(cats.iter().any(|c| c.name == "Bakery")); assert!(cats.iter().any(|c| c.name == "Bakery"));
} }
#[tokio::test] #[tokio::test]
async fn create_duplicate_category_conflicts() { async fn create_duplicate_category_conflicts() {
let db = setup().await; let db = setup().await;
let list = create_list(&db, "Weekly shop").await;
let categories = SqliteCategoryRepository; let categories = SqliteCategoryRepository;
let result = db let result = db
.run(move |txn| { .run(move |txn| {
let categories = categories.clone(); let categories = categories.clone();
Box::pin(async move { Box::pin(async move {
categories categories
.create_category(txn, list.id, "Produce".into()) .create_category(txn, "Produce".into())
.await .await
}) })
}) })
@@ -1078,6 +1412,24 @@ mod tests {
assert!(matches!(result, Err(DomainError::Conflict))); assert!(matches!(result, Err(DomainError::Conflict)));
} }
#[tokio::test]
async fn category_by_name_resolves_globally() {
let db = setup().await;
let categories = SqliteCategoryRepository;
let found = db
.run(move |txn| {
let categories = categories.clone();
Box::pin(async move {
categories
.category_by_name(txn, "produce".into())
.await
})
})
.await
.unwrap();
assert_eq!(found.unwrap().name, "Produce");
}
// ---- ItemRepository ---- // ---- ItemRepository ----
#[tokio::test] #[tokio::test]
@@ -1139,6 +1491,40 @@ mod tests {
assert!(matches!(result, Err(DomainError::NotFound))); assert!(matches!(result, Err(DomainError::NotFound)));
} }
#[tokio::test]
async fn add_items_bulk_inserts_all_and_bumps_revision_once() {
let db = setup().await;
let list = create_list(&db, "Weekly shop").await;
let items = SqliteItemRepository;
let new_items = vec![
NewItem {
name: "Penne".into(),
quantity: "500g".into(),
note: String::new(),
category_id: None,
},
NewItem {
name: "Tomato".into(),
quantity: "2".into(),
note: String::new(),
category_id: None,
},
];
let revision = db
.run(move |txn| {
let items = items.clone();
Box::pin(async move { items.add_items_bulk(txn, list.id, new_items).await })
})
.await
.unwrap();
assert_eq!(revision, 1);
let listed = get_items(&db, list.id).await;
let mut names = listed.iter().map(|i| i.name.clone()).collect::<Vec<_>>();
names.sort_unstable();
assert_eq!(names, vec!["Penne".to_owned(), "Tomato".to_owned()]);
}
#[tokio::test] #[tokio::test]
async fn update_item_changes_fields_and_bumps_version() { async fn update_item_changes_fields_and_bumps_version() {
let db = setup().await; let db = setup().await;
@@ -1385,4 +1771,296 @@ mod tests {
assert!(items[0].checked); assert!(items[0].checked);
assert_eq!(items[1].name, "Second"); assert_eq!(items[1].name, "Second");
} }
// ---- MealRepository / MealIngredientRepository ----
async fn create_meal(db: &SqliteDatabase, name: &str) -> Meal {
let meals = SqliteMealRepository;
let name = name.to_owned();
db.run(move |txn| {
let meals = meals.clone();
Box::pin(async move { meals.create_meal(txn, name, String::new()).await })
})
.await
.unwrap()
}
async fn add_ingredient(
db: &SqliteDatabase,
meal_id: i64,
name: &str,
category_id: Option<i64>,
) -> MealIngredient {
let ingredients = SqliteMealIngredientRepository;
let name_for_insert = name.to_owned();
db.run(move |txn| {
let ingredients = ingredients.clone();
Box::pin(async move {
ingredients
.add_ingredient(
txn,
meal_id,
name_for_insert,
String::new(),
String::new(),
category_id,
)
.await
})
})
.await
.unwrap();
let ingredients = SqliteMealIngredientRepository;
db.run(move |txn| {
let ingredients = ingredients.clone();
Box::pin(async move { ingredients.ingredients_for_meal(txn, meal_id).await })
})
.await
.unwrap()
.into_iter()
.find(|ingredient| ingredient.name == name)
.unwrap()
}
#[tokio::test]
async fn create_meal_returns_meal_with_id() {
let db = setup().await;
let meal = create_meal(&db, "Pasta").await;
assert!(meal.id > 0);
assert_eq!(meal.name, "Pasta");
assert!(meal.ingredients.is_empty());
}
#[tokio::test]
async fn list_meals_returns_all_meals() {
let db = setup().await;
create_meal(&db, "Pasta").await;
create_meal(&db, "Salad").await;
let meals = SqliteMealRepository;
let meals = db
.run(move |txn| {
let meals = meals.clone();
Box::pin(async move { meals.list_meals(txn).await })
})
.await
.unwrap();
let mut names = meals.iter().map(|m| m.name.clone()).collect::<Vec<_>>();
names.sort_unstable();
assert_eq!(names, vec!["Pasta".to_owned(), "Salad".to_owned()]);
}
#[tokio::test]
async fn get_meal_returns_meal_with_ingredients() {
let db = setup().await;
let meal = create_meal(&db, "Pasta").await;
add_ingredient(&db, meal.id, "Penne", None).await;
add_ingredient(&db, meal.id, "Tomato", None).await;
let meals = SqliteMealRepository;
let fetched = db
.run(move |txn| {
let meals = meals.clone();
Box::pin(async move { meals.get_meal(txn, meal.id).await })
})
.await
.unwrap()
.unwrap();
assert_eq!(fetched.name, "Pasta");
let mut names = fetched
.ingredients
.iter()
.map(|i| i.name.clone())
.collect::<Vec<_>>();
names.sort_unstable();
assert_eq!(names, vec!["Penne".to_owned(), "Tomato".to_owned()]);
}
#[tokio::test]
async fn get_meal_returns_none_for_unknown() {
let db = setup().await;
let meals = SqliteMealRepository;
let found = db
.run(move |txn| {
let meals = meals.clone();
Box::pin(async move { meals.get_meal(txn, 9999).await })
})
.await
.unwrap();
assert!(found.is_none());
}
#[tokio::test]
async fn update_meal_changes_fields() {
let db = setup().await;
let meal = create_meal(&db, "Pasta").await;
let meals = SqliteMealRepository;
db.run(move |txn| {
let meals = meals.clone();
Box::pin(async move {
meals
.update_meal(txn, meal.id, "Pasta al pomodoro".into(), "desc".into())
.await
})
})
.await
.unwrap();
let fetched = db
.run(move |txn| {
let meals = meals.clone();
Box::pin(async move { meals.get_meal(txn, meal.id).await })
})
.await
.unwrap()
.unwrap();
assert_eq!(fetched.name, "Pasta al pomodoro");
assert_eq!(fetched.description, "desc");
}
#[tokio::test]
async fn update_missing_meal_fails() {
let db = setup().await;
let meals = SqliteMealRepository;
let result = db
.run(move |txn| {
let meals = meals.clone();
Box::pin(async move {
meals
.update_meal(txn, 9999, "X".into(), String::new())
.await
})
})
.await;
assert!(matches!(result, Err(DomainError::NotFound)));
}
#[tokio::test]
async fn delete_meal_removes_it_and_ingredients() {
let db = setup().await;
let meal = create_meal(&db, "Pasta").await;
add_ingredient(&db, meal.id, "Penne", None).await;
let meals = SqliteMealRepository;
db.run(move |txn| {
let meals = meals.clone();
Box::pin(async move { meals.delete_meal(txn, meal.id).await })
})
.await
.unwrap();
let found = db
.run(move |txn| {
let meals = meals.clone();
Box::pin(async move { meals.get_meal(txn, meal.id).await })
})
.await
.unwrap();
assert!(found.is_none());
}
#[tokio::test]
async fn delete_missing_meal_fails() {
let db = setup().await;
let meals = SqliteMealRepository;
let result = db
.run(move |txn| {
let meals = meals.clone();
Box::pin(async move { meals.delete_meal(txn, 9999).await })
})
.await;
assert!(matches!(result, Err(DomainError::NotFound)));
}
#[tokio::test]
async fn add_ingredient_with_unknown_category_fails() {
let db = setup().await;
let meal = create_meal(&db, "Pasta").await;
let ingredients = SqliteMealIngredientRepository;
let result = db
.run(move |txn| {
let ingredients = ingredients.clone();
Box::pin(async move {
ingredients
.add_ingredient(txn, meal.id, "X".into(), String::new(), String::new(), Some(9999))
.await
})
})
.await;
assert!(matches!(result, Err(DomainError::NotFound)));
}
#[tokio::test]
async fn update_ingredient_changes_fields() {
let db = setup().await;
let meal = create_meal(&db, "Pasta").await;
let ingredient = add_ingredient(&db, meal.id, "Penne", None).await;
let ingredients = SqliteMealIngredientRepository;
db.run(move |txn| {
let ingredients = ingredients.clone();
Box::pin(async move {
ingredients
.update_ingredient(
txn,
meal.id,
ingredient.id,
"Rigatoni".into(),
"500g".into(),
String::new(),
None,
)
.await
})
})
.await
.unwrap();
let fetched = db
.run(move |txn| {
let ingredients = ingredients.clone();
Box::pin(async move { ingredients.ingredients_for_meal(txn, meal.id).await })
})
.await
.unwrap();
assert_eq!(fetched[0].name, "Rigatoni");
assert_eq!(fetched[0].quantity, "500g");
}
#[tokio::test]
async fn delete_ingredient_removes_it() {
let db = setup().await;
let meal = create_meal(&db, "Pasta").await;
let ingredient = add_ingredient(&db, meal.id, "Penne", None).await;
let ingredients = SqliteMealIngredientRepository;
db.run(move |txn| {
let ingredients = ingredients.clone();
Box::pin(async move {
ingredients
.delete_ingredient(txn, meal.id, ingredient.id)
.await
})
})
.await
.unwrap();
let remaining = db
.run(move |txn| {
let ingredients = ingredients.clone();
Box::pin(async move { ingredients.ingredients_for_meal(txn, meal.id).await })
})
.await
.unwrap();
assert!(remaining.is_empty());
}
#[tokio::test]
async fn delete_missing_ingredient_fails() {
let db = setup().await;
let meal = create_meal(&db, "Pasta").await;
let ingredients = SqliteMealIngredientRepository;
let result = db
.run(move |txn| {
let ingredients = ingredients.clone();
Box::pin(async move {
ingredients
.delete_ingredient(txn, meal.id, 9999)
.await
})
})
.await;
assert!(matches!(result, Err(DomainError::NotFound)));
}
} }
+369 -29
View File
@@ -1,8 +1,9 @@
use maud::{DOCTYPE, Markup, html}; use maud::{DOCTYPE, Markup, html};
use pulldown_cmark::{Options, Parser, html as cmark_html};
use crate::{ use crate::{
domain::PresenceUser, domain::PresenceUser,
domain::{Category, GroceryList, Item, User}, domain::{Category, GroceryList, Item, Meal, MealIngredient, User},
}; };
pub fn login_page(error: Option<&str>, invite: Option<&str>) -> Markup { pub fn login_page(error: Option<&str>, invite: Option<&str>) -> Markup {
@@ -144,6 +145,349 @@ pub fn lists_page(user: &User, lists: &[GroceryList], csrf_token: &str) -> Marku
) )
} }
pub fn meals_page(user: &User, meals: &[Meal]) -> Markup {
page(
"Meals",
Some(user),
html! {
div class="page-heading" {
div {
p class="eyebrow" { "MEAL LIBRARY" }
h1 { "Meals" }
p class="lede" { "Save a meal and add its ingredients to any list." }
}
a class="button button-primary" href="/meals/new" { "New meal" }
}
div class="dashboard-grid" {
section class="panel" {
div class="panel-heading" {
h2 { "All meals" }
span class="count-badge" { (meals.len()) }
}
@if meals.is_empty() {
div class="empty-state" {
div class="empty-mark" { "🍽" }
h3 { "No meals yet" }
p { "Create a meal to reuse its ingredients across your lists." }
}
} @else {
div class="list-cards" {
@for meal in meals {
a class="list-card" href=(format!("/meals/{}", meal.id)) {
span class="list-card-icon" { "🍽" }
span class="list-card-copy" {
strong { (meal.name) }
small { (meal.ingredients.len()) " ingredients" }
}
span class="list-card-arrow" { "" }
}
}
}
}
}
}
},
)
}
pub fn meal_picker(meals: &[Meal], list_id: i64, csrf_token: &str) -> Markup {
html! {
div class="meal-picker-backdrop" onclick="if (event.target === this) this.remove()" {
div class="meal-picker-modal" role="dialog" aria-modal="true" aria-label="Add a meal" {
div class="meal-picker-header" {
div {
p class="eyebrow" { "ADD TO LIST" }
h2 { "Add a meal" }
}
button class="meal-picker-close" type="button" aria-label="Close" onclick="this.closest('.meal-picker-backdrop').remove()" { "" }
}
@if meals.is_empty() {
div class="meal-picker-empty" {
span class="empty-mark" { "🍽" }
h3 { "No meals yet" }
p { "Create a meal first, then add it to any list." }
a class="button button-primary" href="/meals/new" { "Create a meal" }
}
} @else {
div class="meal-picker-list" {
@for meal in meals {
form
hx-post=(format!("/lists/{}/add-meal", list_id))
hx-target="#list-items"
hx-swap="outerHTML"
hx-on::after-request="if (event.detail.successful) this.closest('.meal-picker-backdrop').remove()"
class="meal-picker-row"
{
input type="hidden" name="csrf" value=(csrf_token);
input type="hidden" name="meal_id" value=(meal.id);
button class="meal-picker-button" type="submit" {
span class="meal-picker-icon" { "🍽" }
span class="meal-picker-copy" {
strong { (meal.name) }
small { (meal.ingredients.len()) " ingredients" }
}
span class="meal-picker-add" { "Add" }
}
}
}
}
}
}
}
}
}
pub fn meal_form_page(
user: &User,
meal: Option<&Meal>,
csrf_token: &str,
) -> Markup {
let (title, action, name, description) = match meal {
Some(meal) => (
"Edit meal",
format!("/meals/{}/edit", meal.id),
meal.name.clone(),
meal.description.clone(),
),
None => ("New meal", "/meals".into(), String::new(), String::new()),
};
page(
title,
Some(user),
html! {
div class="page-heading" {
a class="back-link" href="/meals" { "← All meals" }
h1 { (title) }
}
section class="panel" {
form method="post" action=(action) class="stack" {
input type="hidden" name="csrf" value=(csrf_token);
label for="meal-name" { "Name" }
input id="meal-name" name="name" type="text" maxlength="120" value=(name) required;
label for="meal-description" { "Description (markdown)" }
textarea id="meal-description" name="description" rows="8" { (description) }
button class="button button-primary" type="submit" { "Save meal" }
}
}
@if meal.is_none() {
p class="muted" { "You can add ingredients after creating the meal." }
}
},
)
}
pub fn meal_page(
user: &User,
meal: &Meal,
categories: &[Category],
csrf_token: &str,
) -> Markup {
page(
&meal.name,
Some(user),
html! {
div class="page-heading" {
a class="back-link" href="/meals" { "← All meals" }
div class="list-topbar-actions" {
button type="button" class="button button-small button-quiet" onclick="document.getElementById('meal-edit-modal').showModal()" { "Edit" }
form method="post" action=(format!("/meals/{}/delete", meal.id)) {
input type="hidden" name="csrf" value=(csrf_token);
button class="danger-link" type="submit" { "Delete" }
}
}
}
dialog id="meal-edit-modal" class="item-modal" {
div class="item-modal-card" {
div class="item-modal-header" {
h3 { "Edit meal" }
button type="button" class="meal-picker-close" aria-label="Close" onclick="this.closest('dialog').close()" { "" }
}
form method="post" action=(format!("/meals/{}/edit", meal.id)) class="stack" {
input type="hidden" name="csrf" value=(csrf_token);
label { "Name" }
input name="name" value=(meal.name) maxlength="120" required;
label { "Description (markdown)" }
textarea name="description" rows="8" { (meal.description) }
button class="button button-primary" type="submit" { "Save meal" }
}
}
}
div class="list-layout" {
section class="panel list-panel" {
div class="list-heading" {
div {
p class="eyebrow" { "MEAL" }
h1 { (meal.name) }
}
}
@if meal.description.is_empty() {
p class="muted" { "No description." }
} @else {
div class="markdown" { (render_markdown(&meal.description)) }
}
h2 class="category-heading" { "Ingredients" }
@if meal.ingredients.is_empty() {
p class="muted" { "No ingredients yet." }
} @else {
div class="item-list" {
@for group in ingredient_groups(&meal.ingredients, categories) {
(ingredient_category_group(&group.0, &group.1, meal.id, categories, csrf_token))
}
}
}
}
aside class="side-column" {
section class="panel" {
div class="panel-heading" { h2 { "Add ingredient" } }
form method="post" action=(format!("/meals/{}/ingredients", meal.id)) class="stack" {
input type="hidden" name="csrf" value=(csrf_token);
label { "Name" }
input name="name" type="text" maxlength="120" required;
label { "Quantity" }
input name="quantity" type="text" maxlength="40";
label { "Note" }
input name="note" type="text" maxlength="120";
label { "Category" }
select name="category_id" {
(category_options(categories, None))
}
button class="button button-primary" type="submit" { "Add ingredient" }
}
}
}
}
},
)
}
fn ingredient_row(
ingredient: &MealIngredient,
meal_id: i64,
categories: &[Category],
csrf_token: &str,
) -> Markup {
html! {
div class="item-copy" {
@if !ingredient.quantity.is_empty() {
span class="item-qty" { "(" (ingredient.quantity) ")" }
}
strong { (ingredient.name) }
@if !ingredient.note.is_empty() {
small { (ingredient.note) }
}
}
button type="button" class="item-actions-button" aria-label="Ingredient actions" onclick=(format!("document.getElementById('ingredient-edit-{}').showModal()", ingredient.id)) { "•••" }
dialog id=(format!("ingredient-edit-{}", ingredient.id)) class="item-modal" {
div class="item-modal-card" {
div class="item-modal-header" {
h3 { (ingredient.name) }
button type="button" class="meal-picker-close" aria-label="Close" onclick="this.closest('dialog').close()" { "" }
}
form
hx-post=(format!("/meals/{}/ingredients/{}/edit", meal_id, ingredient.id))
hx-target="body"
hx-swap="outerHTML"
class="stack"
{
input type="hidden" name="csrf" value=(csrf_token);
label { "Name" }
input name="name" value=(ingredient.name) maxlength="120" required;
label { "Quantity" }
input name="quantity" value=(ingredient.quantity) maxlength="40";
label { "Note" }
input name="note" value=(ingredient.note) maxlength="120";
label { "Category" }
select name="category_id" {
(category_options(categories, ingredient.category_id))
}
button class="button button-primary" type="submit" { "Save" }
}
form method="post" action=(format!("/meals/{}/ingredients/{}/delete", meal_id, ingredient.id)) {
input type="hidden" name="csrf" value=(csrf_token);
button class="danger-link" type="submit" { "Remove" }
}
}
}
}
}
fn ingredient_groups<'a>(
ingredients: &'a [MealIngredient],
categories: &[Category],
) -> Vec<(String, Vec<&'a MealIngredient>)> {
let mut groups = Vec::new();
for category in categories {
let in_category = ingredients
.iter()
.filter(|ingredient| ingredient.category_id == Some(category.id))
.collect::<Vec<_>>();
if !in_category.is_empty() {
groups.push((category.name.clone(), in_category));
}
}
let uncategorized = ingredients
.iter()
.filter(|ingredient| ingredient.category_id.is_none())
.collect::<Vec<_>>();
if !uncategorized.is_empty() {
groups.push(("Uncategorized".into(), uncategorized));
}
groups
}
fn ingredient_category_group(
name: &str,
ingredients: &[&MealIngredient],
meal_id: i64,
categories: &[Category],
csrf_token: &str,
) -> Markup {
html! {
section class="category-group" {
h2 class="category-heading" { (name) }
ul class="ingredient-list" {
@for ingredient in ingredients {
li {
(ingredient_row(ingredient, meal_id, categories, csrf_token))
}
}
}
}
}
}
fn render_markdown(source: &str) -> Markup {
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
let parser = Parser::new_ext(source, options);
let mut buffer = String::new();
cmark_html::push_html(&mut buffer, parser);
html! {
(maud::PreEscaped(buffer))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_markdown_turns_bullets_into_list_html() {
let html = render_markdown("- one\n- two\n").into_string();
assert!(html.contains("<ul>"), "expected <ul>, got: {html}");
assert!(html.contains("<li>"), "expected <li>, got: {html}");
assert!(!html.contains("* one"), "raw bullet leaked through: {html}");
}
#[test]
fn render_markdown_renders_emphasis() {
let html = render_markdown("**bold** and *italic*").into_string();
assert!(html.contains("<strong>"), "expected <strong>, got: {html}");
assert!(html.contains("<em>"), "expected <em>, got: {html}");
}
}
pub fn list_page( pub fn list_page(
user: &User, user: &User,
list: &GroceryList, list: &GroceryList,
@@ -160,8 +504,10 @@ pub fn list_page(
a class="back-link" href="/lists" { "← All lists" } a class="back-link" href="/lists" { "← All lists" }
div class="list-topbar-actions" { div class="list-topbar-actions" {
span class="live-pill" { span class="live-dot" {} "Live" } span class="live-pill" { span class="live-dot" {} "Live" }
button class="button button-small add-meal-button" hx-get=(format!("/meals?picker={}", list.id)) hx-target="#meal-picker" hx-swap="innerHTML" { "+ Add meal" }
} }
} }
div id="meal-picker" class="meal-picker" {}
div class="list-layout" { div class="list-layout" {
section class="panel list-panel" { section class="panel list-panel" {
div class="list-heading" { div class="list-heading" {
@@ -175,7 +521,7 @@ pub fn list_page(
} }
aside class="side-column" { aside class="side-column" {
(presence_panel(presence, false)) (presence_panel(presence, false))
(categories_panel(list, categories, csrf_token, false)) (categories_panel(categories, csrf_token, false))
section class="panel tip-panel" { section class="panel tip-panel" {
span class="tip-label" { "TIP" } span class="tip-label" { "TIP" }
p { "Check items off as you go. Everyone viewing this list will see it instantly." } p { "Check items off as you go. Everyone viewing this list will see it instantly." }
@@ -332,23 +678,26 @@ fn item_row(item: &Item, categories: &[Category], csrf_token: &str) -> Markup {
} }
} }
div class="item-copy" { div class="item-copy" {
@if !item.quantity.is_empty() {
span class="item-qty" { "(" (item.quantity) ")" }
}
strong { (item.name) } strong { (item.name) }
@if !item.quantity.is_empty() || !item.note.is_empty() { @if !item.note.is_empty() {
small { small { (item.note) }
@if !item.quantity.is_empty() { (item.quantity) }
@if !item.quantity.is_empty() && !item.note.is_empty() { " · " }
@if !item.note.is_empty() { (item.note) }
}
} }
} }
details class="item-actions" { button type="button" class="item-actions-button" aria-label="Item actions" onclick=(format!("document.getElementById('item-edit-{}').showModal()", item.id)) { "•••" }
summary aria-label="Item actions" { "•••" } dialog id=(format!("item-edit-{}", item.id)) class="item-modal" {
div class="item-menu" { div class="item-modal-card" {
div class="item-modal-header" {
h3 { (item.name) }
button type="button" class="meal-picker-close" aria-label="Close" onclick="this.closest('dialog').close()" { "" }
}
form form
hx-post=(format!("/lists/{}/items/{}/edit", item.list_id, item.id)) hx-post=(format!("/lists/{}/items/{}/edit", item.list_id, item.id))
hx-target="#list-items" hx-target="#list-items"
hx-swap="outerHTML" hx-swap="outerHTML"
class="edit-form stack" class="stack"
{ {
input type="hidden" name="csrf" value=(csrf_token); input type="hidden" name="csrf" value=(csrf_token);
label { "Name" } label { "Name" }
@@ -361,7 +710,7 @@ fn item_row(item: &Item, categories: &[Category], csrf_token: &str) -> Markup {
select name="category_id" { select name="category_id" {
(category_options(categories, item.category_id)) (category_options(categories, item.category_id))
} }
button class="button button-small button-secondary" type="submit" { "Save" } button class="button button-primary" type="submit" { "Save" }
} }
form form
hx-post=(format!("/lists/{}/items/{}/delete", item.list_id, item.id)) hx-post=(format!("/lists/{}/items/{}/delete", item.list_id, item.id))
@@ -395,7 +744,6 @@ fn category_options(categories: &[Category], selected: Option<i64>) -> Markup {
} }
pub fn categories_panel( pub fn categories_panel(
list: &GroceryList,
categories: &[Category], categories: &[Category],
csrf_token: &str, csrf_token: &str,
out_of_band: bool, out_of_band: bool,
@@ -407,10 +755,10 @@ pub fn categories_panel(
} }
p { "Organize items by aisle or shopping area." } p { "Organize items by aisle or shopping area." }
form form
hx-post=(format!("/lists/{}/categories", list.id)) hx-post="/categories"
hx-target="#category-result" hx-target="#category-result"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-on::after-request="if (event.detail.successful) this.reset()" hx-on::after-request="if (event.detail.successful) window.location.reload()"
class="category-form" class="category-form"
{ {
input type="hidden" name="csrf" value=(csrf_token); input type="hidden" name="csrf" value=(csrf_token);
@@ -448,19 +796,7 @@ pub fn live_list_fragments(
) -> Markup { ) -> Markup {
html! { html! {
(list_content_fragment(list, items, categories, csrf_token, true)) (list_content_fragment(list, items, categories, csrf_token, true))
(categories_panel(list, categories, csrf_token, true)) (categories_panel(categories, csrf_token, true))
}
}
pub fn category_created(
list: &GroceryList,
items: &[Item],
categories: &[Category],
csrf_token: &str,
) -> Markup {
html! {
p class="category-success" { "Category added." }
(live_list_fragments(list, items, categories, csrf_token))
} }
} }
@@ -579,6 +915,10 @@ fn page(title: &str, user: Option<&User>, content: Markup) -> Markup {
header class="site-header" { header class="site-header" {
a class="brand" href="/lists" { span class="brand-mark" { "S" } "Sustenance" } a class="brand" href="/lists" { span class="brand-mark" { "S" } "Sustenance" }
@if let Some(user) = user { @if let Some(user) = user {
nav class="site-nav" {
a href="/lists" { "Lists" }
a href="/meals" { "Meals" }
}
div class="account-nav" { div class="account-nav" {
span class="user-name" { (user.display_name) } span class="user-name" { (user.display_name) }
form method="post" action="/logout" { form method="post" action="/logout" {
+188 -9
View File
@@ -39,6 +39,17 @@ a { color: inherit; }
.brand { display: inline-flex; align-items: center; gap: 10px; text-decoration: none; font-weight: 800; letter-spacing: -.03em; } .brand { display: inline-flex; align-items: center; gap: 10px; text-decoration: none; font-weight: 800; letter-spacing: -.03em; }
.brand-mark { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 11px 11px 11px 3px; background: var(--deep-sage); color: white; transform: rotate(-6deg); } .brand-mark { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 11px 11px 11px 3px; background: var(--deep-sage); color: white; transform: rotate(-6deg); }
.site-nav { display: flex; align-items: center; gap: 6px; }
.site-nav a {
padding: 8px 14px;
border-radius: 11px;
color: var(--muted);
text-decoration: none;
font-size: .9rem;
font-weight: 700;
transition: color .16s ease, background .16s ease;
}
.site-nav a:hover { color: var(--ink); background: #eef2ea; }
.account-nav { display: flex; align-items: center; gap: 16px; color: var(--muted); font-size: .9rem; } .account-nav { display: flex; align-items: center; gap: 16px; color: var(--muted); font-size: .9rem; }
.user-name { color: var(--ink); font-weight: 700; } .user-name { color: var(--ink); font-weight: 700; }
.text-button { border: 0; padding: 0; color: var(--deep-sage); background: transparent; cursor: pointer; font-weight: 700; } .text-button { border: 0; padding: 0; color: var(--deep-sage); background: transparent; cursor: pointer; font-weight: 700; }
@@ -63,6 +74,8 @@ h3 { margin-bottom: 6px; font-size: 1rem; }
.stack label { color: var(--muted); font-size: .82rem; font-weight: 700; } .stack label { color: var(--muted); font-size: .82rem; font-weight: 700; }
input { width: 100%; min-height: 46px; padding: 10px 13px; border: 1px solid var(--line); border-radius: 12px; outline: none; color: var(--ink); background: #fff; } input { width: 100%; min-height: 46px; padding: 10px 13px; border: 1px solid var(--line); border-radius: 12px; outline: none; color: var(--ink); background: #fff; }
input:focus { border-color: var(--deep-sage); box-shadow: 0 0 0 4px rgba(85, 113, 93, .12); } input:focus { border-color: var(--deep-sage); box-shadow: 0 0 0 4px rgba(85, 113, 93, .12); }
textarea { width: 100%; padding: 10px 13px; border: 1px solid var(--line); border-radius: 12px; outline: none; color: var(--ink); background: #fff; font: inherit; resize: vertical; }
textarea:focus { border-color: var(--deep-sage); box-shadow: 0 0 0 4px rgba(85, 113, 93, .12); }
.button { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 10px 17px; border: 0; border-radius: 12px; cursor: pointer; text-decoration: none; font-weight: 800; transition: transform .16s ease, box-shadow .16s ease, background .16s ease; } .button { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 10px 17px; border: 0; border-radius: 12px; cursor: pointer; text-decoration: none; font-weight: 800; transition: transform .16s ease, box-shadow .16s ease, background .16s ease; }
.button:hover { transform: translateY(-1px); } .button:hover { transform: translateY(-1px); }
.button-primary { color: #fff; background: var(--deep-sage); box-shadow: 0 8px 18px rgba(85, 113, 93, .2); } .button-primary { color: #fff; background: var(--deep-sage); box-shadow: 0 8px 18px rgba(85, 113, 93, .2); }
@@ -94,6 +107,13 @@ input:focus { border-color: var(--deep-sage); box-shadow: 0 0 0 4px rgba(85, 113
.list-topbar-actions { display: flex; align-items: center; gap: 12px; } .list-topbar-actions { display: flex; align-items: center; gap: 12px; }
.live-pill { display: inline-flex; align-items: center; gap: 7px; color: var(--deep-sage); font-size: .78rem; font-weight: 800; } .live-pill { display: inline-flex; align-items: center; gap: 7px; color: var(--deep-sage); font-size: .78rem; font-weight: 800; }
.live-dot { width: 8px; height: 8px; border-radius: 50%; background: #75ae6e; box-shadow: 0 0 0 4px rgba(117, 174, 110, .15); } .live-dot { width: 8px; height: 8px; border-radius: 50%; background: #75ae6e; box-shadow: 0 0 0 4px rgba(117, 174, 110, .15); }
.add-meal-button {
color: #fff;
background: var(--deep-sage);
box-shadow: 0 8px 18px rgba(85, 113, 93, .25);
}
.add-meal-button:hover { transform: translateY(-2px); box-shadow: 0 12px 24px rgba(85, 113, 93, .32); }
.add-meal-button:active { transform: translateY(0); }
.list-layout { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(265px, .72fr); gap: 22px; align-items: start; } .list-layout { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(265px, .72fr); gap: 22px; align-items: start; }
.list-panel { min-width: 0; } .list-panel { min-width: 0; }
.list-heading { display: flex; justify-content: space-between; margin-bottom: 25px; } .list-heading { display: flex; justify-content: space-between; margin-bottom: 25px; }
@@ -112,14 +132,71 @@ input:focus { border-color: var(--deep-sage); box-shadow: 0 0 0 4px rgba(85, 113
.check-form { flex: 0 0 auto; } .check-form { flex: 0 0 auto; }
.check-button { display: grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 2px solid #c8d6c1; border-radius: 9px; color: #fff; background: transparent; cursor: pointer; font-size: .88rem; font-weight: 900; } .check-button { display: grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 2px solid #c8d6c1; border-radius: 9px; color: #fff; background: transparent; cursor: pointer; font-size: .88rem; font-weight: 900; }
.is-checked .check-button { border-color: var(--deep-sage); background: var(--deep-sage); } .is-checked .check-button { border-color: var(--deep-sage); background: var(--deep-sage); }
.item-copy { display: grid; flex: 1; min-width: 0; gap: 2px; } .item-copy { display: grid; grid-template-columns: auto 1fr; flex: 1; min-width: 0; gap: 2px 7px; align-items: baseline; }
.item-copy strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .item-copy strong { grid-column: 2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.item-copy small { overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; font-size: .78rem; } .item-qty { grid-column: 1; grid-row: 1; color: var(--muted); font-weight: 700; white-space: nowrap; }
.item-copy small { grid-column: 1 / -1; overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; font-size: .78rem; }
.is-checked .item-copy strong { color: var(--muted); text-decoration: line-through; } .is-checked .item-copy strong { color: var(--muted); text-decoration: line-through; }
.item-actions { position: relative; } .item-actions-button {
.item-actions summary { padding: 7px 5px; color: var(--muted); cursor: pointer; list-style: none; font-size: .78rem; letter-spacing: 2px; } flex: 0 0 auto;
.item-actions summary::-webkit-details-marker { display: none; } padding: 7px 8px;
.item-menu { position: absolute; z-index: 2; right: 0; width: min(265px, 80vw); padding: 14px; border: 1px solid var(--line); border-radius: 15px; background: var(--card); box-shadow: var(--shadow); } border: 0;
border-radius: 9px;
color: var(--muted);
background: transparent;
cursor: pointer;
font-size: .9rem;
letter-spacing: 2px;
line-height: 1;
}
.item-actions-button:hover { color: var(--ink); background: #f0f3ea; }
/* Rendered markdown (meal descriptions) */
.markdown { line-height: 1.6; color: var(--ink); }
.markdown p { margin: 0 0 12px; }
.markdown ul, .markdown ol { margin: 0 0 12px; padding-left: 22px; }
.markdown li { margin-bottom: 4px; }
.markdown h1, .markdown h2, .markdown h3, .markdown h4 { margin: 18px 0 8px; letter-spacing: -.02em; }
.markdown h1 { font-size: 1.5rem; }
.markdown h2 { font-size: 1.25rem; }
.markdown h3 { font-size: 1.1rem; }
.markdown code { padding: 2px 5px; border-radius: 6px; background: #eef2ea; font-size: .9em; }
.markdown pre { padding: 12px; border-radius: 12px; background: #eef2ea; overflow-x: auto; }
.markdown pre code { padding: 0; background: transparent; }
.markdown blockquote { margin: 0 0 12px; padding-left: 14px; border-left: 3px solid var(--sage); color: var(--muted); }
.markdown a { color: var(--deep-sage); text-decoration: underline; }
/* Meal ingredient list */
.ingredient-list { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }
.ingredient-list li {
display: flex;
align-items: center;
gap: 12px;
min-height: 52px;
padding: 8px 6px 8px 4px;
border-bottom: 1px solid #edf0e6;
}
.ingredient-list li:last-child { border-bottom: 0; }
/* Item / ingredient edit modal */
.item-modal {
width: min(100%, 420px);
padding: 0;
border: 1px solid rgba(221, 225, 210, .9);
border-radius: 24px;
background: rgba(255, 253, 248, .98);
box-shadow: var(--shadow);
}
.item-modal::backdrop {
background: rgba(37, 53, 46, .28);
}
.item-modal-card { padding: 22px 24px 24px; }
.item-modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.item-modal-header h3 { margin: 0; font-size: 1.15rem; letter-spacing: -.02em; }
.item-modal .stack { margin-bottom: 14px; }
.edit-form { margin-bottom: 12px; } .edit-form { margin-bottom: 12px; }
.edit-form input { min-height: 38px; padding: 7px 10px; font-size: .85rem; } .edit-form input { min-height: 38px; padding: 7px 10px; font-size: .85rem; }
.danger-link { padding: 0; border: 0; color: var(--coral); background: none; cursor: pointer; font-size: .8rem; font-weight: 800; } .danger-link { padding: 0; border: 0; color: var(--coral); background: none; cursor: pointer; font-size: .8rem; font-weight: 800; }
@@ -153,9 +230,109 @@ input:focus { border-color: var(--deep-sage); box-shadow: 0 0 0 4px rgba(85, 113
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
/* Meal picker modal */
.meal-picker-backdrop {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
place-items: center;
padding: 20px;
background: rgba(37, 53, 46, .28);
animation: meal-picker-fade .15s ease;
}
@keyframes meal-picker-fade { from { opacity: 0; } to { opacity: 1; } }
.meal-picker-modal {
width: min(100%, 460px);
max-height: min(78vh, 620px);
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid rgba(221, 225, 210, .9);
border-radius: 24px;
background: rgba(255, 253, 248, .98);
box-shadow: var(--shadow);
animation: meal-picker-pop .18s ease;
}
@keyframes meal-picker-pop { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
.meal-picker-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 22px 24px 16px;
border-bottom: 1px solid #edf0e6;
}
.meal-picker-header .eyebrow { margin-bottom: 5px; }
.meal-picker-header h2 { margin-bottom: 0; font-size: 1.25rem; letter-spacing: -.02em; }
.meal-picker-close {
display: grid;
place-items: center;
flex: 0 0 auto;
width: 34px;
height: 34px;
padding: 0;
border: 1px solid var(--line);
border-radius: 11px;
color: var(--muted);
background: #fff;
cursor: pointer;
font-size: .9rem;
transition: color .16s ease, border-color .16s ease;
}
.meal-picker-close:hover { color: var(--ink); border-color: var(--sage); }
.meal-picker-list {
display: grid;
gap: 8px;
padding: 16px 24px 22px;
overflow-y: auto;
}
.meal-picker-row { margin: 0; }
.meal-picker-button {
display: flex;
align-items: center;
gap: 13px;
width: 100%;
padding: 12px 13px;
border: 1px solid var(--line);
border-radius: 16px;
color: var(--ink);
background: #fff;
cursor: pointer;
text-align: left;
transition: border-color .16s ease, transform .16s ease;
}
.meal-picker-button:hover { border-color: var(--sage); transform: translateX(2px); }
.meal-picker-icon {
display: grid;
place-items: center;
flex: 0 0 auto;
width: 38px;
height: 38px;
border-radius: 13px;
color: var(--deep-sage);
background: #eef4e9;
font-size: 1.1rem;
}
.meal-picker-copy { display: grid; flex: 1; min-width: 0; gap: 2px; }
.meal-picker-copy strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .95rem; }
.meal-picker-copy small { color: var(--muted); font-size: .76rem; }
.meal-picker-add {
flex: 0 0 auto;
padding: 6px 12px;
border-radius: 99px;
color: var(--deep-sage);
background: #e7f0e1;
font-size: .74rem;
font-weight: 800;
}
.meal-picker-empty { padding: 34px 22px 30px; text-align: center; color: var(--muted); }
.meal-picker-empty h3 { color: var(--ink); }
.meal-picker-empty p { margin-bottom: 18px; }
@media (max-width: 780px) { @media (max-width: 780px) {
.site-header, .site-main, .site-footer { width: min(100% - 28px, 600px); } .site-header, .site-main, .site-footer { width: min(100% - 28px, 600px); }
.site-header { padding: 20px 0; } .site-header { padding: 18px 0; }
.site-main { margin-top: 20px; } .site-main { margin-top: 20px; }
.dashboard-grid, .list-layout { grid-template-columns: 1fr; } .dashboard-grid, .list-layout { grid-template-columns: 1fr; }
.side-column { grid-template-columns: repeat(2, minmax(0, 1fr)); } .side-column { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -163,12 +340,14 @@ input:focus { border-color: var(--deep-sage); box-shadow: 0 0 0 4px rgba(85, 113
} }
@media (max-width: 500px) { @media (max-width: 500px) {
.site-header { flex-wrap: wrap; gap: 12px 16px; }
.site-nav { order: 3; width: 100%; justify-content: center; gap: 8px; }
.site-nav a { flex: 1; text-align: center; padding: 10px 8px; }
.account-nav { gap: 9px; } .account-nav { gap: 9px; }
.user-name { max-width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .user-name { max-width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.panel { padding: 20px 16px; border-radius: 20px; } .panel { padding: 20px 16px; border-radius: 20px; }
.page-heading { margin-bottom: 24px; } .page-heading { margin-bottom: 24px; }
.list-topbar { margin-bottom: 20px; } .list-topbar { margin-bottom: 20px; }
.list-topbar-actions .button { display: none; }
.list-heading h1 { font-size: clamp(1.45rem, 7vw, 1.75rem); } .list-heading h1 { font-size: clamp(1.45rem, 7vw, 1.75rem); }
.add-item-form { grid-template-columns: minmax(0, 1fr) 75px; } .add-item-form { grid-template-columns: minmax(0, 1fr) 75px; }
.add-button { grid-column: 1 / -1; } .add-button { grid-column: 1 / -1; }