import { type DateTime, getWeekStart, toDateOnly } from "@batch-cooking/date-tools"; import type { CookingTaskIngredientView, OptimizedCookingPlanView } from "@batch-cooking/shared"; import type { Prisma } from "@prisma/client"; import { prisma } from "../../db/prisma.js"; import { type OptimizerRecipeInput, type OptimizerStepInput, optimizeCookingPlan, } from "../../lib/recipe-matching/cooking-optimizer.js"; import { toIngredientView, toUnitView } from "../recipe/recipe.service.js"; /** * Prisma `include` for a `Planning` query that needs, for every item, its * recipe's ordered steps with the full detected-technique tree — the raw * material the optimizer works on (see `cooking-optimizer.ts`). It's the * `steps` sub-tree of `recipe.service.ts`'s own `recipeInclude`, resolved * the same way so {@link toIngredientView}/{@link toUnitView} can be reused * as-is; deliberately narrower than a full `RecipeView` fetch (no * diets/favorites/recipe-level ingredient list — the optimizer reads * quantities off the technique clauses, not the recipe header). */ function cookingSessionPlanningInclude() { return { items: { include: { recipe: { select: { id: true, name: true, portions: true, steps: { orderBy: { order: "asc" }, include: { techSteps: { orderBy: { order: "asc" }, include: { techStep: true, ingredients: { include: { ingredient: { include: { allergies: { include: { allergy: { include: { category: true } } } }, diets: { include: { diet: true } }, }, }, unit: true, }, }, utensils: { include: { utensil: true } }, }, }, }, }, }, }, }, }, } satisfies Prisma.PlanningInclude; } type PlanningWithSteps = Prisma.PlanningGetPayload<{ include: ReturnType; }>; type PlanningItemWithSteps = PlanningWithSteps["items"][number]; /** * Maps one planning item's recipe (with {@link cookingSessionPlanningInclude}) * to the optimizer's pure input shape — ingredient/unit/technique/utensil * rows resolved to their reference views here so the optimizer itself never * touches Prisma. `Decimal` quantities become plain numbers (same * `Number(...)` conversion as `recipe.service.ts`'s own view mappers); an * unresolved-unit line keeps `unit: null`. */ function toOptimizerRecipe(item: PlanningItemWithSteps): OptimizerRecipeInput { const steps: OptimizerStepInput[] = item.recipe.steps.map((step) => ({ stepId: step.id, order: step.order, description: step.description, techSteps: step.techSteps.map((techStep) => { const ingredients: CookingTaskIngredientView[] = techStep.ingredients.map((line) => ({ ingredient: toIngredientView(line.ingredient), quantity: line.quantity === null ? null : Number(line.quantity), unit: line.unit === null ? null : toUnitView(line.unit), })); return { techStep: { id: techStep.techStep.id, key: techStep.techStep.key }, order: techStep.order, ingredients, utensils: techStep.utensils.map(({ utensil }) => ({ id: utensil.id, key: utensil.key })), }; }), })); return { recipeId: item.recipe.id, name: item.recipe.name, // The slot's own portion count vs. the recipe's as-written yield — the // optimizer scales technique-clause quantities by the ratio, same // reasoning as `shopping-list.service.ts`'s `aggregateShoppingList`. portions: item.portions, recipePortions: item.recipe.portions, steps, }; } /** * Builds the household's optimized cooking plan for the week covering * `date` — every recipe planned that week, reorganized into ordered phases * that pool shared prep and float passive cooks into the background (see * `cooking-optimizer.ts`). `date` follows the same convention as * `planning.service.ts`'s `getPlanningForDate` (a caller-parsed `?date=`, * not necessarily a Monday). * * Like `getShoppingListForDate` and unlike `getPlanningForDate`, this * **never** returns `null` — no household and "no planning covers this week * yet" both degrade to an empty `phases`/`recipes` on an otherwise normal * {@link OptimizedCookingPlanView} (the week's date range is always * computable from `date` alone). */ export async function getCookingPlanForDate( houseId: number | null, date: DateTime, ): Promise { try { const weekStart = getWeekStart(toDateOnly(date)); const weekFinish = weekStart.plus({ days: 6 }); const emptyPlan: OptimizedCookingPlanView = { startDate: weekStart.toJSDate().toISOString(), finishDate: weekFinish.toJSDate().toISOString(), recipes: [], phases: [], }; if (houseId === null) { return emptyPlan; } // Same "covering range" lookup as getShoppingListForDate — see // getPlanningForDate's doc comment for the UTC-midnight `Date` rationale. const dateOnly = toDateOnly(date).toJSDate(); const planning = await prisma.planning.findFirst({ where: { houseId, startDate: { lte: dateOnly }, finishDate: { gte: dateOnly }, }, orderBy: { startDate: "desc" }, include: cookingSessionPlanningInclude(), }); if (!planning) { return emptyPlan; } const { recipes, phases } = optimizeCookingPlan(planning.items.map(toOptimizerRecipe)); return { startDate: planning.startDate.toISOString(), finishDate: planning.finishDate.toISOString(), recipes, phases, }; } catch (err) { // Rethrown as-is — `wrapAsyncHandler`/the error middleware handles it, // this service layer just isn't allowed a bare `await` per the repo's // async/try-catch convention. throw err; } }