feat(shopping-list): coche les articles « dans le panier » (#109) #121
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 });
|
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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
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`)
|
## 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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue