feat(recept): Baka-kategori + 16 nya redaktionella recept

- Ny 'baking'-tagg (RECIPE_TAGS, text[] - ingen migration). Browse-vyn far
  en 'Baka'-chip som filtrerar pa taggen; befintliga lussekatter +
  saffransbullar taggas ocksa.
- extra-recipes.ts: 16 nya recept som fyller de glesa kategorierna -
  4 bak (kanelbullar, kardemummabullar, appelsmulpaj, frukostfrallor),
  3 frukost, 3 mellanmal (tomma kategorin -> fylld), 3 efterratt, 3 lunch.
- Endast verifierade canonical-ingredienser + berakningsbara enheter, sa
  seed harleder narings-/allergendata utan att krascha (run.ts 675/691).
- Nytt rent invariant-test (ingen DB) kor riktiga computeRecipeNutrition pa
  alla 16: id:n finns, naring berakningsbar, inga slug-krockar. 5 tester grona.
- Seed ar additiv (onConflictDoUpdate) - 'pnpm db:seed' lagger till + taggar
  om utan dataforlust. Full typecheck 20/20.
This commit is contained in:
Claude
2026-08-18 23:35:47 +00:00
parent 7d0fca0dad
commit 100d70dc2d
6 changed files with 799 additions and 12 deletions
+23 -9
View File
@@ -42,14 +42,26 @@ interface BrowseRecipe {
verificationStatus: string;
}
const CATEGORIES = [
// De flesta kategorier filtrerar på måltidstyp (MEAL_TYPES). "Baka" filtrerar i
// stället på taggen "baking" (bullar/bröd/pajer) därför bär varje kategori
// antingen ett mealType eller ett tag. Etiketter återanvänder myday.mealType.*
// där de finns; annars en egen svensk etikett.
const CATEGORIES: ReadonlyArray<{
key: string;
labelKey: string | null;
label?: string;
mealType?: string;
tag?: string;
glyph: string;
}> = [
{ key: "all", labelKey: null, glyph: "🍴" },
{ key: "breakfast", labelKey: "myday.mealType.breakfast", glyph: "🥣" },
{ key: "lunch", labelKey: "myday.mealType.lunch", glyph: "🥗" },
{ key: "dinner", labelKey: "myday.mealType.dinner", glyph: "🍽️" },
{ key: "snack", labelKey: "myday.mealType.snack", glyph: "🍎" },
{ key: "dessert", labelKey: "myday.mealType.dessert", glyph: "🍰" },
] as const;
{ key: "breakfast", labelKey: "myday.mealType.breakfast", mealType: "breakfast", glyph: "🥣" },
{ key: "lunch", labelKey: "myday.mealType.lunch", mealType: "lunch", glyph: "🥗" },
{ key: "dinner", labelKey: "myday.mealType.dinner", mealType: "dinner", glyph: "🍽️" },
{ key: "snack", labelKey: "myday.mealType.snack", mealType: "snack", glyph: "🍎" },
{ key: "dessert", labelKey: "myday.mealType.dessert", mealType: "dessert", glyph: "🍰" },
{ key: "baking", labelKey: null, label: "Baka", tag: "baking", glyph: "🥐" },
];
export default function BrowseRecipesScreen() {
const [category, setCategory] = useState<string>("all");
@@ -60,8 +72,10 @@ export default function BrowseRecipesScreen() {
queryKey: ["browse-recipes", category, term],
queryFn: () => {
// Bygg query-strängen som resten av appen (template + encodeURIComponent).
const cat = CATEGORIES.find((c) => c.key === category);
let path = "/v1/recipes?limit=50";
if (category !== "all") path += `&mealType=${category}`;
if (cat?.mealType) path += `&mealType=${cat.mealType}`;
if (cat?.tag) path += `&tags=${cat.tag}`;
if (term) path += `&search=${encodeURIComponent(term)}`;
return api<{ recipes: BrowseRecipe[] }>(path);
},
@@ -85,7 +99,7 @@ export default function BrowseRecipesScreen() {
{CATEGORIES.map((c) => (
<Button
key={c.key}
label={`${c.glyph} ${c.labelKey ? t(c.labelKey as never) : "Alla"}`}
label={`${c.glyph} ${c.labelKey ? t(c.labelKey as never) : (c.label ?? "Alla")}`}
variant={category === c.key ? "secondary" : "ghost"}
onPress={() => setCategory(c.key)}
/>
@@ -0,0 +1,682 @@
import type { SeedRecipe } from "./recipes.js";
/**
* Extra redaktionella recept (spec §1415) egna formuleringar, sourceType
* own_editorial. Fyller upp de glesa kategorierna (frukost/lunch/mellanmål/
* efterrätt/bak) så bläddra-vyn och "Vad ska vi äta?" har mer att välja på.
*
* HÅRDA REGLER (annars kraschar seed):
* - Varje `ing` MÅSTE finnas i SEED_INGREDIENTS (annars kastar run.ts rad 675).
* - (ing, unit) måste vara beräkningsbar: GRAM/KILOGRAM alltid; volym kräver
* density; COUNT kräver gramsPerPiece (annars kastar run.ts rad 691).
* - Näring + allergener härleds automatiskt ur ingredienserna vid seed.
*
* Alla ingrediens/enhet-par nedan är verifierade mot katalogen.
*/
export const EXTRA_RECIPES: SeedRecipe[] = [
// ---------------------------------------------------------------------------
// BAK (tag: baking)
// ---------------------------------------------------------------------------
{
slug: "kanelbullar",
titleSv: "Kanelbullar",
descriptionSv:
"Mjuka, saftiga bullar med smörig kanelfyllning den självklara fikan att baka en söndag.",
cuisine: "swedish",
mealTypes: ["snack", "dessert"],
tags: ["baking", "vegetarian", "kid_friendly"],
methods: ["oven"],
equipment: ["oven"],
difficulty: "medium",
prepMin: 45,
cookMin: 12,
portions: 24,
spiceLevel: 0,
mealPrepFriendly: true,
freezerFriendly: true,
peakSeasons: [],
holidayTags: ["fika"],
variantType: "standard",
dnaCarb: "flour_wheat",
dnaVegetables: [],
dnaFlavor: ["sweet", "cinnamon", "cardamom", "buttery"],
verificationStatus: "editorial",
ingredients: [
{ ing: "flour_wheat", nameSv: "Vetemjöl", qty: 650, unit: "GRAM", optional: false },
{ ing: "milk_3", nameSv: "Mjölk", qty: 3, unit: "DECILITER", optional: false },
{ ing: "yeast_fresh", nameSv: "Jäst", qty: 25, unit: "GRAM", optional: false },
{ ing: "butter", nameSv: "Smör", qty: 175, unit: "GRAM", optional: false },
{ ing: "sugar", nameSv: "Strösocker", qty: 150, unit: "GRAM", optional: false },
{ ing: "cardamom", nameSv: "Kardemumma", qty: 3, unit: "GRAM", optional: false },
{ ing: "salt", nameSv: "Salt", qty: 1, unit: "TEASPOON", optional: false },
{ ing: "egg", nameSv: "Ägg", qty: 1, unit: "COUNT", optional: false },
{ ing: "cinnamon", nameSv: "Kanel", qty: 3, unit: "TEASPOON", optional: false },
],
steps: [
{
text: "Smält 75 g av smöret och rör ner mjölken. Värm till fingervarmt (37 °C) och lös upp jästen i vätskan.",
},
{
text: "Blanda i socker (spara 50 g till fyllningen), stött kardemumma, salt och det mesta av mjölet. Arbeta degen smidig i 810 minuter och låt jäsa under bakduk i 40 minuter.",
},
{
text: "Kavla ut degen till en rektangel. Bred på resten av det mjuka smöret och strö över kanel och det sparade sockret.",
},
{ text: "Rulla ihop, skär 24 bullar och lägg i formar. Jäs 30 minuter till." },
{
text: "Pensla med uppvispat ägg och grädda mitt i ugnen tills gyllenbruna.",
temperatureC: 225,
},
],
},
{
slug: "kardemummabullar",
titleSv: "Kardemummabullar",
descriptionSv: "Doftande bullar där kardemumman får ta plats både i degen och i fyllningen.",
cuisine: "swedish",
mealTypes: ["snack", "dessert"],
tags: ["baking", "vegetarian", "kid_friendly"],
methods: ["oven"],
equipment: ["oven"],
difficulty: "medium",
prepMin: 45,
cookMin: 12,
portions: 24,
spiceLevel: 0,
mealPrepFriendly: true,
freezerFriendly: true,
peakSeasons: [],
holidayTags: ["fika"],
variantType: "standard",
dnaCarb: "flour_wheat",
dnaVegetables: [],
dnaFlavor: ["sweet", "cardamom", "buttery"],
verificationStatus: "editorial",
ingredients: [
{ ing: "flour_wheat", nameSv: "Vetemjöl", qty: 650, unit: "GRAM", optional: false },
{ ing: "milk_3", nameSv: "Mjölk", qty: 3, unit: "DECILITER", optional: false },
{ ing: "yeast_fresh", nameSv: "Jäst", qty: 25, unit: "GRAM", optional: false },
{ ing: "butter", nameSv: "Smör", qty: 175, unit: "GRAM", optional: false },
{ ing: "sugar", nameSv: "Strösocker", qty: 150, unit: "GRAM", optional: false },
{ ing: "cardamom", nameSv: "Kardemumma", qty: 8, unit: "GRAM", optional: false },
{ ing: "salt", nameSv: "Salt", qty: 1, unit: "TEASPOON", optional: false },
{ ing: "egg", nameSv: "Ägg", qty: 1, unit: "COUNT", optional: false },
],
steps: [
{
text: "Smält 75 g smör, rör ner mjölken och värm till fingervarmt. Lös upp jästen i vätskan.",
},
{
text: "Blanda i hälften av sockret, hälften av den stötta kardemumman, salt och mjölet. Arbeta degen smidig och låt jäsa 40 minuter under bakduk.",
},
{ text: "Kavla ut, bred på det mjuka smöret och strö över resten av socker och kardemumma." },
{ text: "Rulla, skär 24 bullar, lägg i formar och jäs 30 minuter." },
{ text: "Pensla med ägg och grädda gyllenbruna mitt i ugnen.", temperatureC: 225 },
],
},
{
slug: "appelsmulpaj",
titleSv: "Äppelsmulpaj",
descriptionSv:
"Syrliga äpplen under ett gyllene lock av havregryn och smör ljummen med grädde är den oslagbar.",
cuisine: "swedish",
mealTypes: ["dessert"],
tags: ["baking", "vegetarian"],
methods: ["oven"],
equipment: ["oven"],
difficulty: "beginner",
prepMin: 15,
cookMin: 25,
portions: 6,
spiceLevel: 0,
mealPrepFriendly: false,
freezerFriendly: true,
peakSeasons: ["autumn"],
holidayTags: [],
variantType: "standard",
dnaCarb: "oats",
dnaVegetables: [],
dnaFlavor: ["sweet", "cinnamon", "apple"],
verificationStatus: "editorial",
ingredients: [
{ ing: "apple", nameSv: "Äpple", qty: 6, unit: "COUNT", optional: false },
{ ing: "sugar", nameSv: "Strösocker", qty: 100, unit: "GRAM", optional: false },
{ ing: "cinnamon", nameSv: "Kanel", qty: 2, unit: "TEASPOON", optional: false },
{ ing: "butter", nameSv: "Smör", qty: 100, unit: "GRAM", optional: false },
{ ing: "flour_wheat", nameSv: "Vetemjöl", qty: 150, unit: "GRAM", optional: false },
{ ing: "oats", nameSv: "Havregryn", qty: 100, unit: "GRAM", optional: false },
],
steps: [
{
text: "Skala och skär äpplena i klyftor. Lägg i en smord form och blanda med kanel och hälften av sockret.",
},
{ text: "Nyp ihop smör, mjöl, havregryn och resten av sockret till ett grovt smul." },
{
text: "Fördela smulet över äpplena och grädda mitt i ugnen tills locket är gyllene och äpplena mjuka.",
temperatureC: 200,
},
{ text: "Servera ljummen med vispad grädde." },
],
},
{
slug: "frallor",
titleSv: "Frukostfrallor",
descriptionSv:
"Nygräddade frallor med krispig skorpa och luftigt inkråm perfekta till frukostbordet.",
cuisine: "swedish",
mealTypes: ["breakfast"],
tags: ["baking", "vegetarian"],
methods: ["oven"],
equipment: ["oven"],
difficulty: "medium",
prepMin: 30,
cookMin: 12,
portions: 12,
spiceLevel: 0,
mealPrepFriendly: true,
freezerFriendly: true,
peakSeasons: [],
holidayTags: [],
variantType: "standard",
dnaCarb: "flour_wheat",
dnaVegetables: [],
dnaFlavor: ["bread", "mild"],
verificationStatus: "editorial",
ingredients: [
{ ing: "flour_wheat", nameSv: "Vetemjöl", qty: 600, unit: "GRAM", optional: false },
{ ing: "milk_3", nameSv: "Mjölk", qty: 3, unit: "DECILITER", optional: false },
{ ing: "yeast_fresh", nameSv: "Jäst", qty: 25, unit: "GRAM", optional: false },
{ ing: "butter", nameSv: "Smör", qty: 50, unit: "GRAM", optional: false },
{ ing: "salt", nameSv: "Salt", qty: 1.5, unit: "TEASPOON", optional: false },
{ ing: "sugar", nameSv: "Strösocker", qty: 15, unit: "GRAM", optional: false },
],
steps: [
{ text: "Smält smöret, rör ner mjölken och värm till fingervarmt. Lös upp jästen." },
{
text: "Tillsätt salt, socker och mjöl och arbeta degen smidig. Låt jäsa 40 minuter under bakduk.",
},
{ text: "Forma 12 runda frallor, lägg på plåt och jäs 30 minuter till." },
{
text: "Grädda högt upp i ugnen tills frallorna är gyllene och låter ihåliga när man knackar på undersidan.",
temperatureC: 250,
},
],
},
// ---------------------------------------------------------------------------
// FRUKOST
// ---------------------------------------------------------------------------
{
slug: "havregrynsgrot-med-apple",
titleSv: "Havregrynsgröt med äpple och kanel",
descriptionSv: "Krämig gröt kokt på mjölk, riven äpple och en nypa kanel. Klar på tio minuter.",
cuisine: "swedish",
mealTypes: ["breakfast"],
tags: ["vegetarian", "kid_friendly", "quick", "budget"],
methods: ["stovetop"],
equipment: ["stove"],
difficulty: "beginner",
prepMin: 2,
cookMin: 6,
portions: 2,
spiceLevel: 0,
mealPrepFriendly: false,
freezerFriendly: false,
peakSeasons: [],
holidayTags: [],
variantType: "standard",
dnaCarb: "oats",
dnaVegetables: [],
dnaFlavor: ["mild", "cinnamon", "apple"],
verificationStatus: "editorial",
ingredients: [
{ ing: "oats", nameSv: "Havregryn", qty: 80, unit: "GRAM", optional: false },
{ ing: "milk_3", nameSv: "Mjölk", qty: 5, unit: "DECILITER", optional: false },
{ ing: "apple", nameSv: "Äpple", qty: 1, unit: "COUNT", optional: false },
{ ing: "cinnamon", nameSv: "Kanel", qty: 1, unit: "TEASPOON", optional: false },
{ ing: "salt", nameSv: "Salt", qty: 0.5, unit: "TEASPOON", optional: false },
],
steps: [
{ text: "Koka upp mjölken i en kastrull och häll i havregrynen." },
{ text: "Sjud under omrörning i cirka 5 minuter tills gröten är krämig." },
{ text: "Riv ner äpplet, rör i kanel och salt och servera direkt." },
],
},
{
slug: "bar-banansmoothie",
titleSv: "Bär- och banansmoothie",
descriptionSv:
"Krämig smoothie på yoghurt, banan och jordgubbar snabb frukost eller mellanmål.",
cuisine: "international",
mealTypes: ["breakfast", "snack"],
tags: ["vegetarian", "kid_friendly", "quick"],
methods: ["no_cook"],
equipment: ["blender"],
difficulty: "beginner",
prepMin: 5,
cookMin: 0,
portions: 2,
spiceLevel: 0,
mealPrepFriendly: false,
freezerFriendly: false,
peakSeasons: ["summer"],
holidayTags: [],
variantType: "standard",
dnaVegetables: [],
dnaFlavor: ["fruity", "creamy", "sweet"],
verificationStatus: "editorial",
ingredients: [
{ ing: "banana", nameSv: "Banan", qty: 1, unit: "COUNT", optional: false },
{ ing: "strawberry", nameSv: "Jordgubbar", qty: 150, unit: "GRAM", optional: false },
{
ing: "yoghurt_natural",
nameSv: "Naturell yoghurt",
qty: 3,
unit: "DECILITER",
optional: false,
},
{ ing: "honey", nameSv: "Honung", qty: 20, unit: "GRAM", optional: true },
{ ing: "oats", nameSv: "Havregryn", qty: 20, unit: "GRAM", optional: true },
],
steps: [
{ text: "Lägg banan, jordgubbar, yoghurt, honung och havregryn i en mixer." },
{ text: "Mixa slätt och häll upp i glas. Späd med lite vatten om du vill ha den tunnare." },
],
},
{
slug: "aggrora-pa-surdeg",
titleSv: "Krämig äggröra på surdeg",
descriptionSv: "Len äggröra på rågat smör serverad på rostat surdegsbröd med riven ost.",
cuisine: "swedish",
mealTypes: ["breakfast"],
tags: ["vegetarian", "high_protein", "quick"],
methods: ["stovetop"],
equipment: ["stove"],
difficulty: "beginner",
prepMin: 5,
cookMin: 5,
portions: 2,
spiceLevel: 0,
mealPrepFriendly: false,
freezerFriendly: false,
peakSeasons: [],
holidayTags: [],
variantType: "standard",
dnaProtein: "egg",
dnaVegetables: [],
dnaFlavor: ["savory", "creamy"],
verificationStatus: "editorial",
ingredients: [
{ ing: "egg", nameSv: "Ägg", qty: 4, unit: "COUNT", optional: false },
{ ing: "milk_3", nameSv: "Mjölk", qty: 0.5, unit: "DECILITER", optional: false },
{ ing: "butter", nameSv: "Smör", qty: 15, unit: "GRAM", optional: false },
{ ing: "salt", nameSv: "Salt", qty: 0.5, unit: "TEASPOON", optional: false },
{ ing: "black_pepper", nameSv: "Svartpeppar", qty: 1, unit: "MILLILITER", optional: false },
{ ing: "bread_sourdough", nameSv: "Surdegsbröd", qty: 2, unit: "COUNT", optional: false },
{ ing: "cheese_hard", nameSv: "Hårdost", qty: 40, unit: "GRAM", optional: true },
],
steps: [
{ text: "Vispa ihop ägg, mjölk, salt och peppar." },
{ text: "Smält smöret i en kastrull på låg värme och häll i äggen." },
{
text: "Rör långsamt tills äggröran är krämig men helt stannad och inte längre rinnig.",
tip: "Ta av från värmen strax innan den känns färdig den efterstelnar.",
},
{ text: "Rosta brödet, toppa med äggröran och riven ost." },
],
},
// ---------------------------------------------------------------------------
// MELLANMÅL (fyller den tomma kategorin)
// ---------------------------------------------------------------------------
{
slug: "kvarg-med-bar-och-honung",
titleSv: "Kvarg med bär och honung",
descriptionSv: "Proteinrikt mellanmål: krämig kvarg med jordgubbar, honung och lite knaprigt.",
cuisine: "international",
mealTypes: ["snack"],
tags: ["vegetarian", "high_protein", "quick"],
methods: ["no_cook"],
equipment: [],
difficulty: "beginner",
prepMin: 3,
cookMin: 0,
portions: 1,
spiceLevel: 0,
mealPrepFriendly: false,
freezerFriendly: false,
peakSeasons: ["summer"],
holidayTags: [],
variantType: "standard",
dnaVegetables: [],
dnaFlavor: ["creamy", "sweet", "fruity"],
verificationStatus: "editorial",
ingredients: [
{ ing: "quark", nameSv: "Kvarg", qty: 200, unit: "GRAM", optional: false },
{ ing: "strawberry", nameSv: "Jordgubbar", qty: 100, unit: "GRAM", optional: false },
{ ing: "honey", nameSv: "Honung", qty: 15, unit: "GRAM", optional: false },
{ ing: "oats", nameSv: "Havregryn", qty: 20, unit: "GRAM", optional: true },
],
steps: [
{ text: "Lägg upp kvargen i en skål." },
{ text: "Toppa med jordgubbar, en skvätt honung och lite havregryn för knaprighet." },
],
},
{
slug: "appelklyftor-med-jordnotssmor",
titleSv: "Äppelklyftor med jordnötssmör",
descriptionSv: "Snabbt och mättande mellanmål med krispigt äpple och krämigt jordnötssmör.",
cuisine: "international",
mealTypes: ["snack"],
tags: ["vegetarian", "kid_friendly", "quick"],
methods: ["no_cook"],
equipment: [],
difficulty: "beginner",
prepMin: 3,
cookMin: 0,
portions: 1,
spiceLevel: 0,
mealPrepFriendly: false,
freezerFriendly: false,
peakSeasons: [],
holidayTags: [],
variantType: "standard",
dnaVegetables: [],
dnaFlavor: ["fruity", "nutty"],
verificationStatus: "editorial",
ingredients: [
{ ing: "apple", nameSv: "Äpple", qty: 1, unit: "COUNT", optional: false },
{ ing: "peanut_butter", nameSv: "Jordnötssmör", qty: 30, unit: "GRAM", optional: false },
],
steps: [
{ text: "Skär äpplet i klyftor och ta bort kärnhuset." },
{ text: "Servera med jordnötssmör att doppa i." },
],
},
{
slug: "rostade-kikartor",
titleSv: "Rostade kikärtor",
descriptionSv: "Krispiga, kryddiga kikärtor från ugnen ett vegetariskt snacks med sting.",
cuisine: "international",
mealTypes: ["snack"],
tags: ["vegan", "budget", "high_protein"],
methods: ["oven"],
equipment: ["oven"],
difficulty: "beginner",
prepMin: 5,
cookMin: 25,
portions: 4,
spiceLevel: 1,
mealPrepFriendly: true,
freezerFriendly: false,
peakSeasons: [],
holidayTags: [],
variantType: "standard",
dnaVegetables: [],
dnaFlavor: ["savory", "spiced", "crispy"],
verificationStatus: "editorial",
ingredients: [
{ ing: "chickpeas_canned", nameSv: "Kikärtor", qty: 400, unit: "GRAM", optional: false },
{ ing: "olive_oil", nameSv: "Olivolja", qty: 2, unit: "TABLESPOON", optional: false },
{ ing: "paprika_powder", nameSv: "Paprikapulver", qty: 2, unit: "TEASPOON", optional: false },
{ ing: "cumin", nameSv: "Spiskummin", qty: 1, unit: "TEASPOON", optional: true },
{ ing: "salt", nameSv: "Salt", qty: 1, unit: "TEASPOON", optional: false },
],
steps: [
{ text: "Skölj och låt kikärtorna rinna av väl. Torka dem torra i en handduk." },
{ text: "Blanda kikärtorna med olja, paprikapulver, spiskummin och salt." },
{
text: "Rosta på plåt tills de är gyllene och krispiga, skaka om halvvägs.",
temperatureC: 200,
},
],
},
// ---------------------------------------------------------------------------
// EFTERRÄTT
// ---------------------------------------------------------------------------
{
slug: "risgrynsgrot",
titleSv: "Risgrynsgröt",
descriptionSv:
"Krämig risgrynsgröt kokt på mjölk, serverad med kanel, socker och en klick smör.",
cuisine: "swedish",
mealTypes: ["dessert", "breakfast"],
tags: ["vegetarian", "kid_friendly", "budget"],
methods: ["stovetop"],
equipment: ["stove"],
difficulty: "beginner",
prepMin: 5,
cookMin: 40,
portions: 4,
spiceLevel: 0,
mealPrepFriendly: true,
freezerFriendly: false,
peakSeasons: ["winter"],
holidayTags: [],
variantType: "standard",
dnaCarb: "rice_white",
dnaVegetables: [],
dnaFlavor: ["creamy", "cinnamon", "mild"],
verificationStatus: "editorial",
ingredients: [
{ ing: "rice_white", nameSv: "Ris", qty: 200, unit: "GRAM", optional: false },
{ ing: "milk_3", nameSv: "Mjölk", qty: 8, unit: "DECILITER", optional: false },
{ ing: "sugar", nameSv: "Strösocker", qty: 20, unit: "GRAM", optional: false },
{ ing: "cinnamon", nameSv: "Kanel", qty: 2, unit: "TEASPOON", optional: false },
{ ing: "salt", nameSv: "Salt", qty: 0.5, unit: "TEASPOON", optional: false },
{ ing: "butter", nameSv: "Smör", qty: 10, unit: "GRAM", optional: true },
],
steps: [
{ text: "Koka riset i 2 dl vatten några minuter tills vattnet sugits upp." },
{
text: "Häll i mjölken och sjud på låg värme under omrörning i 3540 minuter tills gröten är krämig.",
},
{ text: "Rör i salt och servera med kanel, socker och en klick smör." },
],
},
{
slug: "jordgubbar-med-gradde",
titleSv: "Jordgubbar med vispad grädde",
descriptionSv: "Sommarens enklaste efterrätt: söta jordgubbar och lätt vispad grädde.",
cuisine: "swedish",
mealTypes: ["dessert"],
tags: ["vegetarian", "quick"],
methods: ["no_cook"],
equipment: ["hand_mixer"],
difficulty: "beginner",
prepMin: 10,
cookMin: 0,
portions: 4,
spiceLevel: 0,
mealPrepFriendly: false,
freezerFriendly: false,
peakSeasons: ["summer"],
holidayTags: ["midsommar"],
variantType: "standard",
dnaVegetables: [],
dnaFlavor: ["fruity", "creamy", "sweet"],
verificationStatus: "editorial",
ingredients: [
{ ing: "strawberry", nameSv: "Jordgubbar", qty: 400, unit: "GRAM", optional: false },
{ ing: "cream", nameSv: "Vispgrädde", qty: 2, unit: "DECILITER", optional: false },
{ ing: "sugar", nameSv: "Strösocker", qty: 15, unit: "GRAM", optional: true },
],
steps: [
{
text: "Skölj och dela jordgubbarna. Strö eventuellt över lite socker och låt stå några minuter.",
},
{ text: "Vispa grädden lätt och fluffig." },
{ text: "Servera jordgubbarna med grädden." },
],
},
{
slug: "bananglass",
titleSv: "Bananglass med jordnötssmör",
descriptionSv: "Krämig glass helt utan tillsatt socker bara frusen banan och jordnötssmör.",
cuisine: "international",
mealTypes: ["dessert", "snack"],
tags: ["vegan", "kid_friendly"],
methods: ["no_cook"],
equipment: ["blender"],
difficulty: "beginner",
prepMin: 10,
cookMin: 0,
portions: 2,
spiceLevel: 0,
mealPrepFriendly: false,
freezerFriendly: true,
peakSeasons: ["summer"],
holidayTags: [],
variantType: "standard",
dnaVegetables: [],
dnaFlavor: ["sweet", "nutty", "creamy"],
verificationStatus: "editorial",
ingredients: [
{ ing: "banana", nameSv: "Banan", qty: 3, unit: "COUNT", optional: false },
{ ing: "peanut_butter", nameSv: "Jordnötssmör", qty: 20, unit: "GRAM", optional: true },
],
steps: [
{ text: "Skala bananerna, skiva och frys minst 3 timmar." },
{ text: "Mixa den frusna bananen med jordnötssmör till en krämig glass. Servera direkt." },
],
},
// ---------------------------------------------------------------------------
// LUNCH
// ---------------------------------------------------------------------------
{
slug: "morots-och-linssoppa",
titleSv: "Krämig morots- och linssoppa",
descriptionSv:
"Matig, len soppa på morötter och röda linser med kokosmjölk och en värmande touch av spiskummin.",
cuisine: "international",
mealTypes: ["lunch", "dinner"],
tags: ["vegan", "budget", "batch_cooking"],
methods: ["stovetop"],
equipment: ["stove", "blender"],
difficulty: "beginner",
prepMin: 10,
cookMin: 25,
portions: 4,
spiceLevel: 1,
mealPrepFriendly: true,
freezerFriendly: true,
peakSeasons: ["autumn", "winter"],
holidayTags: [],
variantType: "standard",
dnaVegetables: ["carrot", "onion"],
dnaFlavor: ["savory", "spiced", "creamy"],
verificationStatus: "editorial",
ingredients: [
{ ing: "carrot", nameSv: "Morot", qty: 5, unit: "COUNT", optional: false },
{ ing: "red_lentils", nameSv: "Röda linser", qty: 200, unit: "GRAM", optional: false },
{ ing: "onion", nameSv: "Gul lök", qty: 1, unit: "COUNT", optional: false },
{ ing: "garlic", nameSv: "Vitlök", qty: 2, unit: "COUNT", optional: false },
{ ing: "coconut_milk", nameSv: "Kokosmjölk", qty: 2, unit: "DECILITER", optional: false },
{
ing: "vegetable_stock_cube",
nameSv: "Grönsaksbuljongtärning",
qty: 2,
unit: "COUNT",
optional: false,
},
{ ing: "olive_oil", nameSv: "Olivolja", qty: 1, unit: "TABLESPOON", optional: false },
{ ing: "cumin", nameSv: "Spiskummin", qty: 2, unit: "TEASPOON", optional: false },
{ ing: "salt", nameSv: "Salt", qty: 1, unit: "TEASPOON", optional: false },
{ ing: "black_pepper", nameSv: "Svartpeppar", qty: 1, unit: "MILLILITER", optional: false },
],
steps: [
{
text: "Fräs hackad lök och vitlök mjuka i oljan. Rör i spiskummin och låt fräsa någon minut.",
},
{
text: "Tillsätt skalade och skivade morötter, sköljda linser, smulade buljongtärningar och 8 dl vatten. Sjud tills morötter och linser är helt mjuka, cirka 20 minuter.",
},
{ text: "Mixa soppan slät och rör ner kokosmjölken. Smaka av med salt och peppar." },
{ text: "Späd med lite vatten om du vill ha den tunnare och servera med bröd." },
],
},
{
slug: "rakmacka",
titleSv: "Klassisk räkmacka",
descriptionSv: "Generös räkmacka på surdeg med ägg, majonnäs, gurka och dill.",
cuisine: "swedish",
mealTypes: ["lunch"],
tags: ["high_protein"],
methods: ["stovetop"],
equipment: ["stove"],
difficulty: "beginner",
prepMin: 15,
cookMin: 8,
portions: 2,
spiceLevel: 0,
mealPrepFriendly: false,
freezerFriendly: false,
peakSeasons: ["summer"],
holidayTags: [],
variantType: "standard",
dnaProtein: "shrimp",
dnaVegetables: [],
dnaFlavor: ["fresh", "savory"],
verificationStatus: "editorial",
ingredients: [
{ ing: "bread_sourdough", nameSv: "Surdegsbröd", qty: 2, unit: "COUNT", optional: false },
{ ing: "shrimp", nameSv: "Räkor", qty: 150, unit: "GRAM", optional: false },
{ ing: "egg", nameSv: "Ägg", qty: 2, unit: "COUNT", optional: false },
{ ing: "mayonnaise", nameSv: "Majonnäs", qty: 40, unit: "GRAM", optional: false },
{ ing: "lemon", nameSv: "Citron", qty: 0.5, unit: "COUNT", optional: false },
{ ing: "cucumber", nameSv: "Gurka", qty: 0.25, unit: "COUNT", optional: true },
{ ing: "dill", nameSv: "Dill", qty: 5, unit: "GRAM", optional: true },
{ ing: "lettuce", nameSv: "Sallad", qty: 0.25, unit: "COUNT", optional: true },
],
steps: [
{ text: "Koka äggen hårda i 8 minuter, spola kalla och skala." },
{ text: "Lägg salladsblad på brödet, klicka på majonnäs och lägg på skivat ägg och gurka." },
{ text: "Toppa med räkor, dill och en citronklyfta att pressa över." },
],
},
{
slug: "halloumi-kikartssallad",
titleSv: "Halloumi- och kikärtssallad",
descriptionSv:
"Matig sallad med stekt halloumi, kikärtor och krispiga grönsaker i citrondressing.",
cuisine: "international",
mealTypes: ["lunch", "dinner"],
tags: ["vegetarian", "high_protein"],
methods: ["stovetop"],
equipment: ["stove"],
difficulty: "beginner",
prepMin: 10,
cookMin: 8,
portions: 2,
spiceLevel: 0,
mealPrepFriendly: false,
freezerFriendly: false,
peakSeasons: ["summer"],
holidayTags: [],
variantType: "standard",
dnaProtein: "halloumi",
dnaVegetables: ["tomato", "cucumber"],
dnaFlavor: ["savory", "fresh"],
verificationStatus: "editorial",
ingredients: [
{ ing: "halloumi", nameSv: "Halloumi", qty: 200, unit: "GRAM", optional: false },
{ ing: "chickpeas_canned", nameSv: "Kikärtor", qty: 200, unit: "GRAM", optional: false },
{ ing: "tomato", nameSv: "Tomat", qty: 2, unit: "COUNT", optional: false },
{ ing: "cucumber", nameSv: "Gurka", qty: 0.5, unit: "COUNT", optional: false },
{ ing: "red_onion", nameSv: "Rödlök", qty: 0.5, unit: "COUNT", optional: true },
{ ing: "lettuce", nameSv: "Sallad", qty: 0.5, unit: "COUNT", optional: true },
{ ing: "olive_oil", nameSv: "Olivolja", qty: 2, unit: "TABLESPOON", optional: false },
{ ing: "lemon", nameSv: "Citron", qty: 0.5, unit: "COUNT", optional: false },
{ ing: "salt", nameSv: "Salt", qty: 0.5, unit: "TEASPOON", optional: false },
{ ing: "black_pepper", nameSv: "Svartpeppar", qty: 1, unit: "MILLILITER", optional: false },
],
steps: [
{ text: "Skiva halloumin och stek gyllene på båda sidor i lite av oljan." },
{ text: "Hacka tomat, gurka, rödlök och sallad och blanda med avrunna kikärtor." },
{ text: "Ringla över resten av oljan och pressad citron, salta och peppra." },
{ text: "Toppa salladen med den varma halloumin och servera direkt." },
],
},
];
+5 -1
View File
@@ -67,4 +67,8 @@ export interface SeedRecipe {
steps: SeedRecipeStep[];
}
export { VERIFIED_RECIPES as SEED_RECIPES } from "./verified-recipes.js";
import { VERIFIED_RECIPES } from "./verified-recipes.js";
import { EXTRA_RECIPES } from "./extra-recipes.js";
/** Hela seed-katalogen: committade verifierade recept + nya redaktionella extrarecept. */
export const SEED_RECIPES: SeedRecipe[] = [...VERIFIED_RECIPES, ...EXTRA_RECIPES];
@@ -35097,7 +35097,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
"Saftiga saffransbullar formade som S, med russin som ögon. Hör Lucia och advent till.",
cuisine: "swedish",
mealTypes: ["dessert", "breakfast"],
tags: ["vegetarian", "kid_friendly"],
tags: ["baking", "vegetarian", "kid_friendly"],
methods: ["oven"],
equipment: ["oven"],
difficulty: "medium",
@@ -35213,7 +35213,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
"Saftiga saffrans- och kardemummabullar fyllda med mandelmassa. Ett festligt bakverk till Lucia och advent.",
cuisine: "swedish",
mealTypes: ["dessert", "breakfast"],
tags: ["vegetarian", "kid_friendly"],
tags: ["baking", "vegetarian", "kid_friendly"],
methods: ["oven"],
equipment: ["oven"],
difficulty: "medium",
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import { computeRecipeNutrition, type IngredientNutritionSource } from "@app/nutrition-engine";
import { SEED_INGREDIENTS } from "../src/seed/data/ingredients.js";
import { EXTRA_RECIPES } from "../src/seed/data/extra-recipes.js";
import { VERIFIED_RECIPES } from "../src/seed/data/verified-recipes.js";
/**
* Rent invariant-test (ingen DB) för de nya extrarecepten. Speglar exakt de
* kontroller som seed-körningen gör (run.ts rad 675 + 691) så att ett trasigt
* recept fångas här i stället för mitt i en seed-körning:
* - varje ingrediens-id måste finnas i katalogen,
* - näringen måste gå att beräkna för varje ingrediens (rätt enhet),
* - inga slug-krockar med den befintliga katalogen.
*/
const byId = new Map(SEED_INGREDIENTS.map((i) => [i.id, i]));
function sourcesFor(
recipe: (typeof EXTRA_RECIPES)[number],
): Map<string, IngredientNutritionSource> {
const sources = new Map<string, IngredientNutritionSource>();
for (const ri of recipe.ingredients) {
const info = byId.get(ri.ing);
if (!info) continue; // fångas separat av id-testet nedan
sources.set(ri.ing, {
nutritionPer100: info.nutritionPer100,
densityGPerMl: info.densityGPerMl ?? null,
gramsPerPiece: info.gramsPerPiece ?? null,
});
}
return sources;
}
describe("extrarecept seed-invarianter", () => {
it("har minst 16 recept", () => {
expect(EXTRA_RECIPES.length).toBeGreaterThanOrEqual(16);
});
it("alla ingrediens-id:n finns i katalogen (run.ts rad 675)", () => {
const unknown: string[] = [];
for (const recipe of EXTRA_RECIPES) {
for (const ri of recipe.ingredients) {
if (!byId.has(ri.ing)) unknown.push(`${recipe.slug}: ${ri.ing}`);
}
}
expect(unknown).toEqual([]);
});
it("näringen går att beräkna för varje recept (run.ts rad 691)", () => {
const failed: string[] = [];
for (const recipe of EXTRA_RECIPES) {
const calc = recipe.ingredients.map((ri) => ({
canonicalIngredientId: ri.ing,
quantity: ri.qty,
unit: ri.unit,
optional: ri.optional ?? false,
}));
const nutrition = computeRecipeNutrition(calc, recipe.portions, sourcesFor(recipe));
if (nutrition.uncomputableIngredientIds.length > 0) {
failed.push(`${recipe.slug}: ${nutrition.uncomputableIngredientIds.join(", ")}`);
}
// Rimlig näring per portion (fångar grova enhetsfel).
expect(nutrition.perPortion.kcal).toBeGreaterThan(0);
}
expect(failed).toEqual([]);
});
it("inga slug-krockar med befintliga recept och inga interna dubbletter", () => {
const existing = new Set(VERIFIED_RECIPES.map((r) => r.slug));
const seen = new Set<string>();
const collisions: string[] = [];
for (const recipe of EXTRA_RECIPES) {
if (existing.has(recipe.slug)) collisions.push(`krock: ${recipe.slug}`);
if (seen.has(recipe.slug)) collisions.push(`dubblett: ${recipe.slug}`);
seen.add(recipe.slug);
}
expect(collisions).toEqual([]);
});
it("varje bak-recept är taggat 'baking' (driver Baka-kategorin)", () => {
const baking = EXTRA_RECIPES.filter((r) => r.tags.includes("baking"));
expect(baking.length).toBeGreaterThanOrEqual(4);
});
});
+3
View File
@@ -247,6 +247,9 @@ export const RECIPE_TAGS = [
"leftover_friendly",
"one_pot",
"batch_cooking",
// Tillagd (spec §14, browse): bakverk bullar, bröd, pajer, kakor. text[]-
// kolumn, ingen migration. Driver "Baka"-kategorin i bläddra-vyn.
"baking",
] as const;
export type RecipeTag = (typeof RECIPE_TAGS)[number];