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

    {t("shoppingList.empty")}

    ; @@ -92,19 +125,37 @@ function ShoppingListItems({ items }: { items: ShoppingListItemView[] }) { {t(`recipes.form.category.${group.category}`)}
      - {group.items.map((item) => ( -
    • - - - {ingredientLabel(item.ingredient, t)} - - - {formatShoppingListQuantity(item.quantity)} {t(`catalog.units.${item.unit.key}`)} - -
    • - ))} + {group.items.map((item) => { + const key = itemKey(item); + const isChecked = checkedKeys.has(key); + return ( +
    • + setChecked(key, next)} + > + + + {ingredientLabel(item.ingredient, t)} + + + {formatShoppingListQuantity(item.quantity)}{" "} + {t(`catalog.units.${item.unit.key}`)} + + +
    • + ); + })}
    ))} 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 `
  • ` is just the separator wrapper; the `