diff --git a/e2e/tests/add-meal.spec.ts b/e2e/tests/add-meal.spec.ts index 05bcba3..53478ab 100644 --- a/e2e/tests/add-meal.spec.ts +++ b/e2e/tests/add-meal.spec.ts @@ -96,3 +96,31 @@ test("removing a meal from a list removes its ingredients", async ({ page }) => await expect(page.locator(".item-row").filter({ hasText: "Tomato" })).toHaveCount(0); await expect(row).toHaveCount(0); }); + +test("a meal can be checked off without removing it from the list", async ({ page }) => { + await registerAndLogin(page, "alice@example.com"); + await createMealWithIngredients(page, "Spaghetti Bolognese", [{ name: "Penne" }]); + await createList(page, "Weekly shop"); + + await page.click(".add-meal-button"); + const picker = page.locator(".meal-picker-backdrop"); + await picker.locator(".meal-picker-button").filter({ hasText: "Spaghetti Bolognese" }).click(); + + const panel = page.locator("#list-meals-panel"); + const row = panel.locator(".list-meal-row").filter({ hasText: "Spaghetti Bolognese" }); + await expect(row).toBeVisible(); + await expect(row.locator(".list-meal-check-button")).not.toHaveClass(/is-.*checked/); + + // Check the meal off as eaten. + await row.locator(".list-meal-check-button").click(); + await expect(row).toHaveClass(/is-checked/); + await expect(row.locator(".list-meal-check-button")).toHaveText("✓"); + // The meal is still on the list, not removed. + await expect(row).toBeVisible(); + await expect(page.locator(".item-row").filter({ hasText: "Penne" })).toBeVisible(); + + // Unchecking restores it. + await row.locator(".list-meal-check-button").click(); + await expect(row).not.toHaveClass(/is-checked/); + await expect(row).toBeVisible(); +}); diff --git a/migrations/20240106000000_checkable_list_meals.sql b/migrations/20240106000000_checkable_list_meals.sql new file mode 100644 index 0000000..b10200a --- /dev/null +++ b/migrations/20240106000000_checkable_list_meals.sql @@ -0,0 +1 @@ +ALTER TABLE list_meals ADD COLUMN checked INTEGER NOT NULL DEFAULT 0; diff --git a/src/domain.rs b/src/domain.rs index 4096c9e..26cf6fd 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -103,6 +103,8 @@ pub struct ListMeal { #[allow(dead_code)] pub meal_id: Option, pub name: String, + /// Whether the meal has been eaten (checked off) on this list. + pub checked: bool, /// When the meal was added to the list. #[allow(dead_code)] pub created_at: i64, diff --git a/src/http.rs b/src/http.rs index 6bd6bae..188a7ae 100644 --- a/src/http.rs +++ b/src/http.rs @@ -133,6 +133,10 @@ pub fn build_router(state: AppState) -> Router { "/lists/{list_id}/meals/{list_meal_id}/remove", post(remove_meal_from_list), ) + .route( + "/lists/{list_id}/meals/{list_meal_id}/check", + post(check_list_meal), + ) .route( "/lists/{list_id}/carry", get(carry_over_modal).post(carry_meals), @@ -1177,6 +1181,27 @@ async fn remove_meal_from_list( list_fragment_response(&state, &user, list_id).await } +/// Marks a meal instance on a list as eaten (or not) without removing it. +async fn check_list_meal( + State(state): State, + user: CurrentUser, + Path((list_id, list_meal_id)): Path<(i64, i64)>, + LoggedForm(form): LoggedForm, +) -> Result { + verify_csrf(&user, &form.csrf)?; + require_mutable_list(&state, list_id).await?; + let checked = match form.checked.as_str() { + "1" | "true" => true, + "0" | "false" => false, + _ => return Err(AppError::BadRequest("Invalid checked value.".into())), + }; + state + .meals + .set_list_meal_checked(list_id, list_meal_id, checked) + .await?; + list_fragment_response(&state, &user, list_id).await +} + /// Renders the carry-over modal: a picker of active source lists (newest first, /// excluding the current list) that the user can import meals from. async fn carry_over_modal( diff --git a/src/ports.rs b/src/ports.rs index 75f2d76..40f56f6 100644 --- a/src/ports.rs +++ b/src/ports.rs @@ -195,6 +195,16 @@ pub trait ListMealRepository: Send + Sync { list_id: i64, list_meal_id: i64, ) -> DomainResult; + /// Marks a meal instance as eaten (or not) on a list, bumping the list's + /// revision exactly once. Like items, the meal stays in the list so it can + /// be toggled back. + async fn set_list_meal_checked( + &self, + txn: &mut SqliteConnection, + list_id: i64, + list_meal_id: i64, + checked: bool, + ) -> DomainResult; /// Copies the given meal instances from one list to another without /// expanding their ingredients into items (they were already purchased). /// Returns the new rows and the destination list's bumped revision. diff --git a/src/services.rs b/src/services.rs index ec09353..9ffd72a 100644 --- a/src/services.rs +++ b/src/services.rs @@ -582,6 +582,29 @@ impl MealService { Ok(revision) } + /// Marks a meal instance on a list as eaten (or not), keeping it in the list + /// and bumping the list revision exactly once. + pub async fn set_list_meal_checked( + &self, + list_id: i64, + list_meal_id: i64, + checked: bool, + ) -> DomainResult { + let list_meals = Arc::clone(&self.list_meals); + let revision = self + .db + .run(move |txn| { + Box::pin(async move { + list_meals + .set_list_meal_checked(txn, list_id, list_meal_id, checked) + .await + }) + }) + .await?; + self.realtime.publish_list_changed(list_id, revision).await; + Ok(revision) + } + /// Copies the given meal instances from a source list into the destination /// list without re-expanding their ingredients into items (they were already /// purchased). The source list is left untouched. Publishes a realtime update diff --git a/src/sqlite.rs b/src/sqlite.rs index 6bb6fd2..3d253de 100644 --- a/src/sqlite.rs +++ b/src/sqlite.rs @@ -822,7 +822,7 @@ impl ListMealRepository for SqliteListMealRepository { list_id: i64, ) -> DomainResult> { let rows = sqlx::query( - "SELECT id, meal_id, name, created_at + "SELECT id, meal_id, name, checked, created_at FROM list_meals WHERE list_id = ?1 ORDER BY created_at ASC, id ASC", @@ -837,7 +837,8 @@ impl ListMealRepository for SqliteListMealRepository { id: row.get(0), meal_id: row.get(1), name: row.get(2), - created_at: row.get(3), + checked: row.get::(3) != 0, + created_at: row.get(4), }) .collect()) } @@ -887,6 +888,31 @@ impl ListMealRepository for SqliteListMealRepository { bump_revision(txn, list_id).await } + async fn set_list_meal_checked( + &self, + txn: &mut SqliteConnection, + list_id: i64, + list_meal_id: i64, + checked: bool, + ) -> DomainResult { + let changed = sqlx::query( + "UPDATE list_meals + SET checked = ?1 + WHERE id = ?2 AND list_id = ?3", + ) + .bind(checked as i64) + .bind(list_meal_id) + .bind(list_id) + .execute(&mut *txn) + .await + .map_err(db_error)? + .rows_affected(); + if changed == 0 { + return Err(DomainError::NotFound); + } + bump_revision(txn, list_id).await + } + async fn copy_meals_to_list( &self, txn: &mut SqliteConnection, @@ -915,6 +941,7 @@ impl ListMealRepository for SqliteListMealRepository { id, meal_id, name, + checked: false, created_at: now(), }); } @@ -2784,6 +2811,7 @@ mod tests { id, meal_id: Some(meal.id), name: meal.name.clone(), + checked: false, created_at: 0, } } @@ -2879,6 +2907,79 @@ mod tests { assert!(matches!(result, Err(DomainError::NotFound))); } + #[tokio::test] + async fn list_meal_checked_state_is_set_not_toggled_and_bumps_revision() { + let db = setup().await; + let list = create_list(&db, "Weekly shop").await; + let meal = create_meal(&db, "Pasta").await; + let list_meal = add_meal_to_list(&db, list.id, &meal).await; + + let list_meals = SqliteListMealRepository; + let revision = db + .run(move |txn| { + let list_meals = list_meals; + Box::pin(async move { + list_meals + .set_list_meal_checked(txn, list.id, list_meal.id, true) + .await + }) + }) + .await + .unwrap(); + assert_eq!(revision, 1); + + // The meal is flagged as eaten but remains in the list. + let meals = db + .run(move |txn| { + let list_meals = list_meals; + Box::pin(async move { list_meals.list_meals(txn, list.id).await }) + }) + .await + .unwrap(); + assert_eq!(meals.len(), 1); + assert!(meals[0].checked); + + // Unchecking sets the state back without removing the meal. + let revision = db + .run(move |txn| { + let list_meals = list_meals; + Box::pin(async move { + list_meals + .set_list_meal_checked(txn, list.id, list_meal.id, false) + .await + }) + }) + .await + .unwrap(); + assert_eq!(revision, 2); + let meals = db + .run(move |txn| { + let list_meals = list_meals; + Box::pin(async move { list_meals.list_meals(txn, list.id).await }) + }) + .await + .unwrap(); + assert!(!meals[0].checked); + } + + #[tokio::test] + async fn checking_an_unknown_list_meal_fails() { + let db = setup().await; + let list = create_list(&db, "Weekly shop").await; + let list_meals = SqliteListMealRepository; + let result = db + .run(move |txn| { + let list_meals = list_meals; + Box::pin(async move { + list_meals + .set_list_meal_checked(txn, list.id, 9999, true) + .await + }) + }) + .await; + assert!(matches!(result, Err(DomainError::NotFound))); + } + #[tokio::test] async fn copying_meals_to_another_list_does_not_add_items() { let db = setup().await; diff --git a/src/views.rs b/src/views.rs index 9c471df..fda7e85 100644 --- a/src/views.rs +++ b/src/views.rs @@ -1178,7 +1178,26 @@ pub fn list_meals_panel( } @else { div class="list-meals" { @for meal in list_meals { - div class="list-meal-row" { + div + class=(if meal.checked { "list-meal-row is-checked" } else { "list-meal-row" }) + id=(format!("list-meal-{}", meal.id)) + { + @if editable { + form + class="list-meal-check" + hx-post=(format!("/lists/{}/meals/{}/check", list_id, meal.id)) + hx-target="#list-items" + hx-swap="morph:outerHTML" + { + input type="hidden" name="csrf" value=(csrf_token); + input type="hidden" name="checked" value=(if meal.checked { "0" } else { "1" }); + button type="submit" class="check-button list-meal-check-button" aria-label=(if meal.checked { format!("Mark {} as not eaten", meal.name) } else { format!("Mark {} as eaten", meal.name) }) { + @if meal.checked { "✓" } @else { "" } + } + } + } @else if meal.checked { + span class="check-button check-button-static list-meal-check-button" { "✓" } + } span class="list-meal-icon" { "🍽" } span class="list-meal-name" { (meal.name) } @if editable { diff --git a/static/style.css b/static/style.css index 00866f6..19ae7c8 100644 --- a/static/style.css +++ b/static/style.css @@ -259,7 +259,11 @@ textarea:focus { border-color: var(--deep-sage); box-shadow: 0 0 0 4px rgba(85, .list-meals { display: grid; gap: 8px; } .list-meal-row { display: flex; align-items: center; gap: 10px; padding: 6px 4px; border-bottom: 1px solid #edf0e6; } .list-meal-row:last-child { border-bottom: 0; } +.list-meal-check { margin: 0; flex: 0 0 auto; } +.list-meal-check .check-button { width: 26px; height: 26px; } +.is-checked .list-meal-name { color: var(--muted); text-decoration: line-through; } .list-meal-icon { display: grid; place-items: center; flex: 0 0 auto; width: 30px; height: 30px; border-radius: 10px; color: var(--deep-sage); background: #eef4e9; font-size: .95rem; } +.is-checked .list-meal-icon { filter: grayscale(.4); opacity: .7; } .list-meal-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .9rem; font-weight: 700; } .list-meal-remove { margin: 0; flex: 0 0 auto; } .list-meal-remove-button { padding: 2px 7px; border: 0; border-radius: 7px; color: var(--muted); background: transparent; cursor: pointer; font-size: .8rem; line-height: 1; }