feat(shopping-list): coche les articles « dans le panier » (#109) #121

Merged
kyuno merged 1 commit from feat/shopping-list-check-items into main 2026-09-08 07:08:49 +02:00
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 }); 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\?/, { cy.intercept("GET", /\/shopping-list\?/, {
statusCode: 200, statusCode: 200,
body: { body: {
@ -54,10 +54,12 @@ describe("Shopping list page (phone)", () => {
cy.visit("/liste-de-courses"); cy.visit("/liste-de-courses");
cy.wait("@getList"); cy.wait("@getList");
cy.get(".shopping-list__item").should(($el) => { // The interactive row is the `CheckboxOption` label (issue #109), not
// --space-md (16px) on phone vs. the desktop default --space-sm // the `<li>` wrapper. --space-md (16px) padding on phone vs. the desktop
// (8px) — see shopping-list-page.scss's phone block. // 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(getComputedStyle($el[0]).paddingTop).to.equal("16px");
expect($el[0].getBoundingClientRect().height).to.be.gte(44);
}); });
cy.document() cy.document()

View file

@ -30,3 +30,15 @@ Feature: Shopping list
When I visit "/liste-de-courses" When I visit "/liste-de-courses"
And I click the next week arrow And I click the next week arrow
Then the shopping list should show "Oignon" at quantity "1 kg" 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); 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 { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { apiClient } from "../../api/client"; import { apiClient } from "../../api/client";
import { CheckboxOption } from "../../components/ui/Checkbox";
import { WeekNavigator } from "../../features/planning/WeekNavigator"; import { WeekNavigator } from "../../features/planning/WeekNavigator";
import { import {
CategoryIcon, CategoryIcon,
@ -10,6 +11,7 @@ import {
} from "../../features/recipes/ingredients/ingredient-icons"; } from "../../features/recipes/ingredients/ingredient-icons";
import { ingredientLabel } from "../../features/recipes/ingredients/ingredient-label"; import { ingredientLabel } from "../../features/recipes/ingredients/ingredient-label";
import { formatShoppingListQuantity, groupShoppingListItems } from "./shopping-list"; import { formatShoppingListQuantity, groupShoppingListItems } from "./shopping-list";
import { shoppingListProgress } from "./shopping-list-progress";
import "./shopping-list-page.scss"; import "./shopping-list-page.scss";
/** Load state for the `GET /shopping-list` call — same discriminated-union shape as `PlanningPage`'s own `PlanningState`. */ /** 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: "loaded"; list: ShoppingListView }
| { status: "error" }; | { 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 * Shopping list section — routed at `/liste-de-courses`. Every ingredient
* line of every recipe planned for a selectable week, aggregated server-side * 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 * (`GET /shopping-list`, see the API's `shopping-list.service.ts`) into one
* quantity per (ingredient, unit) pair, grouped by supermarket aisle for * quantity per (ingredient, unit) pair, grouped by supermarket aisle for
* display. Deliberately simple by design — a read-only list, no * display.
* 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 * Each line can be ticked off as "in the cart" while shopping (issue #109).
* keep in sync with it. * 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() { export function ShoppingListPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const [weekStart, setWeekStart] = useState<DateTime>(() => getWeekStart(DateTime.utc())); const [weekStart, setWeekStart] = useState<DateTime>(() => getWeekStart(DateTime.utc()));
const [state, setState] = useState<ShoppingListState>({ status: "loading" }); const [state, setState] = useState<ShoppingListState>({ status: "loading" });
const weekKey = formatDateOnly(weekStart);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setState({ status: "loading" }); setState({ status: "loading" });
apiClient apiClient
.getShoppingListForWeek(formatDateOnly(weekStart)) .getShoppingListForWeek(weekKey)
.then((list) => { .then((list) => {
if (!cancelled) setState({ status: "loaded", list }); if (!cancelled) setState({ status: "loaded", list });
}) })
@ -49,7 +61,7 @@ export function ShoppingListPage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [weekStart]); }, [weekKey]);
return ( return (
<div className="shopping-list-page" data-tour="shopping-list-summary"> <div className="shopping-list-page" data-tour="shopping-list-summary">
@ -68,14 +80,35 @@ export function ShoppingListPage() {
</p> </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> </div>
); );
} }
/** The list itself, grouped by aisle (see `groupShoppingListItems`) — or the empty-week message if nothing's planned. */ /** 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 { 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) { if (items.length === 0) {
return <p className="shopping-list-page__status">{t("shoppingList.empty")}</p>; 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}`)} {t(`recipes.form.category.${group.category}`)}
</h2> </h2>
<ul className="shopping-list__items"> <ul className="shopping-list__items">
{group.items.map((item) => ( {group.items.map((item) => {
<li key={`${item.ingredient.id}-${item.unit.id}`} className="shopping-list__item"> const key = itemKey(item);
<span className="shopping-list__item-icon" aria-hidden="true"> const isChecked = checkedKeys.has(key);
<IngredientTypeIcon icon={item.ingredient.icon} /> return (
</span> <li
<span className="shopping-list__item-name"> key={key}
{ingredientLabel(item.ingredient, t)} className={
</span> isChecked
<span className="shopping-list__item-quantity"> ? "shopping-list__item shopping-list__item--checked"
{formatShoppingListQuantity(item.quantity)} {t(`catalog.units.${item.unit.key}`)} : "shopping-list__item"
</span> }
</li> >
))} <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> </ul>
</section> </section>
))} ))}

View file

@ -66,11 +66,10 @@
box-shadow: var(--shadow-sm); 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 { .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); border-bottom: 1px solid var(--color-border);
&:last-child { &:last-child {
@ -103,20 +102,56 @@
} }
} }
// --- Phone (issue #90) ------------------------------------------------------ // Reset the app-wide "selectable card" look (global.scss's
// This list has no checkbox/delete button at all — it's deliberately // `label:has(> input[type="checkbox"])`) for this context: a shopping-list
// read-only (see ShoppingListPage.tsx's own doc comment) — so, unlike the // tick is a "done / in the cart" completion, not a "pick this option"
// planning grid or the cooking-session task cards, there's no *tap* target // highlight, so it reads as a struck-through, dimmed row inside the plain
// to enlarge here; the chantier's original ticket assumed some existed // list rather than a bordered, primary-tinted chip. Same scoped-override
// (written before this page's actual code was read). The structure also // approach `recipes.scss` takes for its own display-menu checkboxes.
// already doesn't overflow: `&-name` is `flex: 1; min-width: 0` and wraps // Nested under `.shopping-list__items` on purpose — `label:has(> …)` in
// rather than pushing `&-quantity` out. What phone-viewport reading // global.scss carries a `[type="checkbox"]` attribute selector, so a bare
// genuinely rewards here — held in one hand, glanced at repeatedly while // `.shopping-list__item-check` would lose to it on specificity.
// pushing a cart — is a slightly roomier row and a clearer icon, not a .shopping-list__items .shopping-list__item-check {
// structural change. 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 { @include bp.phone {
.shopping-list__item { .shopping-list__items .shopping-list__item-check {
padding: var(--space-md); padding: var(--space-md);
min-height: 2.75rem;
} }
.shopping-list__item-icon svg { .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`) ## Liste de courses (`/liste-de-courses`)
`pages/shopping-list/ShoppingListPage.tsx` (+ `shopping-list-page.scss`, `pages/shopping-list/ShoppingListPage.tsx` (+ `shopping-list-page.scss`,
`shopping-list.ts`) — même `WeekNavigator` que `PlanningPage` (semaine `shopping-list.ts`, `shopping-list-progress.ts`) — même `WeekNavigator` que
sélectionnable), mais un rendu bien plus simple en dessous : une liste, `PlanningPage` (semaine sélectionnable), mais un rendu bien plus simple en
volontairement pas une grille. Délibérément **en lecture seule** — pas de dessous : une liste, volontairement pas une grille.
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 Chaque ligne se coche comme "dans le panier" pendant les courses (issue
séparée qu'il faudrait garder synchronisée avec lui lorsqu'une recette est #109). Cet état est **purement local, par semaine**, persisté uniquement
ajoutée/retirée après coup. 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` - `GET /shopping-list?date=YYYY-MM-DD` renvoie toujours un `ShoppingListView`
(jamais `null`, contrairement à `GET /planning`) — chaque ingrédient de (jamais `null`, contrairement à `GET /planning`) — chaque ingrédient de