3 Commits
Author SHA1 Message Date
sbstp de46f150f8 version 0.8.0 [skip ci]
ci/woodpecker/tag/release Pipeline was successful
2026-08-08 22:09:28 -04:00
sbstp 77aac1e6ba add meal carry over feature
ci/woodpecker/push/fmt Pipeline was successful
ci/woodpecker/push/test Pipeline was successful
2026-08-08 21:47:26 -04:00
sbstp 950add40a3 tag cmd should also push [skip ci] 2026-08-08 16:46:48 -04:00
10 changed files with 519 additions and 8 deletions
Generated
+1 -1
View File
@@ -1778,7 +1778,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "sustenance"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"argon2",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "sustenance"
version = "0.7.0"
version = "0.8.0"
edition = "2024"
[dependencies]
+134
View File
@@ -0,0 +1,134 @@
import { expect } from "@playwright/test";
import { test } from "../fixtures";
import { registerAndLogin, createList, createMealWithIngredients } from "../helpers";
/**
* Opens the carry-over modal and waits for htmx to finish swapping it in, so
* the source `<select>`'s `change` trigger is bound before interacting with it.
*/
async function openCarryModal(page: import("@playwright/test").Page) {
await page.click(".carry-over-button");
const carry = page.locator(".meal-picker-backdrop");
await expect(carry).toBeVisible();
// Let htmx finish processing the freshly-swapped modal before selecting a
// source; otherwise the change event can be missed and no hx-get fires.
await page.waitForTimeout(300);
return carry;
}
test("a user can carry meals over from a previous list without re-adding ingredients", async ({
page,
}) => {
await registerAndLogin(page, "alice@example.com");
await createMealWithIngredients(page, "Spaghetti Bolognese", [
{ name: "Penne", quantity: "500g" },
{ name: "Tomato", quantity: "2" },
]);
// Last week's list has the meal added (with its ingredients as items).
await createList(page, "Last week");
await page.click(".add-meal-button");
const picker = page.locator(".meal-picker-backdrop");
await picker
.locator(".meal-picker-button")
.filter({ hasText: "Spaghetti Bolognese" })
.click();
await expect(page.locator(".item-row").filter({ hasText: "Penne" })).toBeVisible();
// Create this week's (new) list.
await createList(page, "This week");
await expect(page.locator(".item-row")).toHaveCount(0);
// Open the carry-over modal and pick the source list.
const carry = await openCarryModal(page);
await expect(carry.locator(".carry-intro")).toContainText("already purchased");
await carry.locator("#carry-source").selectOption({ label: "Last week" });
// The source list's meals appear as selectable rows.
const row = carry.locator(".carry-row").filter({ hasText: "Spaghetti Bolognese" });
await expect(row).toBeVisible();
// Select the meal and finish.
await row.locator('input[type="checkbox"]').check();
await carry.locator(".carry-submit").click();
// The modal closes, the meal appears in the meals panel, but no items are added.
await expect(carry).toHaveCount(0);
const panel = page.locator("#list-meals-panel");
await expect(panel.locator(".list-meal-row").filter({ hasText: "Spaghetti Bolognese" })).toBeVisible();
await expect(page.locator(".item-row")).toHaveCount(0);
});
test("the carry-over modal lists active lists newest first and excludes the current list", async ({
page,
}) => {
await registerAndLogin(page, "alice@example.com");
await createList(page, "First list");
await createList(page, "Second list");
// Open carry-over on the second (current) list.
await page.click(".carry-over-button");
const carry = page.locator(".meal-picker-backdrop");
await expect(carry).toBeVisible();
const options = carry.locator("#carry-source option");
// The current list is excluded; only the other list remains.
await expect(options).toHaveCount(2); // placeholder + one source
await expect(carry.locator("#carry-source")).toContainText("First list");
await expect(carry.locator("#carry-source")).not.toContainText("Second list");
});
test("carrying a meal does not remove it from the source list", async ({ page }) => {
await registerAndLogin(page, "alice@example.com");
await createMealWithIngredients(page, "Pasta", [{ name: "Penne" }]);
await createList(page, "Last week");
await page.click(".add-meal-button");
await page
.locator(".meal-picker-backdrop .meal-picker-button")
.filter({ hasText: "Pasta" })
.click();
await expect(page.locator(".item-row").filter({ hasText: "Penne" })).toBeVisible();
await createList(page, "This week");
const carry = await openCarryModal(page);
// Select the source list; the meal rows load via hx-get.
await carry.locator("#carry-source").selectOption({ label: "Last week" });
const row = carry.locator(".carry-row").filter({ hasText: "Pasta" });
await expect(row).toBeVisible({ timeout: 10_000 });
await row.locator('input[type="checkbox"]').check();
await carry.locator(".carry-submit").click();
await expect(carry).toHaveCount(0);
// The source list still has its meal and items.
await page.goto("/lists");
await page.locator(".list-card").filter({ hasText: "Last week" }).click();
await expect(page.locator("#list-meals-panel .list-meal-row").filter({ hasText: "Pasta" })).toBeVisible();
await expect(page.locator(".item-row").filter({ hasText: "Penne" })).toBeVisible();
});
test("submitting carry-over with no selection does not add a meal", async ({ page }) => {
await registerAndLogin(page, "alice@example.com");
await createMealWithIngredients(page, "Pasta", [{ name: "Penne" }]);
await createList(page, "Last week");
await page.click(".add-meal-button");
await page
.locator(".meal-picker-backdrop .meal-picker-button")
.filter({ hasText: "Pasta" })
.click();
await expect(page.locator(".item-row").filter({ hasText: "Penne" })).toBeVisible();
await createList(page, "This week");
const carry = await openCarryModal(page);
// Select the source list; the meal rows load via hx-get.
await carry.locator("#carry-source").selectOption({ label: "Last week" });
await expect(carry.locator(".carry-row").filter({ hasText: "Pasta" })).toBeVisible({
timeout: 10_000,
});
// Submit with nothing selected; the modal stays open and no meal is carried.
await carry.locator(".carry-submit").click();
await expect(carry).toBeVisible();
await expect(page.locator("#list-meals-panel .list-meal-row")).toHaveCount(0);
});
+2
View File
@@ -20,6 +20,8 @@ tag ver:
git add Cargo.toml Cargo.lock
git commit -m "version {{ver}} [skip ci]"
git tag "{{ver}}"
git push
git push --tags
# Run the Rust unit tests.
# Usage: just test
+101
View File
@@ -133,6 +133,11 @@ pub fn build_router(state: AppState) -> Router {
"/lists/{list_id}/meals/{list_meal_id}/remove",
post(remove_meal_from_list),
)
.route(
"/lists/{list_id}/carry",
get(carry_over_modal).post(carry_meals),
)
.route("/lists/{list_id}/carry/meals", get(carry_source_meals))
.route("/rewards", get(rewards_page).post(create_rewards_card))
.route("/rewards/{card_id}", get(rewards_scan_page))
.route("/rewards/{card_id}/delete", post(delete_rewards_card))
@@ -338,6 +343,21 @@ struct AddMealForm {
csrf: String,
}
#[derive(Debug, Deserialize)]
struct CarrySourceQuery {
source: i64,
}
#[derive(Debug, Deserialize)]
struct CarryMealsForm {
source_list_id: i64,
/// Selected list_meal ids as a comma-separated string, since the form
/// extractor does not coalesce repeated keys into a Vec.
#[serde(default)]
list_meal_ids: String,
csrf: String,
}
#[derive(Debug, Deserialize)]
struct MealPickerQuery {
picker: Option<i64>,
@@ -1157,6 +1177,87 @@ async fn remove_meal_from_list(
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(
State(state): State<AppState>,
_user: CurrentUser,
Path(dest_list_id): Path<i64>,
) -> Result<Response, AppError> {
require_mutable_list(&state, dest_list_id).await?;
let lists = state.lists.list_summaries().await?;
let sources: Vec<_> = lists
.into_iter()
.filter(|list| list.id != dest_list_id)
.collect();
Ok(html_response(views::carry_over_modal(
&sources,
dest_list_id,
)))
}
/// Renders the selectable meal rows for a chosen source list inside the carry
/// modal.
async fn carry_source_meals(
State(state): State<AppState>,
user: CurrentUser,
Path(dest_list_id): Path<i64>,
Query(query): Query<CarrySourceQuery>,
) -> Result<Response, AppError> {
require_mutable_list(&state, dest_list_id).await?;
let source = require_list(&state, query.source).await?;
if source.id == dest_list_id {
return Err(AppError::BadRequest(
"Cannot carry meals from a list into itself.".into(),
));
}
let list_meals = state.meals.list_meals_on_list(source.id).await?;
Ok(html_response(views::carry_source_meals(
&list_meals,
source.id,
dest_list_id,
&user.session.csrf_token,
)))
}
/// Copies the selected meals from a source list into the current list without
/// re-adding their ingredients.
async fn carry_meals(
State(state): State<AppState>,
user: CurrentUser,
Path(dest_list_id): Path<i64>,
LoggedForm(form): LoggedForm<CarryMealsForm>,
) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?;
require_mutable_list(&state, dest_list_id).await?;
let source = require_list(&state, form.source_list_id).await?;
if source.id == dest_list_id {
return Err(AppError::BadRequest(
"Cannot carry meals from a list into itself.".into(),
));
}
if form.list_meal_ids.is_empty() {
return Err(AppError::BadRequest(
"Select at least one meal to carry over.".into(),
));
}
let list_meal_ids: Vec<i64> = form
.list_meal_ids
.split(',')
.filter_map(|id| id.trim().parse().ok())
.collect();
if list_meal_ids.is_empty() {
return Err(AppError::BadRequest(
"Select at least one meal to carry over.".into(),
));
}
state
.meals
.carry_meals_to_list(source.id, dest_list_id, &list_meal_ids)
.await?;
list_fragment_response(&state, &user, dest_list_id).await
}
async fn create_invitation(
State(state): State<AppState>,
user: CurrentUser,
+11 -1
View File
@@ -182,7 +182,7 @@ pub trait ListMealRepository: Send + Sync {
&self,
txn: &mut SqliteConnection,
list_id: i64,
meal_id: i64,
meal_id: Option<i64>,
name: String,
) -> DomainResult<i64>;
async fn remove_meal(
@@ -191,6 +191,16 @@ pub trait ListMealRepository: Send + Sync {
list_id: i64,
list_meal_id: i64,
) -> DomainResult<i64>;
/// 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.
async fn copy_meals_to_list(
&self,
txn: &mut SqliteConnection,
source_list_id: i64,
dest_list_id: i64,
list_meal_ids: &[i64],
) -> DomainResult<(Vec<ListMeal>, i64)>;
}
#[async_trait]
+29 -1
View File
@@ -532,7 +532,7 @@ impl MealService {
return Err(DomainError::NotFound);
}
let list_meal_id = list_meals
.add_meal(txn, list_id, meal.id, meal.name.clone())
.add_meal(txn, list_id, Some(meal.id), meal.name.clone())
.await?;
let new_items = meal
.ingredients
@@ -578,6 +578,34 @@ impl MealService {
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
/// for the destination only.
pub async fn carry_meals_to_list(
&self,
source_list_id: i64,
dest_list_id: i64,
list_meal_ids: &[i64],
) -> DomainResult<Vec<ListMeal>> {
let list_meals = Arc::clone(&self.list_meals);
let ids = list_meal_ids.to_vec();
let (copied, revision) = self
.db
.run(move |txn| {
Box::pin(async move {
list_meals
.copy_meals_to_list(txn, source_list_id, dest_list_id, &ids)
.await
})
})
.await?;
self.realtime
.publish_list_changed(dest_list_id, revision)
.await;
Ok(copied)
}
}
pub struct InvitationService {
+133 -2
View File
@@ -846,7 +846,7 @@ impl ListMealRepository for SqliteListMealRepository {
&self,
txn: &mut SqliteConnection,
list_id: i64,
meal_id: i64,
meal_id: Option<i64>,
name: String,
) -> DomainResult<i64> {
sqlx::query(
@@ -886,6 +886,41 @@ impl ListMealRepository for SqliteListMealRepository {
}
bump_revision(txn, list_id).await
}
async fn copy_meals_to_list(
&self,
txn: &mut SqliteConnection,
source_list_id: i64,
dest_list_id: i64,
list_meal_ids: &[i64],
) -> DomainResult<(Vec<ListMeal>, i64)> {
let mut copied = Vec::with_capacity(list_meal_ids.len());
for list_meal_id in list_meal_ids {
let row =
sqlx::query("SELECT meal_id, name FROM list_meals WHERE id = ?1 AND list_id = ?2")
.bind(list_meal_id)
.bind(source_list_id)
.fetch_optional(&mut *txn)
.await
.map_err(db_error)?;
let Some(row) = row else {
return Err(DomainError::NotFound);
};
let meal_id: Option<i64> = row.get(0);
let name: String = row.get(1);
let id = self
.add_meal(txn, dest_list_id, meal_id, name.clone())
.await?;
copied.push(ListMeal {
id,
meal_id,
name,
created_at: now(),
});
}
let revision = bump_revision(txn, dest_list_id).await?;
Ok((copied, revision))
}
}
#[derive(Clone, Copy)]
@@ -2747,7 +2782,9 @@ mod tests {
let id = db
.run(move |txn| {
let list_meals = list_meals.clone();
Box::pin(async move { list_meals.add_meal(txn, list_id, meal_id, name).await })
Box::pin(
async move { list_meals.add_meal(txn, list_id, Some(meal_id), name).await },
)
})
.await
.unwrap();
@@ -2850,6 +2887,100 @@ mod tests {
assert!(matches!(result, Err(DomainError::NotFound)));
}
#[tokio::test]
async fn copying_meals_to_another_list_does_not_add_items() {
let db = setup().await;
let source = create_list(&db, "Last week").await;
let dest = create_list(&db, "This week").await;
let meal = create_meal(&db, "Pasta").await;
add_ingredient(&db, meal.id, "Penne", None).await;
let list_meal = add_meal_to_list(&db, source.id, &meal).await;
let list_meals = SqliteListMealRepository;
let (copied, revision) = db
.run(move |txn| {
let list_meals = list_meals.clone();
Box::pin(async move {
list_meals
.copy_meals_to_list(txn, source.id, dest.id, &[list_meal.id])
.await
})
})
.await
.unwrap();
// The meal is copied to the destination, but no items are created.
assert_eq!(copied.len(), 1);
assert_eq!(copied[0].name, "Pasta");
assert_eq!(copied[0].meal_id, Some(meal.id));
assert!(get_items(&db, dest.id).await.is_empty());
// The destination revision is bumped exactly once.
assert_eq!(revision, 1);
// The source meal is untouched.
let source_meals = db
.run(move |txn| {
let list_meals = list_meals.clone();
Box::pin(async move { list_meals.list_meals(txn, source.id).await })
})
.await
.unwrap();
assert_eq!(source_meals.len(), 1);
}
#[tokio::test]
async fn copying_a_meal_with_a_deleted_catalog_meal_preserves_its_name() {
let db = setup().await;
let source = create_list(&db, "Last week").await;
let dest = create_list(&db, "This week").await;
let meal = create_meal(&db, "Pasta").await;
let list_meal = add_meal_to_list(&db, source.id, &meal).await;
// Delete the catalog meal; the list_meals row keeps its name but meal_id
// becomes NULL.
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 list_meals = SqliteListMealRepository;
let (copied, _) = db
.run(move |txn| {
let list_meals = list_meals.clone();
Box::pin(async move {
list_meals
.copy_meals_to_list(txn, source.id, dest.id, &[list_meal.id])
.await
})
})
.await
.unwrap();
assert_eq!(copied.len(), 1);
assert_eq!(copied[0].name, "Pasta");
assert_eq!(copied[0].meal_id, None);
}
#[tokio::test]
async fn copying_an_unknown_meal_fails() {
let db = setup().await;
let source = create_list(&db, "Last week").await;
let dest = create_list(&db, "This week").await;
let list_meals = SqliteListMealRepository;
let result = db
.run(move |txn| {
let list_meals = list_meals.clone();
Box::pin(async move {
list_meals
.copy_meals_to_list(txn, source.id, dest.id, &[9999])
.await
})
})
.await;
assert!(matches!(result, Err(DomainError::NotFound)));
}
// ---- PasskeyRepository ----
#[tokio::test]
+88
View File
@@ -1307,7 +1307,10 @@ pub fn list_meals_panel(
}
}
@if editable {
div class="list-meals-actions" {
button class="button button-small add-meal-button" hx-get=(format!("/meals?picker={}", list_id)) hx-target="#meal-picker" hx-swap="innerHTML" { "+ Add meal" }
button class="button button-small carry-over-button" hx-get=(format!("/lists/{}/carry", list_id)) hx-target="#meal-picker" hx-swap="innerHTML" { "Carry over" }
}
}
};
@@ -1322,6 +1325,91 @@ pub fn list_meals_panel(
}
}
/// The carry-over modal: a picker of active source lists (newest first,
/// excluding the current list) that the user can import meals from.
pub fn carry_over_modal(sources: &[GroceryList], dest_list_id: i64) -> 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="Carry over meals" {
div class="meal-picker-header" {
div {
p class="eyebrow" { "CARRY OVER" }
h2 { "Carry over meals" }
}
button class="meal-picker-close" type="button" aria-label="Close" onclick="this.closest('.meal-picker-backdrop').remove()" { "" }
}
p class="muted carry-intro" { "Import meals from another list. Their ingredients are not re-added — they were already purchased." }
@if sources.is_empty() {
div class="meal-picker-empty" {
p { "No other lists to carry from." }
}
} @else {
div class="carry-source-picker" {
label for="carry-source" { "From list" }
select
id="carry-source"
name="source"
hx-get=(format!("/lists/{}/carry/meals", dest_list_id))
hx-trigger="change"
hx-target="#carry-results"
hx-swap="innerHTML"
{
option value="" selected disabled { "Choose a list…" }
@for source in sources {
option value=(source.id) { (source.name) }
}
}
}
div id="carry-results" class="carry-results" {}
}
}
}
}
}
/// The selectable meal rows for a chosen source list inside the carry modal.
pub fn carry_source_meals(
list_meals: &[ListMeal],
source_id: i64,
dest_list_id: i64,
csrf_token: &str,
) -> Markup {
html! {
@if list_meals.is_empty() {
div class="meal-picker-empty" {
p { "This list has no meals to carry over." }
}
} @else {
form
hx-post=(format!("/lists/{}/carry", dest_list_id))
hx-target="#list-items"
hx-swap="morph:outerHTML"
hx-on::after-request="if (event.detail.successful) this.closest('.meal-picker-backdrop').remove()"
class="carry-form"
{
input type="hidden" name="csrf" value=(csrf_token);
input type="hidden" name="source_list_id" value=(source_id);
// Collected as a comma-separated list by the checkboxes below;
// the form extractor does not coalesce repeated keys into a Vec.
input id="carry-selected" type="hidden" name="list_meal_ids" value="";
div class="carry-list" {
@for meal in list_meals {
label class="carry-row" {
input
type="checkbox"
value=(meal.id)
onchange="var form=this.closest('form'); var box=form.querySelector('#carry-selected'); box.value=Array.from(form.querySelectorAll('input[type=checkbox]:checked')).map(function(c){return c.value}).join(',');";
span class="carry-row-icon" { "🍽" }
span class="carry-row-name" { (meal.name) }
}
}
}
button class="button button-primary carry-submit" type="submit" { "Carry over" }
}
}
}
}
pub fn presence_panel(presence: &[PresenceUser], out_of_band: bool) -> Markup {
if out_of_band {
html! {
+17
View File
@@ -265,6 +265,23 @@ textarea:focus { border-color: var(--deep-sage); box-shadow: 0 0 0 4px rgba(85,
.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; }
.list-meal-remove-button:hover { color: var(--coral); background: #fbeae4; }
.list-meals-panel .add-meal-button { margin-top: 12px; width: 100%; }
.list-meals-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 12px; }
.list-meals-actions .button { width: 100%; margin: 0; }
.carry-over-button { color: var(--deep-sage); background: #eef4e9; }
.carry-over-button:hover { background: #e3eddc; }
.carry-intro { margin: 14px 24px 4px; font-size: .85rem; }
.carry-source-picker { padding: 14px 24px 4px; }
.carry-source-picker label { display: block; margin-bottom: 6px; font-size: .78rem; font-weight: 800; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
.carry-source-picker select { width: 100%; min-height: 42px; padding: 8px 12px; border: 1px solid var(--line); border-radius: 12px; color: var(--ink); background: #fff; font-size: .9rem; }
.carry-results { padding: 14px 24px 22px; overflow-y: auto; }
.carry-form { margin: 0; }
.carry-list { display: grid; gap: 8px; }
.carry-row { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 14px; background: #fff; cursor: pointer; }
.carry-row:hover { border-color: var(--sage); }
.carry-row input { accent-color: var(--deep-sage); width: 17px; height: 17px; }
.carry-row-icon { display: grid; place-items: center; flex: 0 0 auto; width: 34px; height: 34px; border-radius: 11px; color: var(--deep-sage); background: #eef4e9; font-size: .95rem; }
.carry-row-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .9rem; font-weight: 700; }
.carry-submit { margin-top: 14px; width: 100%; }
.sharing-panel p, .tip-panel p { color: var(--muted); font-size: .86rem; }
.invite-result { margin-top: 15px; }
.invite-link-result { padding: 11px; border-radius: 12px; background: #f1f5ec; }