Compare commits
No commits in common. "22119adf07bdbbc02db154dd7f7a65a4e05e6d19" and "f68e8dbdc69ac65701c013dd9b838edaccab4fd5" have entirely different histories.
22119adf07
...
f68e8dbdc6
7 changed files with 48 additions and 248 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 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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -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,37 +92,19 @@ 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);
|
<span className="shopping-list__item-icon" aria-hidden="true">
|
||||||
return (
|
<IngredientTypeIcon icon={item.ingredient.icon} />
|
||||||
<li
|
</span>
|
||||||
key={key}
|
<span className="shopping-list__item-name">
|
||||||
className={
|
{ingredientLabel(item.ingredient, t)}
|
||||||
isChecked
|
</span>
|
||||||
? "shopping-list__item shopping-list__item--checked"
|
<span className="shopping-list__item-quantity">
|
||||||
: "shopping-list__item"
|
{formatShoppingListQuantity(item.quantity)} {t(`catalog.units.${item.unit.key}`)}
|
||||||
}
|
</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,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 {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue