Merge pull request #26 from kyuno053/feat/planning-recipe-picker

feat(planning): dialog de sélection de recette + assignation avec portions
This commit is contained in:
kyuno053 2026-08-19 15:27:01 +02:00 committed by GitHub
commit a8eea46c58
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1076 additions and 40 deletions

View file

@ -0,0 +1,9 @@
-- Adds the number of portions to prepare for a planning slot
-- (`PlanningItem.portions`), entered manually by whoever assigns the
-- recipe — no default exists anywhere to derive it from (no such concept
-- on `Recipe` either). Backfills any pre-existing row with 1 portion via a
-- transient DEFAULT, then drops that default so it isn't implicitly
-- reused for new inserts going forward (the app always sends an explicit
-- value, see `addPlanningItemSchema`).
ALTER TABLE "planning_item" ADD COLUMN "portions" INTEGER NOT NULL DEFAULT 1;
ALTER TABLE "planning_item" ALTER COLUMN "portions" DROP DEFAULT;

View file

@ -199,6 +199,7 @@ model PlanningItem {
weekDay String @map("week_day") weekDay String @map("week_day")
meal String meal String
recipeId Int @map("recipe_id") recipeId Int @map("recipe_id")
portions Int
planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade) planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade)
recipe Recipe @relation(fields: [recipeId], references: [id]) recipe Recipe @relation(fields: [recipeId], references: [id])

View file

@ -1,10 +1,10 @@
import { parseDateOnly } from "@batch-cooking/date-tools"; import { parseDateOnly } from "@batch-cooking/date-tools";
import { HttpError } from "@batch-cooking/error-tools"; import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools"; import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { ErrorCode, getPlanningByDateSchema } from "@batch-cooking/shared"; import { ErrorCode, addPlanningItemSchema, getPlanningByDateSchema } from "@batch-cooking/shared";
import { Router } from "express"; import { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { getPlanningForDate } from "./planning.service.js"; import { addPlanningItem, getPlanningForDate, removePlanningItem } from "./planning.service.js";
/** Router mounted at `/planning` in app.ts. */ /** Router mounted at `/planning` in app.ts. */
export const planningRouter = Router(); export const planningRouter = Router();
@ -34,3 +34,48 @@ planningRouter.get(
res.status(200).json(planning); res.status(200).json(planning);
}), }),
); );
/** Parses and validates the `:id` route param shared by every `/items/:id` route below. */
function parsePlanningItemId(rawId: string | undefined): number {
const id = Number(rawId);
if (!Number.isInteger(id)) {
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "id must be an integer");
}
return id;
}
/**
* Adds a recipe to one (day, meal) slot of the authenticated user's
* household's planning, creating that week's `Planning` row on the fly if
* needed (see {@link addPlanningItem}).
*/
planningRouter.post(
"/items",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const input = addPlanningItemSchema.parse(req.body);
const date = parseDateOnly(input.date);
if (date === null) {
throw new HttpError(
400,
ErrorCode.VALIDATION_ERROR,
`Not a real calendar date: ${input.date}`,
);
}
const { id: viewerId, houseId } = res.locals.userProfile;
const item = await addPlanningItem(houseId, viewerId, houseId, date, input);
res.status(201).json(item);
}),
);
/** Removes one recipe from a planning slot. */
planningRouter.delete(
"/items/:id",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const id = parsePlanningItemId(req.params.id);
await removePlanningItem(id, res.locals.userProfile.houseId);
res.status(204).end();
}),
);

View file

@ -1,6 +1,13 @@
import { type DateTime, toDateOnly } from "@batch-cooking/date-tools"; import { type DateTime, getWeekStart, toDateOnly } from "@batch-cooking/date-tools";
import type { PlanningView } from "@batch-cooking/shared"; import { HttpError } from "@batch-cooking/error-tools";
import {
type AddPlanningItemInput,
ErrorCode,
type PlanningItemView,
type PlanningView,
} from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js"; import { prisma } from "../../db/prisma.js";
import { assertRecipeVisible } from "../recipe/recipe.service.js";
/** /**
* Finds the household's planning that covers `date` and shapes it into a * Finds the household's planning that covers `date` and shapes it into a
@ -12,9 +19,9 @@ import { prisma } from "../../db/prisma.js";
* Returns `null` for two distinct, both entirely normal states a * Returns `null` for two distinct, both entirely normal states a
* `houseId` of `null` (the profile has no household yet households are no * `houseId` of `null` (the profile has no household yet households are no
* longer created automatically at signup, see `auth.service.ts`) and "no * longer created automatically at signup, see `auth.service.ts`) and "no
* planning row covers this date" (the expected case until planning * planning row covers this date yet" (e.g. a week nobody has added a recipe
* creation is built) neither is an error, so both collapse to the same * to via {@link addPlanningItem}) neither is an error, so both collapse
* "nothing to show yet" result rather than throwing. * to the same "nothing to show yet" result rather than throwing.
*/ */
export async function getPlanningForDate( export async function getPlanningForDate(
houseId: number | null, houseId: number | null,
@ -59,7 +66,102 @@ export async function getPlanningForDate(
id: item.id, id: item.id,
weekDay: item.weekDay, weekDay: item.weekDay,
meal: item.meal, meal: item.meal,
portions: item.portions,
recipe: item.recipe, recipe: item.recipe,
})), })),
}; };
} }
/**
* Finds the household's `Planning` covering the week `weekStart` (a Monday)
* starts, creating it on the fly (spanning the full Monday-to-Sunday week)
* if none exists yet. Matched by exact `startDate`, not a covering range
* like {@link getPlanningForDate} that query is for "what covers today",
* this one is for "the row `addPlanningItem` should attach to", so it needs
* to land on the same row a second add to the same week would reuse rather
* than risk matching some other overlapping planning.
*
* Not wrapped in a transaction with the `findFirst` no unique constraint
* exists on `(houseId, startDate)` (see {@link getPlanningForDate}'s doc
* comment on the same gap), so two concurrent first-adds to an empty week
* could each create their own `Planning` row. Accepted at this project's
* scale rather than adding a migration + retry-on-conflict loop for it.
*/
async function findOrCreatePlanningForWeek(houseId: number, weekStart: DateTime) {
const startDate = weekStart.toJSDate();
const existing = await prisma.planning.findFirst({ where: { houseId, startDate } });
if (existing) {
return existing;
}
return prisma.planning.create({
data: { houseId, startDate, finishDate: weekStart.plus({ days: 6 }).toJSDate() },
});
}
/**
* Adds a recipe to one (day, meal) slot of `houseId`'s planning for the
* week containing `date`, creating that week's `Planning` row first if it
* doesn't exist yet (see {@link findOrCreatePlanningForWeek}) this is the
* only place a `Planning` row gets created at all today, there's no
* separate "create an empty planning" action. `date` is a caller-parsed
* `DateTime` (see `planning.routes.ts`, which validates `input.date` the
* same way `GET /planning` validates its own `?date=`, before calling
* here) rather than the raw `input.date` string only its week matters,
* not the exact day.
*
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if `houseId` is `null` (the caller has no household).
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `input.recipeId` doesn't match a recipe visible to `viewerId` — see `recipe.service.ts`'s `assertRecipeVisible`.
*/
export async function addPlanningItem(
houseId: number | null,
viewerId: number,
viewerHouseId: number | null,
date: DateTime,
input: AddPlanningItemInput,
): Promise<PlanningItemView> {
if (houseId === null) {
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
}
await assertRecipeVisible(input.recipeId, viewerId, viewerHouseId);
const weekStart = getWeekStart(toDateOnly(date));
const planning = await findOrCreatePlanningForWeek(houseId, weekStart);
const item = await prisma.planningItem.create({
data: {
planningId: planning.id,
weekDay: input.weekDay,
meal: input.meal,
recipeId: input.recipeId,
portions: input.portions,
},
include: { recipe: { select: { id: true, name: true } } },
});
return {
id: item.id,
weekDay: item.weekDay,
meal: item.meal,
portions: item.portions,
recipe: item.recipe,
};
}
/**
* Removes one planning item outright. The parent `Planning` row is left in
* place even if this was its last item an empty planning is a normal,
* already-handled state on the read side (`getPlanningForDate`'s grid just
* shows every slot's "+"), not worth a cleanup pass here.
*
* @throws {HttpError} `404 PLANNING_ITEM_NOT_FOUND` if `id` doesn't match any planning item, or does but belongs to a planning outside `houseId` — never `403`, same "don't confirm what exists" reasoning as `RECIPE_NOT_FOUND` elsewhere.
*/
export async function removePlanningItem(id: number, houseId: number | null): Promise<void> {
const item = await prisma.planningItem.findUnique({
where: { id },
include: { planning: true },
});
if (!item || houseId === null || item.planning.houseId !== houseId) {
throw new HttpError(404, ErrorCode.PLANNING_ITEM_NOT_FOUND, `Planning item ${id} not found`);
}
await prisma.planningItem.delete({ where: { id } });
}

View file

@ -36,7 +36,14 @@ recipeRouter.get(
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => { wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const input = listRecipesSchema.parse(req.query); const input = listRecipesSchema.parse(req.query);
const { id: viewerId, houseId } = res.locals.userProfile; const { id: viewerId, houseId } = res.locals.userProfile;
res.status(200).json(await listRecipes(viewerId, houseId, input.tab, input.search)); res.status(200).json(
await listRecipes(viewerId, houseId, input.tab, {
search: input.search,
suitableForHousehold: input.suitableForHousehold,
ingredientIds: input.ingredientIds,
dietIds: input.dietIds,
}),
);
}), }),
); );

View file

@ -140,9 +140,71 @@ function visibleToViewerWhere(
}; };
} }
/**
* `Recipe` rows that avoid every member of `houseId`'s declared allergens
* and, for every member with a declared regime, are tagged with that
* regime the planning recipe picker's "convient à tout le foyer" toggle
* (`suitableForHousehold` on `listRecipes`). Computed server-side (a small
* extra query to gather the household's members' allergy/regime ids)
* rather than exposed to the client as raw per-member data: a member's
* allergies/regime are private the same way visibility already keeps a
* recipe's existence private (404, never 403) nothing here should let
* one member infer another's medical/dietary info from the shape of a
* filtered list. Deliberately excludes `UserProfileDislikedIngredient`
* the schema already treats disliked ingredients as a taste preference,
* not a safety constraint (see that model's doc comment), so it doesn't
* belong in a filter framed around what's safe/appropriate to serve.
*/
async function suitableForHouseholdWhere(houseId: number): Promise<Prisma.RecipeWhereInput> {
const members = await prisma.userProfile.findMany({
where: { houseId },
select: { dietId: true, allergies: { select: { allergyId: true } } },
});
const requiredDietIds = [
...new Set(members.map((m) => m.dietId).filter((id): id is number => id !== null)),
];
const excludedAllergyIds = [
...new Set(members.flatMap((m) => m.allergies.map((a) => a.allergyId))),
];
const conditions: Prisma.RecipeWhereInput[] = [];
if (requiredDietIds.length > 0) {
// Every diet declared by a member must be among this recipe's tags —
// not "at least one", since a recipe suiting a vegetarian member
// doesn't automatically suit a gluten-free one too.
conditions.push({ AND: requiredDietIds.map((dietId) => ({ diets: { some: { dietId } } })) });
}
if (excludedAllergyIds.length > 0) {
conditions.push({
ingredients: {
none: { ingredient: { allergies: { some: { allergyId: { in: excludedAllergyIds } } } } },
},
});
}
return { AND: conditions };
}
/**
* Optional narrowing filters for {@link listRecipes}, on top of the
* mandatory `tab`/`viewerId`/`viewerHouseId` grouped into one object
* rather than a growing list of positional optional params now that the
* planning recipe picker adds two more on top of `search`/
* `suitableForHousehold`.
*/
export interface ListRecipesFilters {
/** Case-insensitive name substring. */
search?: string;
/** The planning recipe picker's "convient à tout le foyer" toggle — see {@link suitableForHouseholdWhere}. */
suitableForHousehold?: boolean;
/** Recipe must carry *every* one of these ingredient ids (AND, not "any of") — the planning recipe picker's ingredient filter. */
ingredientIds?: number[];
/** Recipe must be tagged with *every* one of these diet ids (AND, same reasoning) — the planning recipe picker's regime filter. */
dietIds?: number[];
}
/** /**
* The recipes visible to `viewerId` under one catalog tab, alphabetically, * The recipes visible to `viewerId` under one catalog tab, alphabetically,
* optionally filtered further by a case-insensitive name substring. No * optionally filtered further (see {@link ListRecipesFilters}). No
* "toutes" tab every recipe a viewer can see falls under exactly one of * "toutes" tab every recipe a viewer can see falls under exactly one of
* `perso`/`foyer`/`publique` (its own visibility); `favoris` is an * `perso`/`foyer`/`publique` (its own visibility); `favoris` is an
* orthogonal, cross-cutting filter on top (and re-applies * orthogonal, cross-cutting filter on top (and re-applies
@ -153,12 +215,29 @@ export async function listRecipes(
viewerId: number, viewerId: number,
viewerHouseId: number | null, viewerHouseId: number | null,
tab: RecipeTab, tab: RecipeTab,
search?: string, filters: ListRecipesFilters = {},
): Promise<RecipeSummaryView[]> { ): Promise<RecipeSummaryView[]> {
const { search, suitableForHousehold, ingredientIds, dietIds } = filters;
const conditions: Prisma.RecipeWhereInput[] = []; const conditions: Prisma.RecipeWhereInput[] = [];
if (search) { if (search) {
conditions.push({ name: { contains: search, mode: "insensitive" } }); conditions.push({ name: { contains: search, mode: "insensitive" } });
} }
// No-op without a household — nothing to filter against, same posture as
// the `foyer` tab returning everything it can rather than throwing.
if (suitableForHousehold && viewerHouseId !== null) {
conditions.push(await suitableForHouseholdWhere(viewerHouseId));
}
if (ingredientIds && ingredientIds.length > 0) {
// One condition per required id (AND) — a recipe must carry all of
// them, not just one, same "every one, not any one" posture as
// suitableForHouseholdWhere's requiredDietIds.
conditions.push({
AND: ingredientIds.map((ingredientId) => ({ ingredients: { some: { ingredientId } } })),
});
}
if (dietIds && dietIds.length > 0) {
conditions.push({ AND: dietIds.map((dietId) => ({ diets: { some: { dietId } } })) });
}
switch (tab) { switch (tab) {
case "favoris": case "favoris":
@ -360,6 +439,25 @@ export async function removeFavorite(id: number, viewerId: number): Promise<void
await prisma.recipeFavorite.deleteMany({ where: { userProfileId: viewerId, recipeId: id } }); await prisma.recipeFavorite.deleteMany({ where: { userProfileId: viewerId, recipeId: id } });
} }
/**
* Guard for other modules that need to confirm a recipe is visible to a
* viewer before referencing it (e.g. `planning.service.ts`'s
* `addPlanningItem`, before creating a `PlanningItem` pointing at it)
* exported rather than duplicating {@link canView} at the call site.
*
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe, or does but isn't visible to `viewerId` never `403`, same reasoning as `getRecipe`.
*/
export async function assertRecipeVisible(
id: number,
viewerId: number,
viewerHouseId: number | null,
): Promise<void> {
const recipe = await findRecipeOrThrow(id, viewerId);
if (!canView(recipe, viewerId, viewerHouseId)) {
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
}
}
/** Re-fetches a recipe by id (with {@link recipeInclude}), or throws `404 RECIPE_NOT_FOUND` — the shared "load or reject" step for every recipe endpoint. Does *not* check visibility on its own — callers combine it with {@link canView} (read paths) or {@link assertIsAuthor} (write paths). */ /** Re-fetches a recipe by id (with {@link recipeInclude}), or throws `404 RECIPE_NOT_FOUND` — the shared "load or reject" step for every recipe endpoint. Does *not* check visibility on its own — callers combine it with {@link canView} (read paths) or {@link assertIsAuthor} (write paths). */
async function findRecipeOrThrow(id: number, viewerId: number): Promise<RecipeWithDetails> { async function findRecipeOrThrow(id: number, viewerId: number): Promise<RecipeWithDetails> {
const recipe = await prisma.recipe.findUnique({ const recipe = await prisma.recipe.findUnique({

View file

@ -108,7 +108,13 @@ describe("Planning", () => {
}, },
}); });
await prisma.planningItem.create({ await prisma.planningItem.create({
data: { planningId: planning.id, weekDay: "lundi", meal: "diner", recipeId: recipe.id }, data: {
planningId: planning.id,
weekDay: "lundi",
meal: "diner",
recipeId: recipe.id,
portions: 4,
},
}); });
const res = await agent.get("/planning").query({ date: today() }); const res = await agent.get("/planning").query({ date: today() });
@ -159,7 +165,13 @@ describe("Planning", () => {
}, },
}); });
await prisma.planningItem.create({ await prisma.planningItem.create({
data: { planningId: planning.id, weekDay: "mardi", meal: "dejeuner", recipeId: recipe.id }, data: {
planningId: planning.id,
weekDay: "mardi",
meal: "dejeuner",
recipeId: recipe.id,
portions: 2,
},
}); });
const res = await agent.get("/planning").query({ date: isoDate(nextWeek) }); const res = await agent.get("/planning").query({ date: isoDate(nextWeek) });

View file

@ -461,7 +461,13 @@ describe("Recipes", () => {
}, },
}); });
await prisma.planningItem.create({ await prisma.planningItem.create({
data: { planningId: planning.id, weekDay: "lundi", meal: "diner", recipeId: recipe.id }, data: {
planningId: planning.id,
weekDay: "lundi",
meal: "diner",
recipeId: recipe.id,
portions: 4,
},
}); });
const res = await agent.delete(`/recipes/${recipe.id}`); const res = await agent.delete(`/recipes/${recipe.id}`);

View file

@ -82,11 +82,18 @@ describe("Planning grid", () => {
startDate: "2026-08-17T00:00:00.000Z", startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z", finishDate: "2026-08-23T00:00:00.000Z",
items: [ items: [
{ id: 1, weekDay: "mardi", meal: "diner", recipe: { id: 1, name: "Ratatouille" } }, {
id: 1,
weekDay: "mardi",
meal: "diner",
portions: 4,
recipe: { id: 1, name: "Ratatouille" },
},
{ {
id: 2, id: 2,
weekDay: "mercredi", weekDay: "mercredi",
meal: "dejeuner", meal: "dejeuner",
portions: 2,
recipe: { id: 2, name: "Curry de lentilles" }, recipe: { id: 2, name: "Curry de lentilles" },
}, },
], ],

View file

@ -1,4 +1,5 @@
import { import {
type AddPlanningItemInput,
type AllergyView, type AllergyView,
type ApiErrorResponse, type ApiErrorResponse,
type CreateRecipeInput, type CreateRecipeInput,
@ -7,6 +8,7 @@ import {
type HouseView, type HouseView,
type IngredientView, type IngredientView,
type LoginInput, type LoginInput,
type PlanningItemView,
type PlanningView, type PlanningView,
type PreferencesView, type PreferencesView,
type RecipeSummaryView, type RecipeSummaryView,
@ -128,6 +130,22 @@ export class ApiClient {
return this.request(`/planning?date=${date}`); return this.request(`/planning?date=${date}`);
} }
/**
* Adds a recipe to one (day, meal) slot of the household's planning for
* the week containing `input.date`, creating that week's planning on the
* fly if it doesn't exist yet rejects with `HOUSE_NOT_FOUND` (no
* household) or `RECIPE_NOT_FOUND` (the recipe isn't visible to the
* caller).
*/
public addPlanningItem(input: AddPlanningItemInput): Promise<PlanningItemView> {
return this.request("/planning/items", { method: "POST", body: JSON.stringify(input) });
}
/** Removes one recipe from a planning slot — rejects with `PLANNING_ITEM_NOT_FOUND`. */
public removePlanningItem(id: number): Promise<void> {
return this.request(`/planning/items/${id}`, { method: "DELETE" });
}
/** Reference list of dietary regimes to pick from (signup wizard, `/foyer`). Public — no session required. */ /** Reference list of dietary regimes to pick from (signup wizard, `/foyer`). Public — no session required. */
public getDiets(): Promise<DietView[]> { public getDiets(): Promise<DietView[]> {
return this.request("/reference/diets"); return this.request("/reference/diets");
@ -143,10 +161,27 @@ export class ApiClient {
return this.request("/reference/ingredients"); return this.request("/reference/ingredients");
} }
/** One catalog tab (favoris/perso/foyer/publique — see `RecipeTab`), optionally filtered further by a name substring. */ /**
public listRecipes(tab: RecipeTab, search?: string): Promise<RecipeSummaryView[]> { * One catalog tab (favoris/perso/foyer/publique see `RecipeTab`),
* optionally narrowed further `search` (name substring),
* `suitableForHousehold` (the planning recipe picker's "convient à tout
* le foyer" toggle), `ingredientIds`/`dietIds` (that same picker's
* ingredient/regime filters a recipe must carry *every* id listed).
*/
public listRecipes(
tab: RecipeTab,
filters: {
search?: string;
suitableForHousehold?: boolean;
ingredientIds?: number[];
dietIds?: number[];
} = {},
): Promise<RecipeSummaryView[]> {
const params = new URLSearchParams({ tab }); const params = new URLSearchParams({ tab });
if (search) params.set("search", search); if (filters.search) params.set("search", filters.search);
if (filters.suitableForHousehold) params.set("suitableForHousehold", "true");
for (const id of filters.ingredientIds ?? []) params.append("ingredientIds", String(id));
for (const id of filters.dietIds ?? []) params.append("dietIds", String(id));
return this.request(`/recipes?${params.toString()}`); return this.request(`/recipes?${params.toString()}`);
} }

View file

@ -0,0 +1,104 @@
import { type ReactNode, useEffect, useRef } from "react";
import "./dialog.scss";
/**
* App-wide modal primitive a native `<dialog>` (`showModal()`), not a
* `role="dialog"` div: the browser handles the modal semantics, the
* focus trap, Escape-to-close and the backdrop for free instead of this
* component reimplementing all four. First modal in the app every other
* "confirm/cancel" surface so far (`RecipeDetailPanel`'s delete button,
* the settings pages' danger zones) is an inline two-step reveal, not an
* overlay; a full recipe catalog + filters (`RecipePickerDialog`) doesn't
* fit inline, hence this.
*
* Mounted only while open (see `PlanningPage`'s conditional rendering of
* `RecipePickerDialog`, same convention as its own `CalendarPopover`)
* `showModal()` fires once on mount rather than toggling on an `isOpen`
* prop, since closing this component means the caller stops rendering it
* rather than flipping a prop on a permanently-mounted instance.
*/
export function Dialog({
onClose,
title,
children,
className,
}: {
onClose: () => void;
title?: string;
children: ReactNode;
className?: string;
}) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
dialogRef.current?.showModal();
}, []);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
// `close` covers every way a native dialog can close — Escape (which
// fires `cancel` first, then `close`) as much as a future
// `<form method="dialog">` — so this is the one listener needed to
// keep the caller's own "is this open" state (e.g. `PlanningPage`'s
// `openSlot`) in sync with it.
dialog.addEventListener("close", onClose);
return () => dialog.removeEventListener("close", onClose);
}, [onClose]);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
// Attached imperatively (not a JSX `onClick`) since the click-to-close
// it implements is already reachable from the keyboard via Escape
// (native `cancel`/`close`, wired above) — a JSX `onClick` here would
// trip the "needs a matching keyboard handler" a11y lint for a
// non-interactive element even though one already exists, just not in
// a form that lint rule can see.
function handleClick(e: MouseEvent) {
// Re-read from the ref (not the outer `dialog` const) — TS can't
// carry that early-return narrowing into a nested function, since it
// can't prove the function won't run at some later point where it no
// longer holds (even though here, as an event listener on this same
// element, it trivially still does).
const current = dialogRef.current;
if (!current) return;
// A click lands on the `<dialog>` element itself both for the
// backdrop *and* for its own unfilled padding/margin — comparing
// against its content box (not just `e.target`) is what actually
// distinguishes "outside the panel" from "on it".
const rect = current.getBoundingClientRect();
const inside =
e.clientX >= rect.left &&
e.clientX <= rect.right &&
e.clientY >= rect.top &&
e.clientY <= rect.bottom;
if (!inside) current.close();
}
dialog.addEventListener("click", handleClick);
return () => dialog.removeEventListener("click", handleClick);
}, []);
return (
<dialog
ref={dialogRef}
className={["dialog-panel", className].filter(Boolean).join(" ")}
aria-label={title}
>
{title && (
<div className="dialog-panel__header">
<h2>{title}</h2>
<button
type="button"
className="dialog-panel__close"
onClick={() => dialogRef.current?.close()}
aria-label="Fermer"
>
</button>
</div>
)}
<div className="dialog-panel__body">{children}</div>
</dialog>
);
}

View file

@ -0,0 +1,63 @@
// Modal panel see Dialog.tsx. A native <dialog> opened via showModal(),
// so the browser supplies the backdrop/centering/focus-trap; this only
// resets its default UA styling (border, padding, colors) and layers the
// header/body layout on top. Colocated here rather than in global.scss
// since it's one component's styling, same convention as every feature's
// own .scss file (recipes.scss, planning-page.scss, ).
.dialog-panel {
display: flex;
flex-direction: column;
width: 100%;
max-width: 48rem;
max-height: calc(100vh - var(--space-2xl));
margin: auto; // UA default for a shown <dialog>, kept explicit
padding: 0;
border: none;
background: var(--color-surface);
color: var(--color-text);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
overflow: hidden;
&::backdrop {
background: rgba(0, 0, 0, 0.45);
}
}
.dialog-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
padding: var(--space-md) var(--space-lg);
border-bottom: 1px solid var(--color-border);
h2 {
font-size: var(--font-size-lg);
}
}
.dialog-panel__close {
flex: none;
width: 2rem;
height: 2rem;
border: none;
border-radius: var(--radius-base);
background: transparent;
color: var(--color-text-muted);
font-size: var(--font-size-md);
cursor: pointer;
&:hover {
background: var(--color-surface-alt);
color: var(--color-text);
}
}
.dialog-panel__body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: var(--space-lg);
}

View file

@ -0,0 +1,283 @@
import {
type DietView,
ErrorCode,
type IngredientView,
type Meal,
type PlanningItemView,
type RecipeSummaryView,
type RecipeTab,
type WeekDay,
} from "@batch-cooking/shared";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ApiError, apiClient } from "../../api/client";
import { CheckboxOption } from "../../components/ui/Checkbox";
import { Dialog } from "../../components/ui/Dialog";
import { errorMessageService } from "../../services/error-message.service";
import { DietTagSelect } from "../recipes/DietTagSelect";
import { IngredientPicker } from "../recipes/IngredientPicker";
import { RecipeTable } from "../recipes/RecipeTable";
import { RecipeTabs } from "../recipes/RecipeTabs";
import "./recipe-picker-dialog.scss";
/** Debounce for the search field — same value as `RecipesPage`'s. */
const SEARCH_DEBOUNCE_MS = 300;
/** Load state for the filtered catalog list, same discriminated-union shape as `RecipesPage`'s `RecipeListState`. */
type ListState =
| { status: "loading" }
| { status: "loaded"; recipes: RecipeSummaryView[] }
| { status: "error" };
/**
* The (day, meal) slot a `RecipePickerDialog` is adding a recipe to
* `date` is that day's `YYYY-MM-DD` (the specific date within the
* displayed week, not just its weekday), needed by `POST /planning/items`
* to resolve which week's `Planning` row to attach to.
*/
export interface PlanningSlot {
date: string;
weekDay: WeekDay;
meal: Meal;
}
/**
* Recipe-selection dialog opened from a planning grid cell's "+" button
* (see `PlanningPage.tsx`'s `MealCell`) the same catalog browsing
* experience as `/recettes` (`RecipeTabs` + `RecipeTable`, reused as-is),
* with three extra filters layered on top of the plain name search
* (ingredients / regime / "convient à tout le foyer" toggle, all wired to
* `GET /recipes`'s corresponding query params) since browsing here is
* about finding something to cook, not just looking something up.
*
* Mounted only while open (see `PlanningPage`, same conditional-mount
* convention as its own `CalendarPopover`) every piece of local state
* below resets for free the next time it's reopened, no manual reset
* needed.
*
* Selecting a row doesn't navigate anywhere (unlike `RecipesPage`'s own
* use of `RecipeTable`) it switches this same dialog to a small
* "how many portions?" confirmation step, then calls `POST
* /planning/items` on submit.
*/
export function RecipePickerDialog({
slot,
onClose,
onAdded,
}: {
slot: PlanningSlot;
onClose: () => void;
onAdded: (item: PlanningItemView) => void;
}) {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<RecipeTab>("favoris");
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [selectedIngredientIds, setSelectedIngredientIds] = useState<number[]>([]);
const [selectedDietIds, setSelectedDietIds] = useState<number[]>([]);
const [suitableForHousehold, setSuitableForHousehold] = useState(false);
const [hasHousehold, setHasHousehold] = useState(false);
const [isIngredientPickerOpen, setIsIngredientPickerOpen] = useState(false);
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
const [listState, setListState] = useState<ListState>({ status: "loading" });
// The recipe picked in step 1 — `null` while still browsing, set once a
// row is clicked to switch this dialog into its confirmation step.
const [selectedRecipe, setSelectedRecipe] = useState<RecipeSummaryView | null>(null);
const [portions, setPortions] = useState("1");
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
useEffect(() => {
const timeout = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS);
return () => window.clearTimeout(timeout);
}, [search]);
// Reference lists + "does the viewer have a household" — loaded once,
// they don't change while the dialog is open.
useEffect(() => {
apiClient
.getIngredients()
.then(setIngredientsCatalog)
.catch(() => setIngredientsCatalog([]));
apiClient
.getDiets()
.then(setDietsCatalog)
.catch(() => setDietsCatalog([]));
apiClient
.getCurrentHouse()
.then((house) => setHasHousehold(house !== null))
.catch(() => setHasHousehold(false));
}, []);
useEffect(() => {
let cancelled = false;
setListState({ status: "loading" });
apiClient
.listRecipes(activeTab, {
search: debouncedSearch.trim() || undefined,
suitableForHousehold: suitableForHousehold || undefined,
ingredientIds: selectedIngredientIds,
dietIds: selectedDietIds,
})
.then((recipes) => {
if (!cancelled) setListState({ status: "loaded", recipes });
})
.catch(() => {
if (!cancelled) setListState({ status: "error" });
});
return () => {
cancelled = true;
};
}, [activeTab, debouncedSearch, selectedIngredientIds, selectedDietIds, suitableForHousehold]);
const selectedIngredients = ingredientsCatalog.filter((ingredient) =>
selectedIngredientIds.includes(ingredient.id),
);
async function handleConfirm() {
if (!selectedRecipe) return;
const parsedPortions = Number(portions);
if (!Number.isInteger(parsedPortions) || parsedPortions < 1) return;
setIsSubmitting(true);
setSubmitError(null);
try {
const item = await apiClient.addPlanningItem({
date: slot.date,
weekDay: slot.weekDay,
meal: slot.meal,
recipeId: selectedRecipe.id,
portions: parsedPortions,
});
onAdded(item);
onClose();
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setSubmitError(errorMessageService.getLabel(code));
setIsSubmitting(false);
}
}
if (selectedRecipe) {
return (
<Dialog
onClose={onClose}
title={t("planning.picker.confirmTitle", { recipe: selectedRecipe.name })}
>
<div className="recipe-picker-confirm">
<label htmlFor="planning-picker-portions">{t("planning.picker.portionsLabel")}</label>
<input
id="planning-picker-portions"
type="number"
min="1"
step="1"
value={portions}
onChange={(e) => setPortions(e.target.value)}
/>
{submitError && <p className="field-error">{submitError}</p>}
<div className="recipe-picker-confirm__actions">
<button type="button" onClick={() => setSelectedRecipe(null)} disabled={isSubmitting}>
{t("planning.picker.backButton")}
</button>
<button
type="button"
className="recipe-picker-confirm__confirm"
onClick={handleConfirm}
disabled={isSubmitting}
>
{isSubmitting ? t("planning.picker.adding") : t("planning.picker.confirmButton")}
</button>
</div>
</div>
</Dialog>
);
}
return (
<Dialog onClose={onClose} title={t("planning.picker.title")} className="recipe-picker-dialog">
<div className="recipe-picker__filters">
<input
type="search"
className="recipe-picker__search"
placeholder={t("planning.picker.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="recipe-picker__ingredient-filter">
<span className="recipe-picker__filter-label">
{t("planning.picker.ingredientsFilterLabel")}
</span>
<div className="recipe-picker__chips">
{selectedIngredients.map((ingredient) => (
<span key={ingredient.id} className="filter-chip">
{t(`catalog.ingredients.${ingredient.key}`)}
<button
type="button"
onClick={() =>
setSelectedIngredientIds((ids) => ids.filter((id) => id !== ingredient.id))
}
>
</button>
</span>
))}
<button
type="button"
className="recipe-picker__toggle-ingredient-picker"
onClick={() => setIsIngredientPickerOpen((open) => !open)}
aria-expanded={isIngredientPickerOpen}
>
+{" "}
{isIngredientPickerOpen
? t("planning.picker.hideIngredientPicker")
: t("planning.picker.addIngredientFilter")}
</button>
</div>
{isIngredientPickerOpen && (
<IngredientPicker
ingredients={ingredientsCatalog}
excludeIds={selectedIngredientIds}
onSelect={(ingredient) => setSelectedIngredientIds((ids) => [...ids, ingredient.id])}
/>
)}
</div>
<DietTagSelect diets={dietsCatalog} value={selectedDietIds} onChange={setSelectedDietIds} />
{hasHousehold && (
<CheckboxOption checked={suitableForHousehold} onChange={setSuitableForHousehold}>
{t("planning.picker.suitableForHouseholdLabel")}
</CheckboxOption>
)}
</div>
<RecipeTabs active={activeTab} onChange={setActiveTab} />
{listState.status === "loading" && (
<p className="recipes-page__status">{t("planning.picker.loading")}</p>
)}
{listState.status === "error" && (
<p className="recipes-page__status recipes-page__status--error">{t("common.loadError")}</p>
)}
{listState.status === "loaded" && listState.recipes.length === 0 && (
<p className="recipes-page__status">{t("planning.picker.empty")}</p>
)}
{listState.status === "loaded" && listState.recipes.length > 0 && (
<RecipeTable
recipes={listState.recipes}
selectedId={null}
onSelect={(id) =>
setSelectedRecipe(listState.recipes.find((recipe) => recipe.id === id) ?? null)
}
/>
)}
</Dialog>
);
}

View file

@ -0,0 +1,125 @@
// RecipePickerDialog the dialog panel itself is styled generically by
// Dialog.tsx's dialog.scss; only this feature's own filter bar / chips /
// portions-confirmation step live here. `.recipes-page__status` and
// `.field-error` come from recipes.scss (already loaded RecipeTable /
// RecipeTabs import it themselves).
.recipe-picker-dialog {
max-width: 56rem;
}
.recipe-picker__filters {
display: flex;
flex-direction: column;
gap: var(--space-md);
margin-bottom: var(--space-md);
}
.recipe-picker__search {
width: 100%;
padding: var(--space-sm) var(--space-md);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
font-size: var(--font-size-base);
}
.recipe-picker__filter-label {
display: block;
margin-bottom: var(--space-xs);
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--color-text-muted);
}
.recipe-picker__chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-xs);
}
.filter-chip {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface));
color: var(--color-primary);
font-size: var(--font-size-sm);
font-weight: 600;
button {
border: none;
background: transparent;
color: inherit;
cursor: pointer;
padding: 0;
font-size: var(--font-size-xs);
}
}
.recipe-picker__toggle-ingredient-picker {
padding: var(--space-xs) var(--space-sm);
border: 1.5px dashed var(--color-border);
border-radius: var(--radius-pill);
background: transparent;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
cursor: pointer;
&:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
}
// --- Portions confirmation step -----------------------------------------
.recipe-picker-confirm {
display: flex;
flex-direction: column;
gap: var(--space-sm);
label {
font-weight: 600;
}
input[type="number"] {
padding: var(--space-sm) var(--space-md);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
font-size: var(--font-size-base);
max-width: 8rem;
}
}
.recipe-picker-confirm__actions {
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
margin-top: var(--space-sm);
}
.recipe-picker-confirm__confirm {
background: var(--color-primary);
color: var(--color-surface);
border: none;
border-radius: var(--radius-base);
padding: var(--space-sm) var(--space-md);
font-weight: 600;
cursor: pointer;
&:hover {
background: var(--color-primary-hover);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}

View file

@ -118,8 +118,23 @@
"diner": "Dîner" "diner": "Dîner"
}, },
"grid": { "grid": {
"addRecipeSoon": "Recherche de recettes à venir", "addRecipe": "Ajouter une recette",
"removeRecipe": "Retirer cette recette" "removeRecipe": "Retirer cette recette"
},
"picker": {
"title": "Choisir une recette",
"searchPlaceholder": "Rechercher une recette…",
"ingredientsFilterLabel": "Ingrédients",
"addIngredientFilter": "Ajouter un ingrédient",
"hideIngredientPicker": "Masquer",
"suitableForHouseholdLabel": "Convient à tout le foyer",
"loading": "Chargement des recettes…",
"empty": "Aucune recette ne correspond à ces filtres.",
"confirmTitle": "Ajouter {{recipe}}",
"portionsLabel": "Nombre de portions",
"backButton": "Retour",
"confirmButton": "Ajouter au planning",
"adding": "Ajout…"
} }
}, },
"recipes": { "recipes": {

View file

@ -6,10 +6,17 @@ import {
getWeekStart, getWeekStart,
toDateOnly, toDateOnly,
} from "@batch-cooking/date-tools"; } from "@batch-cooking/date-tools";
import { MEALS, type Meal, type PlanningView, WEEK_DAYS } from "@batch-cooking/shared"; import {
MEALS,
type Meal,
type PlanningItemView,
type PlanningView,
WEEK_DAYS,
} from "@batch-cooking/shared";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { apiClient } from "../api/client"; import { apiClient } from "../api/client";
import { type PlanningSlot, RecipePickerDialog } from "../features/planning/RecipePickerDialog";
import "./planning-page.scss"; import "./planning-page.scss";
/** Load state for the `GET /planning` call — a discriminated union so a stale/impossible combination (e.g. "loading" with data) can't be represented. */ /** Load state for the `GET /planning` call — a discriminated union so a stale/impossible combination (e.g. "loading" with data) can't be represented. */
@ -38,6 +45,12 @@ export function PlanningPage() {
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<PlanningState>({ status: "loading" }); const [state, setState] = useState<PlanningState>({ status: "loading" });
// The slot a `RecipePickerDialog` is currently open for — `null` means
// closed. Mounting the dialog only while this is set (rather than an
// always-mounted `isOpen` toggle) resets its internal filter/search
// state for free on every open, same convention as `WeekNavigator`'s own
// `CalendarPopover` below.
const [openSlot, setOpenSlot] = useState<PlanningSlot | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@ -57,6 +70,43 @@ export function PlanningPage() {
}; };
}, [weekStart]); }, [weekStart]);
/**
* Applies `updater` to the currently loaded planning's items, patching
* local state without a full `GET /planning` refetch same philosophy
* as `RecipesPage`'s `handleFavoriteToggled`/`handleDeleted`. Builds a
* placeholder parent `Planning` if none existed yet (the week's very
* first add, see `addPlanningItem`'s `findOrCreatePlanningForWeek`)
* its `id`/`startDate`/`finishDate` are never read anywhere on this page
* (only `planning.items` is), so a placeholder id is harmless; the next
* week-navigation away and back re-fetches the real row anyway.
*/
function patchPlanningItems(updater: (items: PlanningItemView[]) => PlanningItemView[]) {
setState((prev) => {
if (prev.status !== "loaded") return prev;
const planning = prev.planning ?? {
id: -1,
startDate: weekStart.toISO() ?? "",
finishDate: weekStart.plus({ days: 6 }).toISO() ?? "",
items: [],
};
return { status: "loaded", planning: { ...planning, items: updater(planning.items) } };
});
}
function handleAdded(item: PlanningItemView) {
patchPlanningItems((items) => [...items, item]);
}
/** 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));
try {
await apiClient.removePlanningItem(item.id);
} catch {
patchPlanningItems((items) => [...items, item]);
}
}
return ( return (
<div className="planning-page"> <div className="planning-page">
<div className="planning-page__header"> <div className="planning-page__header">
@ -75,7 +125,20 @@ export function PlanningPage() {
)} )}
{state.status === "loaded" && ( {state.status === "loaded" && (
<PlanningGrid weekStart={weekStart} planning={state.planning} /> <PlanningGrid
weekStart={weekStart}
planning={state.planning}
onAddSlot={setOpenSlot}
onRemoveItem={handleRemove}
/>
)}
{openSlot && (
<RecipePickerDialog
slot={openSlot}
onClose={() => setOpenSlot(null)}
onAdded={handleAdded}
/>
)} )}
</div> </div>
); );
@ -239,7 +302,14 @@ function CalendarPopover({
function PlanningGrid({ function PlanningGrid({
weekStart, weekStart,
planning, planning,
}: { weekStart: DateTime; planning: PlanningView | null }) { onAddSlot,
onRemoveItem,
}: {
weekStart: DateTime;
planning: PlanningView | null;
onAddSlot: (slot: PlanningSlot) => void;
onRemoveItem: (item: PlanningItemView) => void;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const today = toDateOnly(DateTime.utc()); const today = toDateOnly(DateTime.utc());
const days = WEEK_DAYS.map((weekDay, i) => ({ weekDay, date: weekStart.plus({ days: i }) })); const days = WEEK_DAYS.map((weekDay, i) => ({ weekDay, date: weekStart.plus({ days: i }) }));
@ -267,9 +337,9 @@ function PlanningGrid({
<MealCell <MealCell
key={weekDay} key={weekDay}
isToday={date.hasSame(today, "day")} isToday={date.hasSame(today, "day")}
recipes={items items={items.filter((item) => item.weekDay === weekDay && item.meal === meal)}
.filter((item) => item.weekDay === weekDay && item.meal === meal) onAdd={() => onAddSlot({ date: formatDateOnly(date), weekDay, meal })}
.map((item) => ({ id: item.id, name: item.recipe.name }))} onRemove={onRemoveItem}
/> />
))} ))}
</tr> </tr>
@ -280,28 +350,35 @@ function PlanningGrid({
); );
} }
/** One (day, meal) cell: the recipes already planned for it (as pills) 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. */
function MealCell({ function MealCell({
isToday, isToday,
recipes, items,
onAdd,
onRemove,
}: { }: {
isToday: boolean; isToday: boolean;
recipes: { id: number; name: string }[]; items: PlanningItemView[];
onAdd: () => void;
onRemove: (item: PlanningItemView) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<td className={isToday ? "meal-cell today" : "meal-cell"}> <td className={isToday ? "meal-cell today" : "meal-cell"}>
<div className="meal-cell__content"> <div className="meal-cell__content">
{recipes.length > 0 && ( {items.length > 0 && (
<div className="meal-cell__recipes"> <div className="meal-cell__recipes">
{recipes.map((recipe) => ( {items.map((item) => (
<span key={recipe.id} className="recipe-chip"> <span key={item.id} className="recipe-chip">
<span className="recipe-chip__name">{recipe.name}</span> <span className="recipe-chip__name">
{item.recipe.name} · ×{item.portions}
</span>
<button <button
type="button" type="button"
className="recipe-chip__remove" className="recipe-chip__remove"
title={t("planning.grid.removeRecipe")} title={t("planning.grid.removeRecipe")}
onClick={() => onRemove(item)}
> >
</button> </button>
@ -309,7 +386,12 @@ function MealCell({
))} ))}
</div> </div>
)} )}
<button type="button" className="add-recipe-btn" title={t("planning.grid.addRecipeSoon")}> <button
type="button"
className="add-recipe-btn"
title={t("planning.grid.addRecipe")}
onClick={onAdd}
>
+ +
</button> </button>
</div> </div>

View file

@ -48,7 +48,7 @@ export function RecipesPage() {
setListState({ status: "loading" }); setListState({ status: "loading" });
apiClient apiClient
.listRecipes(activeTab, debouncedSearch.trim() || undefined) .listRecipes(activeTab, { search: debouncedSearch.trim() || undefined })
.then((recipes) => { .then((recipes) => {
if (!cancelled) setListState({ status: "loaded", recipes }); if (!cancelled) setListState({ status: "loaded", recipes });
}) })

View file

@ -56,6 +56,8 @@ export enum ErrorCode {
RECIPE_NOT_FOUND = 4045, RECIPE_NOT_FOUND = 4045,
/** A recipe payload's `ingredientId` doesn't match any reference `Ingredient` row. */ /** A recipe payload's `ingredientId` doesn't match any reference `Ingredient` row. */
INGREDIENT_NOT_FOUND = 4046, INGREDIENT_NOT_FOUND = 4046,
/** `DELETE /planning/items/:id` given an id that doesn't match any planning item visible to the caller's household. */
PLANNING_ITEM_NOT_FOUND = 4047,
/** Unexpected/unhandled failure — the catch-all, always logged server-side. */ /** Unexpected/unhandled failure — the catch-all, always logged server-side. */
INTERNAL_ERROR = 5000, INTERNAL_ERROR = 5000,
} }

View file

@ -1,4 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { MEALS, WEEK_DAYS } from "../types/planning.js";
// See schemas/auth.ts for the shared client/server validation rationale. // See schemas/auth.ts for the shared client/server validation rationale.
@ -15,3 +16,24 @@ export const getPlanningByDateSchema = z.object({
}); });
/** Inferred TS type for {@link getPlanningByDateSchema}'s validated output. */ /** Inferred TS type for {@link getPlanningByDateSchema}'s validated output. */
export type GetPlanningByDateInput = z.infer<typeof getPlanningByDateSchema>; export type GetPlanningByDateInput = z.infer<typeof getPlanningByDateSchema>;
/**
* Payload accepted by `POST /planning/items` adds a recipe to one
* (day, meal) slot of the household's planning for the week containing
* `date`, creating that week's `Planning` row on the fly if it doesn't
* exist yet (see `planning.service.ts`'s `addPlanningItem`). `weekDay`/
* `meal` are finally validated against a closed set here until now
* `WEEK_DAYS`/`MEALS` were only a documented convention (see their doc
* comments in `types/planning.ts`), never enforced, since no write endpoint
* existed yet.
*/
export const addPlanningItemSchema = z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date invalide"),
weekDay: z.enum(WEEK_DAYS),
meal: z.enum(MEALS),
recipeId: z.number().int().positive(),
/** How many portions to prepare for this slot — a plain manual entry, see `PlanningItemView.portions`. */
portions: z.number().int().positive("Le nombre de portions doit être positif"),
});
/** Inferred TS type for {@link addPlanningItemSchema}'s validated output. */
export type AddPlanningItemInput = z.infer<typeof addPlanningItemSchema>;

View file

@ -65,10 +65,27 @@ export const recipeTabSchema = z.enum(["favoris", "perso", "foyer", "publique"])
/** Inferred TS type for {@link recipeTabSchema}'s validated output. */ /** Inferred TS type for {@link recipeTabSchema}'s validated output. */
export type RecipeTab = z.infer<typeof recipeTabSchema>; export type RecipeTab = z.infer<typeof recipeTabSchema>;
/** Payload accepted by `GET /recipes`'s query params — `tab` selects the catalog tab, `search` optionally filters it further by name substring. */ /**
* A repeated query param (`?ingredientIds=1&ingredientIds=2`) arrives via
* Express/`qs` as a plain string when there's exactly one, or a string
* array when there's more than one never a bare array for a single
* value. Normalizes both (plus the "absent" case) into `number[] |
* undefined` before the real `z.array` check runs.
*/
function queryIdArray() {
return z.preprocess(
(value) => (value === undefined ? undefined : Array.isArray(value) ? value : [value]),
z.array(z.coerce.number().int().positive()).optional(),
);
}
/** Payload accepted by `GET /recipes`'s query params — `tab` selects the catalog tab, `search` optionally filters it further by name substring, `suitableForHousehold` (the planning recipe picker's "convient à tout le foyer" toggle) further restricts to recipes that avoid every household member's declared allergens and match every member's declared regime (see `recipe.service.ts`'s `listRecipes`) — a no-op if the caller has no household. `ingredientIds`/`dietIds` (the same picker's ingredient/regime filters) further restrict to recipes carrying *every* id listed (AND, not "any of") — see {@link queryIdArray}. `z.coerce.boolean()` since query params always arrive as strings. */
export const listRecipesSchema = z.object({ export const listRecipesSchema = z.object({
tab: recipeTabSchema, tab: recipeTabSchema,
search: z.string().trim().min(1).optional(), search: z.string().trim().min(1).optional(),
suitableForHousehold: z.coerce.boolean().optional(),
ingredientIds: queryIdArray(),
dietIds: queryIdArray(),
}); });
/** Inferred TS type for {@link listRecipesSchema}'s validated output. */ /** Inferred TS type for {@link listRecipesSchema}'s validated output. */
export type ListRecipesInput = z.infer<typeof listRecipesSchema>; export type ListRecipesInput = z.infer<typeof listRecipesSchema>;

View file

@ -1,10 +1,10 @@
/** /**
* The 7 values `PlanningItemView.weekDay` is expected to take lowercase, * The 7 values `PlanningItemView.weekDay` is expected to take lowercase,
* unaccented French day names. Not enforced by the database (`week_day` is * unaccented French day names. Not enforced by the database (`week_day` is
* a plain `String` column, see schema.prisma) or by any write endpoint yet * a plain `String` column, see schema.prisma), but enforced at the write
* (there isn't one), but this is the contract the planning grid * boundary by `schemas/planning.ts`'s `addPlanningItemSchema` (`POST
* (`apps/web`'s `PlanningPage`) reads against, and the one a future * /planning/items`) this is the contract the planning grid (`apps/web`'s
* "add a recipe to a slot" endpoint should write. * `PlanningPage`) reads against, and the one that endpoint writes.
*/ */
export const WEEK_DAYS = [ export const WEEK_DAYS = [
"lundi", "lundi",
@ -20,8 +20,7 @@ export type WeekDay = (typeof WEEK_DAYS)[number];
/** /**
* The 5 values `PlanningItemView.meal` is expected to take, in day order * The 5 values `PlanningItemView.meal` is expected to take, in day order
* same "documented but not enforced yet" status as {@link WEEK_DAYS}, same * same enforcement story as {@link WEEK_DAYS}.
* reason.
*/ */
export const MEALS = ["petit-dejeuner", "collation", "dejeuner", "gouter", "diner"] as const; export const MEALS = ["petit-dejeuner", "collation", "dejeuner", "gouter", "diner"] as const;
/** Inferred TS type for one {@link MEALS} member. */ /** Inferred TS type for one {@link MEALS} member. */
@ -38,6 +37,8 @@ export interface PlanningItemView {
weekDay: string; weekDay: string;
/** Which meal of the day this item is for — see {@link Meal} (free-form for now, same reason). */ /** Which meal of the day this item is for — see {@link Meal} (free-form for now, same reason). */
meal: string; meal: string;
/** How many portions to prepare for this slot — entered by whoever assigns the recipe (`addPlanningItemSchema`'s `portions`), not derived from any recipe default (no such default exists, see `Recipe` in schema.prisma). */
portions: number;
recipe: { recipe: {
id: number; id: number;
name: string; name: string;