Compare commits
2 commits
f146e65348
...
25b4fc97f0
| Author | SHA1 | Date | |
|---|---|---|---|
| 25b4fc97f0 | |||
| 8278878898 |
8 changed files with 413 additions and 0 deletions
108
apps/web/cypress/e2e/leftovers-basket.cy.ts
Normal file
108
apps/web/cypress/e2e/leftovers-basket.cy.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
111
apps/web/src/features/planning/LeftoversBasket.tsx
Normal file
111
apps/web/src/features/planning/LeftoversBasket.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
apps/web/src/features/planning/leftovers-basket.scss
Normal file
116
apps/web/src/features/planning/leftovers-basket.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
39
apps/web/src/features/planning/leftovers-basket.ts
Normal file
39
apps/web/src/features/planning/leftovers-basket.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue