Initial commit (unpacked platform)
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@app/recommendation-engine",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "\"Vad ska vi äta?\" – deterministisk poängsättning med transparenta förklaringar (spec §18)",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@app/recipe-engine": "workspace:*",
|
||||
"@app/shared-types": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Cuisine } from "@app/shared-types";
|
||||
|
||||
export interface ParsedCraving {
|
||||
tags: string[];
|
||||
cuisine?: Cuisine | undefined;
|
||||
maxKcal?: number | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministisk grundtolkning av "Jag är sugen på …" (spec §19).
|
||||
* Täcker vanliga svenska uttryck utan AI-anrop; AAMOS kan förfina tolkningen
|
||||
* (GENERATE_RECIPE_OPTIONS) men denna bas fungerar alltid, även offline-nära.
|
||||
*/
|
||||
const CUISINE_KEYWORDS: Array<[Cuisine, string[]]> = [
|
||||
["thai", ["thai", "thailändskt", "asiatiskt"]],
|
||||
["japanese", ["japanskt", "sushi", "ramen"]],
|
||||
["chinese", ["kinesiskt", "wok"]],
|
||||
["korean", ["koreanskt", "kimchi"]],
|
||||
["vietnamese", ["vietnamesiskt", "pho"]],
|
||||
["indian", ["indiskt", "curry"]],
|
||||
["italian", ["italienskt", "pasta", "pizza"]],
|
||||
["french", ["franskt"]],
|
||||
["spanish", ["spanskt", "tapas", "paella"]],
|
||||
["greek", ["grekiskt", "gyros", "tzatziki"]],
|
||||
["mexican", ["mexikanskt", "tacos", "tex mex", "texmex"]],
|
||||
["american", ["amerikanskt", "burgare", "hamburgare"]],
|
||||
["turkish", ["turkiskt", "kebab"]],
|
||||
["lebanese", ["libanesiskt", "meze"]],
|
||||
["moroccan", ["marockanskt", "tagine"]],
|
||||
["swedish", ["svenskt", "husman", "husmanskost"]],
|
||||
["nordic", ["nordiskt"]],
|
||||
];
|
||||
|
||||
const TAG_KEYWORDS: Array<[string, string[]]> = [
|
||||
["creamy", ["krämigt", "krämig", "gräddigt"]],
|
||||
["spicy", ["starkt", "stark", "hett", "chili"]],
|
||||
["fresh", ["fräscht", "fräsch", "lätt", "somrigt"]],
|
||||
["comfort", ["comfort", "mysmat", "husman", "tröstmat"]],
|
||||
["luxury", ["lyxigt", "lyx", "fest"]],
|
||||
["quick", ["snabbt", "snabb", "enkelt", "enkel"]],
|
||||
["kid_friendly", ["barnvänligt", "barnvänlig", "barn"]],
|
||||
["high_protein", ["proteinrikt", "protein"]],
|
||||
["vegetarian", ["vegetariskt", "vegetarisk", "vego"]],
|
||||
["vegan", ["veganskt", "vegansk"]],
|
||||
["soup", ["soppa"]],
|
||||
["stew", ["gryta", "långkok"]],
|
||||
["grill", ["grill", "grillat"]],
|
||||
];
|
||||
|
||||
export function parseCraving(text: string): ParsedCraving {
|
||||
const lower = text.toLowerCase();
|
||||
const tags: string[] = [];
|
||||
let cuisine: Cuisine | undefined;
|
||||
|
||||
for (const [c, words] of CUISINE_KEYWORDS) {
|
||||
if (words.some((w) => lower.includes(w))) {
|
||||
cuisine = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const [tag, words] of TAG_KEYWORDS) {
|
||||
if (words.some((w) => lower.includes(w))) tags.push(tag);
|
||||
}
|
||||
|
||||
let maxKcal: number | undefined;
|
||||
const kcalMatch = lower.match(/under\s+(\d{2,4})\s*(?:kcal|kalorier)/);
|
||||
if (kcalMatch?.[1]) maxKcal = Number(kcalMatch[1]);
|
||||
|
||||
return { tags, cuisine, maxKcal };
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { RecommendationCandidate, RecommendationContext } from "./types.js";
|
||||
|
||||
/**
|
||||
* "Varför rekommenderar vi detta?" (spec §18).
|
||||
* Bygger en ärlig, konkret svensk förklaring ur de deterministiska delpoängen.
|
||||
* Exempel ur specen: "Ni har 92 % av ingredienserna. Kycklingen bör användas
|
||||
* senast i morgon. Rätten ger 58 gram protein per portion …"
|
||||
*/
|
||||
export function buildWhySv(
|
||||
candidate: RecommendationCandidate,
|
||||
ctx: RecommendationContext,
|
||||
parts: Record<string, number>,
|
||||
): string {
|
||||
const sentences: string[] = [];
|
||||
|
||||
const pct = Math.round(candidate.coverage.coverage * 100);
|
||||
if (pct >= 100) {
|
||||
sentences.push("Ni har alla ingredienser hemma.");
|
||||
} else if (pct >= 60) {
|
||||
sentences.push(`Ni har ${pct} % av ingredienserna hemma.`);
|
||||
} else if (pct > 0) {
|
||||
sentences.push(`Ni har ${pct} % av ingredienserna – resten hamnar på inköpslistan.`);
|
||||
}
|
||||
|
||||
const urgent = candidate.coverage.expiringUsed
|
||||
.filter((m) => m.mostUrgentDaysLeft != null)
|
||||
.sort((a, b) => (a.mostUrgentDaysLeft ?? 99) - (b.mostUrgentDaysLeft ?? 99))[0];
|
||||
if (urgent) {
|
||||
const days = urgent.mostUrgentDaysLeft ?? 0;
|
||||
const when = days <= 0 ? "i dag" : days === 1 ? "senast i morgon" : `inom ${days} dagar`;
|
||||
sentences.push(`${capitalize(urgent.displayNameSv)} bör användas ${when}.`);
|
||||
}
|
||||
|
||||
const protein = Math.round(candidate.nutritionPerPortion.proteinG);
|
||||
if ((parts.nutritionFit ?? 0) >= 0.7 && protein >= 25) {
|
||||
sentences.push(`Rätten ger ${protein} gram protein per portion.`);
|
||||
}
|
||||
if (ctx.isTrainingDay && protein >= 35) {
|
||||
sentences.push("Bra val på en träningsdag.");
|
||||
}
|
||||
|
||||
if ((parts.rating ?? 0) >= 0.8 && candidate.householdRating != null) {
|
||||
sentences.push("Liknande rätter har fått höga betyg av hushållet.");
|
||||
}
|
||||
|
||||
if ((parts.holiday ?? 0) >= 1 && ctx.activeHolidayTags.length > 0) {
|
||||
sentences.push("Passar den kommande högtiden.");
|
||||
} else if ((parts.season ?? 0) >= 1) {
|
||||
sentences.push("Råvarorna är i säsong just nu.");
|
||||
}
|
||||
|
||||
if ((parts.time ?? 0) >= 1 && ctx.isWeekday) {
|
||||
sentences.push(`Klar på ${candidate.totalTimeMinutes} minuter.`);
|
||||
}
|
||||
|
||||
if ((parts.budget ?? 0) >= 1 && candidate.estimatedCostMinorPerPortion != null) {
|
||||
sentences.push(
|
||||
`Cirka ${Math.round(candidate.estimatedCostMinorPerPortion / 100)} kr per portion.`,
|
||||
);
|
||||
}
|
||||
|
||||
if ((parts.craving ?? 0) >= 1) {
|
||||
sentences.push("Matchar det du är sugen på.");
|
||||
}
|
||||
|
||||
if (sentences.length === 0) {
|
||||
sentences.push("En balanserad rätt som passar er profil.");
|
||||
}
|
||||
|
||||
return sentences.join(" ");
|
||||
}
|
||||
|
||||
function capitalize(s: string): string {
|
||||
return s.length === 0 ? s : s[0]!.toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
/** Kompakt kontextsammanfattning (utan persondata) till AAMOS RANK_RECIPES. */
|
||||
export function summarizeContext(ctx: RecommendationContext): string {
|
||||
const parts = [
|
||||
`måltid=${ctx.mealType}`,
|
||||
`personer=${ctx.persons}`,
|
||||
`säsong=${ctx.currentSeason}`,
|
||||
ctx.isWeekday ? "vardag" : "helg",
|
||||
];
|
||||
if (ctx.maxMinutes != null) parts.push(`maxMinuter=${ctx.maxMinutes}`);
|
||||
if (ctx.activeHolidayTags.length > 0) parts.push(`högtider=${ctx.activeHolidayTags.join("+")}`);
|
||||
if (ctx.cravingTags && ctx.cravingTags.length > 0)
|
||||
parts.push(`sugen_på=${ctx.cravingTags.join("+")}`);
|
||||
if (ctx.cravingCuisine) parts.push(`kök=${ctx.cravingCuisine}`);
|
||||
return parts.join(", ");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./types.js";
|
||||
export * from "./scoring.js";
|
||||
export * from "./explain.js";
|
||||
export * from "./craving.js";
|
||||
export * from "./season.js";
|
||||
@@ -0,0 +1,203 @@
|
||||
import type {
|
||||
RecommendationCandidate,
|
||||
RecommendationContext,
|
||||
ScoredRecommendation,
|
||||
ScoringWeights,
|
||||
} from "./types.js";
|
||||
import { DEFAULT_WEIGHTS } from "./types.js";
|
||||
import { buildWhySv } from "./explain.js";
|
||||
|
||||
/**
|
||||
* Poängsätt en kandidat (0–100-skala per delkomponent, viktad summa).
|
||||
* Helt deterministisk: samma indata → samma poäng → förklarbar rekommendation.
|
||||
*/
|
||||
export function scoreCandidate(
|
||||
candidate: RecommendationCandidate,
|
||||
ctx: RecommendationContext,
|
||||
weights: ScoringWeights = DEFAULT_WEIGHTS,
|
||||
): ScoredRecommendation {
|
||||
const parts: Record<string, number> = {};
|
||||
|
||||
// 1. Ingredienstäckning – kärnan i "utgå från vad som finns hemma".
|
||||
parts.coverage = candidate.coverage.coverage;
|
||||
|
||||
// 2. Utgångsdatum: störst poäng när receptet räddar varor som snart går ut.
|
||||
const expiring = candidate.coverage.expiringUsed;
|
||||
if (expiring.length === 0) {
|
||||
parts.expiry = 0;
|
||||
} else {
|
||||
const mostUrgent = Math.min(
|
||||
...expiring.map((m) => (m.mostUrgentDaysLeft == null ? 99 : m.mostUrgentDaysLeft)),
|
||||
);
|
||||
parts.expiry = mostUrgent <= 1 ? 1 : mostUrgent <= 3 ? 0.8 : 0.5;
|
||||
}
|
||||
|
||||
// 3. Näringsfit mot återstående dagsmål.
|
||||
parts.nutritionFit = nutritionFit(candidate, ctx);
|
||||
|
||||
// 4. Smak: favoritkök + styrka nära preferens.
|
||||
parts.taste = ctx.favoriteCuisines.includes(candidate.cuisine) ? 1 : 0.4;
|
||||
|
||||
// 5. Betyg: hushållets egna betyg väger tyngst, annars community (kräver volym).
|
||||
if (candidate.householdRating != null) {
|
||||
parts.rating = clamp01((candidate.householdRating - 2.5) / 2.5);
|
||||
} else if (candidate.ratingAverage != null && candidate.ratingCount >= 5) {
|
||||
parts.rating = clamp01((candidate.ratingAverage - 3) / 2);
|
||||
} else {
|
||||
parts.rating = 0.5;
|
||||
}
|
||||
|
||||
// 6–7. Säsong & högtid.
|
||||
parts.season = candidate.peakSeasons.includes(ctx.currentSeason) ? 1 : 0.3;
|
||||
parts.holiday =
|
||||
ctx.activeHolidayTags.length > 0 &&
|
||||
candidate.holidayTags.some((t) => ctx.activeHolidayTags.includes(t))
|
||||
? 1
|
||||
: 0;
|
||||
|
||||
// 8. Tid: hård maxgräns om satt, annars vardagsbonus för snabbt.
|
||||
if (ctx.maxMinutes != null && candidate.totalTimeMinutes > ctx.maxMinutes) {
|
||||
parts.time = -1; // diskvalificerande straff hanteras i rankAll
|
||||
} else if (ctx.isWeekday) {
|
||||
parts.time =
|
||||
candidate.totalTimeMinutes <= 25 ? 1 : candidate.totalTimeMinutes <= 45 ? 0.6 : 0.2;
|
||||
} else {
|
||||
parts.time = candidate.totalTimeMinutes <= 90 ? 0.7 : 0.5;
|
||||
}
|
||||
|
||||
// 9. Budget.
|
||||
const cost = candidate.estimatedCostMinorPerPortion;
|
||||
if (ctx.maxCostMinorPerPortion != null && cost != null) {
|
||||
parts.budget = cost <= ctx.maxCostMinorPerPortion ? 1 : -0.5;
|
||||
} else if (cost != null) {
|
||||
parts.budget = cost <= 25 ? 1 : cost <= 45 ? 0.6 : 0.3;
|
||||
} else {
|
||||
parts.budget = 0.4;
|
||||
}
|
||||
|
||||
// 10. Variation: nyligen lagat straffas (spec §25: variation).
|
||||
const days = candidate.daysSinceLastCooked;
|
||||
parts.variety = days == null ? 0.8 : days < 7 ? 0 : days < 14 ? 0.4 : 1;
|
||||
|
||||
// 11. Väder (spec §29): varmt → grill/sallad, kallt → gryta/soppa.
|
||||
parts.weather = weatherFit(candidate, ctx);
|
||||
|
||||
// 12. "Jag är sugen på" (spec §19).
|
||||
parts.craving = cravingFit(candidate, ctx);
|
||||
|
||||
const score = weightedSum(parts, weights);
|
||||
|
||||
return {
|
||||
recipeId: candidate.recipeId,
|
||||
titleSv: candidate.titleSv,
|
||||
score: Math.round(score * 10) / 10,
|
||||
parts,
|
||||
whySv: buildWhySv(candidate, ctx, parts),
|
||||
missingIngredients: candidate.coverage.missing
|
||||
.filter((m) => !m.optional)
|
||||
.map((m) => m.displayNameSv),
|
||||
usesExpiring: expiring.map((m) => ({
|
||||
nameSv: m.displayNameSv,
|
||||
daysLeft: m.mostUrgentDaysLeft,
|
||||
})),
|
||||
coveragePercent: Math.round(candidate.coverage.coverage * 100),
|
||||
};
|
||||
}
|
||||
|
||||
/** Ranka alla kandidater; kandidater över tidsgränsen filtreras bort. */
|
||||
export function rankAll(
|
||||
candidates: RecommendationCandidate[],
|
||||
ctx: RecommendationContext,
|
||||
weights: ScoringWeights = DEFAULT_WEIGHTS,
|
||||
limit = 5,
|
||||
): ScoredRecommendation[] {
|
||||
return candidates
|
||||
.map((c) => scoreCandidate(c, ctx, weights))
|
||||
.filter((s) => (s.parts.time ?? 0) >= 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
function nutritionFit(candidate: RecommendationCandidate, ctx: RecommendationContext): number {
|
||||
let fit = 0.5;
|
||||
const protein = candidate.nutritionPerPortion.proteinG;
|
||||
if (ctx.remainingProteinG != null && ctx.remainingProteinG > 0) {
|
||||
// Ju närmare receptet fyller proteinluckan, desto bättre (upp till 1).
|
||||
fit = clamp01(protein / Math.max(20, ctx.remainingProteinG * 0.6));
|
||||
}
|
||||
if (ctx.isTrainingDay && protein >= 35) fit = Math.min(1, fit + 0.3);
|
||||
if (ctx.remainingKcal != null && ctx.remainingKcal > 0) {
|
||||
const kcal = candidate.nutritionPerPortion.kcal;
|
||||
if (kcal > ctx.remainingKcal * 1.3) fit *= 0.5;
|
||||
}
|
||||
return clamp01(fit);
|
||||
}
|
||||
|
||||
function weatherFit(candidate: RecommendationCandidate, ctx: RecommendationContext): number {
|
||||
if (!ctx.weather || ctx.weather === "unknown") return 0.5;
|
||||
const title = candidate.titleSv.toLowerCase();
|
||||
const has = (words: string[]) =>
|
||||
words.some((w) => title.includes(w)) ||
|
||||
candidate.tags.some((t) => words.includes(t)) ||
|
||||
words.includes(candidate.cuisine);
|
||||
switch (ctx.weather) {
|
||||
case "hot":
|
||||
case "warm":
|
||||
return has(["grill", "sallad", "kall", "bowl", "wrap"]) ? 1 : 0.4;
|
||||
case "cold":
|
||||
case "snow":
|
||||
return has(["soppa", "gryta", "långkok", "ugns", "pytt"]) ? 1 : 0.4;
|
||||
case "rain":
|
||||
return has(["gryta", "långkok", "soppa", "paj"]) ? 0.9 : 0.5;
|
||||
default:
|
||||
return 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
function cravingFit(candidate: RecommendationCandidate, ctx: RecommendationContext): number {
|
||||
const tags = ctx.cravingTags ?? [];
|
||||
if (tags.length === 0 && !ctx.cravingCuisine && ctx.cravingMaxKcal == null) return 0.5;
|
||||
let hits = 0;
|
||||
let checks = 0;
|
||||
|
||||
if (ctx.cravingCuisine) {
|
||||
checks += 1;
|
||||
if (candidate.cuisine === ctx.cravingCuisine) hits += 1;
|
||||
}
|
||||
if (ctx.cravingMaxKcal != null) {
|
||||
checks += 1;
|
||||
if (candidate.nutritionPerPortion.kcal <= ctx.cravingMaxKcal) hits += 1;
|
||||
}
|
||||
if (tags.length > 0) {
|
||||
checks += 1;
|
||||
const title = candidate.titleSv.toLowerCase();
|
||||
const candidateTags = new Set<string>(candidate.tags);
|
||||
const matched = tags.some(
|
||||
(t) =>
|
||||
candidateTags.has(t) ||
|
||||
title.includes(t) ||
|
||||
(t === "spicy" && candidate.spiceLevel >= 3) ||
|
||||
(t === "quick" && candidate.totalTimeMinutes <= 25) ||
|
||||
(t === "high_protein" && candidate.nutritionPerPortion.proteinG >= 35),
|
||||
);
|
||||
if (matched) hits += 1;
|
||||
}
|
||||
|
||||
return checks === 0 ? 0.5 : hits / checks;
|
||||
}
|
||||
|
||||
function weightedSum(parts: Record<string, number>, weights: ScoringWeights): number {
|
||||
let total = 0;
|
||||
for (const [key, value] of Object.entries(parts)) {
|
||||
const weight = (weights as unknown as Record<string, number>)[key] ?? 0;
|
||||
total += clampPart(value) * weight;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function clamp01(v: number): number {
|
||||
return Math.max(0, Math.min(1, v));
|
||||
}
|
||||
function clampPart(v: number): number {
|
||||
return Math.max(-1, Math.min(1, v));
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { Season } from "@app/shared-types";
|
||||
|
||||
/**
|
||||
* All kalenderaritmetik i denna modul sker i UTC (Date.UTC/getUTC*).
|
||||
* Motorn ska ge identiskt resultat oavsett serverns tidszon – och UTC har
|
||||
* ingen sommartid, så dygnsmatte med millisekunder är alltid exakt.
|
||||
*/
|
||||
|
||||
/** Meteorologisk årstid för norra halvklotet (SE-marknad). */
|
||||
export function seasonForDate(date: Date): Season {
|
||||
const m = date.getUTCMonth() + 1;
|
||||
if (m >= 3 && m <= 5) return "spring";
|
||||
if (m >= 6 && m <= 8) return "summer";
|
||||
if (m >= 9 && m <= 11) return "autumn";
|
||||
return "winter";
|
||||
}
|
||||
|
||||
/** Midsommarafton: fredagen mellan 19 och 25 juni. */
|
||||
export function midsummerEve(year: number): Date {
|
||||
for (let day = 19; day <= 25; day++) {
|
||||
const d = new Date(Date.UTC(year, 5, day));
|
||||
if (d.getUTCDay() === 5) return d;
|
||||
}
|
||||
return new Date(Date.UTC(year, 5, 24));
|
||||
}
|
||||
|
||||
/** Påskdagen enligt anonym gregoriansk algoritm. */
|
||||
export function easterSunday(year: number): Date {
|
||||
const a = year % 19;
|
||||
const b = Math.floor(year / 100);
|
||||
const c = year % 100;
|
||||
const d = Math.floor(b / 4);
|
||||
const e = b % 4;
|
||||
const f = Math.floor((b + 8) / 25);
|
||||
const g = Math.floor((b - f + 1) / 3);
|
||||
const h = (19 * a + b - d - g + 15) % 30;
|
||||
const i = Math.floor(c / 4);
|
||||
const k = c % 4;
|
||||
const l = (32 + 2 * e + 2 * i - h - k) % 7;
|
||||
const m = Math.floor((a + 11 * h + 22 * l) / 451);
|
||||
const month = Math.floor((h + l - 7 * m + 114) / 31);
|
||||
const day = ((h + l - 7 * m + 114) % 31) + 1;
|
||||
return new Date(Date.UTC(year, month - 1, day));
|
||||
}
|
||||
|
||||
/** Första advent: fjärde söndagen före juldagen. */
|
||||
export function firstAdvent(year: number): Date {
|
||||
const christmas = new Date(Date.UTC(year, 11, 25));
|
||||
const dayOfWeek = christmas.getUTCDay();
|
||||
return new Date(Date.UTC(year, 11, 25 - (dayOfWeek === 0 ? 7 : dayOfWeek) - 21));
|
||||
}
|
||||
|
||||
export interface SeasonEventRuleInput {
|
||||
dateRule:
|
||||
| { kind: "fixed"; monthDay: string }
|
||||
| { kind: "range"; startMonthDay: string; endMonthDay: string }
|
||||
| { kind: "computed"; algorithm: "midsummer" | "easter" | "advent" | "custom" };
|
||||
leadDays: number;
|
||||
}
|
||||
|
||||
/** Är eventet aktivt (inom leadDays före, till och med eventdagen/perioden)? */
|
||||
export function isEventActive(rule: SeasonEventRuleInput, today: Date): boolean {
|
||||
const year = today.getUTCFullYear();
|
||||
const t = strip(today).getTime();
|
||||
const DAY = 86_400_000;
|
||||
|
||||
const activeAround = (target: Date, tailDays = 1): boolean => {
|
||||
const start = strip(target).getTime() - rule.leadDays * DAY;
|
||||
const end = strip(target).getTime() + tailDays * DAY;
|
||||
return t >= start && t <= end;
|
||||
};
|
||||
|
||||
switch (rule.dateRule.kind) {
|
||||
case "fixed": {
|
||||
const [mm, dd] = rule.dateRule.monthDay.split("-").map(Number);
|
||||
return activeAround(new Date(Date.UTC(year, (mm ?? 1) - 1, dd ?? 1)));
|
||||
}
|
||||
case "range": {
|
||||
const [sm, sd] = rule.dateRule.startMonthDay.split("-").map(Number);
|
||||
const [em, ed] = rule.dateRule.endMonthDay.split("-").map(Number);
|
||||
const start = Date.UTC(year, (sm ?? 1) - 1, sd ?? 1) - rule.leadDays * DAY;
|
||||
let end = Date.UTC(year, (em ?? 1) - 1, ed ?? 1);
|
||||
if (end < start) end = Date.UTC(year + 1, (em ?? 1) - 1, ed ?? 1);
|
||||
return t >= start && t <= end;
|
||||
}
|
||||
case "computed": {
|
||||
switch (rule.dateRule.algorithm) {
|
||||
case "midsummer":
|
||||
return activeAround(midsummerEve(year), 2);
|
||||
case "easter":
|
||||
return activeAround(easterSunday(year), 1);
|
||||
case "advent":
|
||||
return activeAround(firstAdvent(year), 28);
|
||||
case "custom":
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** UTC-dygnets start – oberoende av serverns tidszon. */
|
||||
function strip(d: Date): Date {
|
||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type {
|
||||
Cuisine,
|
||||
MealType,
|
||||
NutritionValues,
|
||||
RecipeTag,
|
||||
Season,
|
||||
WeatherHint,
|
||||
} from "@app/shared-types";
|
||||
import type { CoverageResult } from "@app/recipe-engine";
|
||||
|
||||
/** Kandidat som poängsätts. Säkerhetsfiltrering har redan skett (blockers borta). */
|
||||
export interface RecommendationCandidate {
|
||||
recipeId: string;
|
||||
titleSv: string;
|
||||
cuisine: Cuisine;
|
||||
tags: RecipeTag[];
|
||||
totalTimeMinutes: number;
|
||||
nutritionPerPortion: NutritionValues;
|
||||
estimatedCostMinorPerPortion?: number | null;
|
||||
ratingAverage?: number | null;
|
||||
ratingCount: number;
|
||||
peakSeasons: Season[];
|
||||
holidayTags: string[];
|
||||
spiceLevel: number;
|
||||
coverage: CoverageResult;
|
||||
/** Dagar sedan hushållet senast lagade receptet (null = aldrig). */
|
||||
daysSinceLastCooked?: number | null;
|
||||
/** Hushållets snittbetyg på receptet, om finns. */
|
||||
householdRating?: number | null;
|
||||
}
|
||||
|
||||
export interface RecommendationContext {
|
||||
mealType: MealType;
|
||||
persons: number;
|
||||
maxMinutes?: number | undefined;
|
||||
maxCostMinorPerPortion?: number | undefined;
|
||||
/** Proteinlucka kvar i dag (g) för den som frågar – styr proteinfit. */
|
||||
remainingProteinG?: number | undefined;
|
||||
/** Kalorier kvar i dag – recept långt över straffas mjukt. */
|
||||
remainingKcal?: number | undefined;
|
||||
currentSeason: Season;
|
||||
activeHolidayTags: string[];
|
||||
weather?: WeatherHint | undefined;
|
||||
isWeekday: boolean;
|
||||
isTrainingDay?: boolean | undefined;
|
||||
favoriteCuisines: Cuisine[];
|
||||
/** Tolkade "jag är sugen på"-taggar (spec §19), redan normaliserade. */
|
||||
cravingTags?: string[] | undefined;
|
||||
cravingCuisine?: Cuisine | undefined;
|
||||
cravingMaxKcal?: number | undefined;
|
||||
}
|
||||
|
||||
export interface ScoredRecommendation {
|
||||
recipeId: string;
|
||||
titleSv: string;
|
||||
score: number;
|
||||
/** Delpoäng för transparens och evals. */
|
||||
parts: Record<string, number>;
|
||||
whySv: string;
|
||||
missingIngredients: string[];
|
||||
usesExpiring: Array<{ nameSv: string; daysLeft: number | null }>;
|
||||
coveragePercent: number;
|
||||
}
|
||||
|
||||
/** Vikter – justerbara via feature flags/admin utan koddeploy. */
|
||||
export interface ScoringWeights {
|
||||
coverage: number;
|
||||
expiry: number;
|
||||
nutritionFit: number;
|
||||
taste: number;
|
||||
rating: number;
|
||||
season: number;
|
||||
holiday: number;
|
||||
time: number;
|
||||
budget: number;
|
||||
variety: number;
|
||||
weather: number;
|
||||
craving: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_WEIGHTS: ScoringWeights = {
|
||||
coverage: 30,
|
||||
expiry: 20,
|
||||
nutritionFit: 12,
|
||||
taste: 10,
|
||||
rating: 8,
|
||||
season: 4,
|
||||
holiday: 6,
|
||||
time: 8,
|
||||
budget: 6,
|
||||
variety: 6,
|
||||
weather: 3,
|
||||
craving: 15,
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CoverageResult } from "@app/recipe-engine";
|
||||
import {
|
||||
easterSunday,
|
||||
isEventActive,
|
||||
midsummerEve,
|
||||
parseCraving,
|
||||
rankAll,
|
||||
scoreCandidate,
|
||||
seasonForDate,
|
||||
type RecommendationCandidate,
|
||||
type RecommendationContext,
|
||||
} from "../src/index.js";
|
||||
|
||||
const fullCoverage: CoverageResult = { coverage: 1, matches: [], missing: [], expiringUsed: [] };
|
||||
const nutrition = {
|
||||
kcal: 550,
|
||||
proteinG: 45,
|
||||
carbsG: 50,
|
||||
fatG: 18,
|
||||
saturatedFatG: 6,
|
||||
fiberG: 6,
|
||||
sugarG: 4,
|
||||
saltG: 1.5,
|
||||
};
|
||||
|
||||
function candidate(overrides: Partial<RecommendationCandidate> = {}): RecommendationCandidate {
|
||||
return {
|
||||
recipeId: "r1",
|
||||
titleSv: "Kycklinggryta",
|
||||
cuisine: "swedish",
|
||||
tags: [],
|
||||
totalTimeMinutes: 30,
|
||||
nutritionPerPortion: nutrition,
|
||||
estimatedCostMinorPerPortion: 2200,
|
||||
ratingAverage: null,
|
||||
ratingCount: 0,
|
||||
peakSeasons: [],
|
||||
holidayTags: [],
|
||||
spiceLevel: 1,
|
||||
coverage: fullCoverage,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const ctx: RecommendationContext = {
|
||||
mealType: "dinner",
|
||||
persons: 4,
|
||||
currentSeason: "summer",
|
||||
activeHolidayTags: [],
|
||||
isWeekday: true,
|
||||
favoriteCuisines: ["swedish"],
|
||||
remainingProteinG: 60,
|
||||
remainingKcal: 800,
|
||||
};
|
||||
|
||||
describe("poängsättning (spec §18)", () => {
|
||||
it("utgångsvaror lyfter rekommendationen", () => {
|
||||
const expiring = candidate({
|
||||
recipeId: "expiring",
|
||||
coverage: {
|
||||
coverage: 1,
|
||||
matches: [],
|
||||
missing: [],
|
||||
expiringUsed: [
|
||||
{
|
||||
canonicalIngredientId: "chicken",
|
||||
displayNameSv: "kycklingen",
|
||||
required: 500,
|
||||
unit: "GRAM",
|
||||
availableInUnit: 600,
|
||||
covered: true,
|
||||
optional: false,
|
||||
mostUrgentDaysLeft: 1,
|
||||
usesExpiringItem: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const fresh = candidate({ recipeId: "fresh" });
|
||||
const ranked = rankAll([fresh, expiring], ctx);
|
||||
expect(ranked[0]?.recipeId).toBe("expiring");
|
||||
expect(ranked[0]?.whySv).toContain("senast i morgon");
|
||||
});
|
||||
|
||||
it("förklaringen innehåller täckning och protein (specens exempel)", () => {
|
||||
const scored = scoreCandidate(
|
||||
candidate({
|
||||
coverage: { ...fullCoverage, coverage: 0.92 },
|
||||
nutritionPerPortion: { ...nutrition, proteinG: 58 },
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
expect(scored.whySv).toContain("92 %");
|
||||
expect(scored.whySv).toContain("58 gram protein");
|
||||
});
|
||||
|
||||
it("recept över tidsgränsen filtreras bort", () => {
|
||||
const slow = candidate({ recipeId: "slow", totalTimeMinutes: 90 });
|
||||
const ranked = rankAll([slow], { ...ctx, maxMinutes: 45 });
|
||||
expect(ranked).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("nyligen lagat straffas (variation)", () => {
|
||||
const recent = scoreCandidate(candidate({ daysSinceLastCooked: 2 }), ctx);
|
||||
const old = scoreCandidate(candidate({ daysSinceLastCooked: 30 }), ctx);
|
||||
expect(old.score).toBeGreaterThan(recent.score);
|
||||
});
|
||||
});
|
||||
|
||||
describe("'jag är sugen på' (spec §19)", () => {
|
||||
it("tolkar kök, taggar och kcal-gräns", () => {
|
||||
const parsed = parseCraving("något krämigt och asiatiskt under 500 kcal");
|
||||
expect(parsed.cuisine).toBe("thai");
|
||||
expect(parsed.tags).toContain("creamy");
|
||||
expect(parsed.maxKcal).toBe(500);
|
||||
});
|
||||
it("tolkar svenska uttryck", () => {
|
||||
expect(parseCraving("snabb husmanskost").tags).toEqual(
|
||||
expect.arrayContaining(["quick", "comfort"]),
|
||||
);
|
||||
expect(parseCraving("barnvänliga tacos").cuisine).toBe("mexican");
|
||||
});
|
||||
});
|
||||
|
||||
describe("säsongs- och eventmotor (spec §28)", () => {
|
||||
// Motorn räknar i UTC – testerna använder UTC-datum så de är sanna i alla tidszoner.
|
||||
it("årstider", () => {
|
||||
expect(seasonForDate(new Date("2026-07-15T00:00:00Z"))).toBe("summer");
|
||||
expect(seasonForDate(new Date("2026-01-15T00:00:00Z"))).toBe("winter");
|
||||
});
|
||||
it("midsommarafton är alltid en fredag 19–25 juni", () => {
|
||||
for (const year of [2025, 2026, 2027, 2028]) {
|
||||
const eve = midsummerEve(year);
|
||||
expect(eve.getUTCDay()).toBe(5);
|
||||
expect(eve.getUTCMonth()).toBe(5);
|
||||
expect(eve.getUTCDate()).toBeGreaterThanOrEqual(19);
|
||||
expect(eve.getUTCDate()).toBeLessThanOrEqual(25);
|
||||
}
|
||||
});
|
||||
it("påskdagen: kända referensår", () => {
|
||||
expect(easterSunday(2026).toISOString().slice(0, 10)).toBe("2026-04-05");
|
||||
expect(easterSunday(2027).toISOString().slice(0, 10)).toBe("2027-03-28");
|
||||
});
|
||||
it("event aktiveras inom leadDays", () => {
|
||||
const midsummer = {
|
||||
dateRule: { kind: "computed", algorithm: "midsummer" } as const,
|
||||
leadDays: 10,
|
||||
};
|
||||
expect(isEventActive(midsummer, new Date("2026-06-15T00:00:00Z"))).toBe(true);
|
||||
expect(isEventActive(midsummer, new Date("2026-03-01T00:00:00Z"))).toBe(false);
|
||||
const jul = {
|
||||
dateRule: { kind: "range", startMonthDay: "12-20", endMonthDay: "12-26" } as const,
|
||||
leadDays: 21,
|
||||
};
|
||||
expect(isEventActive(jul, new Date("2026-12-05T00:00:00Z"))).toBe(true);
|
||||
expect(isEventActive(jul, new Date("2026-08-02T00:00:00Z"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src", "test"],
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user