fix(3b): delad kompletteringsväg för legacy /cook + deterministiska cooking-assumptions
- cookRecipeInputSchema utökas med actualPortionsEaten, leftoverEstimatePortions, leftoverNote - completeCookingSession-wrapper i apps/api/src/lib/cooking.ts hanterar summavalidering, persistens av svar, profiluppdateringar och analytics (started+completed) för båda vägarna - Legacy POST /v1/recipes/:id/cook använder wrappern med emitStartedEvent - GET /v1/recipes/:id/cooking-assumptions väljer deterministiskt bland icke-valfria ingredienser - i18n: nyckel cooked.portionsSumExceedsPlanned i samtliga 12 lokaler + server-i18n - Nya tester för legacy /cook och cooking-assumptions med valfri första ingrediens Refs: steg 3b-fix, granskningsrunda 2026-08-07
This commit is contained in:
+146
-7
@@ -1,12 +1,15 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { and, eq, gt, isNull, sql } from "drizzle-orm";
|
import { and, eq, gt, isNull, sql } from "drizzle-orm";
|
||||||
import { schema, markMilestone } from "@app/database";
|
import { schema, markMilestone } from "@app/database";
|
||||||
import { allocateFefo } from "@app/inventory-engine";
|
import { allocateFefo, updateCookingAssumptionProfile } from "@app/inventory-engine";
|
||||||
import { scaleNutrition } from "@app/nutrition-engine";
|
import { scaleNutrition } from "@app/nutrition-engine";
|
||||||
import type { Unit } from "@app/shared-types";
|
import type { Unit } from "@app/shared-types";
|
||||||
import { todayIso, emitEvent } from "./helpers.js";
|
import { cookingSessionStarted, cookingSessionCompleted } from "@app/analytics";
|
||||||
|
import { todayIso, emitEvent, trackProductAnalytics } from "./helpers.js";
|
||||||
import { errors } from "./errors.js";
|
import { errors } from "./errors.js";
|
||||||
import { loadFullRecipe } from "../routes/recipes.js";
|
import { loadFullRecipe } from "../routes/recipes.js";
|
||||||
|
import { userLanguageTag } from "./contentLanguage.js";
|
||||||
|
import { t } from "./i18n.js";
|
||||||
import { MEAL_TYPES } from "@app/shared-types";
|
import { MEAL_TYPES } from "@app/shared-types";
|
||||||
|
|
||||||
export interface CompleteCookingInput {
|
export interface CompleteCookingInput {
|
||||||
@@ -19,6 +22,14 @@ export interface CompleteCookingInput {
|
|||||||
date?: string;
|
date?: string;
|
||||||
mealType?: string;
|
mealType?: string;
|
||||||
inventoryOverrides?: Array<{ canonicalIngredientId: string; quantityUsed: number; unit: string }>;
|
inventoryOverrides?: Array<{ canonicalIngredientId: string; quantityUsed: number; unit: string }>;
|
||||||
|
actualPortionsEaten?: number;
|
||||||
|
leftoverEstimatePortions?: number;
|
||||||
|
leftoverNote?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompleteCookingSessionOptions {
|
||||||
|
/** Sant för legacy POST /v1/recipes/:id/cook där session skapas och startas i ett steg. */
|
||||||
|
emitStartedEvent?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CompleteCookingResult {
|
export interface CompleteCookingResult {
|
||||||
@@ -30,8 +41,138 @@ export interface CompleteCookingResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gemensam kärna för att "jag har lagat". Används av både
|
* Delad kompletteringsväg för både POST /v1/recipes/:id/cook (legacy)
|
||||||
* POST /v1/recipes/:id/cook (legacy shortcut) och
|
* och POST /v1/cooking-sessions/:id/complete. Hanterar summavalidering,
|
||||||
|
* persistens av svar, profiluppdateringar och analytics.
|
||||||
|
*/
|
||||||
|
export async function completeCookingSession(
|
||||||
|
app: FastifyInstance,
|
||||||
|
session: typeof schema.cookingSessions.$inferSelect,
|
||||||
|
userId: string,
|
||||||
|
input: CompleteCookingInput,
|
||||||
|
correlationId: string,
|
||||||
|
options: CompleteCookingSessionOptions = {},
|
||||||
|
): Promise<CompleteCookingResult & { session: typeof schema.cookingSessions.$inferSelect }> {
|
||||||
|
const plannedPortions = input.portionsCooked ?? session.plannedPortions;
|
||||||
|
const mealBoxPortions = input.mealBoxPortions ?? 0;
|
||||||
|
const actualPortionsEaten = input.actualPortionsEaten ?? Math.max(0, plannedPortions - mealBoxPortions);
|
||||||
|
const leftoverEstimatePortions = input.leftoverEstimatePortions ?? mealBoxPortions;
|
||||||
|
|
||||||
|
if (actualPortionsEaten + leftoverEstimatePortions > plannedPortions) {
|
||||||
|
const languageTag = await userLanguageTag(app.db, userId);
|
||||||
|
throw errors.badRequest(t("cooked.portionsSumExceedsPlanned", languageTag));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.emitStartedEvent && session.status === "started") {
|
||||||
|
await trackProductAnalytics(
|
||||||
|
app.db,
|
||||||
|
userId,
|
||||||
|
cookingSessionStarted({
|
||||||
|
householdId: session.householdId,
|
||||||
|
properties: {
|
||||||
|
cookingSessionId: session.id,
|
||||||
|
recipeId: session.recipeId,
|
||||||
|
status: session.status,
|
||||||
|
plannedPortions,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await completeCookingSessionCore(
|
||||||
|
app,
|
||||||
|
session,
|
||||||
|
userId,
|
||||||
|
{ ...input, portionsCooked: plannedPortions, mealBoxPortions },
|
||||||
|
correlationId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Persistera användarens svar (ersätter kärnans default-värden).
|
||||||
|
const [updated] = await app.db
|
||||||
|
.update(schema.cookingSessions)
|
||||||
|
.set({
|
||||||
|
actualPortionsEaten,
|
||||||
|
leftoverEstimatePortions,
|
||||||
|
leftoverNote: input.leftoverNote ?? null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(schema.cookingSessions.id, session.id))
|
||||||
|
.returning();
|
||||||
|
if (!updated) throw errors.internal("Kunde inte uppdatera cooking session.");
|
||||||
|
|
||||||
|
// Uppdatera antagandeprofiler per icke-valfri ingrediens (hushållsnivå).
|
||||||
|
const date = new Date().toISOString().slice(0, 10);
|
||||||
|
for (const ing of result.recipeIngredients) {
|
||||||
|
if (ing.optional) continue;
|
||||||
|
const [existing] = await app.db
|
||||||
|
.select()
|
||||||
|
.from(schema.cookingAssumptionProfiles)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.cookingAssumptionProfiles.householdId, session.householdId),
|
||||||
|
eq(schema.cookingAssumptionProfiles.canonicalIngredientId, ing.canonicalIngredientId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const updatedProfile = updateCookingAssumptionProfile(
|
||||||
|
{
|
||||||
|
householdId: session.householdId,
|
||||||
|
canonicalIngredientId: ing.canonicalIngredientId,
|
||||||
|
plannedPortions,
|
||||||
|
actualPortionsEaten,
|
||||||
|
leftoverEstimatePortions,
|
||||||
|
sessionId: session.id,
|
||||||
|
date,
|
||||||
|
},
|
||||||
|
existing ?? { averageEatenPortions: null, averageLeftoverPortions: null, observationCount: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.db
|
||||||
|
.insert(schema.cookingAssumptionProfiles)
|
||||||
|
.values({
|
||||||
|
householdId: session.householdId,
|
||||||
|
canonicalIngredientId: ing.canonicalIngredientId,
|
||||||
|
...updatedProfile,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [
|
||||||
|
schema.cookingAssumptionProfiles.householdId,
|
||||||
|
schema.cookingAssumptionProfiles.canonicalIngredientId,
|
||||||
|
],
|
||||||
|
set: {
|
||||||
|
averageEatenPortions: updatedProfile.averageEatenPortions,
|
||||||
|
averageLeftoverPortions: updatedProfile.averageLeftoverPortions,
|
||||||
|
observationCount: updatedProfile.observationCount,
|
||||||
|
lastSessionAnswers: updatedProfile.lastSessionAnswers,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await trackProductAnalytics(
|
||||||
|
app.db,
|
||||||
|
userId,
|
||||||
|
cookingSessionCompleted({
|
||||||
|
householdId: session.householdId,
|
||||||
|
properties: {
|
||||||
|
cookingSessionId: session.id,
|
||||||
|
recipeId: session.recipeId,
|
||||||
|
portionsCooked: plannedPortions,
|
||||||
|
actualPortionsEaten,
|
||||||
|
leftoverEstimatePortions,
|
||||||
|
mealBoxPortions,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { ...result, session: updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lågnivå-kärna för att "jag har lagat". Används via completeCookingSession
|
||||||
|
* av både POST /v1/recipes/:id/cook (legacy shortcut) och
|
||||||
* POST /v1/cooking-sessions/:id/complete.
|
* POST /v1/cooking-sessions/:id/complete.
|
||||||
*/
|
*/
|
||||||
export async function completeCookingSessionCore(
|
export async function completeCookingSessionCore(
|
||||||
@@ -234,14 +375,12 @@ export async function completeCookingSessionCore(
|
|||||||
correlationId,
|
correlationId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 5. Uppdatera session
|
// 5. Uppdatera session (svaren skrivs över av completeCookingSession).
|
||||||
await app.db
|
await app.db
|
||||||
.update(schema.cookingSessions)
|
.update(schema.cookingSessions)
|
||||||
.set({
|
.set({
|
||||||
status: "completed",
|
status: "completed",
|
||||||
completedAt: new Date(),
|
completedAt: new Date(),
|
||||||
actualPortionsEaten: portionsCooked - mealBoxPortions,
|
|
||||||
leftoverEstimatePortions: mealBoxPortions,
|
|
||||||
plannedDeductions: deductions,
|
plannedDeductions: deductions,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,14 +19,35 @@ const HOUSEHOLD_DEFAULT_NAMES: Record<string, string> = {
|
|||||||
sv: "Hemma",
|
sv: "Hemma",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const COOKED_PORTIONS_SUM_EXCEEDS_PLANNED: Record<string, string> = {
|
||||||
|
da: "Antal spiste portioner og rester må ikke overstige det samlede antal portioner.",
|
||||||
|
de: "Gegessene Portionen und Reste dürfen die Gesamtanzahl der Portionen nicht überschreiten.",
|
||||||
|
en: "Eaten portions and leftovers cannot exceed the total number of portions.",
|
||||||
|
es: "Las raciones comidas y las sobras no pueden superar el número total de raciones.",
|
||||||
|
fi: "Syödyt annokset ja tähteet eivät voi ylittää annosten kokonaismäärää.",
|
||||||
|
fr: "Les portions mangées et les restes ne peuvent pas dépasser le nombre total de portions.",
|
||||||
|
it: "Le porzioni mangiate e gli avanzi non possono superare il numero totale di porzioni.",
|
||||||
|
nb: "Antall spiste porsjoner og rester kan ikke overstige det totale antallet porsjoner.",
|
||||||
|
nl: "Gegeten porties en restjes mogen het totaal aantal porties niet overschrijden.",
|
||||||
|
pl: "Zjedzone porcje i resztki nie mogą przekroczyć całkowitej liczby porcji.",
|
||||||
|
pt: "As porções comidas e as sobras não podem ultrapassar o número total de porções.",
|
||||||
|
sv: "Antalet ätna portioner och rester får inte överstiga det totala antalet portioner.",
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolve(catalog: Record<string, string>, languageTag: string): string {
|
||||||
|
const lang = (languageTag.split("-")[0] ?? "sv").toLowerCase();
|
||||||
|
return catalog[lang] ?? catalog["en"] ?? catalog["sv"]!;
|
||||||
|
}
|
||||||
|
|
||||||
export function t(key: string, languageTag: string): string {
|
export function t(key: string, languageTag: string): string {
|
||||||
|
if (key === "cooked.portionsSumExceedsPlanned") {
|
||||||
|
return resolve(COOKED_PORTIONS_SUM_EXCEEDS_PLANNED, languageTag);
|
||||||
|
}
|
||||||
|
|
||||||
if (key !== "onboarding.householdDefaultName") {
|
if (key !== "onboarding.householdDefaultName") {
|
||||||
// No other server-side keys are supported yet; fall back to a safe default.
|
// No other server-side keys are supported yet; fall back to a safe default.
|
||||||
return HOUSEHOLD_DEFAULT_NAMES["sv"]!;
|
return HOUSEHOLD_DEFAULT_NAMES["sv"]!;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lang = (languageTag.split("-")[0] ?? "sv").toLowerCase();
|
return resolve(HOUSEHOLD_DEFAULT_NAMES, languageTag);
|
||||||
return (
|
|
||||||
HOUSEHOLD_DEFAULT_NAMES[lang] ?? HOUSEHOLD_DEFAULT_NAMES["en"] ?? HOUSEHOLD_DEFAULT_NAMES["sv"]!
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { and, eq, lt } from "drizzle-orm";
|
import { and, eq, inArray, lt } from "drizzle-orm";
|
||||||
import { schema, markMilestone } from "@app/database";
|
import { schema, markMilestone } from "@app/database";
|
||||||
import {
|
import {
|
||||||
cookingSessionStartInputSchema,
|
cookingSessionStartInputSchema,
|
||||||
@@ -8,14 +8,9 @@ import {
|
|||||||
} from "@app/validation";
|
} from "@app/validation";
|
||||||
import { errors, parse } from "../lib/errors.js";
|
import { errors, parse } from "../lib/errors.js";
|
||||||
import { requireActiveHousehold, requireMembership } from "../lib/helpers.js";
|
import { requireActiveHousehold, requireMembership } from "../lib/helpers.js";
|
||||||
import {
|
import { cookingSessionStarted, cookingSessionCancelled } from "@app/analytics";
|
||||||
cookingSessionStarted,
|
|
||||||
cookingSessionCompleted,
|
|
||||||
cookingSessionCancelled,
|
|
||||||
} from "@app/analytics";
|
|
||||||
import { trackProductAnalytics } from "../lib/helpers.js";
|
import { trackProductAnalytics } from "../lib/helpers.js";
|
||||||
import { completeCookingSessionCore } from "../lib/cooking.js";
|
import { completeCookingSession } from "../lib/cooking.js";
|
||||||
import { updateCookingAssumptionProfile } from "@app/inventory-engine";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -137,8 +132,8 @@ export async function cookingSessionRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Complete en session.
|
* Complete en session.
|
||||||
* I steg 3a: anropar samma logik som gamla /cook, men länkar allt till cookingSessionId.
|
* Delad kompletteringsväg via completeCookingSession hanterar summavalidering,
|
||||||
* I steg 3b: sparar svar på max 2–3 frågor och uppdaterar hushållsantagandeprofiler.
|
* persistens, profiluppdateringar och analytics.
|
||||||
*/
|
*/
|
||||||
app.post("/v1/cooking-sessions/:id/complete", auth, async (req) => {
|
app.post("/v1/cooking-sessions/:id/complete", auth, async (req) => {
|
||||||
const { id } = parse(idParamSchema, req.params);
|
const { id } = parse(idParamSchema, req.params);
|
||||||
@@ -148,107 +143,9 @@ export async function cookingSessionRoutes(app: FastifyInstance) {
|
|||||||
throw errors.conflict("Sessionen måste vara startad för att avslutas.");
|
throw errors.conflict("Sessionen måste vara startad för att avslutas.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const mealBoxPortions = input.mealBoxPortions ?? 0;
|
const result = await completeCookingSession(app, session, req.userId, input, req.correlationId);
|
||||||
const plannedPortions = input.portionsCooked ?? session.plannedPortions;
|
|
||||||
const actualPortionsEaten = input.actualPortionsEaten ?? Math.max(0, plannedPortions - mealBoxPortions);
|
|
||||||
const leftoverEstimatePortions = input.leftoverEstimatePortions ?? mealBoxPortions;
|
|
||||||
|
|
||||||
if (actualPortionsEaten + leftoverEstimatePortions > plannedPortions) {
|
return result;
|
||||||
throw errors.badRequest("Åtna portioner + rester får inte överstiga totalt antal portioner.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await completeCookingSessionCore(
|
|
||||||
app,
|
|
||||||
session,
|
|
||||||
req.userId,
|
|
||||||
{ ...input, portionsCooked: plannedPortions, mealBoxPortions },
|
|
||||||
req.correlationId,
|
|
||||||
);
|
|
||||||
|
|
||||||
await app.db
|
|
||||||
.update(schema.cookingSessions)
|
|
||||||
.set({
|
|
||||||
actualPortionsEaten,
|
|
||||||
leftoverEstimatePortions,
|
|
||||||
leftoverNote: input.leftoverNote ?? null,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(schema.cookingSessions.id, id));
|
|
||||||
|
|
||||||
// Uppdatera antagandeprofiler per ingrediens (hushållsnivå).
|
|
||||||
const date = new Date().toISOString().slice(0, 10);
|
|
||||||
for (const ing of result.recipeIngredients) {
|
|
||||||
if (ing.optional) continue;
|
|
||||||
const [existing] = await app.db
|
|
||||||
.select()
|
|
||||||
.from(schema.cookingAssumptionProfiles)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(schema.cookingAssumptionProfiles.householdId, session.householdId),
|
|
||||||
eq(schema.cookingAssumptionProfiles.canonicalIngredientId, ing.canonicalIngredientId),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const updatedProfile = updateCookingAssumptionProfile(
|
|
||||||
{
|
|
||||||
householdId: session.householdId,
|
|
||||||
canonicalIngredientId: ing.canonicalIngredientId,
|
|
||||||
plannedPortions,
|
|
||||||
actualPortionsEaten,
|
|
||||||
leftoverEstimatePortions,
|
|
||||||
sessionId: id,
|
|
||||||
date,
|
|
||||||
},
|
|
||||||
existing ?? { averageEatenPortions: null, averageLeftoverPortions: null, observationCount: 0 },
|
|
||||||
);
|
|
||||||
|
|
||||||
await app.db
|
|
||||||
.insert(schema.cookingAssumptionProfiles)
|
|
||||||
.values({
|
|
||||||
householdId: session.householdId,
|
|
||||||
canonicalIngredientId: ing.canonicalIngredientId,
|
|
||||||
...updatedProfile,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: [
|
|
||||||
schema.cookingAssumptionProfiles.householdId,
|
|
||||||
schema.cookingAssumptionProfiles.canonicalIngredientId,
|
|
||||||
],
|
|
||||||
set: {
|
|
||||||
averageEatenPortions: updatedProfile.averageEatenPortions,
|
|
||||||
averageLeftoverPortions: updatedProfile.averageLeftoverPortions,
|
|
||||||
observationCount: updatedProfile.observationCount,
|
|
||||||
lastSessionAnswers: updatedProfile.lastSessionAnswers,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const [updated] = await app.db
|
|
||||||
.select()
|
|
||||||
.from(schema.cookingSessions)
|
|
||||||
.where(eq(schema.cookingSessions.id, id))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
await trackProductAnalytics(
|
|
||||||
app.db,
|
|
||||||
req.userId,
|
|
||||||
cookingSessionCompleted({
|
|
||||||
householdId: session.householdId,
|
|
||||||
properties: {
|
|
||||||
cookingSessionId: id,
|
|
||||||
recipeId: session.recipeId,
|
|
||||||
portionsCooked: plannedPortions,
|
|
||||||
actualPortionsEaten,
|
|
||||||
leftoverEstimatePortions,
|
|
||||||
mealBoxPortions,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return { ...result, session: updated };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Hämta antagandeprofil för ett recept (per hushåll + ingrediens). */
|
/** Hämta antagandeprofil för ett recept (per hushåll + ingrediens). */
|
||||||
@@ -258,23 +155,38 @@ export async function cookingSessionRoutes(app: FastifyInstance) {
|
|||||||
await requireMembership(app.db, householdId, req.userId);
|
await requireMembership(app.db, householdId, req.userId);
|
||||||
|
|
||||||
const ings = await app.db
|
const ings = await app.db
|
||||||
.select({ canonicalIngredientId: schema.recipeIngredients.canonicalIngredientId })
|
.select({
|
||||||
|
canonicalIngredientId: schema.recipeIngredients.canonicalIngredientId,
|
||||||
|
optional: schema.recipeIngredients.optional,
|
||||||
|
})
|
||||||
.from(schema.recipeIngredients)
|
.from(schema.recipeIngredients)
|
||||||
.where(eq(schema.recipeIngredients.recipeId, id));
|
.where(eq(schema.recipeIngredients.recipeId, id));
|
||||||
if (ings.length === 0) throw errors.notFound("Receptet finns inte.");
|
if (ings.length === 0) throw errors.notFound("Receptet finns inte.");
|
||||||
|
|
||||||
|
const nonOptionalIds = ings.filter((i) => !i.optional).map((i) => i.canonicalIngredientId);
|
||||||
|
if (nonOptionalIds.length === 0) {
|
||||||
|
return {
|
||||||
|
householdId,
|
||||||
|
recipeId: id,
|
||||||
|
defaultActualPortionsEaten: null,
|
||||||
|
defaultLeftoverEstimatePortions: null,
|
||||||
|
observationCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const profiles = await app.db
|
const profiles = await app.db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.cookingAssumptionProfiles)
|
.from(schema.cookingAssumptionProfiles)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(schema.cookingAssumptionProfiles.householdId, householdId),
|
eq(schema.cookingAssumptionProfiles.householdId, householdId),
|
||||||
eq(schema.cookingAssumptionProfiles.canonicalIngredientId, ings[0]!.canonicalIngredientId),
|
inArray(schema.cookingAssumptionProfiles.canonicalIngredientId, nonOptionalIds),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.limit(1);
|
|
||||||
|
// Deterministiskt val: profilen med flest observationer (stabil över tid).
|
||||||
|
const p = profiles.sort((a, b) => b.observationCount - a.observationCount)[0];
|
||||||
|
|
||||||
const p = profiles[0];
|
|
||||||
return {
|
return {
|
||||||
householdId,
|
householdId,
|
||||||
recipeId: id,
|
recipeId: id,
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
todayIso,
|
todayIso,
|
||||||
} from "../lib/helpers.js";
|
} from "../lib/helpers.js";
|
||||||
import { requireFeature } from "../lib/entitlements.js";
|
import { requireFeature } from "../lib/entitlements.js";
|
||||||
import { completeCookingSessionCore } from "../lib/cooking.js";
|
import { completeCookingSession } from "../lib/cooking.js";
|
||||||
|
|
||||||
/** Recept: sök, detalj, betyg, favoriter, "jag har lagat", substitutioner, användarrecept. */
|
/** Recept: sök, detalj, betyg, favoriter, "jag har lagat", substitutioner, användarrecept. */
|
||||||
export async function recipeRoutes(app: FastifyInstance) {
|
export async function recipeRoutes(app: FastifyInstance) {
|
||||||
@@ -433,7 +433,9 @@ export async function recipeRoutes(app: FastifyInstance) {
|
|||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
const result = await completeCookingSessionCore(app, session!, req.userId, input, req.correlationId);
|
const result = await completeCookingSession(app, session!, req.userId, input, req.correlationId, {
|
||||||
|
emitStartedEvent: true,
|
||||||
|
});
|
||||||
|
|
||||||
return { sessionId: session!.id, ...result };
|
return { sessionId: session!.id, ...result };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import "./setup-env.js";
|
import "./setup-env.js";
|
||||||
import { describe, expect, it, beforeAll, afterAll } from "vitest";
|
import { describe, expect, it, beforeAll, afterAll } from "vitest";
|
||||||
import { and, eq, inArray, count } from "drizzle-orm";
|
import { and, eq, inArray, count, sql } from "drizzle-orm";
|
||||||
import { buildServer } from "../src/server.js";
|
import { buildServer } from "../src/server.js";
|
||||||
import { loadConfig } from "../src/config.js";
|
import { loadConfig } from "../src/config.js";
|
||||||
import { createDatabase, closeDatabase, schema } from "@app/database";
|
import { createDatabase, closeDatabase, schema } from "@app/database";
|
||||||
@@ -235,6 +235,115 @@ describe("cooking sessions", () => {
|
|||||||
expect(body.mealBoxId).toBeDefined();
|
expect(body.mealBoxId).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("legacy /cook stores actual portions and leftover estimate on the session row", async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/v1/recipes/${recipeId}/cook`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: {
|
||||||
|
portionsCooked: 4,
|
||||||
|
mealBoxPortions: 1,
|
||||||
|
actualPortionsEaten: 2,
|
||||||
|
leftoverEstimatePortions: 1,
|
||||||
|
leftoverNote: "sparas i kylen",
|
||||||
|
deductInventory: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const { sessionId } = JSON.parse(res.body) as { sessionId: string };
|
||||||
|
|
||||||
|
const session = await testDb.db
|
||||||
|
.select()
|
||||||
|
.from(schema.cookingSessions)
|
||||||
|
.where(eq(schema.cookingSessions.id, sessionId))
|
||||||
|
.limit(1);
|
||||||
|
expect(session[0]!.actualPortionsEaten).toBe(2);
|
||||||
|
expect(session[0]!.leftoverEstimatePortions).toBe(1);
|
||||||
|
expect(session[0]!.leftoverNote).toBe("sparas i kylen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("legacy /cook updates cooking assumption profiles", async () => {
|
||||||
|
await testDb.db.delete(schema.cookingAssumptionProfiles).where(eq(schema.cookingAssumptionProfiles.householdId, householdId));
|
||||||
|
|
||||||
|
const recipe = (await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/v1/recipes/${recipeId}`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})).json() as { ingredients: Array<{ canonicalIngredientId: string; optional?: boolean }> };
|
||||||
|
const firstNonOptionalIngredientId = recipe.ingredients.find((i) => i.canonicalIngredientId && !i.optional)?.canonicalIngredientId;
|
||||||
|
expect(firstNonOptionalIngredientId).toBeDefined();
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/v1/recipes/${recipeId}/cook`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: {
|
||||||
|
portionsCooked: 4,
|
||||||
|
actualPortionsEaten: 3,
|
||||||
|
leftoverEstimatePortions: 1,
|
||||||
|
deductInventory: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const profile = await testDb.db
|
||||||
|
.select()
|
||||||
|
.from(schema.cookingAssumptionProfiles)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.cookingAssumptionProfiles.householdId, householdId),
|
||||||
|
eq(schema.cookingAssumptionProfiles.canonicalIngredientId, firstNonOptionalIngredientId!),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
expect(profile.length).toBe(1);
|
||||||
|
expect(profile[0]!.observationCount).toBe(1);
|
||||||
|
expect(profile[0]!.averageEatenPortions).toBe(3);
|
||||||
|
expect(profile[0]!.averageLeftoverPortions).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("legacy /cook writes cooking_session_started and cooking_session_completed analytics", async () => {
|
||||||
|
const before = await testDb.db
|
||||||
|
.select({ name: schema.productAnalyticsEvents.eventName })
|
||||||
|
.from(schema.productAnalyticsEvents)
|
||||||
|
.where(eq(schema.productAnalyticsEvents.userId, userId));
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/v1/recipes/${recipeId}/cook`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { portionsCooked: 4, deductInventory: true },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const { sessionId } = JSON.parse(res.body) as { sessionId: string };
|
||||||
|
|
||||||
|
const after = await testDb.db
|
||||||
|
.select()
|
||||||
|
.from(schema.productAnalyticsEvents)
|
||||||
|
.where(eq(schema.productAnalyticsEvents.userId, userId))
|
||||||
|
.orderBy(schema.productAnalyticsEvents.occurredAt);
|
||||||
|
const newEvents = after.slice(before.length);
|
||||||
|
const props = (e: (typeof after)[number]) => (e.properties ?? {}) as { cookingSessionId?: string };
|
||||||
|
const started = newEvents.filter((e) => e.eventName === "cooking_session_started" && props(e).cookingSessionId === sessionId);
|
||||||
|
const completed = newEvents.filter((e) => e.eventName === "cooking_session_completed" && props(e).cookingSessionId === sessionId);
|
||||||
|
expect(started.length).toBe(1);
|
||||||
|
expect(completed.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("legacy /cook rejects when eaten + leftovers exceed planned portions", async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/v1/recipes/${recipeId}/cook`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: {
|
||||||
|
portionsCooked: 4,
|
||||||
|
mealBoxPortions: 0,
|
||||||
|
actualPortionsEaten: 3,
|
||||||
|
leftoverEstimatePortions: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
it("stores actual portions and leftover estimate on complete", async () => {
|
it("stores actual portions and leftover estimate on complete", async () => {
|
||||||
const start = await app.inject({
|
const start = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -289,8 +398,9 @@ describe("cooking sessions", () => {
|
|||||||
method: "GET",
|
method: "GET",
|
||||||
url: `/v1/recipes/${recipeId}`,
|
url: `/v1/recipes/${recipeId}`,
|
||||||
headers: { authorization: `Bearer ${token}` },
|
headers: { authorization: `Bearer ${token}` },
|
||||||
})).json() as { ingredients: Array<{ canonicalIngredientId: string }> };
|
})).json() as { ingredients: Array<{ canonicalIngredientId: string; optional?: boolean }> };
|
||||||
const firstIngredientId = recipe.ingredients.find((i) => i.canonicalIngredientId)?.canonicalIngredientId;
|
const firstNonOptionalIngredientId = recipe.ingredients.find((i) => i.canonicalIngredientId && !i.optional)?.canonicalIngredientId;
|
||||||
|
expect(firstNonOptionalIngredientId).toBeDefined();
|
||||||
|
|
||||||
await app.inject({
|
await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -299,22 +409,118 @@ describe("cooking sessions", () => {
|
|||||||
payload: { mealBoxPortions: 1, actualPortionsEaten: 2, leftoverEstimatePortions: 1 },
|
payload: { mealBoxPortions: 1, actualPortionsEaten: 2, leftoverEstimatePortions: 1 },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (firstIngredientId) {
|
const profile = await testDb.db
|
||||||
const profile = await testDb.db
|
.select()
|
||||||
.select()
|
.from(schema.cookingAssumptionProfiles)
|
||||||
.from(schema.cookingAssumptionProfiles)
|
.where(
|
||||||
.where(
|
and(
|
||||||
and(
|
eq(schema.cookingAssumptionProfiles.householdId, householdId),
|
||||||
eq(schema.cookingAssumptionProfiles.householdId, householdId),
|
eq(schema.cookingAssumptionProfiles.canonicalIngredientId, firstNonOptionalIngredientId!),
|
||||||
eq(schema.cookingAssumptionProfiles.canonicalIngredientId, firstIngredientId),
|
),
|
||||||
),
|
)
|
||||||
)
|
.limit(1);
|
||||||
.limit(1);
|
expect(profile.length).toBe(1);
|
||||||
expect(profile.length).toBe(1);
|
expect(profile[0]!.observationCount).toBe(1);
|
||||||
expect(profile[0]!.observationCount).toBe(1);
|
expect(profile[0]!.averageEatenPortions).toBe(2);
|
||||||
expect(profile[0]!.averageEatenPortions).toBe(2);
|
expect(profile[0]!.averageLeftoverPortions).toBe(1);
|
||||||
expect(profile[0]!.averageLeftoverPortions).toBe(1);
|
});
|
||||||
}
|
|
||||||
|
it("cooking-assumptions ignores optional first ingredient and returns defaults from a non-optional one", async () => {
|
||||||
|
await testDb.db.delete(schema.cookingAssumptionProfiles).where(eq(schema.cookingAssumptionProfiles.householdId, householdId));
|
||||||
|
|
||||||
|
// Skapa ett recept där den första ingrediensen är valfri.
|
||||||
|
const [recipe] = await testDb.db
|
||||||
|
.insert(schema.recipes)
|
||||||
|
.values({
|
||||||
|
slug: `optional-first-${Date.now()}`,
|
||||||
|
titleSv: "Testrecept valfri först",
|
||||||
|
descriptionSv: "",
|
||||||
|
cuisine: "international",
|
||||||
|
mealTypes: ["dinner"],
|
||||||
|
tags: [],
|
||||||
|
methods: [],
|
||||||
|
equipment: [],
|
||||||
|
difficulty: "easy",
|
||||||
|
prepTimeMinutes: 5,
|
||||||
|
cookTimeMinutes: 10,
|
||||||
|
totalTimeMinutes: 15,
|
||||||
|
portions: 4,
|
||||||
|
nutritionPerPortion: { kcal: 100, proteinG: 5, fatG: 3, carbsG: 12, saturatedFatG: 1, fiberG: 1, sugarG: 2, saltG: 0.1 },
|
||||||
|
allergens: [],
|
||||||
|
spiceLevel: 0,
|
||||||
|
dna: {
|
||||||
|
cuisine: "international",
|
||||||
|
vegetables: [],
|
||||||
|
flavorProfile: [],
|
||||||
|
spiceLevel: 0,
|
||||||
|
method: "stovetop",
|
||||||
|
timeMinutes: 15,
|
||||||
|
calories: 100,
|
||||||
|
proteinGrams: 5,
|
||||||
|
},
|
||||||
|
status: "published",
|
||||||
|
verificationStatus: "unverified",
|
||||||
|
sourceType: "own_editorial",
|
||||||
|
creatorDisplayName: "Test",
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
const testRecipeId = recipe!.id;
|
||||||
|
|
||||||
|
await testDb.db.insert(schema.recipeIngredients).values([
|
||||||
|
{
|
||||||
|
recipeId: testRecipeId,
|
||||||
|
canonicalIngredientId: "olive_oil",
|
||||||
|
displayNameSv: "Olivolja",
|
||||||
|
quantity: 1,
|
||||||
|
unit: "TABLESPOON",
|
||||||
|
optional: true,
|
||||||
|
sortOrder: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
recipeId: testRecipeId,
|
||||||
|
canonicalIngredientId: "pasta_dry",
|
||||||
|
displayNameSv: "Pasta",
|
||||||
|
quantity: 320,
|
||||||
|
unit: "GRAM",
|
||||||
|
optional: false,
|
||||||
|
sortOrder: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Completa en session och skriv profil för den icke-valfria ingrediensen.
|
||||||
|
const start = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/v1/recipes/${testRecipeId}/cook/start`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { startNow: true, portions: 4 },
|
||||||
|
});
|
||||||
|
const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/v1/cooking-sessions/${sessionId}/complete`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { mealBoxPortions: 0, actualPortionsEaten: 3, leftoverEstimatePortions: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const assumptions = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/v1/recipes/${testRecipeId}/cooking-assumptions`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
expect(assumptions.statusCode).toBe(200);
|
||||||
|
const body = JSON.parse(assumptions.body) as {
|
||||||
|
defaultActualPortionsEaten: number | null;
|
||||||
|
defaultLeftoverEstimatePortions: number | null;
|
||||||
|
observationCount: number;
|
||||||
|
};
|
||||||
|
expect(body.observationCount).toBe(1);
|
||||||
|
expect(body.defaultActualPortionsEaten).toBe(3);
|
||||||
|
expect(body.defaultLeftoverEstimatePortions).toBe(1);
|
||||||
|
|
||||||
|
// Städa upp testreceptet.
|
||||||
|
await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, testRecipeId));
|
||||||
|
await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, testRecipeId));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits cooking_session_completed on complete and cooking_session_cancelled on cancel", async () => {
|
it("emits cooking_session_completed on complete and cooking_session_cancelled on cancel", async () => {
|
||||||
|
|||||||
@@ -382,5 +382,6 @@
|
|||||||
"scan.diff.acceptAll": "Bekræft alle",
|
"scan.diff.acceptAll": "Bekræft alle",
|
||||||
"scan.diff.noChanges": "Ingen forskelle fundet.",
|
"scan.diff.noChanges": "Ingen forskelle fundet.",
|
||||||
"scan.diff.undo": "Fortryd",
|
"scan.diff.undo": "Fortryd",
|
||||||
"scan.diff.undoHint": "Hver ændring kan fortrydes fra varedetaljevisningen."
|
"scan.diff.undoHint": "Hver ændring kan fortrydes fra varedetaljevisningen.",
|
||||||
|
"cooked.portionsSumExceedsPlanned": "Antal spiste portioner og rester må ikke overstige det samlede antal portioner."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -382,5 +382,6 @@
|
|||||||
"scan.diff.acceptAll": "Alle bestätigen",
|
"scan.diff.acceptAll": "Alle bestätigen",
|
||||||
"scan.diff.noChanges": "Keine Unterschiede gefunden.",
|
"scan.diff.noChanges": "Keine Unterschiede gefunden.",
|
||||||
"scan.diff.undo": "Rückgängig",
|
"scan.diff.undo": "Rückgängig",
|
||||||
"scan.diff.undoHint": "Jede Änderung kann in der Artikeldetailansicht rückgängig gemacht werden."
|
"scan.diff.undoHint": "Jede Änderung kann in der Artikeldetailansicht rückgängig gemacht werden.",
|
||||||
|
"cooked.portionsSumExceedsPlanned": "Gegessene Portionen und Reste dürfen die Gesamtanzahl der Portionen nicht überschreiten."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -382,5 +382,6 @@
|
|||||||
"scan.diff.acceptAll": "Confirm all",
|
"scan.diff.acceptAll": "Confirm all",
|
||||||
"scan.diff.noChanges": "No differences found.",
|
"scan.diff.noChanges": "No differences found.",
|
||||||
"scan.diff.undo": "Undo",
|
"scan.diff.undo": "Undo",
|
||||||
"scan.diff.undoHint": "Each change can be undone from the item detail view."
|
"scan.diff.undoHint": "Each change can be undone from the item detail view.",
|
||||||
|
"cooked.portionsSumExceedsPlanned": "Eaten portions and leftovers cannot exceed the total number of portions."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -382,5 +382,6 @@
|
|||||||
"scan.diff.acceptAll": "Confirmar todo",
|
"scan.diff.acceptAll": "Confirmar todo",
|
||||||
"scan.diff.noChanges": "No se encontraron diferencias.",
|
"scan.diff.noChanges": "No se encontraron diferencias.",
|
||||||
"scan.diff.undo": "Deshacer",
|
"scan.diff.undo": "Deshacer",
|
||||||
"scan.diff.undoHint": "Cada cambio se puede deshacer desde la vista de detalle del producto."
|
"scan.diff.undoHint": "Cada cambio se puede deshacer desde la vista de detalle del producto.",
|
||||||
|
"cooked.portionsSumExceedsPlanned": "Las raciones comidas y las sobras no pueden superar el número total de raciones."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -382,5 +382,6 @@
|
|||||||
"scan.diff.acceptAll": "Vahvista kaikki",
|
"scan.diff.acceptAll": "Vahvista kaikki",
|
||||||
"scan.diff.noChanges": "Eroja ei löytynyt.",
|
"scan.diff.noChanges": "Eroja ei löytynyt.",
|
||||||
"scan.diff.undo": "Kumoa",
|
"scan.diff.undo": "Kumoa",
|
||||||
"scan.diff.undoHint": "Jokainen muutos voidaan kumota tuotteen tietonäkymästä."
|
"scan.diff.undoHint": "Jokainen muutos voidaan kumota tuotteen tietonäkymästä.",
|
||||||
|
"cooked.portionsSumExceedsPlanned": "Syödyt annokset ja tähteet eivät voi ylittää annosten kokonaismäärää."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -382,5 +382,6 @@
|
|||||||
"scan.diff.acceptAll": "Tout confirmer",
|
"scan.diff.acceptAll": "Tout confirmer",
|
||||||
"scan.diff.noChanges": "Aucune différence trouvée.",
|
"scan.diff.noChanges": "Aucune différence trouvée.",
|
||||||
"scan.diff.undo": "Annuler",
|
"scan.diff.undo": "Annuler",
|
||||||
"scan.diff.undoHint": "Chaque modification peut être annulée depuis la vue détail de l'article."
|
"scan.diff.undoHint": "Chaque modification peut être annulée depuis la vue détail de l'article.",
|
||||||
|
"cooked.portionsSumExceedsPlanned": "Les portions mangées et les restes ne peuvent pas dépasser le nombre total de portions."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -382,5 +382,6 @@
|
|||||||
"scan.diff.acceptAll": "Conferma tutto",
|
"scan.diff.acceptAll": "Conferma tutto",
|
||||||
"scan.diff.noChanges": "Nessuna differenza trovata.",
|
"scan.diff.noChanges": "Nessuna differenza trovata.",
|
||||||
"scan.diff.undo": "Annulla",
|
"scan.diff.undo": "Annulla",
|
||||||
"scan.diff.undoHint": "Ogni modifica può essere annullata dalla vista dettaglio dell'articolo."
|
"scan.diff.undoHint": "Ogni modifica può essere annullata dalla vista dettaglio dell'articolo.",
|
||||||
|
"cooked.portionsSumExceedsPlanned": "Le porzioni mangiate e gli avanzi non possono superare il numero totale di porzioni."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -382,5 +382,6 @@
|
|||||||
"scan.diff.acceptAll": "Bekreft alle",
|
"scan.diff.acceptAll": "Bekreft alle",
|
||||||
"scan.diff.noChanges": "Ingen forskjeller funnet.",
|
"scan.diff.noChanges": "Ingen forskjeller funnet.",
|
||||||
"scan.diff.undo": "Angre",
|
"scan.diff.undo": "Angre",
|
||||||
"scan.diff.undoHint": "Hver endring kan angres fra varedetaljvisningen."
|
"scan.diff.undoHint": "Hver endring kan angres fra varedetaljvisningen.",
|
||||||
|
"cooked.portionsSumExceedsPlanned": "Antall spiste porsjoner og rester kan ikke overstige det totale antallet porsjoner."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -382,5 +382,6 @@
|
|||||||
"scan.diff.acceptAll": "Alles bevestigen",
|
"scan.diff.acceptAll": "Alles bevestigen",
|
||||||
"scan.diff.noChanges": "Geen verschillen gevonden.",
|
"scan.diff.noChanges": "Geen verschillen gevonden.",
|
||||||
"scan.diff.undo": "Ongedaan maken",
|
"scan.diff.undo": "Ongedaan maken",
|
||||||
"scan.diff.undoHint": "Elke wijziging kan ongedaan worden gemaakt vanuit de detailweergave."
|
"scan.diff.undoHint": "Elke wijziging kan ongedaan worden gemaakt vanuit de detailweergave.",
|
||||||
|
"cooked.portionsSumExceedsPlanned": "Gegeten porties en restjes mogen het totaal aantal porties niet overschrijden."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -396,5 +396,6 @@
|
|||||||
"scan.diff.acceptAll": "Potwierdź wszystko",
|
"scan.diff.acceptAll": "Potwierdź wszystko",
|
||||||
"scan.diff.noChanges": "Nie znaleziono różnic.",
|
"scan.diff.noChanges": "Nie znaleziono różnic.",
|
||||||
"scan.diff.undo": "Cofnij",
|
"scan.diff.undo": "Cofnij",
|
||||||
"scan.diff.undoHint": "Każdą zmianę można cofnąć z widoku szczegółów produktu."
|
"scan.diff.undoHint": "Każdą zmianę można cofnąć z widoku szczegółów produktu.",
|
||||||
|
"cooked.portionsSumExceedsPlanned": "Zjedzone porcje i resztki nie mogą przekroczyć całkowitej liczby porcji."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -382,5 +382,6 @@
|
|||||||
"scan.diff.acceptAll": "Confirmar tudo",
|
"scan.diff.acceptAll": "Confirmar tudo",
|
||||||
"scan.diff.noChanges": "Nenhuma diferença encontrada.",
|
"scan.diff.noChanges": "Nenhuma diferença encontrada.",
|
||||||
"scan.diff.undo": "Desfazer",
|
"scan.diff.undo": "Desfazer",
|
||||||
"scan.diff.undoHint": "Cada alteração pode ser desfeita a partir da vista de detalhes do item."
|
"scan.diff.undoHint": "Cada alteração pode ser desfeita a partir da vista de detalhes do item.",
|
||||||
|
"cooked.portionsSumExceedsPlanned": "As porções comidas e as sobras não podem ultrapassar o número total de porções."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -382,5 +382,6 @@
|
|||||||
"scan.diff.acceptAll": "Bekräfta alla",
|
"scan.diff.acceptAll": "Bekräfta alla",
|
||||||
"scan.diff.noChanges": "Inga skillnader hittades.",
|
"scan.diff.noChanges": "Inga skillnader hittades.",
|
||||||
"scan.diff.undo": "Ångra",
|
"scan.diff.undo": "Ångra",
|
||||||
"scan.diff.undoHint": "Varje ändring kan ångras från varans detaljvy."
|
"scan.diff.undoHint": "Varje ändring kan ångras från varans detaljvy.",
|
||||||
|
"cooked.portionsSumExceedsPlanned": "Antalet ätna portioner och rester får inte överstiga det totala antalet portioner."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,12 @@ export const cookRecipeInputSchema = z.object({
|
|||||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
.optional(),
|
.optional(),
|
||||||
mealType: z.enum(MEAL_TYPES).default("dinner"),
|
mealType: z.enum(MEAL_TYPES).default("dinner"),
|
||||||
|
/** Antal portioner som faktiskt åts (steg 3c). */
|
||||||
|
actualPortionsEaten: z.number().int().min(0).max(24).optional(),
|
||||||
|
/** Uppskattade restportioner efter måltiden (steg 3c). */
|
||||||
|
leftoverEstimatePortions: z.number().int().min(0).max(24).optional(),
|
||||||
|
/** Fritextnotering om rester – aldrig i analytics. */
|
||||||
|
leftoverNote: z.string().max(200).optional(),
|
||||||
});
|
});
|
||||||
export type CookRecipeInput = z.infer<typeof cookRecipeInputSchema>;
|
export type CookRecipeInput = z.infer<typeof cookRecipeInputSchema>;
|
||||||
|
|
||||||
@@ -152,8 +158,5 @@ export const cookingSessionCompleteInputSchema = cookRecipeInputSchema.partial()
|
|||||||
)
|
)
|
||||||
.default([]),
|
.default([]),
|
||||||
mealType: z.enum(MEAL_TYPES).optional(),
|
mealType: z.enum(MEAL_TYPES).optional(),
|
||||||
actualPortionsEaten: z.number().int().min(0).max(24).optional(),
|
|
||||||
leftoverEstimatePortions: z.number().int().min(0).max(24).optional(),
|
|
||||||
leftoverNote: z.string().max(200).optional(),
|
|
||||||
});
|
});
|
||||||
export type CookingSessionCompleteInput = z.infer<typeof cookingSessionCompleteInputSchema>;
|
export type CookingSessionCompleteInput = z.infer<typeof cookingSessionCompleteInputSchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user