diff --git a/apps/web/cypress/e2e/leftovers-basket.cy.ts b/apps/web/cypress/e2e/leftovers-basket.cy.ts new file mode 100644 index 0000000..9377c58 --- /dev/null +++ b/apps/web/cypress/e2e/leftovers-basket.cy.ts @@ -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"); + }); +}); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 4ababb9..59bc4e2 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -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 { + 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 diff --git a/apps/web/src/features/planning/LeftoversBasket.tsx b/apps/web/src/features/planning/LeftoversBasket.tsx new file mode 100644 index 0000000..a79c96e --- /dev/null +++ b/apps/web/src/features/planning/LeftoversBasket.tsx @@ -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(null); + const popoverRef = useRef(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 ( +
+ + + {isOpen && ( +
+

{t("planning.leftovers.title")}

+ {lines.length === 0 ? ( +

{t("planning.leftovers.empty")}

+ ) : ( +
    + {lines.map(({ item, available }) => ( +
  • +
    + {item.recipe.name} + + {t("planning.leftovers.origin", { + day: t(`common.days.${item.weekDay}`), + meal: t(`planning.meals.${item.meal}`), + })} + +
    + + {t("planning.leftovers.available", { count: available })} + + +
  • + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/apps/web/src/features/planning/leftovers-basket.scss b/apps/web/src/features/planning/leftovers-basket.scss new file mode 100644 index 0000000..c0016ed --- /dev/null +++ b/apps/web/src/features/planning/leftovers-basket.scss @@ -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; + } + } +} diff --git a/apps/web/src/features/planning/leftovers-basket.ts b/apps/web/src/features/planning/leftovers-basket.ts new file mode 100644 index 0000000..b269911 --- /dev/null +++ b/apps/web/src/features/planning/leftovers-basket.ts @@ -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); +} diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 1261f86..7088e60 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -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", diff --git a/apps/web/src/pages/planning/PlanningPage.tsx b/apps/web/src/pages/planning/PlanningPage.tsx index 80d4ebf..4c49857 100644 --- a/apps/web/src/pages/planning/PlanningPage.tsx +++ b/apps/web/src/pages/planning/PlanningPage.tsx @@ -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() {

{t("planning.title")}

+ {state.status === "loaded" && ( + + )}