Initial commit (unpacked platform)
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import type { Unit } from "@app/shared-types";
|
||||
import { toGrams, type IngredientUnitInfo } from "@app/nutrition-engine";
|
||||
|
||||
export interface CostableIngredient {
|
||||
canonicalIngredientId: string;
|
||||
quantity: number;
|
||||
unit: Unit;
|
||||
optional: boolean;
|
||||
}
|
||||
|
||||
export interface PriceInfo extends IngredientUnitInfo {
|
||||
/** SEK per kg – från kvittohistorik, användardata eller standardvärde (spec §26). */
|
||||
pricePerKgMinor: number;
|
||||
source: "receipt_history" | "user" | "default";
|
||||
}
|
||||
|
||||
export interface CostEstimate {
|
||||
totalMinor: number | null;
|
||||
perPortionMinor: number | null;
|
||||
/** Andel av ingredienserna (viktat) som hade prisdata. */
|
||||
priceCoverage: number;
|
||||
sourcesUsed: PriceInfo["source"][];
|
||||
}
|
||||
|
||||
/** Kostnadsuppskattning per portion – alltid märkt som uppskattning. */
|
||||
export function estimateCost(
|
||||
ingredients: CostableIngredient[],
|
||||
portions: number,
|
||||
prices: Map<string, PriceInfo>,
|
||||
): CostEstimate {
|
||||
let total = 0;
|
||||
let pricedCount = 0;
|
||||
let mandatoryCount = 0;
|
||||
const sources = new Set<PriceInfo["source"]>();
|
||||
|
||||
for (const ing of ingredients) {
|
||||
if (ing.optional) continue;
|
||||
mandatoryCount += 1;
|
||||
const price = prices.get(ing.canonicalIngredientId);
|
||||
if (!price) continue;
|
||||
const grams = toGrams(ing.quantity, ing.unit, price);
|
||||
if (grams == null) continue;
|
||||
total += (grams / 1000) * price.pricePerKgMinor;
|
||||
pricedCount += 1;
|
||||
sources.add(price.source);
|
||||
}
|
||||
|
||||
if (pricedCount === 0 || mandatoryCount === 0) {
|
||||
return { totalMinor: null, perPortionMinor: null, priceCoverage: 0, sourcesUsed: [] };
|
||||
}
|
||||
|
||||
const coverage = pricedCount / mandatoryCount;
|
||||
return {
|
||||
totalMinor: Math.round(total),
|
||||
perPortionMinor: portions > 0 ? Math.round(total / portions) : null,
|
||||
priceCoverage: Math.round(coverage * 100) / 100,
|
||||
sourcesUsed: [...sources],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./safety.js";
|
||||
export * from "./matching.js";
|
||||
export * from "./scaling.js";
|
||||
export * from "./substitution.js";
|
||||
export * from "./cost.js";
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { Unit } from "@app/shared-types";
|
||||
import { convert, type IngredientUnitInfo } from "@app/nutrition-engine";
|
||||
import { classifyExpiry, type ExpiryInput } from "@app/inventory-engine";
|
||||
|
||||
export interface PantryItem extends ExpiryInput {
|
||||
id: string;
|
||||
canonicalIngredientId?: string | null;
|
||||
quantity: number;
|
||||
unit: Unit;
|
||||
}
|
||||
|
||||
export interface RecipeIngredientRequirement {
|
||||
canonicalIngredientId: string;
|
||||
displayNameSv: string;
|
||||
quantity: number;
|
||||
unit: Unit;
|
||||
optional: boolean;
|
||||
}
|
||||
|
||||
export interface IngredientMatch {
|
||||
canonicalIngredientId: string;
|
||||
displayNameSv: string;
|
||||
required: number;
|
||||
unit: Unit;
|
||||
availableInUnit: number;
|
||||
covered: boolean;
|
||||
optional: boolean;
|
||||
/** Mest brådskande status bland matchande lagerposter. */
|
||||
mostUrgentDaysLeft: number | null;
|
||||
usesExpiringItem: boolean;
|
||||
}
|
||||
|
||||
export interface CoverageResult {
|
||||
/** Andel obligatoriska ingredienser som täcks helt (0–1). */
|
||||
coverage: number;
|
||||
matches: IngredientMatch[];
|
||||
missing: IngredientMatch[];
|
||||
/** Ingredienser som finns hemma och snart går ut – guld för rekommendationen. */
|
||||
expiringUsed: IngredientMatch[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Matcha receptets ingredienser mot hushållets lager (spec §17–18).
|
||||
* Enhetskonvertering via nutrition-engine; poster som inte kan konverteras
|
||||
* räknas som otäckta i stället för att gissas.
|
||||
*/
|
||||
export function computeCoverage(
|
||||
requirements: RecipeIngredientRequirement[],
|
||||
pantry: PantryItem[],
|
||||
unitInfo: Map<string, IngredientUnitInfo>,
|
||||
today: Date = new Date(),
|
||||
): CoverageResult {
|
||||
const byIngredient = new Map<string, PantryItem[]>();
|
||||
for (const item of pantry) {
|
||||
if (!item.canonicalIngredientId || item.quantity <= 0) continue;
|
||||
const list = byIngredient.get(item.canonicalIngredientId) ?? [];
|
||||
list.push(item);
|
||||
byIngredient.set(item.canonicalIngredientId, list);
|
||||
}
|
||||
|
||||
const matches: IngredientMatch[] = [];
|
||||
for (const req of requirements) {
|
||||
const stock = byIngredient.get(req.canonicalIngredientId) ?? [];
|
||||
const info = unitInfo.get(req.canonicalIngredientId) ?? {};
|
||||
let available = 0;
|
||||
let mostUrgent: number | null = null;
|
||||
let usesExpiring = false;
|
||||
|
||||
for (const item of stock) {
|
||||
const inReqUnit = convert(item.quantity, item.unit, req.unit, info);
|
||||
if (inReqUnit == null) continue;
|
||||
available += inReqUnit;
|
||||
const expiry = classifyExpiry(item, today);
|
||||
if (expiry.daysLeft != null) {
|
||||
mostUrgent = mostUrgent == null ? expiry.daysLeft : Math.min(mostUrgent, expiry.daysLeft);
|
||||
}
|
||||
if (expiry.status === "expiring" || expiry.status === "use_soon") usesExpiring = true;
|
||||
}
|
||||
|
||||
matches.push({
|
||||
canonicalIngredientId: req.canonicalIngredientId,
|
||||
displayNameSv: req.displayNameSv,
|
||||
required: req.quantity,
|
||||
unit: req.unit,
|
||||
availableInUnit: Math.round(available * 1000) / 1000,
|
||||
covered: available + 1e-9 >= req.quantity,
|
||||
optional: req.optional,
|
||||
mostUrgentDaysLeft: mostUrgent,
|
||||
usesExpiringItem: usesExpiring,
|
||||
});
|
||||
}
|
||||
|
||||
const mandatory = matches.filter((m) => !m.optional);
|
||||
const coveredCount = mandatory.filter((m) => m.covered).length;
|
||||
const coverage = mandatory.length === 0 ? 1 : coveredCount / mandatory.length;
|
||||
|
||||
return {
|
||||
coverage: Math.round(coverage * 100) / 100,
|
||||
matches,
|
||||
missing: matches.filter((m) => !m.covered),
|
||||
expiringUsed: matches.filter((m) => m.covered && m.usesExpiringItem),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import type { Allergen, DietPattern, ReligiousRule } from "@app/shared-types";
|
||||
|
||||
/**
|
||||
* DETERMINISTISK säkerhets- och kostfiltrering (spec §13, §61.2).
|
||||
* Ingen AI får någonsin avgöra allergisäkerhet. Denna modul är enda vägen.
|
||||
*
|
||||
* Försiktighetsprincip: om en ingrediens saknar data ("unknown") behandlas
|
||||
* receptet som EJ säkert för berörda filter och flaggas för granskning.
|
||||
*/
|
||||
|
||||
export interface IngredientSafetyInfo {
|
||||
id: string;
|
||||
allergens: Allergen[];
|
||||
mayContainAllergens?: Allergen[];
|
||||
isVegan: boolean;
|
||||
isVegetarian: boolean;
|
||||
containsGluten: boolean;
|
||||
containsLactose: boolean;
|
||||
isPork: boolean;
|
||||
isBeef: boolean;
|
||||
isAlcohol: boolean;
|
||||
/** true när datan är verifierad; ovverifierad data ger varning i stället för tyst OK. */
|
||||
dataVerified?: boolean;
|
||||
}
|
||||
|
||||
export interface DietaryConstraints {
|
||||
allergens: Allergen[];
|
||||
intolerances?: string[];
|
||||
dietPattern?: DietPattern;
|
||||
religiousRule?: ReligiousRule;
|
||||
avoidIngredientIds?: string[];
|
||||
spiceLevelMax?: number;
|
||||
/** Behandla "kan innehålla spår av" som blockerande (default true vid allergi). */
|
||||
blockMayContain?: boolean;
|
||||
}
|
||||
|
||||
export type ViolationSeverity = "blocker" | "warning";
|
||||
|
||||
export interface SafetyViolation {
|
||||
severity: ViolationSeverity;
|
||||
code:
|
||||
| "allergen"
|
||||
| "allergen_may_contain"
|
||||
| "diet_pattern"
|
||||
| "religious_rule"
|
||||
| "avoided_ingredient"
|
||||
| "spice_level"
|
||||
| "unverified_data";
|
||||
ingredientId?: string;
|
||||
allergen?: Allergen;
|
||||
messageSv: string;
|
||||
}
|
||||
|
||||
export interface RecipeSafetyInput {
|
||||
ingredients: Array<{ canonicalIngredientId: string; optional: boolean }>;
|
||||
spiceLevel: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returnerar ALLA överträdelser (tom lista = säkert enligt tillgänglig data).
|
||||
* Valfria ingredienser ger varning i stället för blocker (kan uteslutas).
|
||||
*/
|
||||
export function checkRecipeSafety(
|
||||
recipe: RecipeSafetyInput,
|
||||
constraints: DietaryConstraints,
|
||||
ingredientInfo: Map<string, IngredientSafetyInfo>,
|
||||
): SafetyViolation[] {
|
||||
const violations: SafetyViolation[] = [];
|
||||
const userAllergens = new Set(constraints.allergens);
|
||||
const avoid = new Set(constraints.avoidIngredientIds ?? []);
|
||||
const blockMayContain = constraints.blockMayContain ?? userAllergens.size > 0;
|
||||
|
||||
for (const ing of recipe.ingredients) {
|
||||
const info = ingredientInfo.get(ing.canonicalIngredientId);
|
||||
const severity: ViolationSeverity = ing.optional ? "warning" : "blocker";
|
||||
|
||||
if (!info) {
|
||||
violations.push({
|
||||
severity: "warning",
|
||||
code: "unverified_data",
|
||||
ingredientId: ing.canonicalIngredientId,
|
||||
messageSv: `Ingrediensen ${ing.canonicalIngredientId} saknar säkerhetsdata – kontrollera manuellt.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const allergen of info.allergens) {
|
||||
if (userAllergens.has(allergen)) {
|
||||
violations.push({
|
||||
severity,
|
||||
code: "allergen",
|
||||
ingredientId: info.id,
|
||||
allergen,
|
||||
messageSv: `Innehåller ${allergen} (${info.id}).`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (blockMayContain) {
|
||||
for (const allergen of info.mayContainAllergens ?? []) {
|
||||
if (userAllergens.has(allergen)) {
|
||||
violations.push({
|
||||
severity: "warning",
|
||||
code: "allergen_may_contain",
|
||||
ingredientId: info.id,
|
||||
allergen,
|
||||
messageSv: `Kan innehålla spår av ${allergen} (${info.id}).`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const diet = constraints.dietPattern;
|
||||
if (diet === "vegan" && !info.isVegan) {
|
||||
violations.push({
|
||||
severity,
|
||||
code: "diet_pattern",
|
||||
ingredientId: info.id,
|
||||
messageSv: `${info.id} är inte veganskt.`,
|
||||
});
|
||||
} else if ((diet === "vegetarian" || diet === "pescatarian") && !info.isVegetarian) {
|
||||
const isFish = info.allergens.includes("fish") || info.allergens.includes("crustaceans");
|
||||
const allowed = diet === "pescatarian" && isFish;
|
||||
if (!allowed) {
|
||||
violations.push({
|
||||
severity,
|
||||
code: "diet_pattern",
|
||||
ingredientId: info.id,
|
||||
messageSv: `${info.id} är inte ${diet === "vegetarian" ? "vegetariskt" : "pescetarianskt"}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const rule = constraints.religiousRule;
|
||||
if (rule === "halal" && (info.isPork || info.isAlcohol)) {
|
||||
violations.push({
|
||||
severity,
|
||||
code: "religious_rule",
|
||||
ingredientId: info.id,
|
||||
messageSv: `${info.id} är inte förenligt med halal (${info.isPork ? "fläsk" : "alkohol"}).`,
|
||||
});
|
||||
}
|
||||
if (
|
||||
rule === "kosher" &&
|
||||
(info.isPork || info.allergens.includes("crustaceans") || info.allergens.includes("molluscs"))
|
||||
) {
|
||||
violations.push({
|
||||
severity,
|
||||
code: "religious_rule",
|
||||
ingredientId: info.id,
|
||||
messageSv: `${info.id} är inte förenligt med kosher.`,
|
||||
});
|
||||
}
|
||||
if (rule === "hindu_no_beef" && info.isBeef) {
|
||||
violations.push({
|
||||
severity,
|
||||
code: "religious_rule",
|
||||
ingredientId: info.id,
|
||||
messageSv: `${info.id} innehåller nötkött.`,
|
||||
});
|
||||
}
|
||||
if (rule === "buddhist_vegetarian" && !info.isVegetarian) {
|
||||
violations.push({
|
||||
severity,
|
||||
code: "religious_rule",
|
||||
ingredientId: info.id,
|
||||
messageSv: `${info.id} är inte vegetariskt.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (avoid.has(info.id)) {
|
||||
violations.push({
|
||||
severity,
|
||||
code: "avoided_ingredient",
|
||||
ingredientId: info.id,
|
||||
messageSv: `${info.id} finns på din undviklista.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (constraints.spiceLevelMax != null && recipe.spiceLevel > constraints.spiceLevelMax) {
|
||||
violations.push({
|
||||
severity: "warning",
|
||||
code: "spice_level",
|
||||
messageSv: `Styrka ${recipe.spiceLevel} överstiger din maxnivå ${constraints.spiceLevelMax}.`,
|
||||
});
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
/** true om receptet är helt fritt från blockers. */
|
||||
export function isRecipeSafe(violations: SafetyViolation[]): boolean {
|
||||
return !violations.some((v) => v.severity === "blocker");
|
||||
}
|
||||
|
||||
/** Härled ett recepts allergener deterministiskt ur ingredienserna (spec §61.2). */
|
||||
export function deriveRecipeAllergens(
|
||||
ingredientIds: string[],
|
||||
ingredientInfo: Map<string, IngredientSafetyInfo>,
|
||||
): Allergen[] {
|
||||
const set = new Set<Allergen>();
|
||||
for (const id of ingredientIds) {
|
||||
const info = ingredientInfo.get(id);
|
||||
for (const a of info?.allergens ?? []) set.add(a);
|
||||
}
|
||||
return [...set].sort();
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { UNIT_INFO, type Unit } from "@app/shared-types";
|
||||
|
||||
export interface ScalableIngredient {
|
||||
canonicalIngredientId: string;
|
||||
displayNameSv: string;
|
||||
quantity: number;
|
||||
unit: Unit;
|
||||
optional: boolean;
|
||||
}
|
||||
|
||||
/** Enheter som avrundas till "köksvänliga" mängder vid skalning. */
|
||||
const SPOON_UNITS: ReadonlySet<Unit> = new Set(["TABLESPOON", "TEASPOON", "PINCH"]);
|
||||
|
||||
/**
|
||||
* Skala recept till annat antal portioner (Cooking Mode: "skala till sex personer").
|
||||
* Kryddmått avrundas till halva mått; styck till kvartar.
|
||||
*/
|
||||
export function scaleIngredients(
|
||||
ingredients: ScalableIngredient[],
|
||||
fromPortions: number,
|
||||
toPortions: number,
|
||||
): ScalableIngredient[] {
|
||||
if (fromPortions <= 0 || toPortions <= 0) {
|
||||
throw new Error("Portioner måste vara > 0");
|
||||
}
|
||||
const factor = toPortions / fromPortions;
|
||||
return ingredients.map((ing) => ({
|
||||
...ing,
|
||||
quantity: roundForUnit(ing.quantity * factor, ing.unit),
|
||||
}));
|
||||
}
|
||||
|
||||
function roundForUnit(value: number, unit: Unit): number {
|
||||
if (SPOON_UNITS.has(unit)) return Math.round(value * 2) / 2;
|
||||
if (UNIT_INFO[unit].kind === "count") return Math.round(value * 4) / 4;
|
||||
if (unit === "KILOGRAM" || unit === "LITER" || unit === "POUND")
|
||||
return Math.round(value * 100) / 100;
|
||||
if (unit === "CUP_US") return Math.round(value * 4) / 4;
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Substitution, Unit } from "@app/shared-types";
|
||||
|
||||
export interface SubstitutableIngredient {
|
||||
canonicalIngredientId: string;
|
||||
displayNameSv: string;
|
||||
quantity: number;
|
||||
unit: Unit;
|
||||
optional: boolean;
|
||||
}
|
||||
|
||||
export interface SubstitutionResult {
|
||||
ingredients: SubstitutableIngredient[];
|
||||
applied: {
|
||||
fromId: string;
|
||||
toId: string;
|
||||
ratio: number;
|
||||
instructionsSv?: string | undefined;
|
||||
warningSv?: string | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitutionsmotor (spec §20): byt ingrediens med mängdfaktor och
|
||||
* instruktionspåverkan. Näringen räknas ALLTID om av nutrition-engine efteråt –
|
||||
* denna modul ändrar bara ingredienslistan.
|
||||
*/
|
||||
export function applySubstitution(
|
||||
ingredients: SubstitutableIngredient[],
|
||||
substitution: Pick<
|
||||
Substitution,
|
||||
"fromIngredientId" | "toIngredientId" | "ratio" | "instructionsSv" | "notRecommendedFor"
|
||||
>,
|
||||
toDisplayNameSv: string,
|
||||
context?: string,
|
||||
): SubstitutionResult {
|
||||
const target = ingredients.find((i) => i.canonicalIngredientId === substitution.fromIngredientId);
|
||||
if (!target) {
|
||||
throw new Error(
|
||||
`Ingrediensen ${substitution.fromIngredientId} finns inte i receptet och kan inte bytas.`,
|
||||
);
|
||||
}
|
||||
|
||||
let warningSv: string | undefined;
|
||||
if (context && substitution.notRecommendedFor.includes(context)) {
|
||||
warningSv = `Observera: detta byte rekommenderas inte för ${context}.`;
|
||||
}
|
||||
|
||||
const next = ingredients.map((ing) =>
|
||||
ing.canonicalIngredientId === substitution.fromIngredientId
|
||||
? {
|
||||
...ing,
|
||||
canonicalIngredientId: substitution.toIngredientId,
|
||||
displayNameSv: toDisplayNameSv,
|
||||
quantity: Math.round(ing.quantity * substitution.ratio * 100) / 100,
|
||||
}
|
||||
: ing,
|
||||
);
|
||||
|
||||
return {
|
||||
ingredients: next,
|
||||
applied: {
|
||||
fromId: substitution.fromIngredientId,
|
||||
toId: substitution.toIngredientId,
|
||||
ratio: substitution.ratio,
|
||||
instructionsSv: substitution.instructionsSv ?? undefined,
|
||||
warningSv,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user