Compare commits

..

No commits in common. "22119adf07bdbbc02db154dd7f7a65a4e05e6d19" and "f68e8dbdc69ac65701c013dd9b838edaccab4fd5" have entirely different histories.

7 changed files with 48 additions and 248 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 tap target than on desktop, with no horizontal body scroll", () => { it("gives each row a taller, more legible padding 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,12 +54,10 @@ describe("Shopping list page (phone)", () => {
cy.visit("/liste-de-courses"); cy.visit("/liste-de-courses");
cy.wait("@getList"); cy.wait("@getList");
// The interactive row is the `CheckboxOption` label (issue #109), not cy.get(".shopping-list__item").should(($el) => {
// the `<li>` wrapper. --space-md (16px) padding on phone vs. the desktop // --space-md (16px) on phone vs. the desktop default --space-sm
// default --space-sm (8px), plus a ≥44px min-height tap target. // (8px) — see shopping-list-page.scss's phone block.
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,15 +30,3 @@ 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,27 +70,3 @@ 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,7 +3,6 @@ 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,
@ -11,7 +10,6 @@ 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`. */
@ -20,37 +18,27 @@ 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. * display. Deliberately simple by design — a read-only list, no
* * checkboxes/crossing-off state: the source of truth for what's needed is
* Each line can be ticked off as "in the cart" while shopping (issue #109). * the planning itself, not a separate to-do list this page would have to
* That state is a purely local, per-week convenience persisted only in this * keep in sync with it.
* 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(weekKey) .getShoppingListForWeek(formatDateOnly(weekStart))
.then((list) => { .then((list) => {
if (!cancelled) setState({ status: "loaded", list }); if (!cancelled) setState({ status: "loaded", list });
}) })
@ -61,7 +49,7 @@ export function ShoppingListPage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [weekKey]); }, [weekStart]);
return ( return (
<div className="shopping-list-page" data-tour="shopping-list-summary"> <div className="shopping-list-page" data-tour="shopping-list-summary">
@ -80,35 +68,14 @@ export function ShoppingListPage() {
</p> </p>
)} )}
{state.status === "loaded" && ( {state.status === "loaded" && <ShoppingListItems items={state.list.items} />}
// 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({ weekKey, items }: { weekKey: string; items: ShoppingListItemView[] }) { function ShoppingListItems({ items }: { 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>;
@ -125,23 +92,8 @@ function ShoppingListItems({ weekKey, items }: { weekKey: string; items: Shoppin
{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) => (
const key = itemKey(item); <li key={`${item.ingredient.id}-${item.unit.id}`} className="shopping-list__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"> <span className="shopping-list__item-icon" aria-hidden="true">
<IngredientTypeIcon icon={item.ingredient.icon} /> <IngredientTypeIcon icon={item.ingredient.icon} />
</span> </span>
@ -149,13 +101,10 @@ function ShoppingListItems({ weekKey, items }: { weekKey: string; items: Shoppin
{ingredientLabel(item.ingredient, t)} {ingredientLabel(item.ingredient, t)}
</span> </span>
<span className="shopping-list__item-quantity"> <span className="shopping-list__item-quantity">
{formatShoppingListQuantity(item.quantity)}{" "} {formatShoppingListQuantity(item.quantity)} {t(`catalog.units.${item.unit.key}`)}
{t(`catalog.units.${item.unit.key}`)}
</span> </span>
</CheckboxOption>
</li> </li>
); ))}
})}
</ul> </ul>
</section> </section>
))} ))}

View file

@ -66,10 +66,11 @@
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 {
@ -102,56 +103,20 @@
} }
} }
// Reset the app-wide "selectable card" look (global.scss's // --- Phone (issue #90) ------------------------------------------------------
// `label:has(> input[type="checkbox"])`) for this context: a shopping-list // This list has no checkbox/delete button at all — it's deliberately
// tick is a "done / in the cart" completion, not a "pick this option" // read-only (see ShoppingListPage.tsx's own doc comment) — so, unlike the
// highlight, so it reads as a struck-through, dimmed row inside the plain // planning grid or the cooking-session task cards, there's no *tap* target
// list rather than a bordered, primary-tinted chip. Same scoped-override // to enlarge here; the chantier's original ticket assumed some existed
// approach `recipes.scss` takes for its own display-menu checkboxes. // (written before this page's actual code was read). The structure also
// Nested under `.shopping-list__items` on purpose — `label:has(> …)` in // already doesn't overflow: `&-name` is `flex: 1; min-width: 0` and wraps
// global.scss carries a `[type="checkbox"]` attribute selector, so a bare // rather than pushing `&-quantity` out. What phone-viewport reading
// `.shopping-list__item-check` would lose to it on specificity. // genuinely rewards here — held in one hand, glanced at repeatedly while
.shopping-list__items .shopping-list__item-check { // pushing a cart — is a slightly roomier row and a clearer icon, not a
width: 100%; // structural change.
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__items .shopping-list__item-check { .shopping-list__item {
padding: var(--space-md); padding: var(--space-md);
min-height: 2.75rem;
} }
.shopping-list__item-icon svg { .shopping-list__item-icon svg {

View file

@ -1,70 +0,0 @@
/**
* 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,19 +481,13 @@ 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`, `shopping-list-progress.ts`) — même `WeekNavigator` que `shopping-list.ts`) — même `WeekNavigator` que `PlanningPage` (semaine
`PlanningPage` (semaine sélectionnable), mais un rendu bien plus simple en sélectionnable), mais un rendu bien plus simple en dessous : une liste,
dessous : une liste, volontairement pas une grille. 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
Chaque ligne se coche comme "dans le panier" pendant les courses (issue qu'il faut acheter reste le planning lui-même, pas une liste de courses
#109). Cet état est **purement local, par semaine**, persisté uniquement séparée qu'il faudrait garder synchronisée avec lui lorsqu'une recette est
dans le `localStorage` de ce navigateur (`ShoppingListProgressStore`, clé ajoutée/retirée après coup.
`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