feat(planning): dialog de sélection de recette, création de planning, assignation avec portions

Ajoute le chaînon manquant entre le catalogue de recettes et le planning
hebdomadaire :

- Backend : `PlanningItem.portions` (nouvelle colonne + migration),
  `POST /planning/items` / `DELETE /planning/items/:id` (créent la
  semaine de planning à la volée si besoin), `GET /recipes` gagne les
  filtres `ingredientIds`/`dietIds` (ET) en plus de `suitableForHousehold`
  (déjà préparé).
- Frontend : nouveau `Dialog` générique (premier modal de l'app),
  `RecipePickerDialog` qui réutilise le même affichage que le catalogue
  (`RecipeTabs`/`RecipeTable`) avec recherche par nom, filtre ingrédients,
  filtre régime alimentaire, toggle "convient à tout le foyer", puis une
  étape de saisie du nombre de portions.
- `PlanningPage` : le bouton "+" de chaque case ouvre le dialog, le
  bouton "✕" retire la recette (optimiste, avec rollback si l'appel
  échoue), les portions s'affichent sur chaque chip.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-08-19 14:44:02 +02:00
parent f1fefc1f38
commit 8bdbfda3ae
18 changed files with 1029 additions and 36 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

@ -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,80 @@
import { type ReactNode, useEffect, useRef } from "react";
import "./dialog.scss";
/**
* App-wide modal primitive full-screen backdrop + a centered panel,
* closing on Escape or an outside click (same `mousedown`-outside pattern
* as `PlanningPage.tsx`'s `CalendarPopover`, generalized here instead of
* duplicated a third time). 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.
*
* Renders nothing while `isOpen` is `false` callers don't need to guard
* mounting it themselves.
*/
export function Dialog({
isOpen,
onClose,
title,
children,
className,
}: {
isOpen: boolean;
onClose: () => void;
title?: string;
children: ReactNode;
className?: string;
}) {
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isOpen) return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div
className="dialog-overlay"
// Closing on the overlay itself (not on a bubbled click from the
// panel) — same "outside click" idea as CalendarPopover, expressed
// via where the click *landed* instead of a document-level listener
// + ref containment check, since the overlay already exactly frames
// "outside the panel".
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
className={["dialog-panel", className].filter(Boolean).join(" ")}
role="dialog"
aria-modal="true"
aria-label={title}
ref={panelRef}
>
{title && (
<div className="dialog-panel__header">
<h2>{title}</h2>
<button
type="button"
className="dialog-panel__close"
onClick={onClose}
aria-label="Fermer"
>
</button>
</div>
)}
<div className="dialog-panel__body">{children}</div>
</div>
</div>
);
}

View file

@ -0,0 +1,63 @@
// Modal overlay + panel see Dialog.tsx. 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-overlay {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-lg);
background: rgba(0, 0, 0, 0.45);
}
.dialog-panel {
display: flex;
flex-direction: column;
width: 100%;
max-width: 48rem;
max-height: calc(100vh - var(--space-2xl));
background: var(--color-surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
overflow: hidden;
}
.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,289 @@
import {
ErrorCode,
type DietView,
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
isOpen
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)}
autoFocus
/>
{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 isOpen 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;