feat(planning): panier des restes dans l'en-tête (lecture seule + jeter) #50

Merged
kyuno merged 1 commit from feat/leftovers-basket into main 2026-09-06 00:32:42 +02:00
8 changed files with 413 additions and 0 deletions

View file

@ -0,0 +1,108 @@
// Layout-mode spec for the planning header's leftovers basket — mocks
// `GET /planning` (this job has no live backend; apps/api's Mocha suite
// covers the real endpoints). Exercises the popover open/close, an
// outside click, and the "Jeter" button firing `PATCH .../discard-leftover`.
const authenticatedProfile = {
id: 1,
firstName: "Alice",
lastName: "Martin",
email: "alice@example.com",
tokenVersion: 0,
houseId: 1,
dietId: null,
};
/**
* A week with one fresh item that cooked 12 for 10 requested (pool of 2)
* and one leftovers item serving 1 of that pool — so the basket should
* offer 1 line with 1 portion still available.
*/
const planningWithLeftovers = {
id: 1,
startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z",
items: [
{
id: 1,
weekDay: "lundi",
meal: "diner",
portions: 12,
portionsRequested: 10,
discardedPortions: 0,
sourceItem: null,
recipe: { id: 1, name: "Lasagnes" },
},
{
id: 2,
weekDay: "mardi",
meal: "dejeuner",
portions: 1,
portionsRequested: 1,
discardedPortions: 0,
sourceItem: { id: 1 },
recipe: { id: 1, name: "Lasagnes" },
},
],
};
describe("Leftovers basket", () => {
beforeEach(() => {
cy.viewport(1600, 900);
cy.clock(new Date("2026-08-17T09:00:00Z"), ["Date"]);
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
});
it("lists a week's available leftovers, opens and closes on an outside click", () => {
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: planningWithLeftovers });
cy.visit("/");
// Count badge = 1 fresh item with a remaining pool.
cy.get(".leftovers-basket__count").should("have.text", "1");
cy.get(".leftovers-basket__toggle").click();
cy.contains(".leftovers-basket__heading", "Restes disponibles").should("be.visible");
cy.contains(".leftovers-basket__line", "Lasagnes")
.should("contain.text", "Lundi")
.and("contain.text", "1 en restes");
// Clicking away from the popover closes it.
cy.get("h1").click();
cy.get(".leftovers-basket__popover").should("not.exist");
});
it("shows an empty state when nothing is left over", () => {
cy.intercept("GET", /\/planning\?/, {
statusCode: 200,
body: {
...planningWithLeftovers,
items: [{ ...planningWithLeftovers.items[0], portions: 10, portionsRequested: 10 }],
},
});
cy.visit("/");
cy.get(".leftovers-basket__count").should("not.exist");
cy.get(".leftovers-basket__toggle").click();
cy.contains(".leftovers-basket__empty", "Aucun reste cette semaine.").should("be.visible");
});
it("throws away a leftover pool via PATCH and drops it from the list", () => {
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: planningWithLeftovers });
cy.intercept("PATCH", "**/planning/items/1/discard-leftover", {
statusCode: 200,
body: {
...planningWithLeftovers.items[0],
discardedPortions: 1,
},
}).as("discard");
cy.visit("/");
cy.get(".leftovers-basket__toggle").click();
cy.contains(".leftovers-basket__line", "Lasagnes").find(".leftovers-basket__discard").click();
cy.wait("@discard");
// Pool was 1, now fully discarded -> the line is gone, the popover is empty.
cy.contains(".leftovers-basket__empty", "Aucun reste cette semaine.").should("be.visible");
cy.get(".leftovers-basket__count").should("not.exist");
});
});

View file

@ -166,6 +166,11 @@ export class ApiClient {
return this._request(`/planning/items/${id}`, { method: "DELETE" });
}
/** Throws away planning item `id`'s whole remaining leftover pool (`discardedPortions += available`) — idempotent, returns the updated item. Rejects with `PLANNING_ITEM_NOT_FOUND`. */
public discardLeftover(id: number): Promise<PlanningItemView> {
return this._request(`/planning/items/${id}/discard-leftover`, { method: "PATCH" });
}
/**
* Fetches the current user's household's shopping list for the week
* covering `date` (`YYYY-MM-DD`, e.g. from `date-tools`'s

View file

@ -0,0 +1,111 @@
import type { PlanningItemView, PlanningView } from "@batch-cooking/shared";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { apiClient } from "../../api/client";
import { collectLeftoverLines } from "./leftovers-basket";
import "./leftovers-basket.scss";
/**
* The planning header's leftovers basket — an icon (with a count badge)
* that opens a popover listing this week's still-available leftover pools:
* one line per fresh planning item that cooked more than it needed, with
* where it came from, how many portions are left, and a "jeter" button.
*
* Read-only for now: dragging a leftover onto another slot arrives in a
* later iteration. Same conditional-mount + outside-click-to-close pattern
* as `WeekNavigator`'s `CalendarPopover`. The list is derived on every
* render from `planning` (see {@link collectLeftoverLines}) rather than
* held in its own state, so a discard elsewhere on the page is reflected
* here for free.
*/
export function LeftoversBasket({
planning,
onLeftoverDiscarded,
}: {
planning: PlanningView | null;
/** The updated item as returned by `PATCH .../discard-leftover` — the parent patches it into its planning state. */
onLeftoverDiscarded: (item: PlanningItemView) => void;
}) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [discardingId, setDiscardingId] = useState<number | null>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const lines = collectLeftoverLines(planning);
useEffect(() => {
if (!isOpen) return;
function handleClickOutside(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen]);
async function discard(item: PlanningItemView) {
setDiscardingId(item.id);
try {
const updated = await apiClient.discardLeftover(item.id);
onLeftoverDiscarded(updated);
} catch {
// Leave the line in place — the user can retry. The error surface
// here is intentionally minimal: nothing is lost by a failed discard.
} finally {
setDiscardingId(null);
}
}
return (
<div className="leftovers-basket">
<button
type="button"
className="leftovers-basket__toggle"
onClick={() => setIsOpen((open) => !open)}
aria-expanded={isOpen}
aria-label={t("planning.leftovers.tooltip")}
title={t("planning.leftovers.tooltip")}
>
<span aria-hidden="true">🧺</span>
{lines.length > 0 && <span className="leftovers-basket__count">{lines.length}</span>}
</button>
{isOpen && (
<div className="leftovers-basket__popover" ref={popoverRef}>
<h2 className="leftovers-basket__heading">{t("planning.leftovers.title")}</h2>
{lines.length === 0 ? (
<p className="leftovers-basket__empty">{t("planning.leftovers.empty")}</p>
) : (
<ul className="leftovers-basket__list">
{lines.map(({ item, available }) => (
<li key={item.id} className="leftovers-basket__line">
<div className="leftovers-basket__meta">
<span className="leftovers-basket__recipe">{item.recipe.name}</span>
<span className="leftovers-basket__origin">
{t("planning.leftovers.origin", {
day: t(`common.days.${item.weekDay}`),
meal: t(`planning.meals.${item.meal}`),
})}
</span>
</div>
<span className="leftovers-basket__qty">
{t("planning.leftovers.available", { count: available })}
</span>
<button
type="button"
className="leftovers-basket__discard"
onClick={() => void discard(item)}
disabled={discardingId === item.id}
>
{t("planning.leftovers.discard")}
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,116 @@
// Planning header's leftovers basket — an icon button that toggles a
// popover of this week's still-available leftover pools. Same "absolutely
// positioned panel hanging off a relative wrapper" shape as
// `.calendar-popover` (week-navigator.scss).
.leftovers-basket {
position: relative;
&__toggle {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
font-size: var(--font-size-base);
line-height: 1;
cursor: pointer;
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
}
// Small pill showing how many source items still have leftovers.
&__count {
min-width: 1.1rem;
padding: 0 var(--space-xs);
font-size: var(--font-size-xs);
font-weight: 700;
text-align: center;
border-radius: 999px;
background: var(--color-primary);
color: var(--color-surface);
}
&__popover {
position: absolute;
top: calc(100% + var(--space-xs));
right: 0;
z-index: 10;
width: 20rem;
padding: var(--space-md);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
}
&__heading {
margin: 0 0 var(--space-sm);
font-size: var(--font-size-sm);
font-weight: 700;
}
&__empty {
margin: 0;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
&__list {
display: flex;
flex-direction: column;
gap: var(--space-sm);
margin: 0;
padding: 0;
list-style: none;
}
&__line {
display: flex;
align-items: center;
gap: var(--space-sm);
}
&__meta {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
&__recipe {
font-weight: 600;
}
&__origin {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
&__qty {
flex-shrink: 0;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
&__discard {
flex-shrink: 0;
padding: var(--space-xs) var(--space-sm);
font-size: var(--font-size-sm);
cursor: pointer;
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text-muted);
&:hover:not(:disabled) {
color: var(--color-error);
border-color: var(--color-error);
}
&:disabled {
opacity: 0.6;
cursor: default;
}
}
}

View file

@ -0,0 +1,39 @@
import {
computeAvailableLeftover,
type PlanningItemView,
type PlanningView,
} from "@batch-cooking/shared";
/** One row of the leftovers basket — a fresh planning item that still has an unassigned, undiscarded portion pool. */
export interface LeftoverLine {
item: PlanningItemView;
/** Portions still available to serve elsewhere or throw away — always `> 0` for a line that made it into the list. */
available: number;
}
/**
* The leftovers-basket rows for a week's planning: every **fresh** item
* (`sourceItem === null`) whose leftover pool still has something in it,
* newest slots first (grid order). Pure — the `leftoverUses` each item
* needs for {@link computeAvailableLeftover} is reconstructed from the same
* flat `planning.items` array (the "leftovers" items that point back at it),
* so no extra API round-trip: the exact "one formula, two callers" split
* `computeAvailableLeftover`'s own doc comment describes.
*/
export function collectLeftoverLines(planning: PlanningView | null): LeftoverLine[] {
const items = planning?.items ?? [];
return items
.filter((item) => item.sourceItem === null)
.map((item) => ({
item,
available: computeAvailableLeftover({
portions: item.portions,
portionsRequested: item.portionsRequested,
discardedPortions: item.discardedPortions,
leftoverUses: items
.filter((other) => other.sourceItem?.id === item.id)
.map((other) => ({ portions: other.portions })),
}),
}))
.filter((line) => line.available > 0);
}

View file

@ -125,6 +125,14 @@
"title": "Planning de la semaine",
"startCooking": "Commencer à cuisiner",
"loading": "Chargement du planning…",
"leftovers": {
"tooltip": "Restes de la semaine",
"title": "Restes disponibles",
"empty": "Aucun reste cette semaine.",
"origin": "{{day}} · {{meal}}",
"available": "{{count}} en restes",
"discard": "Jeter"
},
"meals": {
"petit-dejeuner": "Petit-déjeuner",
"collation": "Collation",

View file

@ -10,6 +10,7 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { apiClient } from "../../api/client";
import { LeftoversBasket } from "../../features/planning/LeftoversBasket";
import { type PlanningSlot, RecipePickerDialog } from "../../features/planning/RecipePickerDialog";
import { WeekNavigator } from "../../features/planning/WeekNavigator";
import "./planning-page.scss";
@ -93,6 +94,11 @@ export function PlanningPage() {
patchPlanningItems((items) => [...items, item]);
}
/** `PATCH .../discard-leftover` returns the updated item — swap it in by id so the basket recomputes. */
function handleLeftoverDiscarded(updated: PlanningItemView) {
patchPlanningItems((items) => items.map((i) => (i.id === updated.id ? updated : i)));
}
/** Optimistic removal (same rollback-on-failure idea as `FavoriteStarButton`) — the API call already happened by the time this needs to roll back, `removePlanningItem` having already rejected. */
async function handleRemove(item: PlanningItemView) {
patchPlanningItems((items) => items.filter((i) => i.id !== item.id));
@ -112,6 +118,12 @@ export function PlanningPage() {
<div className="planning-page__header">
<h1>{t("planning.title")}</h1>
<WeekNavigator weekStart={weekStart} onChangeWeek={setWeekStart} />
{state.status === "loaded" && (
<LeftoversBasket
planning={state.planning}
onLeftoverDiscarded={handleLeftoverDiscarded}
/>
)}
<button
type="button"
className="planning-page__cook-btn"

View file

@ -38,6 +38,7 @@ apps/web/src/
│ ├── planning/
│ │ ├── RecipePickerDialog.tsx # dialogue "ajouter au planning" — parcourir/prévisualiser/importer, voir plus bas
│ │ ├── recipe-picker-dialog.scss
│ │ ├── LeftoversBasket.tsx + leftovers-basket.ts + leftovers-basket.scss # icône + popover des restes de la semaine dans l'en-tête du planning (issue #34)
│ │ └── WeekNavigator.tsx + week-navigator.scss # arrows + calendrier de sélection de semaine — partagé par PlanningPage et ShoppingListPage, voir plus bas
│ └── recipes/ # catalogue, import, édition — voir plus bas ; sous-dossiers par sous-domaine, pas de fichiers à plat
│ ├── RecipeTable.tsx / RecipeTabs.tsx / RecipeDetailPanel.tsx # racine : composants transverses au sous-domaine (utilisés par plusieurs des sous-dossiers ci-dessous)
@ -390,6 +391,19 @@ une bordure appuyée après `collation`/`dejeuner`/`gouter`.
immédiatement, `DELETE /planning/items/:id`, restauré en cas d'échec).
- Chaque recette planifiée s'affiche en pastille `"{nom} · ×{portions}"` avec
un bouton de retrait (✕).
- **Panier de restes** — `LeftoversBasket` (`features/planning/`) dans
l'en-tête, à côté de `WeekNavigator` : une icône 🧺 avec un badge de
compte qui ouvre un popover (même patron montage conditionnel +
`mousedown` extérieur que `CalendarPopover`) listant les restes encore
disponibles de la semaine. Les lignes sont dérivées de `planning.items`
à chaque rendu par `collectLeftoverLines` (`leftovers-basket.ts`, pur) :
chaque item « frais » (`sourceItem === null`) dont
`computeAvailableLeftover` (`@batch-cooking/shared`, `leftoverUses`
reconstitué depuis le tableau plat) est `> 0`. Bouton « Jeter » par
ligne → `PATCH /planning/items/:id/discard-leftover`, la réponse est
patchée dans `planning.items` par id. Le glisser-déposer d'un reste sur
un autre créneau arrive dans une itération ultérieure. Libellés :
`planning.leftovers.*`.
### `RecipePickerDialog` — parcourir, prévisualiser, importer