diff --git a/apps/web/cypress/e2e/planning-leftovers.feature b/apps/web/cypress/e2e/planning-leftovers.feature new file mode 100644 index 0000000..4caf151 --- /dev/null +++ b/apps/web/cypress/e2e/planning-leftovers.feature @@ -0,0 +1,24 @@ +Feature: Serving leftovers from the planning basket + As a signed-in user + I want to drag a week's leftovers onto another slot + So that I don't cook a fresh batch for a meal an existing one already covers + + Background: + Given I am signed in as "Alice" "Martin" + And my household id is 1 + And the planning has a batch with leftovers to spare + + Scenario: Drag a leftover onto another slot, confirm the portions, and see the "restes" chip + When I visit "/" + And I open the leftovers basket + Then the leftovers basket lists "Lasagnes" with 2 portions + When I drag the "Lasagnes" leftover onto Wednesday lunch + And I confirm serving 2 leftover portions + Then Wednesday lunch shows a leftovers chip for "Lasagnes" + And the leftovers basket count is gone + + Scenario: Throw a leftover away from the basket + When I visit "/" + And I open the leftovers basket + And I throw away the "Lasagnes" leftover + Then the leftovers basket shows nothing left diff --git a/apps/web/cypress/e2e/planning-leftovers.ts b/apps/web/cypress/e2e/planning-leftovers.ts new file mode 100644 index 0000000..520603a --- /dev/null +++ b/apps/web/cypress/e2e/planning-leftovers.ts @@ -0,0 +1,132 @@ +import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor"; + +// Mocks the API via cy.intercept — this job has no live backend (see +// .github/workflows/ci.yml); apps/api's Mocha suite covers the real +// leftover endpoints. Drives @dnd-kit's MouseSensor with raw mouse +// events (mousedown on the handle → two mousemoves → mouseup on the +// target), the reliable way to script one of its drags in Cypress. + +/** One fresh batch: 12 prepared for 10 requested -> a pool of 2, no leftovers item pointing at it yet. */ +const SOURCE_ITEM = { + id: 1, + weekDay: "lundi", + meal: "diner", + portions: 12, + portionsRequested: 10, + discardedPortions: 0, + sourceItem: null, + recipe: { id: 1, name: "Lasagnes" }, +}; + +Given("the planning has a batch with leftovers to spare", () => { + cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] }); + cy.intercept("GET", /\/planning\?/, { + statusCode: 200, + body: { + id: 1, + startDate: "2026-08-17T00:00:00.000Z", + finishDate: "2026-08-23T00:00:00.000Z", + items: [SOURCE_ITEM], + }, + }).as("getPlanning"); +}); + +When("I open the leftovers basket", () => { + cy.get(".leftovers-basket__toggle").click(); +}); + +Then( + "the leftovers basket lists {string} with {int} portions", + (recipe: string, portions: number) => { + cy.contains(".leftovers-basket__line", recipe).should("contain.text", `${portions} en restes`); + }, +); + +/** dnd-kit's MouseSensor listens on the drag element for `mousedown`, then on the document for `mousemove`/`mouseup`; a first move past the 5px activation distance, then a move onto the target, then release. */ +function dragLeftoverOnto(targetSelectorChain: () => Cypress.Chainable>) { + cy.get(".leftovers-basket__drag-handle") + .first() + .then(($handle) => { + const h = $handle[0].getBoundingClientRect(); + const start = { + clientX: Math.round(h.left + h.width / 2), + clientY: Math.round(h.top + h.height / 2), + }; + targetSelectorChain().then(($target) => { + const t = $target[0].getBoundingClientRect(); + const end = { + clientX: Math.round(t.left + t.width / 2), + clientY: Math.round(t.top + t.height / 2), + }; + cy.wrap($handle).trigger("mousedown", { button: 0, ...start }); + cy.get("body") + .trigger("mousemove", { + button: 0, + clientX: start.clientX + 12, + clientY: start.clientY + 12, + }) + .trigger("mousemove", { button: 0, ...end }) + .trigger("mouseup", { button: 0, ...end }); + }); + }); +} + +/** The `` for (Wednesday, lunch): MEALS order puts `dejeuner` at row index 2, WEEK_DAYS puts `mercredi` at td index 2. */ +function wednesdayLunchCell() { + return cy.get(".planning-grid tbody tr").eq(2).find("td").eq(2); +} + +When("I drag the {string} leftover onto Wednesday lunch", (_recipe: string) => { + cy.intercept("POST", "**/planning/items/leftover", (req) => { + req.reply({ + statusCode: 201, + body: { + id: 99, + weekDay: "mercredi", + meal: "dejeuner", + portions: req.body.portions, + portionsRequested: req.body.portions, + discardedPortions: 0, + sourceItem: { id: req.body.sourceItemId }, + recipe: { id: 1, name: "Lasagnes" }, + }, + }); + }).as("assignLeftover"); + + dragLeftoverOnto(wednesdayLunchCell); +}); + +When("I confirm serving {int} leftover portions", (portions: number) => { + cy.get("#leftover-assign-portions").clear().type(String(portions)); + cy.contains(".recipe-picker-confirm__confirm", "Ajouter au planning").click(); + cy.wait("@assignLeftover").its("request.body").should("deep.include", { + sourceItemId: 1, + weekDay: "mercredi", + meal: "dejeuner", + portions, + }); +}); + +Then("Wednesday lunch shows a leftovers chip for {string}", (recipe: string) => { + wednesdayLunchCell() + .find(".recipe-chip--leftover") + .should("contain.text", recipe) + .and("contain.text", "♻️"); +}); + +Then("the leftovers basket count is gone", () => { + cy.get(".leftovers-basket__count").should("not.exist"); +}); + +When("I throw away the {string} leftover", (recipe: string) => { + cy.intercept("PATCH", "**/planning/items/1/discard-leftover", { + statusCode: 200, + body: { ...SOURCE_ITEM, discardedPortions: 2 }, + }).as("discard"); + cy.contains(".leftovers-basket__line", recipe).find(".leftovers-basket__discard").click(); + cy.wait("@discard"); +}); + +Then("the leftovers basket shows nothing left", () => { + cy.contains(".leftovers-basket__empty", "Aucun reste cette semaine.").should("be.visible"); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 19ede70..17a2672 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -16,6 +16,8 @@ "dependencies": { "@batch-cooking/date-tools": "workspace:*", "@batch-cooking/shared": "workspace:*", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/utilities": "^3.2.2", "i18next": "^26.3.6", "lucide-react": "^1.32.0", "react": "^18.3.1", diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 59bc4e2..45155e7 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -2,6 +2,7 @@ import { type AddPlanningItemInput, type AllergyView, type ApiErrorResponse, + type AssignLeftoverInput, type BrowsableSourceItemView, type CreateRecipeInput, type DietView, @@ -171,6 +172,14 @@ export class ApiClient { return this._request(`/planning/items/${id}/discard-leftover`, { method: "PATCH" }); } + /** Serves `portions` of a source item's leftover pool on a slot, creating a "leftovers" planning item (same recipe as the source). Rejects with `LEFTOVER_QUANTITY_EXCEEDED` (over the pool) or `PLANNING_ITEM_NOT_FOUND`. */ + public assignLeftover(input: AssignLeftoverInput): Promise { + return this._request("/planning/items/leftover", { + method: "POST", + body: JSON.stringify(input), + }); + } + /** * 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/LeftoverAssignDialog.tsx b/apps/web/src/features/planning/LeftoverAssignDialog.tsx new file mode 100644 index 0000000..5c79c11 --- /dev/null +++ b/apps/web/src/features/planning/LeftoverAssignDialog.tsx @@ -0,0 +1,81 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Dialog } from "../../components/ui/Dialog"; + +/** + * The little "how many portions?" step shown after a leftover card is + * dropped onto a planning slot — same shape/style as `RecipePickerDialog`'s + * own portions step (`.recipe-picker-confirm`). Defaults to the whole + * available pool, capped at it; `onConfirm` does the actual + * `POST /planning/items/leftover` and reports back a rejection message to + * show inline. + */ +export function LeftoverAssignDialog({ + recipeName, + available, + onCancel, + onConfirm, +}: { + recipeName: string; + available: number; + onCancel: () => void; + /** Resolves to an error label to display, or `null` on success (the caller closes the dialog itself). */ + onConfirm: (portions: number) => Promise; +}) { + const { t } = useTranslation(); + const [portions, setPortions] = useState(String(available)); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const parsed = Number(portions); + const isValid = Number.isInteger(parsed) && parsed >= 1 && parsed <= available; + + async function submit() { + if (!isValid) return; + setIsSubmitting(true); + setError(null); + try { + const message = await onConfirm(parsed); + if (message !== null) { + setError(message); + setIsSubmitting(false); + } + } catch { + setError(t("errors.INTERNAL_ERROR")); + setIsSubmitting(false); + } + } + + return ( + +
+ + setPortions(e.target.value)} + /> + {error &&

{error}

} +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/features/planning/LeftoversBasket.tsx b/apps/web/src/features/planning/LeftoversBasket.tsx index a79c96e..db449a6 100644 --- a/apps/web/src/features/planning/LeftoversBasket.tsx +++ b/apps/web/src/features/planning/LeftoversBasket.tsx @@ -1,8 +1,9 @@ import type { PlanningItemView, PlanningView } from "@batch-cooking/shared"; +import { useDraggable } from "@dnd-kit/core"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { apiClient } from "../../api/client"; -import { collectLeftoverLines } from "./leftovers-basket"; +import { collectLeftoverLines, dragIdForLeftover } from "./leftovers-basket"; import "./leftovers-basket.scss"; /** @@ -11,12 +12,13 @@ import "./leftovers-basket.scss"; * 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. + * Each line is also draggable (`@dnd-kit`, see {@link LeftoverLineCard}) + * onto a planning cell to serve part of its pool there — `PlanningPage` + * owns the `DndContext` and the drop handling. 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 or assignment elsewhere on the page is reflected here for free. */ export function LeftoversBasket({ planning, @@ -79,28 +81,13 @@ export function LeftoversBasket({ ) : (
    {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 })} - - -
  • + void discard(item)} + /> ))}
)} @@ -109,3 +96,66 @@ export function LeftoversBasket({ ); } + +/** + * One basket line, draggable (`@dnd-kit` `useDraggable`) onto a planning + * cell to serve part of its pool there. The drag `data` carries what + * `PlanningPage`'s `onDragEnd` needs to open the "how many portions?" + * step; the sensors' activation distance (see `PlanningPage`) keeps a + * plain click on "Jeter" from starting a drag. + */ +function LeftoverLineCard({ + item, + available, + isDiscarding, + onDiscard, +}: { + item: PlanningItemView; + available: number; + isDiscarding: boolean; + onDiscard: () => void; +}) { + const { t } = useTranslation(); + // The draggable node is the small grip button, not the whole `
  • `: a + // pointer-based collision test then resolves cleanly to the single cell + // under the cursor (a wide `
  • ` box would straddle several cells). + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + id: dragIdForLeftover(item.id), + data: { sourceItemId: item.id, recipeName: item.recipe.name, available }, + }); + + return ( +
  • + +
    + {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 index c0016ed..d25e68e 100644 --- a/apps/web/src/features/planning/leftovers-basket.scss +++ b/apps/web/src/features/planning/leftovers-basket.scss @@ -69,6 +69,29 @@ display: flex; align-items: center; gap: var(--space-sm); + + &.is-dragging { + opacity: 0.5; + } + } + + // Grip handle — the only part that starts a drag, so a click on "Jeter" + // never gets hijacked. `touch-action: none` lets @dnd-kit's PointerSensor + // own the gesture on touch devices. + &__drag-handle { + flex-shrink: 0; + padding: 0 var(--space-xs); + font-size: var(--font-size-base); + line-height: 1; + cursor: grab; + color: var(--color-text-muted); + background: none; + border: none; + touch-action: none; + + &:active { + cursor: grabbing; + } } &__meta { diff --git a/apps/web/src/features/planning/leftovers-basket.ts b/apps/web/src/features/planning/leftovers-basket.ts index b269911..3e55216 100644 --- a/apps/web/src/features/planning/leftovers-basket.ts +++ b/apps/web/src/features/planning/leftovers-basket.ts @@ -11,6 +11,18 @@ export interface LeftoverLine { available: number; } +/** Payload a dragged leftover card carries (`useDraggable`'s `data`) — read by `PlanningPage`'s `onDragEnd` to pre-fill the assign step. */ +export interface LeftoverDragData { + sourceItemId: number; + recipeName: string; + available: number; +} + +/** `useDraggable` id for one leftover line — namespaced so it can't collide with a droppable cell's id. */ +export function dragIdForLeftover(itemId: number): string { + return `leftover-${itemId}`; +} + /** * The leftovers-basket rows for a week's planning: every **fresh** item * (`sourceItem === null`) whose leftover pool still has something in it, diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 7088e60..0ac1ae6 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -131,7 +131,13 @@ "empty": "Aucun reste cette semaine.", "origin": "{{day}} · {{meal}}", "available": "{{count}} en restes", - "discard": "Jeter" + "discard": "Jeter", + "dragHandle": "Glisser les restes de {{recipe}} vers un créneau", + "chipBadge": "Restes d'un autre créneau", + "assignTitle": "Servir des restes de {{recipe}}", + "assignPortionsLabel": "Portions à servir (max {{max}})", + "assignCancel": "Annuler", + "assignConfirm": "Ajouter au planning" }, "meals": { "petit-dejeuner": "Petit-déjeuner", diff --git a/apps/web/src/pages/planning/PlanningPage.tsx b/apps/web/src/pages/planning/PlanningPage.tsx index 4c49857..d24b9c5 100644 --- a/apps/web/src/pages/planning/PlanningPage.tsx +++ b/apps/web/src/pages/planning/PlanningPage.tsx @@ -1,20 +1,57 @@ import { DateTime, formatDateOnly, getWeekStart, toDateOnly } from "@batch-cooking/date-tools"; import { + ErrorCode, MEALS, type Meal, type PlanningItemView, type PlanningView, WEEK_DAYS, + type WeekDay, } from "@batch-cooking/shared"; +import { + type CollisionDetection, + closestCenter, + DndContext, + type DragEndEvent, + KeyboardSensor, + MouseSensor, + pointerWithin, + TouchSensor, + useDroppable, + useSensor, + useSensors, +} from "@dnd-kit/core"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import { apiClient } from "../../api/client"; +import { ApiError, apiClient } from "../../api/client"; +import { LeftoverAssignDialog } from "../../features/planning/LeftoverAssignDialog"; import { LeftoversBasket } from "../../features/planning/LeftoversBasket"; +import type { LeftoverDragData } from "../../features/planning/leftovers-basket"; import { type PlanningSlot, RecipePickerDialog } from "../../features/planning/RecipePickerDialog"; import { WeekNavigator } from "../../features/planning/WeekNavigator"; +import { errorMessageService } from "../../services/error-message.service"; import "./planning-page.scss"; +/** Drop-target payload each planning cell registers (`useDroppable`'s `data`) — the slot a dragged leftover lands on. */ +interface CellDropData { + date: string; + weekDay: WeekDay; + meal: Meal; +} + +/** + * Drop onto whichever cell the pointer is actually over; fall back to the + * nearest cell centre only when the pointer isn't inside any (e.g. the + * keyboard sensor, which has no pointer). Cells are packed edge to edge, so + * a rect-overlap strategy would pick a neighbour whenever the small drag + * handle's translated box crossed a border. + */ +const dropOnCellUnderPointer: CollisionDetection = (args) => { + const underPointer = pointerWithin(args); + return underPointer.length > 0 ? underPointer : closestCenter(args); +}; + /** Load state for the `GET /planning` call — a discriminated union so a stale/impossible combination (e.g. "loading" with data) can't be represented. */ type PlanningState = | { status: "loading" } @@ -48,6 +85,22 @@ export function PlanningPage() { // state for free on every open, same convention as `WeekNavigator`'s own // `CalendarPopover` (features/planning/WeekNavigator.tsx). const [openSlot, setOpenSlot] = useState(null); + // A leftover card has been dropped on a slot and we're asking how many of + // its pool to serve there — `null` means no such step is open. + const [pendingAssign, setPendingAssign] = useState<(LeftoverDragData & CellDropData) | null>( + null, + ); + + // Mouse + touch rather than the unified PointerSensor: same coverage, but + // driven by plain mouse/touch events, which Cypress can script reliably + // (see `planning-leftovers.ts`). A small activation distance so a plain + // click on a basket line's "Jeter" button doesn't start a drag; the + // keyboard sensor keeps the whole flow reachable without a pointer. + const sensors = useSensors( + useSensor(MouseSensor, { activationConstraint: { distance: 5 } }), + useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 5 } }), + useSensor(KeyboardSensor), + ); useEffect(() => { let cancelled = false; @@ -99,6 +152,35 @@ export function PlanningPage() { patchPlanningItems((items) => items.map((i) => (i.id === updated.id ? updated : i))); } + /** A leftover card was dropped on a cell — open the "how many portions?" step for that (source, slot) pair. */ + function handleDragEnd(event: DragEndEvent) { + const drag = event.active.data.current as LeftoverDragData | undefined; + const cell = event.over?.data.current as CellDropData | undefined; + if (!drag || !cell) return; + setPendingAssign({ ...drag, ...cell }); + } + + /** Confirms the assign step — `POST /planning/items/leftover`, then patches the new "leftovers" item into local state. Returns an error label to show inline, or `null` on success. */ + async function confirmAssign(portions: number): Promise { + if (!pendingAssign) return null; + try { + const item = await apiClient.assignLeftover({ + date: pendingAssign.date, + weekDay: pendingAssign.weekDay, + meal: pendingAssign.meal, + sourceItemId: pendingAssign.sourceItemId, + portions, + }); + patchPlanningItems((items) => [...items, item]); + setPendingAssign(null); + return null; + } catch (err) { + return errorMessageService.getLabel( + err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR, + ); + } + } + /** 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)); @@ -114,53 +196,68 @@ export function PlanningPage() { const hasPlannedRecipes = state.status === "loaded" && (state.planning?.items.length ?? 0) > 0; return ( -
    -
    -

    {t("planning.title")}

    - + +
    +
    +

    {t("planning.title")}

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

    {t("planning.loading")}

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

    + {t("common.loadError")} +

    + )} + {state.status === "loaded" && ( - + )} + + {openSlot && ( + setOpenSlot(null)} + onAdded={handleAdded} + /> + )} + + {pendingAssign && ( + setPendingAssign(null)} + onConfirm={confirmAssign} /> )} -
    - - {state.status === "loading" && ( -

    {t("planning.loading")}

    - )} - - {state.status === "error" && ( -

    - {t("common.loadError")} -

    - )} - - {state.status === "loaded" && ( - - )} - - {openSlot && ( - setOpenSlot(null)} - onAdded={handleAdded} - /> - )} -
    + ); } @@ -203,6 +300,9 @@ function PlanningGrid({ item.weekDay === weekDay && item.meal === meal)} onAdd={() => onAddSlot({ date: formatDateOnly(date), weekDay, meal })} onRemove={onRemoveItem} @@ -216,28 +316,55 @@ function PlanningGrid({ ); } -/** One (day, meal) cell: the recipes already planned for it (as pills, each showing its portion count) plus the "+" to add another. */ +/** One (day, meal) cell: the recipes already planned for it (as pills, each showing its portion count) plus the "+" to add another. Also a `@dnd-kit` drop target for a leftover card. */ function MealCell({ isToday, + date, + weekDay, + meal, items, onAdd, onRemove, }: { isToday: boolean; + date: string; + weekDay: WeekDay; + meal: Meal; items: PlanningItemView[]; onAdd: () => void; onRemove: (item: PlanningItemView) => void; }) { const { t } = useTranslation(); + const { setNodeRef, isOver } = useDroppable({ + id: `cell-${weekDay}-${meal}`, + data: { date, weekDay, meal } satisfies CellDropData, + }); + + const className = ["meal-cell", isToday ? "today" : "", isOver ? "meal-cell--drop-over" : ""] + .filter(Boolean) + .join(" "); return ( - +
    {items.length > 0 && (
    {items.map((item) => ( - + + {item.sourceItem !== null && ( + + ♻️ + + )} {item.recipe.name} · ×{item.portions}