Merge pull request 'feat(shopping-list): coche les articles « dans le panier » (#109)' (#121) from feat/shopping-list-check-items into main
Some checks failed
Some checks failed
This commit is contained in:
commit
22119adf07
7 changed files with 248 additions and 48 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,8 +125,23 @@ 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">
|
||||
{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>
|
||||
|
|
@ -101,10 +149,13 @@ function ShoppingListItems({ items }: { items: ShoppingListItemView[] }) {
|
|||
{ingredientLabel(item.ingredient, t)}
|
||||
</span>
|
||||
<span className="shopping-list__item-quantity">
|
||||
{formatShoppingListQuantity(item.quantity)} {t(`catalog.units.${item.unit.key}`)}
|
||||
{formatShoppingListQuantity(item.quantity)}{" "}
|
||||
{t(`catalog.units.${item.unit.key}`)}
|
||||
</span>
|
||||
</CheckboxOption>
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
70
apps/web/src/pages/shopping-list/shopping-list-progress.ts
Normal file
70
apps/web/src/pages/shopping-list/shopping-list-progress.ts
Normal 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();
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue