feat(shopping-list): coche les articles « dans le panier » (#109)
All checks were successful
CI / lint (push) Successful in 2m18s
CI / build (push) Successful in 3m34s
CI / intent-service-test (push) Successful in 6m36s
CI / e2e (push) Successful in 10m22s
CI / test (push) Successful in 33m30s
CI / build-android (push) Has been skipped

La liste de courses était volontairement en lecture seule. Elle gagne un
état « acheté » par article, utile en magasin (au doigt sur mobile).

- `shopping-list-progress.ts` (nouveau) : `ShoppingListProgressStore`,
  calqué sur `CookingProgressStore` — `localStorage` uniquement, par
  semaine (clé `batchcooking:shoppingListChecked:<weekStart>`), aucun état
  serveur. Une coche n'est jamais autoritaire (la liste est recalculée
  depuis le planning à chaque visite) et se perd sans dommage.
- `ShoppingListPage.tsx` : chaque ligne devient un `CheckboxOption` ;
  `<ShoppingListItems key={weekKey}>` remonte à chaque changement de
  semaine (checklist réinitialisée). Clé d'article = `ingredientId-unitId`
  (déjà la `key` React de la ligne).
- `shopping-list-page.scss` : override scopé du look « carte sélectionnable »
  global — un article coché est barré et estompé, pas surligné en primary.
  Cible tactile ≥ 44 px sous le breakpoint phone.
- `specs/frontend-architecture.md` : la note « délibérément en lecture
  seule » est remplacée.

Tests : `shopping-list.feature` — nouveau scénario « cocher un article »
(coché → barré, persiste au reload, n'affecte pas les autres).
`shopping-list-page.cy.ts` — la cible tactile de la ligne (le label
`CheckboxOption`) fait 16 px de padding + ≥ 44 px de haut sur téléphone.
Local : `pnpm lint` + `pnpm --filter web build` OK, `shopping-list.feature`
4/4, `shopping-list-page.cy.ts` 1/1.

Closes #109

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-08 02:19:30 +02:00
parent f68e8dbdc6
commit 592933b1ac
7 changed files with 248 additions and 48 deletions

View file

@ -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 `<li>` 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()

View file

@ -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

View file

@ -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();
});

View file

@ -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<DateTime>(() => getWeekStart(DateTime.utc()));
const [state, setState] = useState<ShoppingListState>({ 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 (
<div className="shopping-list-page" data-tour="shopping-list-summary">
@ -68,14 +80,35 @@ export function ShoppingListPage() {
</p>
)}
{state.status === "loaded" && <ShoppingListItems items={state.list.items} />}
{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.
<ShoppingListItems key={weekKey} weekKey={weekKey} items={state.list.items} />
)}
</div>
);
}
/** 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<Set<string>>(() =>
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 <p className="shopping-list-page__status">{t("shoppingList.empty")}</p>;
@ -92,19 +125,37 @@ function ShoppingListItems({ items }: { items: ShoppingListItemView[] }) {
{t(`recipes.form.category.${group.category}`)}
</h2>
<ul className="shopping-list__items">
{group.items.map((item) => (
<li key={`${item.ingredient.id}-${item.unit.id}`} className="shopping-list__item">
<span className="shopping-list__item-icon" aria-hidden="true">
<IngredientTypeIcon icon={item.ingredient.icon} />
</span>
<span className="shopping-list__item-name">
{ingredientLabel(item.ingredient, t)}
</span>
<span className="shopping-list__item-quantity">
{formatShoppingListQuantity(item.quantity)} {t(`catalog.units.${item.unit.key}`)}
</span>
</li>
))}
{group.items.map((item) => {
const key = itemKey(item);
const isChecked = checkedKeys.has(key);
return (
<li
key={key}
className={
isChecked
? "shopping-list__item shopping-list__item--checked"
: "shopping-list__item"
}
>
<CheckboxOption
className="shopping-list__item-check"
checked={isChecked}
onChange={(next) => setChecked(key, next)}
>
<span className="shopping-list__item-icon" aria-hidden="true">
<IngredientTypeIcon icon={item.ingredient.icon} />
</span>
<span className="shopping-list__item-name">
{ingredientLabel(item.ingredient, t)}
</span>
<span className="shopping-list__item-quantity">
{formatShoppingListQuantity(item.quantity)}{" "}
{t(`catalog.units.${item.unit.key}`)}
</span>
</CheckboxOption>
</li>
);
})}
</ul>
</section>
))}

View file

@ -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 `<li>` is just the separator wrapper; the `<label>`
// inside (`.shopping-list__item-check`) is the actual interactive row.
.shopping-list__item {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--color-border);
&:last-child {
@ -103,20 +102,56 @@
}
}
// --- Phone (issue #90) ------------------------------------------------------
// This list has no checkbox/delete button at all — it's deliberately
// read-only (see ShoppingListPage.tsx's own doc comment) — so, unlike the
// planning grid or the cooking-session task cards, there's no *tap* target
// to enlarge here; the chantier's original ticket assumed some existed
// (written before this page's actual code was read). The structure also
// already doesn't overflow: `&-name` is `flex: 1; min-width: 0` and wraps
// rather than pushing `&-quantity` out. What phone-viewport reading
// genuinely rewards here — held in one hand, glanced at repeatedly while
// pushing a cart — is a slightly roomier row and a clearer icon, not a
// structural change.
// Reset the app-wide "selectable card" look (global.scss's
// `label:has(> input[type="checkbox"])`) for this context: a shopping-list
// tick is a "done / in the cart" completion, not a "pick this option"
// highlight, so it reads as a struck-through, dimmed row inside the plain
// list rather than a bordered, primary-tinted chip. Same scoped-override
// approach `recipes.scss` takes for its own display-menu checkboxes.
// Nested under `.shopping-list__items` on purpose — `label:has(> …)` in
// global.scss carries a `[type="checkbox"]` attribute selector, so a bare
// `.shopping-list__item-check` would lose to it on specificity.
.shopping-list__items .shopping-list__item-check {
width: 100%;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
border: none;
border-radius: 0;
background: none;
font-weight: 400;
&:hover {
border-color: transparent;
background: var(--color-surface-alt);
}
// `.check-mark` (global.scss) already leads the row in DOM order — no
// `margin-left: auto` to undo here, unlike recipes.scss's menu.
&.is-selected {
// Override global's primary tint/weight — "bought", not "selected".
color: var(--color-text-muted);
background: none;
font-weight: 400;
.shopping-list__item-name {
text-decoration: line-through;
color: var(--color-text-muted);
}
.shopping-list__item-quantity {
color: var(--color-text-muted);
}
}
}
// --- Phone (issue #90, extended by #109) ----------------------------------
// A roomier row for one-handed reading while pushing a cart, and now a
// comfortable ≥44px tap target for the tick.
@include bp.phone {
.shopping-list__item {
.shopping-list__items .shopping-list__item-check {
padding: var(--space-md);
min-height: 2.75rem;
}
.shopping-list__item-icon svg {

View file

@ -0,0 +1,70 @@
/**
* Per-week "which shopping-list items are already in the cart" checklist —
* persisted in the browser's `localStorage` only. Like the cooking-session
* checklist ({@link CookingProgressStore}, whose shape this mirrors
* one-for-one), there is no server-side state: `GET /shopping-list`
* recomputes the whole aggregated list from the planning on every visit, so
* a tick is a purely local convenience for the person pushing the cart, not
* shared household state. It also means a tick can safely be dropped
* whenever the underlying list changes — the worst case is re-ticking.
*
* A class with a shared singleton rather than two bare functions because it
* wraps Web Storage IO with named operations — same "behaviour with a verb
* ⇒ service class + exported instance" split as `ApiClient` /
* `CookingProgressStore` (`specs/dev-conventions.md`).
*/
export class ShoppingListProgressStore {
/**
* Key namespace — every value this app stores in `localStorage` is
* `batchcooking:`-prefixed. The week-start `YYYY-MM-DD` is appended so
* each week keeps an independent checklist and ticks never bleed across
* weeks (same scheme as `batchcooking:cookingProgress:`).
*/
private readonly _keyPrefix = "batchcooking:shoppingListChecked:";
/**
* The ticked item keys (`<ingredientId>-<unitId>`, the same key
* `ShoppingListPage` already uses for its React list) for the week
* starting `weekKey` (`YYYY-MM-DD`). Returns an empty set when nothing is
* stored yet, when `localStorage` is unavailable (private-mode Safari
* throws on access), or when the stored value isn't the expected string
* array — a lost checklist just means re-ticking, never a broken page
* (same non-fatal posture as {@link CookingProgressStore}).
*/
public load(weekKey: string): Set<string> {
try {
const raw = localStorage.getItem(this._key(weekKey));
if (raw === null) return new Set();
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return new Set();
return new Set(parsed.filter((key): key is string => typeof key === "string"));
} catch {
return new Set();
}
}
/**
* Replaces the stored checklist for `weekKey` with `checkedKeys` (written
* as a plain string array). A no-op if `localStorage` can't be written —
* see {@link load} for why that's swallowed rather than surfaced.
*/
public save(weekKey: string, checkedKeys: Set<string>): void {
try {
localStorage.setItem(this._key(weekKey), JSON.stringify([...checkedKeys]));
} catch {
// Storage full or unavailable — the in-memory checklist still works
// for this session, it just won't survive a reload.
}
}
/** Full storage key for a given week. */
private _key(weekKey: string): string {
return `${this._keyPrefix}${weekKey}`;
}
}
/**
* Shared instance — the store holds no state of its own (everything lives in
* `localStorage`), so a single one serves every caller.
*/
export const shoppingListProgress = new ShoppingListProgressStore();

View file

@ -481,13 +481,19 @@ quelqu'un l'ajoute à son planning" :
## Liste de courses (`/liste-de-courses`)
`pages/shopping-list/ShoppingListPage.tsx` (+ `shopping-list-page.scss`,
`shopping-list.ts`) — même `WeekNavigator` que `PlanningPage` (semaine
sélectionnable), mais un rendu bien plus simple en dessous : une liste,
volontairement pas une grille. Délibérément **en lecture seule** — pas de
case à cocher/état "acheté" à faire persister : la source de vérité de ce
qu'il faut acheter reste le planning lui-même, pas une liste de courses
séparée qu'il faudrait garder synchronisée avec lui lorsqu'une recette est
ajoutée/retirée après coup.
`shopping-list.ts`, `shopping-list-progress.ts`) — même `WeekNavigator` que
`PlanningPage` (semaine sélectionnable), mais un rendu bien plus simple en
dessous : une liste, volontairement pas une grille.
Chaque ligne se coche comme "dans le panier" pendant les courses (issue
#109). Cet état est **purement local, par semaine**, persisté uniquement
dans le `localStorage` de ce navigateur (`ShoppingListProgressStore`, clé
`batchcooking:shoppingListChecked:<weekStart>`, même schéma que
`CookingProgressStore`) — **aucun état "acheté" côté serveur** : la liste
est recalculée depuis le planning à chaque visite, donc une coche n'est
jamais autoritaire et se perd sans dommage quand le planning change (au
pire, on recoche). La source de vérité de ce qu'il faut acheter reste le
planning lui-même.
- `GET /shopping-list?date=YYYY-MM-DD` renvoie toujours un `ShoppingListView`
(jamais `null`, contrairement à `GET /planning`) — chaque ingrédient de