diff --git a/apps/web/cypress/e2e/shopping-list-page.cy.ts b/apps/web/cypress/e2e/shopping-list-page.cy.ts
index 79ed527..8197d0e 100644
--- a/apps/web/cypress/e2e/shopping-list-page.cy.ts
+++ b/apps/web/cypress/e2e/shopping-list-page.cy.ts
@@ -41,7 +41,7 @@ describe("Shopping list page (phone)", () => {
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
});
- it("gives each row a taller, more legible padding than on desktop, with no horizontal body scroll", () => {
+ it("gives each row a taller, more legible tap target than on desktop, with no horizontal body scroll", () => {
cy.intercept("GET", /\/shopping-list\?/, {
statusCode: 200,
body: {
@@ -54,10 +54,12 @@ describe("Shopping list page (phone)", () => {
cy.visit("/liste-de-courses");
cy.wait("@getList");
- cy.get(".shopping-list__item").should(($el) => {
- // --space-md (16px) on phone vs. the desktop default --space-sm
- // (8px) — see shopping-list-page.scss's phone block.
+ // The interactive row is the `CheckboxOption` label (issue #109), not
+ // the `
` wrapper. --space-md (16px) padding on phone vs. the desktop
+ // default --space-sm (8px), plus a ≥44px min-height tap target.
+ cy.get(".shopping-list__item-check").should(($el) => {
expect(getComputedStyle($el[0]).paddingTop).to.equal("16px");
+ expect($el[0].getBoundingClientRect().height).to.be.gte(44);
});
cy.document()
diff --git a/apps/web/cypress/e2e/shopping-list.feature b/apps/web/cypress/e2e/shopping-list.feature
index 8019072..0148b67 100644
--- a/apps/web/cypress/e2e/shopping-list.feature
+++ b/apps/web/cypress/e2e/shopping-list.feature
@@ -30,3 +30,15 @@ Feature: Shopping list
When I visit "/liste-de-courses"
And I click the next week arrow
Then the shopping list should show "Oignon" at quantity "1 kg"
+
+ Scenario: Ticking an item marks it as in the cart, and the tick survives a reload
+ Given the shopping list for "2026-08-17" contains:
+ | ingredientKey | icon | category | quantity | unitKey |
+ | tomato | VEGETABLE | freshProduce | 400 | gram |
+ | onion | VEGETABLE | freshProduce | 2 | piece |
+ When I visit "/liste-de-courses"
+ And I tick the shopping list item "Tomate"
+ Then the shopping list item "Tomate" should be ticked
+ And the shopping list item "Oignon" should not be ticked
+ When I reload the page
+ Then the shopping list item "Tomate" should be ticked
diff --git a/apps/web/cypress/e2e/shopping-list.ts b/apps/web/cypress/e2e/shopping-list.ts
index 76aa323..a47fd23 100644
--- a/apps/web/cypress/e2e/shopping-list.ts
+++ b/apps/web/cypress/e2e/shopping-list.ts
@@ -70,3 +70,27 @@ Then(
cy.contains(".shopping-list__item", name).should("contain.text", quantity);
},
);
+
+// Issue #109 — a line can be ticked off as "in the cart" (local, per-week,
+// `localStorage` only — see `shopping-list-progress.ts`).
+When("I tick the shopping list item {string}", (name: string) => {
+ cy.contains(".shopping-list__item", name).find("label.shopping-list__item-check").click();
+});
+
+Then("the shopping list item {string} should be ticked", (name: string) => {
+ cy.contains(".shopping-list__item", name)
+ .should("have.class", "shopping-list__item--checked")
+ .find('input[type="checkbox"]')
+ .should("be.checked");
+});
+
+Then("the shopping list item {string} should not be ticked", (name: string) => {
+ cy.contains(".shopping-list__item", name)
+ .should("not.have.class", "shopping-list__item--checked")
+ .find('input[type="checkbox"]')
+ .should("not.be.checked");
+});
+
+When("I reload the page", () => {
+ cy.reload();
+});
diff --git a/apps/web/src/pages/shopping-list/ShoppingListPage.tsx b/apps/web/src/pages/shopping-list/ShoppingListPage.tsx
index 3a9672e..911a448 100644
--- a/apps/web/src/pages/shopping-list/ShoppingListPage.tsx
+++ b/apps/web/src/pages/shopping-list/ShoppingListPage.tsx
@@ -3,6 +3,7 @@ import type { ShoppingListItemView, ShoppingListView } from "@batch-cooking/shar
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { apiClient } from "../../api/client";
+import { CheckboxOption } from "../../components/ui/Checkbox";
import { WeekNavigator } from "../../features/planning/WeekNavigator";
import {
CategoryIcon,
@@ -10,6 +11,7 @@ import {
} from "../../features/recipes/ingredients/ingredient-icons";
import { ingredientLabel } from "../../features/recipes/ingredients/ingredient-label";
import { formatShoppingListQuantity, groupShoppingListItems } from "./shopping-list";
+import { shoppingListProgress } from "./shopping-list-progress";
import "./shopping-list-page.scss";
/** Load state for the `GET /shopping-list` call — same discriminated-union shape as `PlanningPage`'s own `PlanningState`. */
@@ -18,27 +20,37 @@ type ShoppingListState =
| { status: "loaded"; list: ShoppingListView }
| { status: "error" };
+/** React list key for a `(ingredient, unit)` line — also the key the "in the cart" checklist is stored under (see {@link shoppingListProgress}). */
+function itemKey(item: ShoppingListItemView): string {
+ return `${item.ingredient.id}-${item.unit.id}`;
+}
+
/**
* Shopping list section — routed at `/liste-de-courses`. Every ingredient
* line of every recipe planned for a selectable week, aggregated server-side
* (`GET /shopping-list`, see the API's `shopping-list.service.ts`) into one
* quantity per (ingredient, unit) pair, grouped by supermarket aisle for
- * display. Deliberately simple by design — a read-only list, no
- * checkboxes/crossing-off state: the source of truth for what's needed is
- * the planning itself, not a separate to-do list this page would have to
- * keep in sync with it.
+ * display.
+ *
+ * Each line can be ticked off as "in the cart" while shopping (issue #109).
+ * That state is a purely local, per-week convenience persisted only in this
+ * browser's `localStorage` (see {@link shoppingListProgress}) — there is no
+ * server-side "bought" state: the list is recomputed from the planning on
+ * every visit, so a tick is never authoritative and is safe to lose when
+ * the underlying planning changes.
*/
export function ShoppingListPage() {
const { t } = useTranslation();
const [weekStart, setWeekStart] = useState(() => getWeekStart(DateTime.utc()));
const [state, setState] = useState({ status: "loading" });
+ const weekKey = formatDateOnly(weekStart);
useEffect(() => {
let cancelled = false;
setState({ status: "loading" });
apiClient
- .getShoppingListForWeek(formatDateOnly(weekStart))
+ .getShoppingListForWeek(weekKey)
.then((list) => {
if (!cancelled) setState({ status: "loaded", list });
})
@@ -49,7 +61,7 @@ export function ShoppingListPage() {
return () => {
cancelled = true;
};
- }, [weekStart]);
+ }, [weekKey]);
return (
@@ -68,14 +80,35 @@ export function ShoppingListPage() {
)}
- {state.status === "loaded" && }
+ {state.status === "loaded" && (
+ // Keyed by the week so switching weeks remounts with a fresh
+ // checklist loaded for that week, rather than carrying the previous
+ // week's ticks over.
+
+ )}
);
}
/** The list itself, grouped by aisle (see `groupShoppingListItems`) — or the empty-week message if nothing's planned. */
-function ShoppingListItems({ items }: { items: ShoppingListItemView[] }) {
+function ShoppingListItems({ weekKey, items }: { weekKey: string; items: ShoppingListItemView[] }) {
const { t } = useTranslation();
+ const [checkedKeys, setCheckedKeys] = useState>(() =>
+ shoppingListProgress.load(weekKey),
+ );
+
+ function setChecked(key: string, isChecked: boolean) {
+ setCheckedKeys((prev) => {
+ const next = new Set(prev);
+ if (isChecked) {
+ next.add(key);
+ } else {
+ next.delete(key);
+ }
+ shoppingListProgress.save(weekKey, next);
+ return next;
+ });
+ }
if (items.length === 0) {
return
))}
diff --git a/apps/web/src/pages/shopping-list/shopping-list-page.scss b/apps/web/src/pages/shopping-list/shopping-list-page.scss
index 2ce9ba7..7d6c445 100644
--- a/apps/web/src/pages/shopping-list/shopping-list-page.scss
+++ b/apps/web/src/pages/shopping-list/shopping-list-page.scss
@@ -66,11 +66,10 @@
box-shadow: var(--shadow-sm);
}
+// Each row is now a `CheckboxOption` (issue #109) — tick items off as they
+// go in the cart. The `