Initial commit (unpacked platform)

This commit is contained in:
Sven (AAMOS AI)
2026-08-05 19:21:11 +07:00
commit ac5340195a
314 changed files with 57584 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@app/ai-contracts",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Typade kontrakt + HTTP-klient mot AAMOS. Food API får stabila kontrakt oavsett modell (spec §31)",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@app/shared-types": "workspace:*",
"zod": "^4.4.0"
}
}
+281
View File
@@ -0,0 +1,281 @@
import { randomUUID } from "node:crypto";
import {
TASK_CONTRACTS,
aamosResponseEnvelopeSchema,
type AamosRequestEnvelope,
type AamosTaskType,
type TaskInput,
type TaskOutput,
} from "./tasks.js";
import { mockOutputFor } from "./mock.js";
import type { LocaleContext } from "@app/shared-types";
export interface AamosCallOptions {
correlationId?: string;
localeContext?: LocaleContext;
subjectRef?: string | null;
priority?: "low" | "normal" | "high";
consentFlags?: {
personalization: boolean;
anonymizedImprovement: boolean;
imageTraining: boolean;
};
}
export interface AamosResult<T extends AamosTaskType> {
status: "ok" | "uncertain" | "failed";
output: TaskOutput<T> | null;
error?: string | undefined;
modelVersion?: string | undefined;
promptVersion?: string | undefined;
latencyMs?: number | undefined;
costUsd?: number | undefined;
}
/**
* Klientinterface mot AAMOS. Mobilappen anropar ALDRIG detta bara
* Food API/worker (spec §31, §61.13).
*/
export interface AamosClient {
runTask<T extends AamosTaskType>(
taskType: T,
input: TaskInput<T>,
options?: AamosCallOptions,
): Promise<AamosResult<T>>;
healthCheck(): Promise<{ ok: boolean; detail?: string }>;
}
export interface HttpAamosClientConfig {
baseUrl: string;
apiKey: string;
timeoutMs?: number;
maxRetries?: number;
fetchImpl?: typeof fetch;
}
/**
* HTTP-klient mot den befintliga AAMOS-plattformen.
*
* Antaget API (justeras mot AAMOS faktiska dokumentation endast denna fil
* och endpointkonstanterna behöver ändras, inga kontrakt läcker vidare):
* POST {baseUrl}/v1/tasks body: AamosRequestEnvelope → AamosResponseEnvelope
* GET {baseUrl}/v1/health
* Auth: Authorization: Bearer <AAMOS_API_KEY>
*/
export class HttpAamosClient implements AamosClient {
private readonly cfg: Required<Omit<HttpAamosClientConfig, "fetchImpl">>;
private readonly fetchImpl: typeof fetch;
constructor(config: HttpAamosClientConfig) {
this.cfg = {
baseUrl: config.baseUrl.replace(/\/$/, ""),
apiKey: config.apiKey,
timeoutMs: config.timeoutMs ?? 60_000,
maxRetries: config.maxRetries ?? 2,
};
this.fetchImpl = config.fetchImpl ?? fetch;
}
async runTask<T extends AamosTaskType>(
taskType: T,
input: TaskInput<T>,
options: AamosCallOptions = {},
): Promise<AamosResult<T>> {
// Validera input mot kontraktet INNAN nätverksanropet fail fast.
const contract = TASK_CONTRACTS[taskType];
const parsedInput = contract.input.safeParse(input);
if (!parsedInput.success) {
return {
status: "failed",
output: null,
error: `Kontraktsfel (input) för ${taskType}: ${parsedInput.error.message}`,
};
}
const envelope: AamosRequestEnvelope = {
taskId: randomUUID(),
taskType,
contractVersion: "1.0.0",
input: parsedInput.data,
metadata: {
correlationId: options.correlationId ?? randomUUID(),
localeContext: options.localeContext ?? {
languageTag: "sv-SE",
regionCode: "SE",
timeZone: "Europe/Stockholm",
measurementSystem: "METRIC" as const,
temperatureUnit: "CELSIUS" as const,
currencyCode: "SEK",
},
subjectRef: options.subjectRef ?? null,
priority: options.priority ?? "normal",
consentFlags: options.consentFlags ?? {
personalization: true,
anonymizedImprovement: false,
imageTraining: false,
},
},
};
let lastError = "";
for (let attempt = 0; attempt <= this.cfg.maxRetries; attempt++) {
if (attempt > 0) await sleep(500 * 2 ** attempt);
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.cfg.timeoutMs);
const res = await this.fetchImpl(`${this.cfg.baseUrl}/v1/tasks`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${this.cfg.apiKey}`,
"x-correlation-id": envelope.metadata.correlationId,
"x-contract-version": envelope.contractVersion,
},
body: JSON.stringify(envelope),
signal: controller.signal,
});
clearTimeout(timer);
if (res.status === 429 || res.status >= 500) {
lastError = `AAMOS svarade ${res.status}`;
continue; // retry
}
if (!res.ok) {
const body = await safeText(res);
return { status: "failed", output: null, error: `AAMOS ${res.status}: ${body}` };
}
const json: unknown = await res.json();
const parsed = aamosResponseEnvelopeSchema.safeParse(json);
if (!parsed.success) {
return {
status: "failed",
output: null,
error: `Ogiltigt svarskuvert från AAMOS: ${parsed.error.message}`,
};
}
const env = parsed.data;
if (env.status === "failed" || env.output == null) {
return {
status: env.status === "failed" ? "failed" : "uncertain",
output: null,
error: env.error ?? undefined,
modelVersion: env.modelVersion ?? undefined,
promptVersion: env.promptVersion ?? undefined,
latencyMs: env.latencyMs ?? undefined,
costUsd: env.costUsd ?? undefined,
};
}
// Validera output mot kontraktet ett brutet kontrakt är ett fel,
// aldrig något som tyst släpps vidare till användardata.
const parsedOutput = contract.output.safeParse(env.output);
if (!parsedOutput.success) {
return {
status: "failed",
output: null,
error: `Kontraktsfel (output) för ${taskType}: ${parsedOutput.error.message}`,
modelVersion: env.modelVersion ?? undefined,
};
}
return {
status: env.status,
output: parsedOutput.data as TaskOutput<T>,
modelVersion: env.modelVersion ?? undefined,
promptVersion: env.promptVersion ?? undefined,
latencyMs: env.latencyMs ?? undefined,
costUsd: env.costUsd ?? undefined,
};
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);
}
}
return { status: "failed", output: null, error: `AAMOS onåbart: ${lastError}` };
}
async healthCheck(): Promise<{ ok: boolean; detail?: string }> {
try {
const res = await this.fetchImpl(`${this.cfg.baseUrl}/v1/health`, {
headers: { authorization: `Bearer ${this.cfg.apiKey}` },
signal: AbortSignal.timeout(5_000),
});
return { ok: res.ok, detail: `status ${res.status}` };
} catch (err) {
return { ok: false, detail: err instanceof Error ? err.message : String(err) };
}
}
}
/**
* Deterministisk mock för lokal utveckling och tester (AAMOS_MODE=mock).
* Produktionsmiljöer ska ALLTID köra AAMOS_MODE=http.
*/
export class MockAamosClient implements AamosClient {
async runTask<T extends AamosTaskType>(
taskType: T,
input: TaskInput<T>,
): Promise<AamosResult<T>> {
const contract = TASK_CONTRACTS[taskType];
const parsedInput = contract.input.safeParse(input);
if (!parsedInput.success) {
return {
status: "failed",
output: null,
error: `Kontraktsfel (input) för ${taskType}: ${parsedInput.error.message}`,
};
}
const output = mockOutputFor(taskType, parsedInput.data);
const validated = contract.output.parse(output) as TaskOutput<T>;
return {
status: "ok",
output: validated,
modelVersion: "mock-1.0",
promptVersion: "mock",
latencyMs: 42,
costUsd: 0,
};
}
async healthCheck(): Promise<{ ok: boolean; detail?: string }> {
return { ok: true, detail: "mock" };
}
}
export interface AamosEnv {
AAMOS_MODE?: string | undefined;
AAMOS_API_URL?: string | undefined;
AAMOS_API_KEY?: string | undefined;
AAMOS_TIMEOUT_MS?: string | undefined;
}
/** Fabrik: http mot riktiga AAMOS (default), mock för dev/test. */
export function createAamosClient(env: AamosEnv = process.env): AamosClient {
const mode = env.AAMOS_MODE ?? "http";
if (mode === "mock") return new MockAamosClient();
const baseUrl = env.AAMOS_API_URL;
const apiKey = env.AAMOS_API_KEY;
if (!baseUrl || !apiKey) {
throw new Error(
"AAMOS_MODE=http kräver AAMOS_API_URL och AAMOS_API_KEY. " +
"Sätt AAMOS_MODE=mock för lokal utveckling utan AAMOS-åtkomst.",
);
}
return new HttpAamosClient({
baseUrl,
apiKey,
timeoutMs: env.AAMOS_TIMEOUT_MS ? Number(env.AAMOS_TIMEOUT_MS) : undefined,
});
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
async function safeText(res: Response): Promise<string> {
try {
return (await res.text()).slice(0, 500);
} catch {
return "<no body>";
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./tasks.js";
export * from "./client.js";
export { mockOutputFor } from "./mock.js";
+264
View File
@@ -0,0 +1,264 @@
import type { AamosTaskType } from "./tasks.js";
/**
* Deterministiska mock-svar per uppgiftstyp. Realistiska nog för att bygga
* UI-flöden och tester mot inklusive osäkerhetsfall (requiresConfirmation,
* lågt confidence) så att korrigerings-UX:et alltid övas (spec §61.45).
*/
export function mockOutputFor(taskType: AamosTaskType, input: unknown): unknown {
switch (taskType) {
case "ANALYZE_FRIDGE_IMAGE":
case "ANALYZE_PANTRY_IMAGE":
return {
items: [
{
detectedName: "mjölk",
canonicalIngredientId: "milk_3",
brand: "Arla",
estimatedQuantity: 0.7,
unit: "LITER",
bestBeforeDate: null,
confidence: 0.84,
requiresConfirmation: true,
boundingBox: null,
},
{
detectedName: "crème fraîche",
canonicalIngredientId: "creme_fraiche",
brand: null,
estimatedQuantity: 1,
unit: "COUNT",
bestBeforeDate: null,
confidence: 0.61,
requiresConfirmation: true,
boundingBox: null,
},
{
detectedName: "okänd burk",
canonicalIngredientId: null,
brand: null,
estimatedQuantity: null,
unit: null,
bestBeforeDate: null,
confidence: 0.2,
requiresConfirmation: true,
boundingBox: null,
},
],
imageQualityIssues: [],
};
case "ANALYZE_MEAL_IMAGE": {
const hasContext =
typeof input === "object" &&
input !== null &&
(input as { recipeContext?: unknown }).recipeContext != null;
return {
matchesRecipeContext: hasContext ? true : null,
portionFractionEstimate: 1,
kcalRange: { min: 650, max: 750, mostLikely: 700 },
components: [
{
name: "kycklingfilé",
canonicalIngredientId: "chicken_breast",
estimatedGrams: 150,
confidence: 0.8,
},
{
name: "ris",
canonicalIngredientId: "rice_white",
estimatedGrams: 180,
confidence: 0.75,
},
],
confidence: 0.72,
};
}
case "READ_RECEIPT":
return {
storeName: "ICA Supermarket",
purchaseDate: "2026-08-01",
lines: [
{
rawText: "KYCKL FILE 925G",
normalizedName: "Kycklingfilé 925 g",
canonicalIngredientId: "chicken_breast",
quantity: 925,
unit: "GRAM",
unitPriceMinor: 11990,
totalPriceMinor: 11990,
isDiscount: false,
confidence: 0.9,
},
{
rawText: "MELLANMJ 1.5L",
normalizedName: "Mellanmjölk 1,5 l",
canonicalIngredientId: "milk_1_5",
quantity: 1.5,
unit: "LITER",
unitPriceMinor: 1890,
totalPriceMinor: 1890,
isDiscount: false,
confidence: 0.87,
},
],
totalMinor: 13880,
discountTotalMinor: 0,
confidence: 0.85,
};
case "READ_NUTRITION_LABEL":
return {
basis: "per_100_g",
values: {
kcal: 106,
proteinG: 22,
carbsG: 0,
fatG: 2,
saturatedFatG: 0.6,
fiberG: 0,
sugarG: 0,
saltG: 0.2,
},
ingredientsText: "Kycklingfilé (100 %)",
allergensDeclared: [],
gtin: null,
productName: "Kycklingfilé",
brand: null,
confidence: 0.88,
};
case "READ_EXPIRY_DATE":
return { date: "2026-08-06", dateKind: "best_before", confidence: 0.9 };
case "NORMALIZE_PRODUCTS": {
const rawNames =
typeof input === "object" && input !== null
? ((input as { rawNames?: string[] }).rawNames ?? [])
: [];
return {
matches: rawNames.map((raw) => ({
raw,
canonicalIngredientId: null,
normalizedName: raw.toLowerCase(),
confidence: 0.4,
})),
};
}
case "DEDUPLICATE_INVENTORY":
return { duplicateGroups: [] };
case "STRUCTURE_RECIPE_TEXT":
return {
titleSv: "Snabb kycklingpasta",
descriptionSv: "Krämig vardagspasta med kyckling och spenat.",
ingredients: [
{
rawText: "400 g kycklingfilé",
canonicalIngredientId: "chicken_breast",
displayNameSv: "Kycklingfilé",
quantity: 400,
unit: "GRAM",
optional: false,
confidence: 0.9,
},
{
rawText: "300 g pasta",
canonicalIngredientId: "pasta_dry",
displayNameSv: "Pasta",
quantity: 300,
unit: "GRAM",
optional: false,
confidence: 0.9,
},
],
steps: [
{ instructionSv: "Koka pastan enligt anvisning.", timerSeconds: 600, temperatureC: null },
{
instructionSv: "Stek kycklingen tills genomstekt.",
timerSeconds: null,
temperatureC: null,
},
],
prepTimeMinutes: 10,
cookTimeMinutes: 15,
portions: 4,
suggestedCuisine: "italian",
suggestedMealTypes: ["dinner"],
confidence: 0.82,
};
case "GENERATE_RECIPE_OPTIONS":
return {
suggestions: [
{
titleSv: "Krämig kycklingwok med grönsaker",
descriptionSv: "Snabb wok på det som finns hemma.",
ingredients: [
{
canonicalIngredientId: "chicken_breast",
displayNameSv: "Kycklingfilé",
quantity: 400,
unit: "GRAM",
},
{
canonicalIngredientId: "rice_white",
displayNameSv: "Ris",
quantity: 3,
unit: "DECILITER",
},
],
steps: ["Koka riset.", "Woka kycklingen.", "Blanda och servera."],
estimatedTimeMinutes: 25,
confidence: 0.7,
},
],
};
case "RANK_RECIPES": {
const ids =
typeof input === "object" && input !== null
? ((input as { candidateIds?: string[] }).candidateIds ?? [])
: [];
return { rankedIds: ids, rationaleSv: null };
}
case "PARSE_CRAVING":
return { tags: ["creamy"], cuisine: null, maxKcal: null, confidence: 0.6 };
case "UPDATE_USER_MEMORY":
return { memoryUpdates: [] };
case "GENERATE_WEEK_PLAN":
return { entries: [], confidence: 0.5 };
case "MODERATE_RECIPE":
return { flags: [], recommendation: "approve", confidence: 0.9 };
case "TRANSLATE_RECIPE": {
// Deterministisk pseudo-översättning: bevarar alla tal och stegstruktur,
// markerar texten med målspråket så flödet är testbart utan riktig AI.
const inp = input as {
targetLanguageTag: string;
title: string;
description: string | null;
storageGuidance: string | null;
steps: { stepNumber: number; instruction: string; tip: string | null }[];
};
const tag = `[${inp.targetLanguageTag}]`;
return {
title: `${tag} ${inp.title}`,
description: inp.description ? `${tag} ${inp.description}` : null,
storageGuidance: inp.storageGuidance ? `${tag} ${inp.storageGuidance}` : null,
steps: inp.steps.map((s) => ({
stepNumber: s.stepNumber,
instruction: `${tag} ${s.instruction}`,
tip: s.tip ? `${tag} ${s.tip}` : null,
})),
confidence: 0.85,
};
}
}
}
+495
View File
@@ -0,0 +1,495 @@
import { z } from "zod";
import { AI_CONTRACT_VERSION, UNITS } from "@app/shared-types";
/**
* AAMOS-uppgiftstyper. Superset av köns jobbtyper (spec §54): vissa jobb i
* plattformen-workern är rena AAMOS-anrop, andra är deterministiska och anropar
* aldrig AI. Kontraktet här är versionerat AAMOS kan byta modeller fritt
* bakom det (spec §31: "Food API ska få stabila kontrakt oavsett modell").
*/
export const AAMOS_TASK_TYPES = [
"ANALYZE_FRIDGE_IMAGE",
"ANALYZE_PANTRY_IMAGE",
"ANALYZE_MEAL_IMAGE",
"READ_RECEIPT",
"READ_NUTRITION_LABEL",
"READ_EXPIRY_DATE",
"NORMALIZE_PRODUCTS",
"DEDUPLICATE_INVENTORY",
"STRUCTURE_RECIPE_TEXT",
"GENERATE_RECIPE_OPTIONS",
"RANK_RECIPES",
"PARSE_CRAVING",
"UPDATE_USER_MEMORY",
"GENERATE_WEEK_PLAN",
"MODERATE_RECIPE",
"TRANSLATE_RECIPE",
] as const;
export type AamosTaskType = (typeof AAMOS_TASK_TYPES)[number];
const unitSchema = z.enum(UNITS);
const confidence = z.number().min(0).max(1);
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
/**
* Ett detekterat objekt från bildanalys (spec §10). AI får alltid säga
* "osäker/okänd": canonicalIngredientId=null + lågt confidence.
*/
export const detectedItemSchema = z.object({
detectedName: z.string(),
canonicalIngredientId: z.string().nullable(),
brand: z.string().nullable().default(null),
estimatedQuantity: z.number().nullable(),
unit: unitSchema.nullable(),
bestBeforeDate: isoDate.nullable().default(null),
confidence,
requiresConfirmation: z.boolean(),
boundingBox: z
.object({ x: z.number(), y: z.number(), w: z.number(), h: z.number() })
.nullable()
.default(null),
});
export type DetectedItem = z.infer<typeof detectedItemSchema>;
// ---------------------------------------------------------------------------
// Input/Output per uppgiftstyp
// ---------------------------------------------------------------------------
export const analyzeStorageImageInput = z.object({
imageUrls: z.array(z.url()).min(1).max(6),
locationType: z.string(),
marketLocale: z.string().default("sv-SE"),
knownItems: z.array(z.string()).default([]),
});
export const analyzeStorageImageOutput = z.object({
items: z.array(detectedItemSchema),
imageQualityIssues: z.array(z.enum(["dark", "blurry", "occlusion", "too_far"])).default([]),
});
export const analyzeMealImageInput = z.object({
imageUrls: z.array(z.url()).min(1).max(3),
recipeContext: z
.object({
recipeId: z.string(),
titleSv: z.string(),
nutritionPerPortion: z.record(z.string(), z.number()),
portions: z.number(),
})
.nullable()
.default(null),
marketLocale: z.string().default("sv-SE"),
});
/**
* Tallriksfoto (spec §22): intervall + mest sannolikt, aldrig exakt påstående.
* Kalorierna här är UPPSKATTNINGAR som användaren måste kunna korrigera.
*/
export const analyzeMealImageOutput = z.object({
matchesRecipeContext: z.boolean().nullable(),
portionFractionEstimate: z.number().min(0).max(5).nullable(),
kcalRange: z.object({ min: z.number(), max: z.number(), mostLikely: z.number() }).nullable(),
components: z
.array(
z.object({
name: z.string(),
canonicalIngredientId: z.string().nullable(),
estimatedGrams: z.number().nullable(),
confidence,
}),
)
.default([]),
confidence,
});
export const readReceiptInput = z.object({
imageUrls: z.array(z.url()).min(1).max(4),
marketLocale: z.string().default("sv-SE"),
});
export const readReceiptOutput = z.object({
storeName: z.string().nullable(),
purchaseDate: isoDate.nullable(),
lines: z.array(
z.object({
rawText: z.string(),
normalizedName: z.string().nullable(),
canonicalIngredientId: z.string().nullable(),
quantity: z.number().nullable(),
unit: unitSchema.nullable(),
unitPriceMinor: z.number().int().nullable(),
totalPriceMinor: z.number().int().nullable(),
isDiscount: z.boolean().default(false),
confidence,
}),
),
totalMinor: z.number().int().nullable(),
discountTotalMinor: z.number().int().nullable(),
confidence,
});
export const readNutritionLabelInput = z.object({
imageUrls: z.array(z.url()).min(1).max(3),
marketLocale: z.string().default("sv-SE"),
});
/** OCR av näringsdeklaration värden kommer från ETIKETTEN, aldrig från modellens gissning. */
export const readNutritionLabelOutput = z.object({
basis: z.enum(["per_100_g", "per_100_ml", "per_portion"]).nullable(),
values: z
.object({
kcal: z.number().nullable(),
proteinG: z.number().nullable(),
carbsG: z.number().nullable(),
fatG: z.number().nullable(),
saturatedFatG: z.number().nullable(),
fiberG: z.number().nullable(),
sugarG: z.number().nullable(),
saltG: z.number().nullable(),
})
.nullable(),
ingredientsText: z.string().nullable(),
allergensDeclared: z.array(z.string()).default([]),
gtin: z.string().nullable().default(null),
productName: z.string().nullable().default(null),
brand: z.string().nullable().default(null),
confidence,
});
export const readExpiryDateInput = z.object({
imageUrls: z.array(z.url()).min(1).max(2),
});
export const readExpiryDateOutput = z.object({
date: isoDate.nullable(),
dateKind: z.enum(["best_before", "use_by"]).nullable(),
confidence,
});
export const normalizeProductsInput = z.object({
rawNames: z.array(z.string()).min(1).max(100),
marketLocale: z.string().default("sv-SE"),
});
export const normalizeProductsOutput = z.object({
matches: z.array(
z.object({
raw: z.string(),
canonicalIngredientId: z.string().nullable(),
normalizedName: z.string().nullable(),
confidence,
}),
),
});
export const deduplicateInventoryInput = z.object({
items: z.array(
z.object({
id: z.string(),
displayName: z.string(),
canonicalIngredientId: z.string().nullable(),
quantity: z.number(),
unit: unitSchema,
source: z.string(),
createdAt: z.string(),
}),
),
});
export const deduplicateInventoryOutput = z.object({
duplicateGroups: z.array(
z.object({
itemIds: z.array(z.string()).min(2),
confidence,
suggestedAction: z.enum(["merge", "review"]),
reasonSv: z.string(),
}),
),
});
/** Fritextrecept → struktur (spec §35). Näring räknas ALDRIG här det gör nutrition-engine. */
export const structureRecipeTextInput = z.object({
text: z.string().min(20).max(8000),
marketLocale: z.string().default("sv-SE"),
});
export const structureRecipeTextOutput = z.object({
titleSv: z.string().nullable(),
descriptionSv: z.string().nullable(),
ingredients: z.array(
z.object({
rawText: z.string(),
canonicalIngredientId: z.string().nullable(),
displayNameSv: z.string(),
quantity: z.number().nullable(),
unit: unitSchema.nullable(),
optional: z.boolean().default(false),
confidence,
}),
),
steps: z.array(
z.object({
instructionSv: z.string(),
timerSeconds: z.number().nullable().default(null),
temperatureC: z.number().nullable().default(null),
}),
),
prepTimeMinutes: z.number().nullable(),
cookTimeMinutes: z.number().nullable(),
portions: z.number().nullable(),
suggestedCuisine: z.string().nullable(),
suggestedMealTypes: z.array(z.string()).default([]),
confidence,
});
export const generateRecipeOptionsInput = z.object({
cravingText: z.string().nullable(),
pantrySummary: z.array(
z.object({ canonicalIngredientId: z.string(), quantity: z.number(), unit: unitSchema }),
),
constraints: z.object({
allergens: z.array(z.string()),
dietPattern: z.string().nullable(),
maxMinutes: z.number().nullable(),
portions: z.number(),
spiceLevelMax: z.number().nullable(),
}),
marketLocale: z.string().default("sv-SE"),
});
/**
* AI-assisterade originalförslag (spec §15) går ALLTID via redaktionell
* granskning innan de blir permanenta recept. Allergensäkerhet verifieras
* deterministiskt av recipe-engine innan visning.
*/
export const generateRecipeOptionsOutput = z.object({
suggestions: z.array(
z.object({
titleSv: z.string(),
descriptionSv: z.string(),
ingredients: z.array(
z.object({
canonicalIngredientId: z.string().nullable(),
displayNameSv: z.string(),
quantity: z.number(),
unit: unitSchema,
}),
),
steps: z.array(z.string()),
estimatedTimeMinutes: z.number(),
confidence,
}),
),
});
export const rankRecipesInput = z.object({
candidateIds: z.array(z.string()).max(50),
deterministicScores: z.record(z.string(), z.number()),
contextSummary: z.string(),
});
export const rankRecipesOutput = z.object({
rankedIds: z.array(z.string()),
rationaleSv: z.string().nullable(),
});
export const parseCravingInput = z.object({
text: z.string().max(500),
marketLocale: z.string().default("sv-SE"),
});
export const parseCravingOutput = z.object({
tags: z.array(z.string()),
cuisine: z.string().nullable(),
maxKcal: z.number().nullable(),
confidence,
});
export const updateUserMemoryInput = z.object({
scope: z.enum(["user", "household"]),
scopeId: z.string(),
events: z.array(
z.object({
type: z.string(),
occurredAt: z.string(),
payload: z.unknown(),
}),
),
existingMemoryKeys: z.array(z.string()).default([]),
});
export const updateUserMemoryOutput = z.object({
memoryUpdates: z.array(
z.object({
key: z.string(),
kind: z.enum(["structured_fact", "event", "semantic", "profile_summary", "recipe_memory"]),
summarySv: z.string(),
value: z.unknown(),
origin: z.enum(["observed", "ai_inferred"]),
confidence,
expiresAt: z.string().nullable().default(null),
}),
),
});
export const generateWeekPlanInput = z.object({
days: z.array(z.object({ date: isoDate, mealTypes: z.array(z.string()) })),
pantrySummary: z.array(
z.object({
canonicalIngredientId: z.string(),
quantity: z.number(),
unit: unitSchema,
daysToExpiry: z.number().nullable(),
}),
),
candidateRecipes: z.array(
z.object({ id: z.string(), titleSv: z.string(), tags: z.array(z.string()) }),
),
constraints: z.object({
budgetMinorTotal: z.number().int().nullable(),
portionsPerMeal: z.number(),
varietyLevel: z.enum(["low", "medium", "high"]),
noteSv: z.string().nullable(),
}),
});
export const generateWeekPlanOutput = z.object({
entries: z.array(
z.object({
date: isoDate,
mealType: z.string(),
recipeId: z.string().nullable(),
useMealBox: z.boolean().default(false),
motivationSv: z.string().nullable(),
}),
),
confidence,
});
export const moderateRecipeInput = z.object({
titleSv: z.string(),
descriptionSv: z.string(),
ingredients: z.array(z.string()),
steps: z.array(z.string()),
});
export const moderateRecipeOutput = z.object({
flags: z.array(
z.object({
code: z.enum([
"food_safety",
"unrealistic_quantities",
"inappropriate_content",
"spam",
"copyright_suspect",
"other",
]),
severity: z.enum(["info", "warning", "reject"]),
messageSv: z.string(),
}),
),
recommendation: z.enum(["approve", "review", "reject"]),
confidence,
});
// ---------------------------------------------------------------------------
// TRANSLATE_RECIPE (i18n-spec §1314): AI översätter TEXT, aldrig struktur.
// Mängder, ingredient-IDs, tider och allergener bor utanför översättningen och
// kan därför inte ändras av modellen. Deterministisk verifiering sker i worker.
// ---------------------------------------------------------------------------
export const translateRecipeInput = z.object({
sourceLanguageTag: z.string().default("sv"),
targetLanguageTag: z.string(),
title: z.string(),
description: z.string().nullable(),
storageGuidance: z.string().nullable(),
steps: z.array(
z.object({
stepNumber: z.number().int().min(1),
instruction: z.string(),
tip: z.string().nullable(),
}),
),
});
export const translateRecipeOutput = z.object({
title: z.string().min(1),
description: z.string().nullable(),
storageGuidance: z.string().nullable(),
steps: z.array(
z.object({
stepNumber: z.number().int().min(1),
instruction: z.string().min(1),
tip: z.string().nullable(),
}),
),
confidence,
});
// ---------------------------------------------------------------------------
// Kontraktsregister
// ---------------------------------------------------------------------------
export const TASK_CONTRACTS = {
ANALYZE_FRIDGE_IMAGE: { input: analyzeStorageImageInput, output: analyzeStorageImageOutput },
ANALYZE_PANTRY_IMAGE: { input: analyzeStorageImageInput, output: analyzeStorageImageOutput },
ANALYZE_MEAL_IMAGE: { input: analyzeMealImageInput, output: analyzeMealImageOutput },
READ_RECEIPT: { input: readReceiptInput, output: readReceiptOutput },
READ_NUTRITION_LABEL: { input: readNutritionLabelInput, output: readNutritionLabelOutput },
READ_EXPIRY_DATE: { input: readExpiryDateInput, output: readExpiryDateOutput },
NORMALIZE_PRODUCTS: { input: normalizeProductsInput, output: normalizeProductsOutput },
DEDUPLICATE_INVENTORY: { input: deduplicateInventoryInput, output: deduplicateInventoryOutput },
STRUCTURE_RECIPE_TEXT: { input: structureRecipeTextInput, output: structureRecipeTextOutput },
GENERATE_RECIPE_OPTIONS: {
input: generateRecipeOptionsInput,
output: generateRecipeOptionsOutput,
},
RANK_RECIPES: { input: rankRecipesInput, output: rankRecipesOutput },
PARSE_CRAVING: { input: parseCravingInput, output: parseCravingOutput },
UPDATE_USER_MEMORY: { input: updateUserMemoryInput, output: updateUserMemoryOutput },
GENERATE_WEEK_PLAN: { input: generateWeekPlanInput, output: generateWeekPlanOutput },
MODERATE_RECIPE: { input: moderateRecipeInput, output: moderateRecipeOutput },
TRANSLATE_RECIPE: { input: translateRecipeInput, output: translateRecipeOutput },
} as const satisfies Record<AamosTaskType, { input: z.ZodType; output: z.ZodType }>;
export type TaskInput<T extends AamosTaskType> = z.infer<(typeof TASK_CONTRACTS)[T]["input"]>;
export type TaskOutput<T extends AamosTaskType> = z.infer<(typeof TASK_CONTRACTS)[T]["output"]>;
/** Kuvert för anrop mot AAMOS. */
export const aamosRequestEnvelopeSchema = z.object({
taskId: z.string(),
taskType: z.enum(AAMOS_TASK_TYPES),
contractVersion: z.string().default(AI_CONTRACT_VERSION),
input: z.unknown(),
metadata: z.object({
correlationId: z.string(),
/** Lokaliseringskontext (i18n-spec §22): AAMOS svarar på rätt språk och
* respekterar region/måttsystem canonical data förblir språkneutral. */
localeContext: z
.object({
languageTag: z.string(),
regionCode: z.string(),
timeZone: z.string(),
measurementSystem: z.enum(["METRIC", "US_CUSTOMARY", "MIXED"]),
temperatureUnit: z.enum(["CELSIUS", "FAHRENHEIT"]),
currencyCode: z.string(),
})
.default({
languageTag: "sv-SE",
regionCode: "SE",
timeZone: "Europe/Stockholm",
measurementSystem: "METRIC",
temperatureUnit: "CELSIUS",
currencyCode: "SEK",
}),
/** Pseudonymiserat aldrig e-post eller namn till AAMOS (spec §56). */
subjectRef: z.string().nullable(),
priority: z.enum(["low", "normal", "high"]).default("normal"),
consentFlags: z.object({
personalization: z.boolean(),
anonymizedImprovement: z.boolean(),
imageTraining: z.boolean(),
}),
}),
});
export type AamosRequestEnvelope = z.infer<typeof aamosRequestEnvelopeSchema>;
export const aamosResponseEnvelopeSchema = z.object({
taskId: z.string(),
taskType: z.enum(AAMOS_TASK_TYPES),
contractVersion: z.string(),
status: z.enum(["ok", "uncertain", "failed"]),
output: z.unknown().nullable(),
error: z.string().nullable().default(null),
modelVersion: z.string().nullable().default(null),
promptVersion: z.string().nullable().default(null),
latencyMs: z.number().nullable().default(null),
costUsd: z.number().nullable().default(null),
});
export type AamosResponseEnvelope = z.infer<typeof aamosResponseEnvelopeSchema>;
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src", "test"],
"compilerOptions": {
"types": ["node"]
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@app/connectors",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Connector-interface (spec §43): Livsmedelsverket, Open Food Facts, hälsa, framtida butiker/vitvaror",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@app/shared-types": "workspace:*",
"zod": "^4.4.0"
}
}
+60
View File
@@ -0,0 +1,60 @@
import type { Connector, ConnectorContext, ConnectorHealth, ImportResult } from "./types.js";
/**
* Hälsointegrationer (spec §42). Själva datainsamlingen sker i mobilappen
* (HealthKit/Health Connect körs på enheten); backend-connectorn tar emot
* det appen skickar och normaliserar. Kräver separat samtycke
* (consent kind: health_integration) och datan hålls i hälsodomänen (spec §56).
*/
export interface HealthSample {
type: "weight_kg" | "steps" | "active_kcal" | "workout" | "sleep_minutes" | "water_ml";
value: number;
unit: string;
startedAt: string;
endedAt: string;
sourceDevice?: string | undefined;
}
abstract class DeviceHealthConnector implements Connector<HealthSample> {
abstract readonly id: "apple-health" | "health-connect";
abstract readonly nameSv: string;
readonly legalBasis = "user_consent_platform_api" as const;
async connect(): Promise<void> {
/* Godkännande sker på enheten. */
}
async authenticate(ctx: ConnectorContext): Promise<boolean> {
return Boolean(ctx.userId);
}
async importData(
_ctx: ConnectorContext,
params?: Record<string, unknown>,
): Promise<ImportResult<HealthSample>> {
// Appen POST:ar samples till /v1/health-data; denna metod normaliserar dem.
const samples = Array.isArray(params?.samples) ? (params.samples as HealthSample[]) : [];
return {
items: samples,
source: this.id,
importedAt: new Date().toISOString(),
warnings: [],
};
}
async sync(ctx: ConnectorContext): Promise<ImportResult<HealthSample>> {
return this.importData(ctx);
}
async disconnect(): Promise<void> {}
async healthCheck(): Promise<ConnectorHealth> {
return { ok: true, detail: "device-side connector", checkedAt: new Date().toISOString() };
}
}
export class AppleHealthConnector extends DeviceHealthConnector {
override readonly id = "apple-health" as const;
override readonly nameSv = "Apple Health";
}
export class HealthConnectConnector extends DeviceHealthConnector {
override readonly id = "health-connect" as const;
override readonly nameSv = "Android Health Connect";
}
+23
View File
@@ -0,0 +1,23 @@
export * from "./types.js";
export * from "./livsmedelsverket.js";
export * from "./openFoodFacts.js";
export * from "./health.js";
import { ConnectorRegistry } from "./types.js";
import { LivsmedelsverketConnector } from "./livsmedelsverket.js";
import { OpenFoodFactsConnector } from "./openFoodFacts.js";
import { AppleHealthConnector, HealthConnectConnector } from "./health.js";
/**
* Standardregistret. Framtida connectors (butiker, smarta kylskåp, ugnar,
* vågar spec §60) registreras här NÄR laglig åtkomst och avtal finns,
* aldrig före (spec §43: ingen reverse engineering som kärnfunktion).
*/
export function createDefaultRegistry(): ConnectorRegistry {
const registry = new ConnectorRegistry();
registry.register(new LivsmedelsverketConnector());
registry.register(new OpenFoodFactsConnector());
registry.register(new AppleHealthConnector());
registry.register(new HealthConnectConnector());
return registry;
}
+115
View File
@@ -0,0 +1,115 @@
import { z } from "zod";
import type { Connector, ConnectorContext, ConnectorHealth, ImportResult } from "./types.js";
/**
* Livsmedelsverkets livsmedelsdatabas öppna data (laglig kärnkälla för
* näringsvärden, spec §15/§26/§61.11). Detta är den avsedda produktionskällan
* som ersätter seed-estimaten i canonical_ingredients.
*
* API-dokumentation: https://dataportal.livsmedelsverket.se
* (endpoint konfigureras via LIVSMEDELSVERKET_API_URL verifiera aktuell
* version innan produktionsimport; formatet nedan valideras med Zod).
*/
const livsmedelSchema = z.object({
nummer: z.number().or(z.string()),
namn: z.string(),
naringsvarden: z
.array(
z.object({
namn: z.string(),
varde: z.number().nullable(),
enhet: z.string().nullable(),
}),
)
.optional(),
});
export interface LivsmedelsverketItem {
externalId: string;
nameSv: string;
nutrients: Array<{ name: string; value: number | null; unit: string | null }>;
}
export class LivsmedelsverketConnector implements Connector<LivsmedelsverketItem> {
readonly id = "livsmedelsverket" as const;
readonly nameSv = "Livsmedelsverkets livsmedelsdatabas";
readonly legalBasis = "open_data" as const;
readonly attributionSv = "Näringsdata: Livsmedelsverkets livsmedelsdatabas";
constructor(
private readonly baseUrl: string = process.env.LIVSMEDELSVERKET_API_URL ??
"https://dataportal.livsmedelsverket.se/livsmedel/api/v1",
private readonly fetchImpl: typeof fetch = fetch,
) {}
async connect(): Promise<void> {
/* Öppna data ingen anslutning krävs. */
}
async authenticate(): Promise<boolean> {
return true; // Öppet API utan nyckel.
}
async importData(
_ctx: ConnectorContext,
params: { offset?: number; limit?: number } = {},
): Promise<ImportResult<LivsmedelsverketItem>> {
const limit = params.limit ?? 100;
const offset = params.offset ?? 0;
const warnings: string[] = [];
const url = `${this.baseUrl}/livsmedel?offset=${offset}&limit=${limit}`;
const res = await this.fetchImpl(url, { signal: AbortSignal.timeout(20_000) });
if (!res.ok) {
throw new Error(`Livsmedelsverket svarade ${res.status} kontrollera API-version/URL.`);
}
const json: unknown = await res.json();
const rawItems: unknown[] = Array.isArray(json)
? json
: typeof json === "object" &&
json !== null &&
Array.isArray((json as { livsmedel?: unknown[] }).livsmedel)
? (json as { livsmedel: unknown[] }).livsmedel
: [];
const items: LivsmedelsverketItem[] = [];
for (const raw of rawItems) {
const parsed = livsmedelSchema.safeParse(raw);
if (!parsed.success) {
warnings.push("Rad kunde inte tolkas och hoppades över.");
continue;
}
items.push({
externalId: String(parsed.data.nummer),
nameSv: parsed.data.namn,
nutrients: (parsed.data.naringsvarden ?? []).map((n) => ({
name: n.namn,
value: n.varde,
unit: n.enhet,
})),
});
}
return { items, source: this.id, importedAt: new Date().toISOString(), warnings };
}
async sync(ctx: ConnectorContext): Promise<ImportResult<LivsmedelsverketItem>> {
return this.importData(ctx);
}
async disconnect(): Promise<void> {
/* noop */
}
async healthCheck(): Promise<ConnectorHealth> {
try {
const res = await this.fetchImpl(`${this.baseUrl}/livsmedel?offset=0&limit=1`, {
signal: AbortSignal.timeout(8_000),
});
return { ok: res.ok, detail: `status ${res.status}`, checkedAt: new Date().toISOString() };
} catch (err) {
return {
ok: false,
detail: err instanceof Error ? err.message : String(err),
checkedAt: new Date().toISOString(),
};
}
}
}
+155
View File
@@ -0,0 +1,155 @@
import { z } from "zod";
import { BRAND } from "@app/shared-types";
import type { Connector, ConnectorContext, ConnectorHealth, ImportResult } from "./types.js";
/**
* Open Food Facts öppen produktdatabas (ODbL-licens) för streckkodsuppslag
* (spec §11 steg 3: "kontrollera tillåten extern datakälla").
* Attribution krävs enligt licensen och visas i appen.
*/
const offProductSchema = z.object({
code: z.string(),
product: z
.object({
product_name: z.string().optional(),
brands: z.string().optional(),
quantity: z.string().optional(),
ingredients_text: z.string().optional(),
allergens_tags: z.array(z.string()).optional(),
nutriments: z.record(z.string(), z.unknown()).optional(),
image_url: z.string().optional(),
lang: z.string().optional(),
})
.optional(),
status: z.number().optional(),
});
export interface OffProduct {
gtin: string;
name?: string | undefined;
brand?: string | undefined;
quantityText?: string | undefined;
ingredientsText?: string | undefined;
allergenTags: string[];
nutrimentsPer100g: Partial<{
kcal: number;
proteinG: number;
carbsG: number;
fatG: number;
saturatedFatG: number;
fiberG: number;
sugarG: number;
saltG: number;
}>;
imageUrl?: string | undefined;
}
export class OpenFoodFactsConnector implements Connector<OffProduct> {
readonly id = "open-food-facts" as const;
readonly nameSv = "Open Food Facts";
readonly legalBasis = "open_data" as const;
readonly attributionSv = "Produktdata: Open Food Facts (ODbL)";
constructor(
private readonly baseUrl: string = process.env.OFF_API_URL ?? "https://world.openfoodfacts.org",
private readonly fetchImpl: typeof fetch = fetch,
) {}
async connect(): Promise<void> {}
async authenticate(): Promise<boolean> {
return true;
}
/** Slå upp en produkt via GTIN/EAN. */
async lookupBarcode(gtin: string): Promise<OffProduct | null> {
const res = await this.fetchImpl(`${this.baseUrl}/api/v2/product/${gtin}.json`, {
headers: { "user-agent": `${BRAND.name}/0.1 (kontakt: ${BRAND.supportEmail})` },
signal: AbortSignal.timeout(10_000),
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Open Food Facts svarade ${res.status}`);
const parsed = offProductSchema.safeParse(await res.json());
if (!parsed.success || !parsed.data.product) return null;
const p = parsed.data.product;
const n = (p.nutriments ?? {}) as Record<string, unknown>;
const num = (key: string): number | undefined => {
const v = n[key];
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
};
const per100: OffProduct["nutrimentsPer100g"] = {};
const kcal = num("energy-kcal_100g");
if (kcal !== undefined) per100.kcal = kcal;
const protein = num("proteins_100g");
if (protein !== undefined) per100.proteinG = protein;
const carbs = num("carbohydrates_100g");
if (carbs !== undefined) per100.carbsG = carbs;
const fat = num("fat_100g");
if (fat !== undefined) per100.fatG = fat;
const satFat = num("saturated-fat_100g");
if (satFat !== undefined) per100.saturatedFatG = satFat;
const fiber = num("fiber_100g");
if (fiber !== undefined) per100.fiberG = fiber;
const sugar = num("sugars_100g");
if (sugar !== undefined) per100.sugarG = sugar;
const salt = num("salt_100g");
if (salt !== undefined) per100.saltG = salt;
return {
gtin: parsed.data.code,
name: p.product_name,
brand: p.brands,
quantityText: p.quantity,
ingredientsText: p.ingredients_text,
allergenTags: p.allergens_tags ?? [],
nutrimentsPer100g: per100,
imageUrl: p.image_url,
};
}
async importData(
_ctx: ConnectorContext,
params?: Record<string, unknown>,
): Promise<ImportResult<OffProduct>> {
const gtin = typeof params?.gtin === "string" ? params.gtin : null;
if (!gtin)
return {
items: [],
source: this.id,
importedAt: new Date().toISOString(),
warnings: ["gtin saknas"],
};
const product = await this.lookupBarcode(gtin);
return {
items: product ? [product] : [],
source: this.id,
importedAt: new Date().toISOString(),
warnings: [],
};
}
async sync(ctx: ConnectorContext): Promise<ImportResult<OffProduct>> {
return this.importData(ctx);
}
async disconnect(): Promise<void> {}
async healthCheck(): Promise<ConnectorHealth> {
try {
const res = await this.fetchImpl(`${this.baseUrl}/api/v2/product/7310865004703.json`, {
headers: { "user-agent": `${BRAND.name}/0.1` },
signal: AbortSignal.timeout(8_000),
});
return {
ok: res.ok || res.status === 404,
detail: `status ${res.status}`,
checkedAt: new Date().toISOString(),
};
} catch (err) {
return {
ok: false,
detail: err instanceof Error ? err.message : String(err),
checkedAt: new Date().toISOString(),
};
}
}
}
+84
View File
@@ -0,0 +1,84 @@
/**
* Connector-ramverk (spec §43). Gemensamt interface:
* connect, authenticate, import, sync, disconnect, healthCheck.
*
* Juridisk grundregel (spec §26, §43, §61.11): endast lagliga API:er,
* partnerflöden eller licensierade data. Ingen reverse engineering,
* ingen scraping som kärnfunktion.
*/
export type ConnectorId =
| "livsmedelsverket"
| "open-food-facts"
| "apple-health"
| "health-connect"
| "digital-receipts"
| "future-retailers"
| "future-smart-fridges"
| "future-smart-ovens"
| "future-scales";
export interface ConnectorHealth {
ok: boolean;
detail?: string;
checkedAt: string;
}
export interface ConnectorContext {
userId?: string;
householdId?: string;
credentials?: Record<string, string>;
}
export interface ImportResult<T = unknown> {
items: T[];
source: ConnectorId;
importedAt: string;
warnings: string[];
}
export interface Connector<TImport = unknown> {
id: ConnectorId;
nameSv: string;
/** Juridisk status en connector utan klar legal basis får inte aktiveras. */
legalBasis: "open_data" | "licensed" | "user_consent_platform_api" | "agreement_pending";
attributionSv?: string;
connect(ctx: ConnectorContext): Promise<void>;
authenticate(ctx: ConnectorContext): Promise<boolean>;
importData(
ctx: ConnectorContext,
params?: Record<string, unknown>,
): Promise<ImportResult<TImport>>;
sync(ctx: ConnectorContext): Promise<ImportResult<TImport>>;
disconnect(ctx: ConnectorContext): Promise<void>;
healthCheck(): Promise<ConnectorHealth>;
}
export class ConnectorRegistry {
private connectors = new Map<ConnectorId, Connector>();
register(connector: Connector): void {
this.connectors.set(connector.id, connector);
}
get(id: ConnectorId): Connector | undefined {
return this.connectors.get(id);
}
list(): Connector[] {
return [...this.connectors.values()];
}
async healthCheckAll(): Promise<Record<string, ConnectorHealth>> {
const result: Record<string, ConnectorHealth> = {};
for (const c of this.connectors.values()) {
result[c.id] = await c.healthCheck().catch((err) => ({
ok: false,
detail: err instanceof Error ? err.message : String(err),
checkedAt: new Date().toISOString(),
}));
}
return result;
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src"],
"compilerOptions": {
"types": ["node"]
}
}
+18
View File
@@ -0,0 +1,18 @@
import "dotenv/config";
import { defineConfig } from "drizzle-kit";
/**
* Migrationer genereras till infrastructure/migrations (spec §49) och körs
* med `pnpm db:migrate`. Ingen destruktiv ändring utan backup + rollback (spec §63).
*/
export default defineConfig({
dialect: "postgresql",
schema: "./src/schema/index.ts",
out: "../../infrastructure/migrations",
casing: "snake_case",
dbCredentials: {
url: process.env.DATABASE_URL ?? "postgres://app_user:app_dev_password@localhost:5432/app",
},
strict: true,
verbose: true,
});
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@app/database",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Drizzle-schema, migrationer, seed och databasklient (separat databas, spec §52)",
"exports": {
".": "./src/index.ts",
"./schema": "./src/schema/index.ts",
"./seed": "./src/seed/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/migrate.ts",
"db:seed": "tsx src/seed/run.ts"
},
"dependencies": {
"@app/nutrition-engine": "workspace:*",
"@app/shared-types": "workspace:*",
"dotenv": "^16.4.0",
"drizzle-orm": "^0.45.0",
"pg": "^8.13.0"
},
"devDependencies": {
"@types/pg": "^8.11.0",
"drizzle-kit": "^0.31.0"
}
}
+50
View File
@@ -0,0 +1,50 @@
import pg from "pg";
import { drizzle } from "drizzle-orm/node-postgres";
import * as schema from "./schema/index.js";
export type Database = ReturnType<typeof createDatabase>["db"];
let sharedPool: pg.Pool | undefined;
/**
* Skapar en databasklient. API och worker delar mönster men äger varsin pool.
* DATABASE_URL pekar på appens separata databas med egen minimalprivilegie-användare
* (minsta möjliga privilegier, spec §52).
*/
export function createDatabase(connectionString?: string) {
const url =
connectionString ??
process.env.DATABASE_URL ??
"postgres://app_user:app_dev_password@localhost:5432/app";
const pool = new pg.Pool({
connectionString: url,
max: Number(process.env.DATABASE_POOL_MAX ?? 10),
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 10_000,
});
const db = drizzle(pool, { schema, casing: "snake_case" });
return { db, pool };
}
/** Singleton för processer som bara behöver en anslutning. */
export function getDatabase() {
if (!sharedPool) {
const { db, pool } = createDatabase();
sharedPool = pool;
sharedDb = db;
}
return sharedDb!;
}
let sharedDb: Database | undefined;
export async function closeDatabase(): Promise<void> {
if (sharedPool) {
await sharedPool.end();
sharedPool = undefined;
sharedDb = undefined;
}
}
export { schema };
+3
View File
@@ -0,0 +1,3 @@
export * from "./client.js";
export * as schema from "./schema/index.js";
export * from "./schema/index.js";
+60
View File
@@ -0,0 +1,60 @@
import { config as loadDotenv } from "dotenv";
import { existsSync } from "node:fs";
import path from "node:path";
// Ladda .env från paketet ELLER monorepo-roten (pnpm --filter sätter cwd till paketet).
for (const candidate of [".env", "../.env", "../../.env"]) {
const p = path.resolve(process.cwd(), candidate);
if (existsSync(p)) {
loadDotenv({ path: p });
break;
}
}
import { fileURLToPath } from "node:url";
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { createDatabase } from "./client.js";
/**
* Kör alla väntande SQL-migrationer från infrastructure/migrations.
* Används i dev, CI och deploy (spec §63: migration + rollback + kontroll).
*/
const migrationsFolder = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../../infrastructure/migrations",
);
async function main() {
const { db, pool } = createDatabase();
console.log(`[migrate] Kör migrationer från ${migrationsFolder}`);
await migrate(db, { migrationsFolder });
// i18n M7: accentokänslig + fuzzy sök. Extensions kräver rättigheter
// i produktion skapas de av create-database.sql (master); här är de idempotenta
// och hoppas över med varning om rättighet saknas.
try {
await pool.query("CREATE EXTENSION IF NOT EXISTS unaccent");
await pool.query("CREATE EXTENSION IF NOT EXISTS pg_trgm");
await pool.query(
"CREATE INDEX IF NOT EXISTS canonical_ingredients_name_trgm_idx ON canonical_ingredients USING gin (name_sv gin_trgm_ops)",
);
await pool.query(
"CREATE INDEX IF NOT EXISTS ingredient_translations_name_trgm_idx ON ingredient_translations USING gin (name gin_trgm_ops)",
);
await pool.query(
"CREATE INDEX IF NOT EXISTS recipes_title_trgm_idx ON recipes USING gin (title_sv gin_trgm_ops)",
);
console.log("[migrate] Sök-extensions + trigramindex på plats (i18n M7).");
} catch (err) {
console.warn(
"[migrate] VARNING: kunde inte skapa sök-extensions/index (kör create-database.sql som master först):",
(err as Error).message,
);
}
console.log("[migrate] Klart.");
await pool.end();
}
main().catch((err) => {
console.error("[migrate] MISSLYCKADES:", err);
process.exit(1);
});
+114
View File
@@ -0,0 +1,114 @@
/**
* Delade kolumnhjälpare och pg-enums för hela schemat.
* Alla enum-värden kommer från @app/shared-types så att databas,
* API och app aldrig glider isär.
*/
import { pgEnum, timestamp } from "drizzle-orm/pg-core";
import {
ACTIVITY_LEVELS,
ALLERGENS,
CONSENT_KINDS,
CONSENT_STATUSES,
COOKING_METHODS,
CREATOR_LEVELS,
CUISINES,
DATE_KINDS,
DIET_PATTERNS,
EVENT_TYPES,
EXPIRY_STATUSES,
GOAL_TYPES,
HOUSEHOLD_ROLES,
INVENTORY_SOURCES,
INVENTORY_TRANSACTION_TYPES,
JOB_STATUSES,
JOB_TYPES,
MEAL_LOG_SOURCES,
MEAL_TYPES,
MEMORY_KINDS,
NOTIFICATION_TYPES,
PRECISION_MODES,
PROFILE_VISIBILITIES,
RECIPE_DIFFICULTIES,
RECIPE_SIMILARITY_CLASSES,
RECIPE_SOURCE_TYPES,
RECIPE_STATUSES,
RECIPE_VARIANT_TYPES,
RECIPE_VERIFICATION_STATUSES,
RELIGIOUS_RULES,
SCAN_TYPES,
SEXES,
SIGNAL_ORIGINS,
STORAGE_LOCATION_TYPES,
SUBSCRIPTION_PLANS,
SUBSCRIPTION_PROVIDERS,
SUBSCRIPTION_STATUSES,
TASTE_AXES,
UNITS,
USER_ROLES,
VERIFICATION_STATUSES,
tuple,
} from "@app/shared-types";
// --- Tidsstämplar som återanvänds av alla tabeller ---
export const createdAt = () =>
timestamp("created_at", { withTimezone: true }).notNull().defaultNow();
export const updatedAt = () =>
timestamp("updated_at", { withTimezone: true }).notNull().defaultNow();
// --- pg-enums ---
export const userRoleEnum = pgEnum("user_role", tuple(USER_ROLES));
export const sexEnum = pgEnum("sex", tuple(SEXES));
export const activityLevelEnum = pgEnum("activity_level", tuple(ACTIVITY_LEVELS));
export const goalTypeEnum = pgEnum("goal_type", tuple(GOAL_TYPES));
export const dietPatternEnum = pgEnum("diet_pattern", tuple(DIET_PATTERNS));
export const religiousRuleEnum = pgEnum("religious_rule", tuple(RELIGIOUS_RULES));
export const precisionModeEnum = pgEnum("precision_mode", tuple(PRECISION_MODES));
export const allergenEnum = pgEnum("allergen", tuple(ALLERGENS));
export const consentKindEnum = pgEnum("consent_kind", tuple(CONSENT_KINDS));
export const consentStatusEnum = pgEnum("consent_status", tuple(CONSENT_STATUSES));
export const householdRoleEnum = pgEnum("household_role", tuple(HOUSEHOLD_ROLES));
export const storageLocationTypeEnum = pgEnum(
"storage_location_type",
tuple(STORAGE_LOCATION_TYPES),
);
export const unitEnum = pgEnum("unit", tuple(UNITS));
export const inventorySourceEnum = pgEnum("inventory_source", tuple(INVENTORY_SOURCES));
export const inventoryTransactionTypeEnum = pgEnum(
"inventory_transaction_type",
tuple(INVENTORY_TRANSACTION_TYPES),
);
export const expiryStatusEnum = pgEnum("expiry_status", tuple(EXPIRY_STATUSES));
export const dateKindEnum = pgEnum("date_kind", tuple(DATE_KINDS));
export const cuisineEnum = pgEnum("cuisine", tuple(CUISINES));
export const mealTypeEnum = pgEnum("meal_type", tuple(MEAL_TYPES));
export const cookingMethodEnum = pgEnum("cooking_method", tuple(COOKING_METHODS));
export const recipeDifficultyEnum = pgEnum("recipe_difficulty", tuple(RECIPE_DIFFICULTIES));
export const recipeStatusEnum = pgEnum("recipe_status", tuple(RECIPE_STATUSES));
export const recipeVerificationStatusEnum = pgEnum(
"recipe_verification_status",
tuple(RECIPE_VERIFICATION_STATUSES),
);
export const recipeVariantTypeEnum = pgEnum("recipe_variant_type", tuple(RECIPE_VARIANT_TYPES));
export const recipeSourceTypeEnum = pgEnum("recipe_source_type", tuple(RECIPE_SOURCE_TYPES));
export const recipeSimilarityClassEnum = pgEnum(
"recipe_similarity_class",
tuple(RECIPE_SIMILARITY_CLASSES),
);
export const scanTypeEnum = pgEnum("scan_type", tuple(SCAN_TYPES));
export const jobTypeEnum = pgEnum("job_type", tuple(JOB_TYPES));
export const jobStatusEnum = pgEnum("job_status", tuple(JOB_STATUSES));
export const eventTypeEnum = pgEnum("event_type", tuple(EVENT_TYPES));
export const mealLogSourceEnum = pgEnum("meal_log_source", tuple(MEAL_LOG_SOURCES));
export const memoryKindEnum = pgEnum("memory_kind", tuple(MEMORY_KINDS));
export const signalOriginEnum = pgEnum("signal_origin", tuple(SIGNAL_ORIGINS));
export const tasteAxisEnum = pgEnum("taste_axis", tuple(TASTE_AXES));
export const subscriptionPlanEnum = pgEnum("subscription_plan", tuple(SUBSCRIPTION_PLANS));
export const subscriptionStatusEnum = pgEnum("subscription_status", tuple(SUBSCRIPTION_STATUSES));
export const subscriptionProviderEnum = pgEnum(
"subscription_provider",
tuple(SUBSCRIPTION_PROVIDERS),
);
export const creatorLevelEnum = pgEnum("creator_level", tuple(CREATOR_LEVELS));
export const profileVisibilityEnum = pgEnum("profile_visibility", tuple(PROFILE_VISIBILITIES));
export const notificationTypeEnum = pgEnum("notification_type", tuple(NOTIFICATION_TYPES));
export const verificationStatusEnum = pgEnum("verification_status", tuple(VERIFICATION_STATUSES));
@@ -0,0 +1,67 @@
import {
doublePrecision,
index,
integer,
pgTable,
primaryKey,
text,
timestamp,
varchar,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
import { createdAt, householdRoleEnum, storageLocationTypeEnum, updatedAt } from "./_shared.js";
import { users } from "./users.js";
/** Hushåll delar lager, inköpslista, plan, matlådor och budget (spec §7). */
export const households = pgTable(
"households",
{
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
inviteCode: text("invite_code").notNull(),
weeklyBudgetMinor: integer("weekly_budget_minor"),
/** ISO 4217. Pengagränsen går vid hushållet (i18n-spec §20) alla belopp i hushållet tolkas i denna valuta. */
currencyCode: varchar("currency_code", { length: 3 }).notNull().default("SEK"),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [uniqueIndex("households_invite_code_unique").on(t.inviteCode)],
);
export const householdMembers = pgTable(
"household_members",
{
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
role: householdRoleEnum("role").notNull().default("member"),
/** Individuell portionsfaktor (spec §7: samma rätt, anpassad per person). */
portionFactor: doublePrecision("portion_factor").notNull().default(1),
joinedAt: timestamp("joined_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
primaryKey({ columns: [t.householdId, t.userId] }),
index("household_members_user_idx").on(t.userId),
],
);
/** Kyl, frys, skafferi, garagefrys, vinkyl, matkällare, matlådor, egna platser (spec §8). */
export const storageLocations = pgTable(
"storage_locations",
{
id: uuid("id").primaryKey().defaultRandom(),
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
type: storageLocationTypeEnum("type").notNull(),
name: text("name").notNull(),
sublocations: text("sublocations").array().notNull().default([]),
sortOrder: integer("sort_order").notNull().default(0),
createdAt: createdAt(),
},
(t) => [index("storage_locations_household_idx").on(t.householdId)],
);
+18
View File
@@ -0,0 +1,18 @@
export * from "./_shared.js";
export * from "./users.js";
export * from "./locale.js";
export * from "./households.js";
export * from "./ingredients.js";
export * from "./products.js";
export * from "./inventory.js";
export * from "./recipes.js";
export * from "./translations.js";
export * from "./markets.js";
export * from "./meals.js";
export * from "./planning.js";
export * from "./receipts.js";
export * from "./scans.js";
export * from "./memory.js";
export * from "./seasons.js";
export * from "./subscriptions.js";
export * from "./platform.js";
@@ -0,0 +1,81 @@
import {
boolean,
doublePrecision,
index,
integer,
jsonb,
pgTable,
text,
uniqueIndex,
} from "drizzle-orm/pg-core";
import type {
NutritionDeclaration,
NutritionProvenance,
Season,
StorageLocationType,
} from "@app/shared-types";
import { allergenEnum, unitEnum, updatedAt, createdAt } from "./_shared.js";
/**
* Kanoniska ingredienser navet som alla datakällor normaliseras mot (spec §9).
* Näringsvärden per 100 g/ml med spårbar källa. AI hittar aldrig på värden här:
* produktion importerar Livsmedelsverkets öppna livsmedelsdatabas via connector.
*/
export const canonicalIngredients = pgTable(
"canonical_ingredients",
{
/** Stabilt slug-id, t.ex. "chicken_breast". */
id: text("id").primaryKey(),
nameSv: text("name_sv").notNull(),
nameEn: text("name_en").notNull(),
/** Sökhjälp: synonymer och vanliga kvitto-/OCR-varianter. */
aliases: text("aliases").array().notNull().default([]),
category: text("category").notNull(),
defaultUnit: unitEnum("default_unit").notNull(),
densityGPerMl: doublePrecision("density_g_per_ml"),
gramsPerPiece: doublePrecision("grams_per_piece"),
allergens: allergenEnum("allergens").array().notNull().default([]),
isVegan: boolean("is_vegan").notNull().default(false),
isVegetarian: boolean("is_vegetarian").notNull().default(false),
containsGluten: boolean("contains_gluten").notNull().default(false),
containsLactose: boolean("contains_lactose").notNull().default(false),
isPork: boolean("is_pork").notNull().default(false),
isBeef: boolean("is_beef").notNull().default(false),
isAlcohol: boolean("is_alcohol").notNull().default(false),
nutritionPer100: jsonb("nutrition_per_100").$type<NutritionDeclaration>().notNull(),
nutritionProvenance: jsonb("nutrition_provenance").$type<NutritionProvenance>().notNull(),
peakSeasons: text("peak_seasons").array().$type<Season[]>().notNull().default([]),
/** Riktvärden i dagar per förvaringsplats vägledning, aldrig garanti (spec §13). */
shelfLifeGuidance:
jsonb("shelf_life_guidance").$type<Partial<Record<StorageLocationType, number>>>(),
defaultPriceMinorPerKg: integer("default_price_minor_per_kg"),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [index("canonical_ingredients_category_idx").on(t.category)],
);
/** Substitutionsmotor (spec §20): mängdfaktor + påverkan + begränsningar. */
export const substitutions = pgTable(
"substitutions",
{
id: text("id").primaryKey(),
fromIngredientId: text("from_ingredient_id")
.notNull()
.references(() => canonicalIngredients.id),
toIngredientId: text("to_ingredient_id")
.notNull()
.references(() => canonicalIngredients.id),
ratio: doublePrecision("ratio").notNull().default(1),
instructionsSv: text("instructions_sv"),
bestFor: text("best_for").array().notNull().default([]),
notRecommendedFor: text("not_recommended_for").array().notNull().default([]),
flavorImpactSv: text("flavor_impact_sv"),
textureImpactSv: text("texture_impact_sv"),
priority: integer("priority").notNull().default(0),
},
(t) => [
index("substitutions_from_idx").on(t.fromIngredientId),
uniqueIndex("substitutions_pair_unique").on(t.fromIngredientId, t.toIngredientId),
],
);
+107
View File
@@ -0,0 +1,107 @@
import {
boolean,
date,
doublePrecision,
index,
jsonb,
pgTable,
text,
timestamp,
uuid,
integer,
} from "drizzle-orm/pg-core";
import type { NutritionDeclaration } from "@app/shared-types";
import {
createdAt,
dateKindEnum,
expiryStatusEnum,
inventorySourceEnum,
inventoryTransactionTypeEnum,
unitEnum,
updatedAt,
} from "./_shared.js";
import { households, storageLocations } from "./households.js";
import { canonicalIngredients } from "./ingredients.js";
import { products } from "./products.js";
import { users } from "./users.js";
/**
* Food Twin (spec §8): lagerposter med fullständig spårbarhet.
* `quantity` är ett cachat saldo sanningen är inventory_transactions.
*/
export const inventoryItems = pgTable(
"inventory_items",
{
id: uuid("id").primaryKey().defaultRandom(),
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
canonicalIngredientId: text("canonical_ingredient_id").references(
() => canonicalIngredients.id,
),
productId: uuid("product_id").references(() => products.id),
displayName: text("display_name").notNull(),
brand: text("brand"),
quantity: doublePrecision("quantity").notNull().default(0),
unit: unitEnum("unit").notNull(),
storageLocationId: uuid("storage_location_id")
.notNull()
.references(() => storageLocations.id),
sublocation: text("sublocation"),
purchasedAt: date("purchased_at"),
openedAt: date("opened_at"),
bestBeforeDate: date("best_before_date"),
useByDate: date("use_by_date"),
dateKind: dateKindEnum("date_kind"),
frozenAt: date("frozen_at"),
thawedAt: date("thawed_at"),
priceMinor: integer("price_minor"),
nutritionPer100: jsonb("nutrition_per_100").$type<NutritionDeclaration>(),
source: inventorySourceEnum("source").notNull(),
confidence: doublePrecision("confidence").notNull().default(1),
verifiedByUser: boolean("verified_by_user").notNull().default(false),
lastVerifiedAt: timestamp("last_verified_at", { withTimezone: true }),
expiryStatus: expiryStatusEnum("expiry_status").notNull().default("unknown"),
/** Sätts när saldot nått 0 och posten arkiverats. */
depletedAt: timestamp("depleted_at", { withTimezone: true }),
modelVersion: text("model_version"),
promptVersion: text("prompt_version"),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [
index("inventory_items_household_idx").on(t.householdId),
index("inventory_items_household_bb_idx").on(t.householdId, t.bestBeforeDate),
index("inventory_items_location_idx").on(t.storageLocationId),
index("inventory_items_canonical_idx").on(t.canonicalIngredientId),
],
);
/** Transaktionsloggen är källan till sanning (spec §8): + inköp, använt, kasserat … */
export const inventoryTransactions = pgTable(
"inventory_transactions",
{
id: uuid("id").primaryKey().defaultRandom(),
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
inventoryItemId: uuid("inventory_item_id")
.notNull()
.references(() => inventoryItems.id, { onDelete: "cascade" }),
type: inventoryTransactionTypeEnum("type").notNull(),
quantityDelta: doublePrecision("quantity_delta").notNull(),
unit: unitEnum("unit").notNull(),
refType: text("ref_type"),
refId: uuid("ref_id"),
actorUserId: uuid("actor_user_id").references(() => users.id, { onDelete: "set null" }),
note: text("note"),
/** Värde i SEK för matsvinnsberäkning vid discard (spec §12, §26). */
valueMinor: integer("value_minor"),
createdAt: createdAt(),
},
(t) => [
index("inventory_tx_item_idx").on(t.inventoryItemId),
index("inventory_tx_household_time_idx").on(t.householdId, t.createdAt),
index("inventory_tx_type_idx").on(t.type),
],
);
+32
View File
@@ -0,0 +1,32 @@
import { boolean, integer, pgEnum, pgTable, text, uuid } from "drizzle-orm/pg-core";
import { MEASUREMENT_SYSTEMS, TEMPERATURE_UNITS, tuple } from "@app/shared-types";
import { updatedAt } from "./_shared.js";
import { users } from "./users.js";
export const measurementSystemEnum = pgEnum("measurement_system", tuple(MEASUREMENT_SYSTEMS));
export const temperatureUnitEnum = pgEnum("temperature_unit", tuple(TEMPERATURE_UNITS));
/**
* Locale-preferenser per användare (i18n-spec §6, §29):
* språk, region, tidszon, måttsystem, temperatur, valuta, veckostart, 12/24 h
* alla oberoende av varandra. Saknas rad gäller regiondefaults (SE).
*/
export const userLocalePreferences = pgTable("user_locale_preferences", {
userId: uuid("user_id")
.primaryKey()
.references(() => users.id, { onDelete: "cascade" }),
/** BCP 47, t.ex. sv-SE, en-US. */
languageTag: text("language_tag").notNull().default("sv-SE"),
/** ISO 3166-1 alpha-2. */
regionCode: text("region_code").notNull().default("SE"),
/** IANA-tidszon. */
timeZone: text("time_zone").notNull().default("Europe/Stockholm"),
measurementSystem: measurementSystemEnum("measurement_system").notNull().default("METRIC"),
temperatureUnit: temperatureUnitEnum("temperature_unit").notNull().default("CELSIUS"),
/** ISO 4217. */
currencyCode: text("currency_code").notNull().default("SEK"),
/** 0 = söndag … 6 = lördag. */
firstDayOfWeek: integer("first_day_of_week").notNull().default(1),
use24HourTime: boolean("use_24_hour_time").notNull().default(true),
updatedAt: updatedAt(),
});
+39
View File
@@ -0,0 +1,39 @@
/**
* Marknadsprofiler (i18n-spec §1819, M6).
*
* Näringsdeklaration och allergenframhävning skiljer sig per marknad:
* - EU/EES: energi i kJ + kcal, "salt" (inte natrium), 14 deklarationspliktiga allergener.
* - US: kcal ("Calories"), natrium i mg, FDA "Big 9".
* - CA: kcal, natrium, Health Canada-lista (senap ingår, selleri ingår inte).
*
* Beräkningen är ALLTID samma deterministiska motor profilerna styr bara
* VISNING och VILKA allergener som måste framhävas per marknad. Appens interna
* allergensäkerhet (spec §61.2) filtrerar alltid på användarens egna allergier,
* oavsett marknad profilen kan aldrig slå av en spärr.
*/
import { boolean, pgTable, primaryKey, text, varchar } from "drizzle-orm/pg-core";
import { allergenEnum, createdAt, updatedAt } from "./_shared.js";
export const nutritionDisplayProfiles = pgTable("nutrition_display_profiles", {
/** ISO 3166-1 alpha-2, "EU" som samlingsprofil och fallback. */
regionCode: varchar("region_code", { length: 2 }).primaryKey(),
/** Visa energi som: kcal, kj eller båda. */
energyDisplay: text("energy_display", { enum: ["kcal", "kj", "both"] }).notNull(),
/** Visa salt (g) eller natrium (mg). */
saltDisplay: text("salt_display", { enum: ["salt", "sodium"] }).notNull(),
/** Etikettnyckel för energi ("Energi", "Calories" …) texten bor i i18n-resurser. */
energyLabelKey: text("energy_label_key").notNull(),
createdAt: createdAt(),
updatedAt: updatedAt(),
});
export const allergenMarketRules = pgTable(
"allergen_market_rules",
{
regionCode: varchar("region_code", { length: 2 }).notNull(),
allergen: allergenEnum("allergen").notNull(),
/** Måste framhävas i deklaration på denna marknad. */
mustHighlight: boolean("must_highlight").notNull().default(true),
},
(t) => [primaryKey({ columns: [t.regionCode, t.allergen] })],
);
+81
View File
@@ -0,0 +1,81 @@
import {
boolean,
date,
doublePrecision,
index,
integer,
jsonb,
pgTable,
text,
timestamp,
uuid,
} from "drizzle-orm/pg-core";
import type { NutritionValues } from "@app/shared-types";
import { createdAt, mealLogSourceEnum, mealTypeEnum } from "./_shared.js";
import { households, storageLocations } from "./households.js";
import { recipes } from "./recipes.js";
import { users } from "./users.js";
/**
* Måltidslogg (spec §23). Näringsvärden är alltid deterministiskt härledda
* (recept/produkt/användarinmatning) aldrig påhittade av AI (spec §61.1).
*/
export const meals = pgTable(
"meals",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
householdId: uuid("household_id").references(() => households.id, { onDelete: "set null" }),
date: date("date").notNull(),
mealType: mealTypeEnum("meal_type").notNull(),
source: mealLogSourceEnum("source").notNull(),
recipeId: uuid("recipe_id").references(() => recipes.id, { onDelete: "set null" }),
titleSv: text("title_sv").notNull(),
portionFraction: doublePrecision("portion_fraction").notNull().default(1),
nutrition: jsonb("nutrition").$type<NutritionValues>().notNull(),
nutritionIsEstimate: boolean("nutrition_is_estimate").notNull().default(false),
estimateMinKcal: doublePrecision("estimate_min_kcal"),
estimateMaxKcal: doublePrecision("estimate_max_kcal"),
items: jsonb("items"),
photoUrl: text("photo_url"),
scanJobId: uuid("scan_job_id"),
loggedAt: timestamp("logged_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
index("meals_user_date_idx").on(t.userId, t.date),
index("meals_household_idx").on(t.householdId),
],
);
/** Matlådor (spec §24) rekommenderas före ny matlagning när rimligt. */
export const mealBoxes = pgTable(
"meal_boxes",
{
id: uuid("id").primaryKey().defaultRandom(),
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
recipeId: uuid("recipe_id").references(() => recipes.id, { onDelete: "set null" }),
titleSv: text("title_sv").notNull(),
portions: integer("portions").notNull(),
portionsRemaining: integer("portions_remaining").notNull(),
nutritionPerPortion: jsonb("nutrition_per_portion").$type<NutritionValues>(),
cookedAt: date("cooked_at").notNull(),
storageLocationId: uuid("storage_location_id")
.notNull()
.references(() => storageLocations.id),
frozen: boolean("frozen").notNull().default(false),
recommendedUseBy: date("recommended_use_by").notNull(),
reservedForUserId: uuid("reserved_for_user_id").references(() => users.id, {
onDelete: "set null",
}),
status: text("status").notNull().default("available"),
createdAt: createdAt(),
},
(t) => [
index("meal_boxes_household_idx").on(t.householdId),
index("meal_boxes_use_by_idx").on(t.householdId, t.recommendedUseBy),
],
);
+91
View File
@@ -0,0 +1,91 @@
import {
boolean,
doublePrecision,
index,
jsonb,
pgTable,
text,
timestamp,
uuid,
} from "drizzle-orm/pg-core";
import {
createdAt,
memoryKindEnum,
signalOriginEnum,
tasteAxisEnum,
updatedAt,
} from "./_shared.js";
import { households } from "./households.js";
import { recipes } from "./recipes.js";
import { users } from "./users.js";
/**
* AAMOS Memory-spegel (spec §32). Varje post är transparent, korrigerbar,
* pausbar och raderbar via "Vad plattformen vet om mig".
* Personligt minne är INTE automatiskt träningsdata (spec §32, §33).
*/
export const memoryItems = pgTable(
"memory_items",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id").references(() => users.id, { onDelete: "cascade" }),
householdId: uuid("household_id").references(() => households.id, { onDelete: "cascade" }),
kind: memoryKindEnum("kind").notNull(),
key: text("key").notNull(),
summarySv: text("summary_sv").notNull(),
value: jsonb("value"),
origin: signalOriginEnum("origin").notNull(),
confidence: doublePrecision("confidence").notNull().default(0.5),
verifiedByUser: boolean("verified_by_user").notNull().default(false),
paused: boolean("paused").notNull().default(false),
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
expiresAt: timestamp("expires_at", { withTimezone: true }),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [
index("memory_items_user_idx").on(t.userId, t.kind),
index("memory_items_household_idx").on(t.householdId, t.kind),
index("memory_items_key_idx").on(t.key),
],
);
/** Smaksignaler (spec §30): skilj user_stated / observed / ai_inferred. */
export const tasteSignals = pgTable(
"taste_signals",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
axis: tasteAxisEnum("axis").notNull(),
direction: doublePrecision("direction").notNull(),
strength: doublePrecision("strength").notNull().default(0.5),
origin: signalOriginEnum("origin").notNull(),
refRecipeId: uuid("ref_recipe_id").references(() => recipes.id, { onDelete: "set null" }),
createdAt: createdAt(),
},
(t) => [index("taste_signals_user_idx").on(t.userId, t.axis)],
);
/** Food Memory (spec §39): långsiktiga matminnen kopplade till högtider och betyg. */
export const foodMemories = pgTable(
"food_memories",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id").references(() => users.id, { onDelete: "cascade" }),
householdId: uuid("household_id").references(() => households.id, { onDelete: "cascade" }),
recipeId: uuid("recipe_id").references(() => recipes.id, { onDelete: "set null" }),
titleSv: text("title_sv").notNull(),
summarySv: text("summary_sv").notNull(),
holidayTag: text("holiday_tag"),
photoUrl: text("photo_url"),
stars: doublePrecision("stars"),
occurredAt: timestamp("occurred_at", { withTimezone: true }).notNull(),
createdAt: createdAt(),
},
(t) => [
index("food_memories_household_idx").on(t.householdId, t.occurredAt),
index("food_memories_holiday_idx").on(t.holidayTag),
],
);
+96
View File
@@ -0,0 +1,96 @@
import {
boolean,
date,
doublePrecision,
index,
integer,
pgTable,
text,
uuid,
} from "drizzle-orm/pg-core";
import { createdAt, mealTypeEnum, unitEnum, updatedAt } from "./_shared.js";
import { households } from "./households.js";
import { mealBoxes } from "./meals.js";
import { canonicalIngredients } from "./ingredients.js";
import { recipes } from "./recipes.js";
import { users } from "./users.js";
/** Veckoplanering (spec §25). */
export const weekPlans = pgTable(
"week_plans",
{
id: uuid("id").primaryKey().defaultRandom(),
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
weekStartDate: date("week_start_date").notNull(),
status: text("status").notNull().default("draft"),
generatedBy: text("generated_by").notNull().default("user"),
notes: text("notes"),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [index("week_plans_household_week_idx").on(t.householdId, t.weekStartDate)],
);
export const weekPlanEntries = pgTable(
"week_plan_entries",
{
id: uuid("id").primaryKey().defaultRandom(),
weekPlanId: uuid("week_plan_id")
.notNull()
.references(() => weekPlans.id, { onDelete: "cascade" }),
date: date("date").notNull(),
mealType: mealTypeEnum("meal_type").notNull(),
recipeId: uuid("recipe_id").references(() => recipes.id, { onDelete: "set null" }),
mealBoxId: uuid("meal_box_id").references(() => mealBoxes.id, { onDelete: "set null" }),
titleSv: text("title_sv").notNull(),
portions: integer("portions").notNull().default(2),
status: text("status").notNull().default("planned"),
/** Förklaring vid dynamisk omplanering (spec §25). */
rescheduleReasonSv: text("reschedule_reason_sv"),
sortOrder: integer("sort_order").notNull().default(0),
},
(t) => [index("week_plan_entries_plan_idx").on(t.weekPlanId)],
);
/** Inköpslista (spec §27) delas i hushållet, fungerar offline, uppdaterar lagret. */
export const shoppingLists = pgTable(
"shopping_lists",
{
id: uuid("id").primaryKey().defaultRandom(),
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
name: text("name").notNull().default("Inköpslista"),
status: text("status").notNull().default("active"),
weekPlanId: uuid("week_plan_id").references(() => weekPlans.id, { onDelete: "set null" }),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [index("shopping_lists_household_idx").on(t.householdId)],
);
export const shoppingListItems = pgTable(
"shopping_list_items",
{
id: uuid("id").primaryKey().defaultRandom(),
shoppingListId: uuid("shopping_list_id")
.notNull()
.references(() => shoppingLists.id, { onDelete: "cascade" }),
canonicalIngredientId: text("canonical_ingredient_id").references(
() => canonicalIngredients.id,
),
displayName: text("display_name").notNull(),
quantity: doublePrecision("quantity").notNull().default(1),
unit: unitEnum("unit").notNull().default("COUNT"),
storeSection: text("store_section").notNull().default("hygien_ovrigt"),
suggestedPackageSize: text("suggested_package_size"),
estimatedPriceMinor: integer("estimated_price_minor"),
checked: boolean("checked").notNull().default(false),
addedByUserId: uuid("added_by_user_id").references(() => users.id, { onDelete: "set null" }),
origin: text("origin").notNull().default("manual"),
sortOrder: integer("sort_order").notNull().default(0),
},
(t) => [index("shopping_list_items_list_idx").on(t.shoppingListId)],
);
+202
View File
@@ -0,0 +1,202 @@
import {
boolean,
doublePrecision,
index,
integer,
jsonb,
pgTable,
primaryKey,
text,
timestamp,
uuid,
} from "drizzle-orm/pg-core";
import {
createdAt,
creatorLevelEnum,
eventTypeEnum,
notificationTypeEnum,
profileVisibilityEnum,
updatedAt,
} from "./_shared.js";
import { households } from "./households.js";
import { users } from "./users.js";
/**
* Domänhändelser (spec §55) i outbox-mönster: skrivs transaktionellt med
* affärsdata och publiceras asynkront till konsumenter (analytics, memory,
* recommendations, training).
*/
export const domainEvents = pgTable(
"domain_events",
{
id: uuid("id").primaryKey().defaultRandom(),
type: eventTypeEnum("type").notNull(),
userId: uuid("user_id"),
householdId: uuid("household_id"),
payload: jsonb("payload").notNull(),
correlationId: text("correlation_id"),
occurredAt: timestamp("occurred_at", { withTimezone: true }).notNull().defaultNow(),
publishedAt: timestamp("published_at", { withTimezone: true }),
},
(t) => [
index("domain_events_unpublished_idx").on(t.publishedAt, t.occurredAt),
index("domain_events_type_idx").on(t.type, t.occurredAt),
index("domain_events_household_idx").on(t.householdId, t.occurredAt),
],
);
export const featureFlags = pgTable("feature_flags", {
key: text("key").primaryKey(),
enabled: boolean("enabled").notNull().default(false),
descriptionSv: text("description_sv"),
rolloutPercent: integer("rollout_percent").notNull().default(100),
updatedAt: updatedAt(),
});
/** Audit log (spec §56): vem gjorde vad, när, mot vad. */
export const auditLogs = pgTable(
"audit_logs",
{
id: uuid("id").primaryKey().defaultRandom(),
actorUserId: uuid("actor_user_id"),
actorType: text("actor_type").notNull().default("user"),
action: text("action").notNull(),
targetType: text("target_type"),
targetId: text("target_id"),
metadata: jsonb("metadata"),
ip: text("ip"),
correlationId: text("correlation_id"),
createdAt: createdAt(),
},
(t) => [
index("audit_logs_actor_idx").on(t.actorUserId, t.createdAt),
index("audit_logs_action_idx").on(t.action, t.createdAt),
],
);
/** Idempotency-nycklar för säkra POST-retries (spec §56, §59). */
export const idempotencyKeys = pgTable(
"idempotency_keys",
{
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
key: text("key").notNull(),
endpoint: text("endpoint").notNull(),
responseStatus: integer("response_status"),
responseBody: jsonb("response_body"),
createdAt: createdAt(),
},
(t) => [primaryKey({ columns: [t.userId, t.key, t.endpoint] })],
);
/** Notiser (spec §40). */
export const notifications = pgTable(
"notifications",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
type: notificationTypeEnum("type").notNull(),
titleSv: text("title_sv").notNull(),
bodySv: text("body_sv").notNull(),
data: jsonb("data"),
/** i18n-spec §26: mall + variabler; titleSv/bodySv är renderad cache. */
templateKey: text("template_key"),
variables: jsonb("variables"),
locale: text("locale"),
scheduledFor: timestamp("scheduled_for", { withTimezone: true }),
sentAt: timestamp("sent_at", { withTimezone: true }),
readAt: timestamp("read_at", { withTimezone: true }),
createdAt: createdAt(),
},
(t) => [index("notifications_user_idx").on(t.userId, t.createdAt)],
);
/** Push-tokens för Expo push. */
export const pushTokens = pgTable(
"push_tokens",
{
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
token: text("token").notNull(),
platform: text("platform").notNull(),
updatedAt: updatedAt(),
},
(t) => [primaryKey({ columns: [t.userId, t.token] })],
);
/** Creator-statistik (spec §37). */
export const creatorStats = pgTable("creator_stats", {
userId: uuid("user_id")
.primaryKey()
.references(() => users.id, { onDelete: "cascade" }),
visibility: profileVisibilityEnum("visibility").notNull().default("private"),
level: creatorLevelEnum("level").notNull().default("beginner"),
publishedRecipes: integer("published_recipes").notNull().default(0),
followers: integer("followers").notNull().default(0),
totalCooks: integer("total_cooks").notNull().default(0),
totalFavorites: integer("total_favorites").notNull().default(0),
averageRating: doublePrecision("average_rating"),
verifiedRecipes: integer("verified_recipes").notNull().default(0),
badges: text("badges").array().notNull().default([]),
updatedAt: updatedAt(),
});
export const creatorFollows = pgTable(
"creator_follows",
{
followerUserId: uuid("follower_user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
creatorUserId: uuid("creator_user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
createdAt: createdAt(),
},
(t) => [primaryKey({ columns: [t.followerUserId, t.creatorUserId] })],
);
/** AI-evals (spec §34): fast testbibliotek + körningar. Ingen modelländring utan eval. */
export const aiEvalCases = pgTable("ai_eval_cases", {
id: uuid("id").primaryKey().defaultRandom(),
taskType: text("task_type").notNull(),
nameSv: text("name_sv").notNull(),
inputRef: jsonb("input_ref").notNull(),
expectedOutput: jsonb("expected_output").notNull(),
category: text("category").notNull().default("standard"),
active: boolean("active").notNull().default(true),
createdAt: createdAt(),
});
export const aiEvalRuns = pgTable(
"ai_eval_runs",
{
id: uuid("id").primaryKey().defaultRandom(),
taskType: text("task_type").notNull(),
modelVersion: text("model_version").notNull(),
promptVersion: text("prompt_version").notNull(),
metrics: jsonb("metrics").notNull(),
passed: boolean("passed").notNull(),
notes: text("notes"),
createdAt: createdAt(),
},
(t) => [index("ai_eval_runs_task_idx").on(t.taskType, t.createdAt)],
);
/** Householdsvinn per vecka aggregat för budget/matsvinnsvyer (spec §26). */
export const wasteSummaries = pgTable(
"waste_summaries",
{
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
weekStartDate: text("week_start_date").notNull(),
discardedCount: integer("discarded_count").notNull().default(0),
discardedValueMinor: integer("discarded_value_minor").notNull().default(0),
updatedAt: updatedAt(),
},
(t) => [primaryKey({ columns: [t.householdId, t.weekStartDate] })],
);
+61
View File
@@ -0,0 +1,61 @@
import { sql } from "drizzle-orm";
import {
doublePrecision,
index,
integer,
jsonb,
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
import type { NutritionDeclaration } from "@app/shared-types";
import { allergenEnum, createdAt, unitEnum, updatedAt, verificationStatusEnum } from "./_shared.js";
import { canonicalIngredients } from "./ingredients.js";
/**
* Produktdatabas med versionshantering (spec §11): innehåll och näringsvärden
* ändras över tid, därför är (gtin, version) unik och endast en rad är aktuell
* (valid_to IS NULL).
*/
export const products = pgTable(
"products",
{
id: uuid("id").primaryKey().defaultRandom(),
gtin: text("gtin"),
name: text("name").notNull(),
brand: text("brand"),
canonicalIngredientId: text("canonical_ingredient_id").references(
() => canonicalIngredients.id,
),
packageSizeValue: doublePrecision("package_size_value"),
packageSizeUnit: unitEnum("package_size_unit"),
ingredientsText: text("ingredients_text"),
allergens: allergenEnum("allergens").array().notNull().default([]),
mayContainAllergens: allergenEnum("may_contain_allergens").array().notNull().default([]),
nutrition: jsonb("nutrition").$type<NutritionDeclaration>(),
imageUrls: text("image_urls").array().notNull().default([]),
language: text("language").notNull().default("sv"),
market: text("market").notNull().default("SE"),
dataSource: text("data_source").notNull(),
verificationStatus: verificationStatusEnum("verification_status")
.notNull()
.default("unverified"),
version: integer("version").notNull().default(1),
validFrom: timestamp("valid_from", { withTimezone: true }).notNull().defaultNow(),
validTo: timestamp("valid_to", { withTimezone: true }),
createdByUserId: uuid("created_by_user_id"),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [
uniqueIndex("products_gtin_version_unique")
.on(t.gtin, t.version)
.where(sql`${t.gtin} IS NOT NULL`),
index("products_gtin_current_idx")
.on(t.gtin)
.where(sql`${t.validTo} IS NULL`),
index("products_canonical_idx").on(t.canonicalIngredientId),
],
);
+79
View File
@@ -0,0 +1,79 @@
import {
boolean,
date,
doublePrecision,
index,
pgTable,
text,
uuid,
integer,
} from "drizzle-orm/pg-core";
import { createdAt, unitEnum } from "./_shared.js";
import { households } from "./households.js";
import { canonicalIngredients } from "./ingredients.js";
import { products } from "./products.js";
/** Kvitton (spec §12): lager + budget + prishistorik + matsvinnsvärde. */
export const receipts = pgTable(
"receipts",
{
id: uuid("id").primaryKey().defaultRandom(),
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
storeName: text("store_name"),
purchaseDate: date("purchase_date"),
totalMinor: integer("total_minor"),
discountMinor: integer("discount_minor"),
imageUrl: text("image_url"),
scanJobId: uuid("scan_job_id"),
status: text("status").notNull().default("pending"),
createdAt: createdAt(),
},
(t) => [index("receipts_household_idx").on(t.householdId, t.purchaseDate)],
);
export const receiptLines = pgTable(
"receipt_lines",
{
id: uuid("id").primaryKey().defaultRandom(),
receiptId: uuid("receipt_id")
.notNull()
.references(() => receipts.id, { onDelete: "cascade" }),
rawText: text("raw_text").notNull(),
normalizedName: text("normalized_name"),
canonicalIngredientId: text("canonical_ingredient_id").references(
() => canonicalIngredients.id,
),
productId: uuid("product_id").references(() => products.id),
quantity: doublePrecision("quantity"),
unit: unitEnum("unit"),
unitPriceMinor: integer("unit_price_minor"),
totalPriceMinor: integer("total_price_minor"),
confidence: doublePrecision("confidence").notNull().default(0),
verifiedByUser: boolean("verified_by_user").notNull().default(false),
addedToInventory: boolean("added_to_inventory").notNull().default(false),
},
(t) => [index("receipt_lines_receipt_idx").on(t.receiptId)],
);
/** Prishistorik per ingrediens/produkt grund för budget och prognoser (spec §12, §26). */
export const priceObservations = pgTable(
"price_observations",
{
id: uuid("id").primaryKey().defaultRandom(),
canonicalIngredientId: text("canonical_ingredient_id").references(
() => canonicalIngredients.id,
),
productId: uuid("product_id").references(() => products.id),
storeName: text("store_name"),
priceMinor: integer("price_minor").notNull(),
quantity: doublePrecision("quantity"),
unit: unitEnum("unit"),
pricePerKgMinor: integer("price_per_kg_minor"),
observedAt: date("observed_at").notNull(),
source: text("source").notNull().default("receipt"),
createdAt: createdAt(),
},
(t) => [index("price_observations_ingredient_idx").on(t.canonicalIngredientId, t.observedAt)],
);
+231
View File
@@ -0,0 +1,231 @@
import {
boolean,
doublePrecision,
index,
integer,
jsonb,
pgTable,
primaryKey,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
import type { NutritionValues, RecipeDNA, Season } from "@app/shared-types";
import {
allergenEnum,
createdAt,
cuisineEnum,
mealTypeEnum,
recipeDifficultyEnum,
recipeSimilarityClassEnum,
recipeSourceTypeEnum,
recipeStatusEnum,
recipeVariantTypeEnum,
recipeVerificationStatusEnum,
unitEnum,
updatedAt,
} from "./_shared.js";
import { canonicalIngredients } from "./ingredients.js";
import { users } from "./users.js";
/** Juridiskt source registry (spec §15). */
export const recipeSourceRegistry = pgTable("recipe_source_registry", {
id: uuid("id").primaryKey().defaultRandom(),
sourceName: text("source_name").notNull(),
license: text("license").notNull(),
rightToStore: boolean("right_to_store").notNull(),
rightToModify: boolean("right_to_modify").notNull(),
rightToDisplay: boolean("right_to_display").notNull(),
attributionRequired: boolean("attribution_required").notNull().default(false),
attributionText: text("attribution_text"),
commercialUse: boolean("commercial_use").notNull(),
validFrom: timestamp("valid_from", { withTimezone: true }).notNull().defaultNow(),
validTo: timestamp("valid_to", { withTimezone: true }),
notes: text("notes"),
createdAt: createdAt(),
});
export const recipes = pgTable(
"recipes",
{
id: uuid("id").primaryKey().defaultRandom(),
slug: text("slug").notNull(),
titleSv: text("title_sv").notNull(),
descriptionSv: text("description_sv").notNull().default(""),
country: text("country"),
region: text("region"),
cuisine: cuisineEnum("cuisine").notNull(),
mealTypes: mealTypeEnum("meal_types").array().notNull().default([]),
tags: text("tags").array().notNull().default([]),
methods: text("methods").array().notNull().default([]),
equipment: text("equipment").array().notNull().default([]),
difficulty: recipeDifficultyEnum("difficulty").notNull().default("easy"),
prepTimeMinutes: integer("prep_time_minutes").notNull().default(0),
cookTimeMinutes: integer("cook_time_minutes").notNull().default(0),
totalTimeMinutes: integer("total_time_minutes").notNull().default(0),
portions: integer("portions").notNull().default(4),
/** Beräknas ALLTID deterministiskt av nutrition-engine (spec §61.1). */
nutritionPerPortion: jsonb("nutrition_per_portion").$type<NutritionValues>().notNull(),
/** Härledda ur ingrediensernas allergener deterministiskt (spec §61.2). */
allergens: allergenEnum("allergens").array().notNull().default([]),
spiceLevel: integer("spice_level").notNull().default(0),
estimatedCostMinorPerPortion: integer("estimated_cost_minor_per_portion"),
storageGuidanceSv: text("storage_guidance_sv"),
mealPrepFriendly: boolean("meal_prep_friendly").notNull().default(false),
freezerFriendly: boolean("freezer_friendly").notNull().default(false),
peakSeasons: text("peak_seasons").array().$type<Season[]>().notNull().default([]),
holidayTags: text("holiday_tags").array().notNull().default([]),
dna: jsonb("dna").$type<RecipeDNA>().notNull(),
variantType: recipeVariantTypeEnum("variant_type").notNull().default("standard"),
variantOfRecipeId: uuid("variant_of_recipe_id"),
forkedFromRecipeId: uuid("forked_from_recipe_id"),
status: recipeStatusEnum("status").notNull().default("draft"),
verificationStatus: recipeVerificationStatusEnum("verification_status")
.notNull()
.default("unverified"),
sourceType: recipeSourceTypeEnum("source_type").notNull(),
sourceRegistryId: uuid("source_registry_id").references(() => recipeSourceRegistry.id),
creatorUserId: uuid("creator_user_id").references(() => users.id, { onDelete: "set null" }),
creatorDisplayName: text("creator_display_name"),
imageUrls: text("image_urls").array().notNull().default([]),
version: integer("version").notNull().default(1),
ratingAverage: doublePrecision("rating_average"),
ratingCount: integer("rating_count").notNull().default(0),
cookCount: integer("cook_count").notNull().default(0),
favoriteCount: integer("favorite_count").notNull().default(0),
moderationNote: text("moderation_note"),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [
uniqueIndex("recipes_slug_unique").on(t.slug),
index("recipes_status_idx").on(t.status),
index("recipes_cuisine_idx").on(t.cuisine),
index("recipes_variant_of_idx").on(t.variantOfRecipeId),
index("recipes_creator_idx").on(t.creatorUserId),
index("recipes_total_time_idx").on(t.totalTimeMinutes),
],
);
export const recipeIngredients = pgTable(
"recipe_ingredients",
{
id: uuid("id").primaryKey().defaultRandom(),
recipeId: uuid("recipe_id")
.notNull()
.references(() => recipes.id, { onDelete: "cascade" }),
canonicalIngredientId: text("canonical_ingredient_id")
.notNull()
.references(() => canonicalIngredients.id),
displayNameSv: text("display_name_sv").notNull(),
quantity: doublePrecision("quantity").notNull(),
unit: unitEnum("unit").notNull(),
note: text("note"),
optional: boolean("optional").notNull().default(false),
groupName: text("group_name"),
sortOrder: integer("sort_order").notNull().default(0),
},
(t) => [
index("recipe_ingredients_recipe_idx").on(t.recipeId),
index("recipe_ingredients_canonical_idx").on(t.canonicalIngredientId),
],
);
export const recipeSteps = pgTable(
"recipe_steps",
{
id: uuid("id").primaryKey().defaultRandom(),
recipeId: uuid("recipe_id")
.notNull()
.references(() => recipes.id, { onDelete: "cascade" }),
stepNumber: integer("step_number").notNull(),
instructionSv: text("instruction_sv").notNull(),
timerSeconds: integer("timer_seconds"),
temperatureC: integer("temperature_c"),
tip: text("tip"),
},
(t) => [
index("recipe_steps_recipe_idx").on(t.recipeId),
uniqueIndex("recipe_steps_recipe_step_unique").on(t.recipeId, t.stepNumber),
],
);
export const recipeRatings = pgTable(
"recipe_ratings",
{
id: uuid("id").primaryKey().defaultRandom(),
recipeId: uuid("recipe_id")
.notNull()
.references(() => recipes.id, { onDelete: "cascade" }),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
stars: integer("stars").notNull(),
feedbackTags: text("feedback_tags").array().notNull().default([]),
comment: text("comment"),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [
uniqueIndex("recipe_ratings_user_recipe_unique").on(t.recipeId, t.userId),
index("recipe_ratings_recipe_idx").on(t.recipeId),
],
);
export const recipeFavorites = pgTable(
"recipe_favorites",
{
recipeId: uuid("recipe_id")
.notNull()
.references(() => recipes.id, { onDelete: "cascade" }),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
createdAt: createdAt(),
},
(t) => [
primaryKey({ columns: [t.recipeId, t.userId] }),
index("recipe_favorites_user_idx").on(t.userId),
],
);
/** Logg över lagningar grund för betyg, ranking, Food Memory (spec §3739). */
export const recipeCooks = pgTable(
"recipe_cooks",
{
id: uuid("id").primaryKey().defaultRandom(),
recipeId: uuid("recipe_id")
.notNull()
.references(() => recipes.id, { onDelete: "cascade" }),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
householdId: uuid("household_id"),
portionsCooked: integer("portions_cooked").notNull(),
cookedAt: timestamp("cooked_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
index("recipe_cooks_recipe_idx").on(t.recipeId),
index("recipe_cooks_user_idx").on(t.userId),
],
);
/** Dubblett-/variantklassning mellan recept (spec §36). */
export const recipeSimilarities = pgTable(
"recipe_similarities",
{
id: uuid("id").primaryKey().defaultRandom(),
recipeAId: uuid("recipe_a_id")
.notNull()
.references(() => recipes.id, { onDelete: "cascade" }),
recipeBId: uuid("recipe_b_id")
.notNull()
.references(() => recipes.id, { onDelete: "cascade" }),
similarityScore: doublePrecision("similarity_score").notNull(),
classification: recipeSimilarityClassEnum("classification").notNull(),
details: jsonb("details"),
createdAt: createdAt(),
},
(t) => [uniqueIndex("recipe_similarities_pair_unique").on(t.recipeAId, t.recipeBId)],
);
+73
View File
@@ -0,0 +1,73 @@
import {
doublePrecision,
index,
integer,
jsonb,
pgTable,
text,
timestamp,
uuid,
} from "drizzle-orm/pg-core";
import { createdAt, jobStatusEnum, jobTypeEnum, scanTypeEnum, updatedAt } from "./_shared.js";
import { households } from "./households.js";
import { users } from "./users.js";
/**
* Asynkrona skannings-/AI-jobb (spec §50, §54):
* App → signed S3 upload → API job → worker → AAMOS → result → app.
*/
export const scanJobs = pgTable(
"scan_jobs",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
householdId: uuid("household_id").references(() => households.id, { onDelete: "set null" }),
scanType: scanTypeEnum("scan_type").notNull(),
jobType: jobTypeEnum("job_type").notNull(),
status: jobStatusEnum("status").notNull().default("queued"),
s3Keys: text("s3_keys").array().notNull().default([]),
context: jsonb("context"),
/** Strukturerat AI-resultat validerat mot ai-contracts innan lagring. */
result: jsonb("result"),
error: text("error"),
modelVersion: text("model_version"),
promptVersion: text("prompt_version"),
latencyMs: integer("latency_ms"),
costUsd: doublePrecision("cost_usd"),
attempts: integer("attempts").notNull().default(0),
completedAt: timestamp("completed_at", { withTimezone: true }),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [
index("scan_jobs_user_idx").on(t.userId, t.createdAt),
index("scan_jobs_status_idx").on(t.status),
],
);
/**
* Verifierade korrigeringar (spec §33): AI sa X, användaren sa Y.
* Grund för träningsdata används ENDAST enligt samtycke.
*/
export const aiCorrections = pgTable(
"ai_corrections",
{
id: uuid("id").primaryKey().defaultRandom(),
scanJobId: uuid("scan_job_id").references(() => scanJobs.id, { onDelete: "set null" }),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
taskType: text("task_type").notNull(),
aiOutput: jsonb("ai_output").notNull(),
userCorrection: jsonb("user_correction").notNull(),
modelVersion: text("model_version"),
promptVersion: text("prompt_version"),
/** Snapshot av samtyckesläget när korrigeringen skapades. */
consentSnapshot: jsonb("consent_snapshot").notNull(),
exportedToTraining: timestamp("exported_to_training", { withTimezone: true }),
createdAt: createdAt(),
},
(t) => [index("ai_corrections_task_idx").on(t.taskType, t.createdAt)],
);
+30
View File
@@ -0,0 +1,30 @@
import { boolean, integer, jsonb, pgTable, text, uniqueIndex } from "drizzle-orm/pg-core";
import { createdAt, updatedAt } from "./_shared.js";
type DateRule =
| { kind: "fixed"; monthDay: string }
| { kind: "range"; startMonthDay: string; endMonthDay: string }
| { kind: "computed"; algorithm: "midsummer" | "easter" | "advent" | "custom" };
/**
* Datadriven Season & Events Engine (spec §28): midsommar, jul, påsk, kräftskiva,
* Ramadan, Eid, Thanksgiving m.fl. per marknad, utan hårdkodning i motorerna.
*/
export const seasonEvents = pgTable(
"season_events",
{
id: text("id").primaryKey(),
slug: text("slug").notNull(),
nameSv: text("name_sv").notNull(),
market: text("market").notNull().default("SE"),
dateRule: jsonb("date_rule").$type<DateRule>().notNull(),
leadDays: integer("lead_days").notNull().default(7),
foodTags: text("food_tags").array().notNull().default([]),
recipeSlugs: text("recipe_slugs").array().notNull().default([]),
priority: integer("priority").notNull().default(0),
active: boolean("active").notNull().default(true),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [uniqueIndex("season_events_slug_market_unique").on(t.slug, t.market)],
);
@@ -0,0 +1,127 @@
import {
boolean,
index,
integer,
jsonb,
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import {
createdAt,
subscriptionPlanEnum,
subscriptionProviderEnum,
subscriptionStatusEnum,
updatedAt,
} from "./_shared.js";
import { households } from "./households.js";
import { users } from "./users.js";
/** Backend är source of truth för Premium (spec §47, §61.14). */
export const subscriptions = pgTable(
"subscriptions",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
householdId: uuid("household_id").references(() => households.id, { onDelete: "set null" }),
provider: subscriptionProviderEnum("provider").notNull(),
productId: text("product_id").notNull(),
plan: subscriptionPlanEnum("plan").notNull(),
originalTransactionId: text("original_transaction_id"),
status: subscriptionStatusEnum("status").notNull(),
purchasedAt: timestamp("purchased_at", { withTimezone: true }),
expiresAt: timestamp("expires_at", { withTimezone: true }),
gracePeriodExpiresAt: timestamp("grace_period_expires_at", { withTimezone: true }),
canceledAt: timestamp("canceled_at", { withTimezone: true }),
lastVerifiedAt: timestamp("last_verified_at", { withTimezone: true }),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [
index("subscriptions_user_idx").on(t.userId),
uniqueIndex("subscriptions_original_tx_unique")
.on(t.provider, t.originalTransactionId)
.where(sql`${t.originalTransactionId} IS NOT NULL`),
],
);
/** Händelselogg: köp, förnyelse, grace, churn (spec §47). */
export const subscriptionEvents = pgTable(
"subscription_events",
{
id: uuid("id").primaryKey().defaultRandom(),
subscriptionId: uuid("subscription_id").references(() => subscriptions.id, {
onDelete: "cascade",
}),
userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }),
eventType: text("event_type").notNull(),
payload: jsonb("payload"),
createdAt: createdAt(),
},
(t) => [index("subscription_events_sub_idx").on(t.subscriptionId, t.createdAt)],
);
/** Råa store-transaktioner för revision (spec §47). */
export const storeTransactions = pgTable(
"store_transactions",
{
id: uuid("id").primaryKey().defaultRandom(),
provider: subscriptionProviderEnum("provider").notNull(),
transactionId: text("transaction_id").notNull(),
originalTransactionId: text("original_transaction_id"),
userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }),
productId: text("product_id"),
rawPayload: jsonb("raw_payload").notNull(),
processedAt: timestamp("processed_at", { withTimezone: true }),
createdAt: createdAt(),
},
(t) => [uniqueIndex("store_transactions_unique").on(t.provider, t.transactionId)],
);
/** App Store Server Notifications / Play RTDN tas emot rått, processas av worker. */
export const storeNotifications = pgTable(
"store_notifications",
{
id: uuid("id").primaryKey().defaultRandom(),
provider: subscriptionProviderEnum("provider").notNull(),
notificationType: text("notification_type"),
rawPayload: jsonb("raw_payload").notNull(),
signatureVerified: boolean("signature_verified").notNull().default(false),
processed: boolean("processed").notNull().default(false),
processedAt: timestamp("processed_at", { withTimezone: true }),
error: text("error"),
createdAt: createdAt(),
},
(t) => [index("store_notifications_pending_idx").on(t.processed, t.createdAt)],
);
/** Trial-spårning: en trial per användare (spec §46). */
export const trials = pgTable("trials", {
userId: uuid("user_id")
.primaryKey()
.references(() => users.id, { onDelete: "cascade" }),
startedAt: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(),
endsAt: timestamp("ends_at", { withTimezone: true }).notNull(),
});
/** Månatlig AI-användning för fair use / gratiskvot (spec §4546). */
export const aiUsageCounters = pgTable(
"ai_usage_counters",
{
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
/** Format YYYY-MM. */
month: text("month").notNull(),
aiScans: integer("ai_scans").notNull().default(0),
aiTokensIn: integer("ai_tokens_in").notNull().default(0),
aiTokensOut: integer("ai_tokens_out").notNull().default(0),
updatedAt: updatedAt(),
},
(t) => [uniqueIndex("ai_usage_user_month_unique").on(t.userId, t.month)],
);
@@ -0,0 +1,98 @@
/**
* Översättningstabeller (i18n-spec §1114, M2M3).
*
* Principer:
* - Svenska källfält (nameSv, titleSv, instructionSv …) är alltid sanningen.
* - Översättningar är RADER per språk aldrig nya kolumner per språk.
* - languageTag är BCP 47 (vanligen bara primärt språk: "en", "de"; regional
* variant tillåts när det behövs: "en-US" vinner över "en" vid upplösning).
* - Recept-/stegöversättningar följer AI-utkastflödet draft_ai → in_review →
* published med deterministisk verifiering (samma stegantal, bevarade tal).
*/
import { integer, jsonb, pgEnum, pgTable, text, uuid, varchar } from "drizzle-orm/pg-core";
import { index, primaryKey, uniqueIndex } from "drizzle-orm/pg-core";
import { TRANSLATION_SOURCES, TRANSLATION_STATUSES, tuple } from "@app/shared-types";
import { createdAt, unitEnum, updatedAt } from "./_shared.js";
import { canonicalIngredients } from "./ingredients.js";
import { recipes } from "./recipes.js";
export const translationStatusEnum = pgEnum("translation_status", tuple(TRANSLATION_STATUSES));
export const translationSourceEnum = pgEnum("translation_source", tuple(TRANSLATION_SOURCES));
/** Ingrediensnamn + alias per språk (i18n-spec §11). Seed flyttar nameEn hit. */
export const ingredientTranslations = pgTable(
"ingredient_translations",
{
id: uuid("id").primaryKey().defaultRandom(),
ingredientId: text("ingredient_id")
.notNull()
.references(() => canonicalIngredients.id, { onDelete: "cascade" }),
languageTag: varchar("language_tag", { length: 35 }).notNull(),
name: text("name").notNull(),
aliases: text("aliases").array().notNull().default([]),
source: translationSourceEnum("source").notNull().default("seed"),
status: translationStatusEnum("status").notNull().default("published"),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [
uniqueIndex("ingredient_translations_unique").on(t.ingredientId, t.languageTag),
index("ingredient_translations_lang_idx").on(t.languageTag),
],
);
/** Enhetsetiketter per språk (i18n-spec §9, §12). Visning aldrig lagringsformat. */
export const unitTranslations = pgTable(
"unit_translations",
{
unitCode: unitEnum("unit_code").notNull(),
languageTag: varchar("language_tag", { length: 35 }).notNull(),
abbreviation: text("abbreviation").notNull(),
name: text("name").notNull(),
},
(t) => [primaryKey({ columns: [t.unitCode, t.languageTag] })],
);
/** Recepttitel/-beskrivning per språk med AI-utkastflöde (i18n-spec §1314). */
export const recipeTranslations = pgTable(
"recipe_translations",
{
id: uuid("id").primaryKey().defaultRandom(),
recipeId: uuid("recipe_id")
.notNull()
.references(() => recipes.id, { onDelete: "cascade" }),
languageTag: varchar("language_tag", { length: 35 }).notNull(),
title: text("title").notNull(),
description: text("description"),
storageGuidance: text("storage_guidance"),
status: translationStatusEnum("status").notNull().default("draft_ai"),
source: translationSourceEnum("source").notNull().default("ai"),
/** Resultat av de deterministiska verifieringarna (stegantal, bevarade tal …). */
verification: jsonb("verification").$type<{
ok: boolean;
checks: Record<string, boolean>;
notes?: string[];
}>(),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [
uniqueIndex("recipe_translations_unique").on(t.recipeId, t.languageTag),
index("recipe_translations_lang_idx").on(t.languageTag, t.status),
],
);
/** Stegtexter per språk. Struktur (timer, temperatur) bor kvar i recipe_steps. */
export const recipeStepTranslations = pgTable(
"recipe_step_translations",
{
recipeId: uuid("recipe_id")
.notNull()
.references(() => recipes.id, { onDelete: "cascade" }),
languageTag: varchar("language_tag", { length: 35 }).notNull(),
stepNumber: integer("step_number").notNull(),
instruction: text("instruction").notNull(),
tip: text("tip"),
},
(t) => [primaryKey({ columns: [t.recipeId, t.languageTag, t.stepNumber] })],
);
+194
View File
@@ -0,0 +1,194 @@
import {
boolean,
doublePrecision,
index,
integer,
pgTable,
primaryKey,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
import {
activityLevelEnum,
allergenEnum,
consentKindEnum,
consentStatusEnum,
createdAt,
dietPatternEnum,
goalTypeEnum,
precisionModeEnum,
religiousRuleEnum,
sexEnum,
updatedAt,
userRoleEnum,
cuisineEnum,
} from "./_shared.js";
/** Kontodata. Hälsodata ligger i user_health_profiles (separationskrav, spec §56). */
export const users = pgTable(
"users",
{
id: uuid("id").primaryKey().defaultRandom(),
email: text("email").notNull(),
/** null = e-posten ej verifierad ännu (icke-blockerande appen fungerar ändå). */
emailVerifiedAt: timestamp("email_verified_at", { withTimezone: true }),
displayName: text("display_name").notNull(),
role: userRoleEnum("role").notNull().default("user"),
locale: text("locale").notNull().default("sv-SE"),
precisionMode: precisionModeEnum("precision_mode").notNull().default("simple"),
onboardingCompleted: boolean("onboarding_completed").notNull().default(false),
deletedAt: timestamp("deleted_at", { withTimezone: true }),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [uniqueIndex("users_email_unique").on(t.email)],
);
/** Autentiseringsuppgifter separerade från kontot (byts utan att röra users). */
export const userCredentials = pgTable("user_credentials", {
userId: uuid("user_id")
.primaryKey()
.references(() => users.id, { onDelete: "cascade" }),
/** scrypt-hash i formatet scrypt$N$r$p$salt$hash (ingen extern native-dependency). */
passwordHash: text("password_hash").notNull(),
passwordUpdatedAt: timestamp("password_updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
});
/** Admin-2FA (TOTP, RFC 6238). Endast admin-konton; secret per användare. */
export const adminTotp = pgTable("admin_totp", {
userId: uuid("user_id")
.primaryKey()
.references(() => users.id, { onDelete: "cascade" }),
secretBase32: text("secret_base32").notNull(),
/** null = setup påbörjad men inte bekräftad med kod ännu. */
enabledAt: timestamp("enabled_at", { withTimezone: true }),
/** Senast accepterade TOTP-steg förhindrar återanvändning inom fönstret. */
lastUsedStep: integer("last_used_step"),
createdAt: createdAt(),
});
/** E-postverifiering av konton. Samma säkerhetsmodell som lösenordsåterställning. */
export const emailVerificationTokens = pgTable(
"email_verification_tokens",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
tokenHash: text("token_hash").notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
usedAt: timestamp("used_at", { withTimezone: true }),
createdAt: createdAt(),
},
(t) => [
index("email_verification_tokens_user_idx").on(t.userId),
uniqueIndex("email_verification_tokens_hash_unique").on(t.tokenHash),
],
);
/**
* Lösenordsåterställning via e-post. Token lagras ENDAST hashad (sha256),
* 30 min TTL, engångsbruk; alla tidigare oanvända tokens ogiltigförklaras
* när en ny begärs. Svaret på forgot-password avslöjar aldrig om kontot finns.
*/
export const passwordResetTokens = pgTable(
"password_reset_tokens",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
tokenHash: text("token_hash").notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
usedAt: timestamp("used_at", { withTimezone: true }),
createdAt: createdAt(),
},
(t) => [
index("password_reset_tokens_user_idx").on(t.userId),
uniqueIndex("password_reset_tokens_hash_unique").on(t.tokenHash),
],
);
/** Roterande refresh-tokens, lagrade hashade (spec §56: säker tokenhantering). */
export const refreshTokens = pgTable(
"refresh_tokens",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
tokenHash: text("token_hash").notNull(),
familyId: uuid("family_id").notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
revokedAt: timestamp("revoked_at", { withTimezone: true }),
replacedByTokenId: uuid("replaced_by_token_id"),
userAgent: text("user_agent"),
ip: text("ip"),
createdAt: createdAt(),
},
(t) => [
index("refresh_tokens_user_idx").on(t.userId),
uniqueIndex("refresh_tokens_hash_unique").on(t.tokenHash),
],
);
/**
* Hälsoprofil logiskt och behörighetsmässigt separerad från hushållsdata
* (spec §7, §56). Delas aldrig med andra hushållsmedlemmar.
*/
export const userHealthProfiles = pgTable("user_health_profiles", {
userId: uuid("user_id")
.primaryKey()
.references(() => users.id, { onDelete: "cascade" }),
birthYear: integer("birth_year"),
sex: sexEnum("sex"),
heightCm: doublePrecision("height_cm"),
weightKg: doublePrecision("weight_kg"),
targetWeightKg: doublePrecision("target_weight_kg"),
activityLevel: activityLevelEnum("activity_level").notNull().default("moderate"),
trainingSessionsPerWeek: integer("training_sessions_per_week"),
trainingTypes: text("training_types").array().notNull().default([]),
updatedAt: updatedAt(),
});
export const userPreferences = pgTable("user_preferences", {
userId: uuid("user_id")
.primaryKey()
.references(() => users.id, { onDelete: "cascade" }),
primaryGoal: goalTypeEnum("primary_goal"),
goals: goalTypeEnum("goals").array().notNull().default([]),
dietPattern: dietPatternEnum("diet_pattern").notNull().default("omnivore"),
religiousRule: religiousRuleEnum("religious_rule").notNull().default("none"),
/** Deterministisk allergifiltrering utgår härifrån (spec §61.2). */
allergens: allergenEnum("allergens").array().notNull().default([]),
intolerances: text("intolerances").array().notNull().default([]),
avoidIngredientIds: text("avoid_ingredient_ids").array().notNull().default([]),
favoriteCuisines: cuisineEnum("favorite_cuisines").array().notNull().default([]),
dislikedDishes: text("disliked_dishes").array().notNull().default([]),
spiceLevelMax: integer("spice_level_max").notNull().default(3),
weeklyBudgetMinor: integer("weekly_budget_minor"),
maxCookingMinutesWeekday: integer("max_cooking_minutes_weekday"),
equipment: text("equipment").array().notNull().default([]),
defaultPortions: integer("default_portions").notNull().default(2),
updatedAt: updatedAt(),
});
/** Separata samtycken (spec §33): personlig funktion ≠ anonymiserad förbättring ≠ bildträning. */
export const userConsents = pgTable(
"user_consents",
{
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
kind: consentKindEnum("kind").notNull(),
status: consentStatusEnum("status").notNull(),
grantedAt: timestamp("granted_at", { withTimezone: true }),
revokedAt: timestamp("revoked_at", { withTimezone: true }),
updatedAt: updatedAt(),
},
(t) => [primaryKey({ columns: [t.userId, t.kind] })],
);
@@ -0,0 +1,1032 @@
{
"es": {
"milk_3": "Leche entera",
"milk_1_5": "Leche semidesnatada",
"oat_drink": "Bebida de avena",
"cream": "Nata para montar",
"cooking_cream": "Nata para cocinar",
"creme_fraiche": "Crème fraîche",
"quark": "Quark",
"yoghurt_natural": "Yogur natural",
"butter": "Mantequilla",
"cheese_hard": "Queso curado",
"vasterbotten_cheese": "Queso Västerbotten",
"feta": "Queso feta",
"halloumi": "Halloumi",
"egg": "Huevo",
"chicken_breast": "Pechuga de pollo",
"chicken_thigh": "Muslo de pollo",
"minced_beef": "Carne picada de ternera",
"minced_mixed": "Carne picada mixta",
"falukorv": "Salchicha falukorv",
"bacon": "Beicon",
"pork_loin": "Lomo de cerdo",
"meatball_pork_beef": "Albóndigas",
"salmon": "Filete de salmón",
"cod": "Filete de bacalao",
"shrimp": "Gambas",
"pickled_herring": "Arenque en escabeche",
"anchovy_swedish": "Anchoas suecas",
"potato": "Patata",
"new_potato": "Patatas nuevas",
"onion": "Cebolla",
"red_onion": "Cebolla morada",
"garlic": "Ajo",
"carrot": "Zanahoria",
"tomato": "Tomate",
"cucumber": "Pepino",
"bell_pepper": "Pimiento",
"spinach": "Espinacas",
"lettuce": "Lechuga",
"broccoli": "Brócoli",
"zucchini": "Calabacín",
"leek": "Puerro",
"dill": "Eneldo",
"parsley": "Perejil",
"basil": "Albahaca",
"ginger": "Jengibre",
"mushroom": "Champiñones",
"frozen_peas": "Guisantes congelados",
"corn": "Maíz dulce",
"olives": "Aceitunas",
"avocado": "Aguacate",
"lemon": "Limón",
"lime": "Lima",
"apple": "Manzana",
"banana": "Plátano",
"strawberry": "Fresas",
"pasta_dry": "Pasta seca",
"pasta_gluten_free": "Pasta sin gluten",
"rice_white": "Arroz blanco",
"noodles_egg": "Fideos al huevo",
"flour_wheat": "Harina de trigo",
"oats": "Copos de avena",
"breadcrumbs": "Pan rallado",
"bread_sourdough": "Pan de masa madre",
"tortilla": "Tortillas de trigo",
"hamburger_bun": "Panes de hamburguesa",
"red_lentils": "Lentejas rojas",
"chickpeas_canned": "Garbanzos",
"black_beans_canned": "Frijoles negros",
"kidney_beans_canned": "Alubias rojas",
"tofu": "Tofu",
"canned_tomatoes": "Tomate triturado en lata",
"tomato_paste": "Concentrado de tomate",
"coconut_milk": "Leche de coco",
"olive_oil": "Aceite de oliva",
"rapeseed_oil": "Aceite de colza",
"sugar": "Azúcar",
"honey": "Miel",
"soy_sauce": "Salsa de soja",
"fish_sauce": "Salsa de pescado",
"red_curry_paste": "Pasta de curry rojo",
"mustard": "Mostaza",
"mayonnaise": "Mayonesa",
"vegetable_stock_cube": "Pastilla de caldo de verduras",
"chicken_stock_cube": "Pastilla de caldo de pollo",
"peanut_butter": "Crema de cacahuete",
"teriyaki_sauce": "Salsa teriyaki",
"taco_spice": "Sazonador de tacos",
"salsa": "Salsa mexicana",
"sesame_seeds": "Semillas de sésamo",
"salt": "Sal",
"black_pepper": "Pimienta negra",
"paprika_powder": "Pimentón",
"cumin": "Comino",
"chili_flakes": "Copos de chile",
"oregano_dried": "Orégano seco",
"thyme_dried": "Tomillo seco",
"curry_powder": "Curry en polvo",
"garam_masala": "Garam masala",
"turmeric": "Cúrcuma",
"cinnamon": "Canela",
"allspice": "Pimienta de Jamaica"
},
"it": {
"milk_3": "Latte intero",
"milk_1_5": "Latte parzialmente scremato",
"oat_drink": "Bevanda all'avena",
"cream": "Panna da montare",
"cooking_cream": "Panna da cucina",
"creme_fraiche": "Crème fraîche",
"quark": "Quark",
"yoghurt_natural": "Yogurt bianco",
"butter": "Burro",
"cheese_hard": "Formaggio stagionato",
"vasterbotten_cheese": "Formaggio Västerbotten",
"feta": "Feta",
"halloumi": "Halloumi",
"egg": "Uovo",
"chicken_breast": "Petto di pollo",
"chicken_thigh": "Coscia di pollo",
"minced_beef": "Macinato di manzo",
"minced_mixed": "Macinato misto",
"falukorv": "Salsiccia falukorv",
"bacon": "Pancetta affumicata",
"pork_loin": "Lonza di maiale",
"meatball_pork_beef": "Polpette",
"salmon": "Filetto di salmone",
"cod": "Filetto di merluzzo",
"shrimp": "Gamberetti",
"pickled_herring": "Aringa marinata",
"anchovy_swedish": "Acciughe svedesi",
"potato": "Patata",
"new_potato": "Patate novelle",
"onion": "Cipolla",
"red_onion": "Cipolla rossa",
"garlic": "Aglio",
"carrot": "Carota",
"tomato": "Pomodoro",
"cucumber": "Cetriolo",
"bell_pepper": "Peperone",
"spinach": "Spinaci",
"lettuce": "Lattuga",
"broccoli": "Broccoli",
"zucchini": "Zucchina",
"leek": "Porro",
"dill": "Aneto",
"parsley": "Prezzemolo",
"basil": "Basilico",
"ginger": "Zenzero",
"mushroom": "Funghi",
"frozen_peas": "Piselli surgelati",
"corn": "Mais dolce",
"olives": "Olive",
"avocado": "Avocado",
"lemon": "Limone",
"lime": "Lime",
"apple": "Mela",
"banana": "Banana",
"strawberry": "Fragole",
"pasta_dry": "Pasta secca",
"pasta_gluten_free": "Pasta senza glutine",
"rice_white": "Riso bianco",
"noodles_egg": "Noodles all'uovo",
"flour_wheat": "Farina di grano",
"oats": "Fiocchi d'avena",
"breadcrumbs": "Pangrattato",
"bread_sourdough": "Pane a lievitazione naturale",
"tortilla": "Tortilla di grano",
"hamburger_bun": "Panini per hamburger",
"red_lentils": "Lenticchie rosse",
"chickpeas_canned": "Ceci",
"black_beans_canned": "Fagioli neri",
"kidney_beans_canned": "Fagioli rossi",
"tofu": "Tofu",
"canned_tomatoes": "Polpa di pomodoro",
"tomato_paste": "Concentrato di pomodoro",
"coconut_milk": "Latte di cocco",
"olive_oil": "Olio d'oliva",
"rapeseed_oil": "Olio di colza",
"sugar": "Zucchero",
"honey": "Miele",
"soy_sauce": "Salsa di soia",
"fish_sauce": "Salsa di pesce",
"red_curry_paste": "Pasta di curry rosso",
"mustard": "Senape",
"mayonnaise": "Maionese",
"vegetable_stock_cube": "Dado vegetale",
"chicken_stock_cube": "Dado di pollo",
"peanut_butter": "Burro di arachidi",
"teriyaki_sauce": "Salsa teriyaki",
"taco_spice": "Spezie per taco",
"salsa": "Salsa messicana",
"sesame_seeds": "Semi di sesamo",
"salt": "Sale",
"black_pepper": "Pepe nero",
"paprika_powder": "Paprika in polvere",
"cumin": "Cumino",
"chili_flakes": "Peperoncino in scaglie",
"oregano_dried": "Origano secco",
"thyme_dried": "Timo secco",
"curry_powder": "Curry in polvere",
"garam_masala": "Garam masala",
"turmeric": "Curcuma",
"cinnamon": "Cannella",
"allspice": "Pimento"
},
"de": {
"milk_3": "Vollmilch",
"milk_1_5": "Fettarme Milch",
"oat_drink": "Haferdrink",
"cream": "Schlagsahne",
"cooking_cream": "Kochsahne",
"creme_fraiche": "Crème fraîche",
"quark": "Quark",
"yoghurt_natural": "Naturjoghurt",
"butter": "Butter",
"cheese_hard": "Hartkäse",
"vasterbotten_cheese": "Västerbotten-Käse",
"feta": "Feta",
"halloumi": "Halloumi",
"egg": "Ei",
"chicken_breast": "Hähnchenbrust",
"chicken_thigh": "Hähnchenschenkel",
"minced_beef": "Rinderhackfleisch",
"minced_mixed": "Gemischtes Hackfleisch",
"falukorv": "Falukorv-Wurst",
"bacon": "Bacon",
"pork_loin": "Schweinelachs",
"meatball_pork_beef": "Fleischbällchen",
"salmon": "Lachsfilet",
"cod": "Kabeljaufilet",
"shrimp": "Garnelen",
"pickled_herring": "Eingelegter Hering",
"anchovy_swedish": "Schwedische Anchovis",
"potato": "Kartoffel",
"new_potato": "Frühkartoffeln",
"onion": "Zwiebel",
"red_onion": "Rote Zwiebel",
"garlic": "Knoblauch",
"carrot": "Karotte",
"tomato": "Tomate",
"cucumber": "Gurke",
"bell_pepper": "Paprika",
"spinach": "Spinat",
"lettuce": "Kopfsalat",
"broccoli": "Brokkoli",
"zucchini": "Zucchini",
"leek": "Lauch",
"dill": "Dill",
"parsley": "Petersilie",
"basil": "Basilikum",
"ginger": "Ingwer",
"mushroom": "Champignons",
"frozen_peas": "TK-Erbsen",
"corn": "Zuckermais",
"olives": "Oliven",
"avocado": "Avocado",
"lemon": "Zitrone",
"lime": "Limette",
"apple": "Apfel",
"banana": "Banane",
"strawberry": "Erdbeeren",
"pasta_dry": "Pasta",
"pasta_gluten_free": "Glutenfreie Pasta",
"rice_white": "Weißer Reis",
"noodles_egg": "Eiernudeln",
"flour_wheat": "Weizenmehl",
"oats": "Haferflocken",
"breadcrumbs": "Paniermehl",
"bread_sourdough": "Sauerteigbrot",
"tortilla": "Tortilla-Wraps",
"hamburger_bun": "Burgerbrötchen",
"red_lentils": "Rote Linsen",
"chickpeas_canned": "Kichererbsen",
"black_beans_canned": "Schwarze Bohnen",
"kidney_beans_canned": "Kidneybohnen",
"tofu": "Tofu",
"canned_tomatoes": "Gehackte Dosentomaten",
"tomato_paste": "Tomatenmark",
"coconut_milk": "Kokosmilch",
"olive_oil": "Olivenöl",
"rapeseed_oil": "Rapsöl",
"sugar": "Zucker",
"honey": "Honig",
"soy_sauce": "Sojasauce",
"fish_sauce": "Fischsauce",
"red_curry_paste": "Rote Currypaste",
"mustard": "Senf",
"mayonnaise": "Mayonnaise",
"vegetable_stock_cube": "Gemüsebrühwürfel",
"chicken_stock_cube": "Hühnerbrühwürfel",
"peanut_butter": "Erdnussbutter",
"teriyaki_sauce": "Teriyaki-Sauce",
"taco_spice": "Taco-Gewürzmischung",
"salsa": "Salsa",
"sesame_seeds": "Sesam",
"salt": "Salz",
"black_pepper": "Schwarzer Pfeffer",
"paprika_powder": "Paprikapulver",
"cumin": "Kreuzkümmel",
"chili_flakes": "Chiliflocken",
"oregano_dried": "Getrockneter Oregano",
"thyme_dried": "Getrockneter Thymian",
"curry_powder": "Currypulver",
"garam_masala": "Garam Masala",
"turmeric": "Kurkuma",
"cinnamon": "Zimt",
"allspice": "Piment"
},
"fr": {
"milk_3": "Lait entier",
"milk_1_5": "Lait demi-écrémé",
"oat_drink": "Boisson à l'avoine",
"cream": "Crème entière",
"cooking_cream": "Crème à cuisiner",
"creme_fraiche": "Crème fraîche",
"quark": "Fromage blanc",
"yoghurt_natural": "Yaourt nature",
"butter": "Beurre",
"cheese_hard": "Fromage à pâte dure",
"vasterbotten_cheese": "Fromage Västerbotten",
"feta": "Feta",
"halloumi": "Halloumi",
"egg": "Œuf",
"chicken_breast": "Blanc de poulet",
"chicken_thigh": "Haut de cuisse de poulet",
"minced_beef": "Bœuf haché",
"minced_mixed": "Hachis porc-bœuf",
"falukorv": "Saucisse falukorv",
"bacon": "Lardons",
"pork_loin": "Filet de porc",
"meatball_pork_beef": "Boulettes de viande",
"salmon": "Filet de saumon",
"cod": "Filet de cabillaud",
"shrimp": "Crevettes",
"pickled_herring": "Hareng mariné",
"anchovy_swedish": "Anchois suédois",
"potato": "Pomme de terre",
"new_potato": "Pommes de terre nouvelles",
"onion": "Oignon jaune",
"red_onion": "Oignon rouge",
"garlic": "Ail",
"carrot": "Carotte",
"tomato": "Tomate",
"cucumber": "Concombre",
"bell_pepper": "Poivron",
"spinach": "Épinards",
"lettuce": "Laitue",
"broccoli": "Brocoli",
"zucchini": "Courgette",
"leek": "Poireau",
"dill": "Aneth",
"parsley": "Persil",
"basil": "Basilic",
"ginger": "Gingembre",
"mushroom": "Champignons",
"frozen_peas": "Petits pois surgelés",
"corn": "Maïs doux",
"olives": "Olives",
"avocado": "Avocat",
"lemon": "Citron",
"lime": "Citron vert",
"apple": "Pomme",
"banana": "Banane",
"strawberry": "Fraises",
"pasta_dry": "Pâtes sèches",
"pasta_gluten_free": "Pâtes sans gluten",
"rice_white": "Riz blanc",
"noodles_egg": "Nouilles aux œufs",
"flour_wheat": "Farine de blé",
"oats": "Flocons d'avoine",
"breadcrumbs": "Chapelure",
"bread_sourdough": "Pain au levain",
"tortilla": "Tortillas de blé",
"hamburger_bun": "Pains à burger",
"red_lentils": "Lentilles corail",
"chickpeas_canned": "Pois chiches",
"black_beans_canned": "Haricots noirs",
"kidney_beans_canned": "Haricots rouges",
"tofu": "Tofu",
"canned_tomatoes": "Tomates concassées",
"tomato_paste": "Concentré de tomate",
"coconut_milk": "Lait de coco",
"olive_oil": "Huile d'olive",
"rapeseed_oil": "Huile de colza",
"sugar": "Sucre",
"honey": "Miel",
"soy_sauce": "Sauce soja",
"fish_sauce": "Sauce poisson",
"red_curry_paste": "Pâte de curry rouge",
"mustard": "Moutarde",
"mayonnaise": "Mayonnaise",
"vegetable_stock_cube": "Bouillon cube de légumes",
"chicken_stock_cube": "Bouillon cube de volaille",
"peanut_butter": "Beurre de cacahuète",
"teriyaki_sauce": "Sauce teriyaki",
"taco_spice": "Épices à tacos",
"salsa": "Sauce salsa",
"sesame_seeds": "Graines de sésame",
"salt": "Sel",
"black_pepper": "Poivre noir",
"paprika_powder": "Paprika en poudre",
"cumin": "Cumin",
"chili_flakes": "Piment en flocons",
"oregano_dried": "Origan séché",
"thyme_dried": "Thym séché",
"curry_powder": "Curry en poudre",
"garam_masala": "Garam masala",
"turmeric": "Curcuma",
"cinnamon": "Cannelle",
"allspice": "Piment de la Jamaïque"
},
"da": {
"milk_3": "Sødmælk",
"milk_1_5": "Letmælk",
"oat_drink": "Havredrik",
"cream": "Piskefløde",
"cooking_cream": "Madlavningsfløde",
"creme_fraiche": "Creme fraiche",
"quark": "Kvark",
"yoghurt_natural": "Yoghurt naturel",
"butter": "Smør",
"cheese_hard": "Fast ost",
"vasterbotten_cheese": "Västerbotten-ost",
"feta": "Feta",
"halloumi": "Halloumi",
"egg": "Æg",
"chicken_breast": "Kyllingebryst",
"chicken_thigh": "Kyllingelår",
"minced_beef": "Hakket oksekød",
"minced_mixed": "Blandet fars",
"falukorv": "Falukorv",
"bacon": "Bacon",
"pork_loin": "Svinekam",
"meatball_pork_beef": "Kødboller",
"salmon": "Laksefilet",
"cod": "Torskefilet",
"shrimp": "Rejer",
"pickled_herring": "Marineret sild",
"anchovy_swedish": "Svenske ansjoser",
"potato": "Kartoffel",
"new_potato": "Nye kartofler",
"onion": "Løg",
"red_onion": "Rødløg",
"garlic": "Hvidløg",
"carrot": "Gulerod",
"tomato": "Tomat",
"cucumber": "Agurk",
"bell_pepper": "Peberfrugt",
"spinach": "Spinat",
"lettuce": "Salat",
"broccoli": "Broccoli",
"zucchini": "Squash",
"leek": "Porre",
"dill": "Dild",
"parsley": "Persille",
"basil": "Basilikum",
"ginger": "Ingefær",
"mushroom": "Champignoner",
"frozen_peas": "Frosne ærter",
"corn": "Majs",
"olives": "Oliven",
"avocado": "Avocado",
"lemon": "Citron",
"lime": "Lime",
"apple": "Æble",
"banana": "Banan",
"strawberry": "Jordbær",
"pasta_dry": "Pasta",
"pasta_gluten_free": "Glutenfri pasta",
"rice_white": "Hvide ris",
"noodles_egg": "Æggenudler",
"flour_wheat": "Hvedemel",
"oats": "Havregryn",
"breadcrumbs": "Rasp",
"bread_sourdough": "Surdejsbrød",
"tortilla": "Tortillaer",
"hamburger_bun": "Burgerboller",
"red_lentils": "Røde linser",
"chickpeas_canned": "Kikærter",
"black_beans_canned": "Sorte bønner",
"kidney_beans_canned": "Kidneybønner",
"tofu": "Tofu",
"canned_tomatoes": "Hakkede tomater",
"tomato_paste": "Tomatpuré",
"coconut_milk": "Kokosmælk",
"olive_oil": "Olivenolie",
"rapeseed_oil": "Rapsolie",
"sugar": "Sukker",
"honey": "Honning",
"soy_sauce": "Sojasauce",
"fish_sauce": "Fiskesauce",
"red_curry_paste": "Rød karrypasta",
"mustard": "Sennep",
"mayonnaise": "Mayonnaise",
"vegetable_stock_cube": "Grøntsagsbouillonterning",
"chicken_stock_cube": "Hønsebouillonterning",
"peanut_butter": "Peanutbutter",
"teriyaki_sauce": "Teriyakisauce",
"taco_spice": "Tacokrydderi",
"salsa": "Salsa",
"sesame_seeds": "Sesamfrø",
"salt": "Salt",
"black_pepper": "Sort peber",
"paprika_powder": "Paprikapulver",
"cumin": "Spidskommen",
"chili_flakes": "Chiliflager",
"oregano_dried": "Tørret oregano",
"thyme_dried": "Tørret timian",
"curry_powder": "Karry",
"garam_masala": "Garam masala",
"turmeric": "Gurkemeje",
"cinnamon": "Kanel",
"allspice": "Allehånde"
},
"nb": {
"milk_3": "Helmelk",
"milk_1_5": "Lettmelk",
"oat_drink": "Havredrikk",
"cream": "Kremfløte",
"cooking_cream": "Matfløte",
"creme_fraiche": "Crème fraîche",
"quark": "Kvarg",
"yoghurt_natural": "Naturell yoghurt",
"butter": "Smør",
"cheese_hard": "Fast ost",
"vasterbotten_cheese": "Västerbottenost",
"feta": "Fetaost",
"halloumi": "Halloumi",
"egg": "Egg",
"chicken_breast": "Kyllingfilet",
"chicken_thigh": "Kyllinglår",
"minced_beef": "Karbonadedeig",
"minced_mixed": "Kjøttdeig",
"falukorv": "Falukorv",
"bacon": "Bacon",
"pork_loin": "Svinekam",
"meatball_pork_beef": "Kjøttboller",
"salmon": "Laksefilet",
"cod": "Torskefilet",
"shrimp": "Reker",
"pickled_herring": "Sursild",
"anchovy_swedish": "Svenske ansjos",
"potato": "Potet",
"new_potato": "Nypoteter",
"onion": "Gul løk",
"red_onion": "Rødløk",
"garlic": "Hvitløk",
"carrot": "Gulrot",
"tomato": "Tomat",
"cucumber": "Agurk",
"bell_pepper": "Paprika",
"spinach": "Spinat",
"lettuce": "Salat",
"broccoli": "Brokkoli",
"zucchini": "Squash",
"leek": "Purre",
"dill": "Dill",
"parsley": "Persille",
"basil": "Basilikum",
"ginger": "Ingefær",
"mushroom": "Sjampinjonger",
"frozen_peas": "Frosne erter",
"corn": "Mais",
"olives": "Oliven",
"avocado": "Avokado",
"lemon": "Sitron",
"lime": "Lime",
"apple": "Eple",
"banana": "Banan",
"strawberry": "Jordbær",
"pasta_dry": "Pasta",
"pasta_gluten_free": "Glutenfri pasta",
"rice_white": "Hvit ris",
"noodles_egg": "Eggnudler",
"flour_wheat": "Hvetemel",
"oats": "Havregryn",
"breadcrumbs": "Griljermel",
"bread_sourdough": "Surdeigsbrød",
"tortilla": "Tortillalefser",
"hamburger_bun": "Hamburgerbrød",
"red_lentils": "Røde linser",
"chickpeas_canned": "Kikerter",
"black_beans_canned": "Svarte bønner",
"kidney_beans_canned": "Kidneybønner",
"tofu": "Tofu",
"canned_tomatoes": "Hakkede tomater",
"tomato_paste": "Tomatpuré",
"coconut_milk": "Kokosmelk",
"olive_oil": "Olivenolje",
"rapeseed_oil": "Rapsolje",
"sugar": "Sukker",
"honey": "Honning",
"soy_sauce": "Soyasaus",
"fish_sauce": "Fiskesaus",
"red_curry_paste": "Rød currypaste",
"mustard": "Sennep",
"mayonnaise": "Majones",
"vegetable_stock_cube": "Grønnsaksbuljongterning",
"chicken_stock_cube": "Kyllingbuljongterning",
"peanut_butter": "Peanøttsmør",
"teriyaki_sauce": "Teriyakisaus",
"taco_spice": "Tacokrydder",
"salsa": "Salsa",
"sesame_seeds": "Sesamfrø",
"salt": "Salt",
"black_pepper": "Sort pepper",
"paprika_powder": "Paprikapulver",
"cumin": "Spisskummen",
"chili_flakes": "Chiliflak",
"oregano_dried": "Tørket oregano",
"thyme_dried": "Tørket timian",
"curry_powder": "Karripulver",
"garam_masala": "Garam masala",
"turmeric": "Gurkemeie",
"cinnamon": "Kanel",
"allspice": "Allehånde"
},
"fi": {
"milk_3": "Täysmaito",
"milk_1_5": "Kevytmaito",
"oat_drink": "Kaurajuoma",
"cream": "Kuohukerma",
"cooking_cream": "Ruokakerma",
"creme_fraiche": "Ranskankerma",
"quark": "Rahka",
"yoghurt_natural": "Maustamaton jogurtti",
"butter": "Voi",
"cheese_hard": "Kova juusto",
"vasterbotten_cheese": "Västerbotten-juusto",
"feta": "Feta",
"halloumi": "Halloumi",
"egg": "Kananmuna",
"chicken_breast": "Broilerin rintafilee",
"chicken_thigh": "Broilerin reisi",
"minced_beef": "Naudan jauheliha",
"minced_mixed": "Sika-nautajauheliha",
"falukorv": "Falukorv-makkara",
"bacon": "Pekoni",
"pork_loin": "Porsaan ulkofilee",
"meatball_pork_beef": "Lihapullat",
"salmon": "Lohifilee",
"cod": "Turskafilee",
"shrimp": "Katkaravut",
"pickled_herring": "Etikkasilli",
"anchovy_swedish": "Ruotsalainen anjovis",
"potato": "Peruna",
"new_potato": "Uudet perunat",
"onion": "Sipuli",
"red_onion": "Punasipuli",
"garlic": "Valkosipuli",
"carrot": "Porkkana",
"tomato": "Tomaatti",
"cucumber": "Kurkku",
"bell_pepper": "Paprika",
"spinach": "Pinaatti",
"lettuce": "Salaatti",
"broccoli": "Parsakaali",
"zucchini": "Kesäkurpitsa",
"leek": "Purjo",
"dill": "Tilli",
"parsley": "Persilja",
"basil": "Basilika",
"ginger": "Inkivääri",
"mushroom": "Herkkusienet",
"frozen_peas": "Pakastehernet",
"corn": "Maissi",
"olives": "Oliivit",
"avocado": "Avokado",
"lemon": "Sitruuna",
"lime": "Limetti",
"apple": "Omena",
"banana": "Banaani",
"strawberry": "Mansikat",
"pasta_dry": "Pasta",
"pasta_gluten_free": "Gluteeniton pasta",
"rice_white": "Valkoinen riisi",
"noodles_egg": "Munanuudelit",
"flour_wheat": "Vehnäjauho",
"oats": "Kaurahiutaleet",
"breadcrumbs": "Korppujauho",
"bread_sourdough": "Hapanjuurileipä",
"tortilla": "Tortillat",
"hamburger_bun": "Hampurilaissämpylät",
"red_lentils": "Punaiset linssit",
"chickpeas_canned": "Kikherneet",
"black_beans_canned": "Mustapavut",
"kidney_beans_canned": "Kidneypavut",
"tofu": "Tofu",
"canned_tomatoes": "Tomaattimurska",
"tomato_paste": "Tomaattipyree",
"coconut_milk": "Kookosmaito",
"olive_oil": "Oliiviöljy",
"rapeseed_oil": "Rypsiöljy",
"sugar": "Sokeri",
"honey": "Hunaja",
"soy_sauce": "Soijakastike",
"fish_sauce": "Kalakastike",
"red_curry_paste": "Punainen currytahna",
"mustard": "Sinappi",
"mayonnaise": "Majoneesi",
"vegetable_stock_cube": "Kasvisliemikuutio",
"chicken_stock_cube": "Kanaliemikuutio",
"peanut_butter": "Maapähkinävoi",
"teriyaki_sauce": "Teriyakikastike",
"taco_spice": "Tacomauste",
"salsa": "Salsakastike",
"sesame_seeds": "Seesaminsiemenet",
"salt": "Suola",
"black_pepper": "Mustapippuri",
"paprika_powder": "Paprikajauhe",
"cumin": "Juustokumina",
"chili_flakes": "Chilihiutaleet",
"oregano_dried": "Kuivattu oregano",
"thyme_dried": "Kuivattu timjami",
"curry_powder": "Curryjauhe",
"garam_masala": "Garam masala",
"turmeric": "Kurkuma",
"cinnamon": "Kaneli",
"allspice": "Maustepippuri"
},
"nl": {
"milk_3": "Volle melk",
"milk_1_5": "Halfvolle melk",
"oat_drink": "Haverdrank",
"cream": "Slagroom",
"cooking_cream": "Kookroom",
"creme_fraiche": "Crème fraîche",
"quark": "Kwark",
"yoghurt_natural": "Naturel yoghurt",
"butter": "Boter",
"cheese_hard": "Harde kaas",
"vasterbotten_cheese": "Västerbottenkaas",
"feta": "Feta",
"halloumi": "Halloumi",
"egg": "Ei",
"chicken_breast": "Kipfilet",
"chicken_thigh": "Kippendij",
"minced_beef": "Rundergehakt",
"minced_mixed": "Half-om-half gehakt",
"falukorv": "Falukorv-worst",
"bacon": "Spekreepjes",
"pork_loin": "Varkenshaas",
"meatball_pork_beef": "Gehaktballetjes",
"salmon": "Zalmfilet",
"cod": "Kabeljauwfilet",
"shrimp": "Garnalen",
"pickled_herring": "Zure haring",
"anchovy_swedish": "Zweedse ansjovis",
"potato": "Aardappel",
"new_potato": "Nieuwe aardappelen",
"onion": "Ui",
"red_onion": "Rode ui",
"garlic": "Knoflook",
"carrot": "Wortel",
"tomato": "Tomaat",
"cucumber": "Komkommer",
"bell_pepper": "Paprika",
"spinach": "Spinazie",
"lettuce": "Sla",
"broccoli": "Broccoli",
"zucchini": "Courgette",
"leek": "Prei",
"dill": "Dille",
"parsley": "Peterselie",
"basil": "Basilicum",
"ginger": "Gember",
"mushroom": "Champignons",
"frozen_peas": "Diepvrieserwten",
"corn": "Maïs",
"olives": "Olijven",
"avocado": "Avocado",
"lemon": "Citroen",
"lime": "Limoen",
"apple": "Appel",
"banana": "Banaan",
"strawberry": "Aardbeien",
"pasta_dry": "Pasta",
"pasta_gluten_free": "Glutenvrije pasta",
"rice_white": "Witte rijst",
"noodles_egg": "Eiernoedels",
"flour_wheat": "Tarwebloem",
"oats": "Havermout",
"breadcrumbs": "Paneermeel",
"bread_sourdough": "Zuurdesembrood",
"tortilla": "Tortillawraps",
"hamburger_bun": "Hamburgerbroodjes",
"red_lentils": "Rode linzen",
"chickpeas_canned": "Kikkererwten",
"black_beans_canned": "Zwarte bonen",
"kidney_beans_canned": "Kidneybonen",
"tofu": "Tofu",
"canned_tomatoes": "Gehakte tomaten uit blik",
"tomato_paste": "Tomatenpuree",
"coconut_milk": "Kokosmelk",
"olive_oil": "Olijfolie",
"rapeseed_oil": "Koolzaadolie",
"sugar": "Suiker",
"honey": "Honing",
"soy_sauce": "Sojasaus",
"fish_sauce": "Vissaus",
"red_curry_paste": "Rode currypasta",
"mustard": "Mosterd",
"mayonnaise": "Mayonaise",
"vegetable_stock_cube": "Groentebouillonblokje",
"chicken_stock_cube": "Kippenbouillonblokje",
"peanut_butter": "Pindakaas",
"teriyaki_sauce": "Teriyakisaus",
"taco_spice": "Tacokruiden",
"salsa": "Salsa",
"sesame_seeds": "Sesamzaad",
"salt": "Zout",
"black_pepper": "Zwarte peper",
"paprika_powder": "Paprikapoeder",
"cumin": "Komijn",
"chili_flakes": "Chilivlokken",
"oregano_dried": "Gedroogde oregano",
"thyme_dried": "Gedroogde tijm",
"curry_powder": "Kerriepoeder",
"garam_masala": "Garam masala",
"turmeric": "Kurkuma",
"cinnamon": "Kaneel",
"allspice": "Piment"
},
"pl": {
"milk_3": "Mleko pełne",
"milk_1_5": "Mleko półtłuste",
"oat_drink": "Napój owsiany",
"cream": "Śmietanka kremówka",
"cooking_cream": "Śmietanka do gotowania",
"creme_fraiche": "Crème fraîche",
"quark": "Twaróg",
"yoghurt_natural": "Jogurt naturalny",
"butter": "Masło",
"cheese_hard": "Ser twardy",
"vasterbotten_cheese": "Ser Västerbotten",
"feta": "Feta",
"halloumi": "Halloumi",
"egg": "Jajko",
"chicken_breast": "Pierś z kurczaka",
"chicken_thigh": "Udko z kurczaka",
"minced_beef": "Mielona wołowina",
"minced_mixed": "Mięso mielone wieprzowo-wołowe",
"falukorv": "Kiełbasa falukorv",
"bacon": "Boczek",
"pork_loin": "Schab",
"meatball_pork_beef": "Klopsiki",
"salmon": "Filet z łososia",
"cod": "Filet z dorsza",
"shrimp": "Krewetki",
"pickled_herring": "Śledź marynowany",
"anchovy_swedish": "Szwedzkie anchois",
"potato": "Ziemniak",
"new_potato": "Młode ziemniaki",
"onion": "Cebula",
"red_onion": "Czerwona cebula",
"garlic": "Czosnek",
"carrot": "Marchewka",
"tomato": "Pomidor",
"cucumber": "Ogórek",
"bell_pepper": "Papryka",
"spinach": "Szpinak",
"lettuce": "Sałata",
"broccoli": "Brokuły",
"zucchini": "Cukinia",
"leek": "Por",
"dill": "Koperek",
"parsley": "Pietruszka",
"basil": "Bazylia",
"ginger": "Imbir",
"mushroom": "Pieczarki",
"frozen_peas": "Mrożony groszek",
"corn": "Kukurydza",
"olives": "Oliwki",
"avocado": "Awokado",
"lemon": "Cytryna",
"lime": "Limonka",
"apple": "Jabłko",
"banana": "Banan",
"strawberry": "Truskawki",
"pasta_dry": "Makaron",
"pasta_gluten_free": "Makaron bezglutenowy",
"rice_white": "Ryż biały",
"noodles_egg": "Makaron jajeczny",
"flour_wheat": "Mąka pszenna",
"oats": "Płatki owsiane",
"breadcrumbs": "Bułka tarta",
"bread_sourdough": "Chleb na zakwasie",
"tortilla": "Tortille",
"hamburger_bun": "Bułki do burgerów",
"red_lentils": "Czerwona soczewica",
"chickpeas_canned": "Ciecierzyca",
"black_beans_canned": "Czarna fasola",
"kidney_beans_canned": "Fasola kidney",
"tofu": "Tofu",
"canned_tomatoes": "Pomidory krojone",
"tomato_paste": "Koncentrat pomidorowy",
"coconut_milk": "Mleko kokosowe",
"olive_oil": "Oliwa z oliwek",
"rapeseed_oil": "Olej rzepakowy",
"sugar": "Cukier",
"honey": "Miód",
"soy_sauce": "Sos sojowy",
"fish_sauce": "Sos rybny",
"red_curry_paste": "Czerwona pasta curry",
"mustard": "Musztarda",
"mayonnaise": "Majonez",
"vegetable_stock_cube": "Kostka bulionu warzywnego",
"chicken_stock_cube": "Kostka bulionu drobiowego",
"peanut_butter": "Masło orzechowe",
"teriyaki_sauce": "Sos teriyaki",
"taco_spice": "Przyprawa do tacos",
"salsa": "Salsa",
"sesame_seeds": "Sezam",
"salt": "Sól",
"black_pepper": "Czarny pieprz",
"paprika_powder": "Papryka w proszku",
"cumin": "Kmin rzymski",
"chili_flakes": "Płatki chili",
"oregano_dried": "Suszone oregano",
"thyme_dried": "Suszony tymianek",
"curry_powder": "Curry w proszku",
"garam_masala": "Garam masala",
"turmeric": "Kurkuma",
"cinnamon": "Cynamon",
"allspice": "Ziele angielskie"
},
"pt": {
"milk_3": "Leite gordo",
"milk_1_5": "Leite meio-gordo",
"oat_drink": "Bebida de aveia",
"cream": "Natas para bater",
"cooking_cream": "Natas para cozinhar",
"creme_fraiche": "Crème fraîche",
"quark": "Queijo quark",
"yoghurt_natural": "Iogurte natural",
"butter": "Manteiga",
"cheese_hard": "Queijo curado",
"vasterbotten_cheese": "Queijo Västerbotten",
"feta": "Queijo feta",
"halloumi": "Halloumi",
"egg": "Ovo",
"chicken_breast": "Peito de frango",
"chicken_thigh": "Coxa de frango",
"minced_beef": "Carne de vaca picada",
"minced_mixed": "Carne picada mista",
"falukorv": "Salsicha falukorv",
"bacon": "Bacon",
"pork_loin": "Lombo de porco",
"meatball_pork_beef": "Almôndegas",
"salmon": "Filete de salmão",
"cod": "Filete de bacalhau",
"shrimp": "Camarões",
"pickled_herring": "Arenque em conserva",
"anchovy_swedish": "Anchovas suecas",
"potato": "Batata",
"new_potato": "Batatas novas",
"onion": "Cebola",
"red_onion": "Cebola roxa",
"garlic": "Alho",
"carrot": "Cenoura",
"tomato": "Tomate",
"cucumber": "Pepino",
"bell_pepper": "Pimento",
"spinach": "Espinafres",
"lettuce": "Alface",
"broccoli": "Brócolos",
"zucchini": "Curgete",
"leek": "Alho-francês",
"dill": "Endro",
"parsley": "Salsa",
"basil": "Manjericão",
"ginger": "Gengibre",
"mushroom": "Cogumelos",
"frozen_peas": "Ervilhas congeladas",
"corn": "Milho doce",
"olives": "Azeitonas",
"avocado": "Abacate",
"lemon": "Limão",
"lime": "Lima",
"apple": "Maçã",
"banana": "Banana",
"strawberry": "Morangos",
"pasta_dry": "Massa seca",
"pasta_gluten_free": "Massa sem glúten",
"rice_white": "Arroz branco",
"noodles_egg": "Noodles de ovo",
"flour_wheat": "Farinha de trigo",
"oats": "Flocos de aveia",
"breadcrumbs": "Pão ralado",
"bread_sourdough": "Pão de fermentação natural",
"tortilla": "Tortilhas",
"hamburger_bun": "Pães de hambúrguer",
"red_lentils": "Lentilhas vermelhas",
"chickpeas_canned": "Grão-de-bico",
"black_beans_canned": "Feijão preto",
"kidney_beans_canned": "Feijão vermelho",
"tofu": "Tofu",
"canned_tomatoes": "Tomate picado em lata",
"tomato_paste": "Concentrado de tomate",
"coconut_milk": "Leite de coco",
"olive_oil": "Azeite",
"rapeseed_oil": "Óleo de colza",
"sugar": "Açúcar",
"honey": "Mel",
"soy_sauce": "Molho de soja",
"fish_sauce": "Molho de peixe",
"red_curry_paste": "Pasta de caril vermelho",
"mustard": "Mostarda",
"mayonnaise": "Maionese",
"vegetable_stock_cube": "Cubo de caldo de legumes",
"chicken_stock_cube": "Cubo de caldo de galinha",
"peanut_butter": "Manteiga de amendoim",
"teriyaki_sauce": "Molho teriyaki",
"taco_spice": "Tempero para tacos",
"salsa": "Salsa",
"sesame_seeds": "Sementes de sésamo",
"salt": "Sal",
"black_pepper": "Pimenta preta",
"paprika_powder": "Colorau",
"cumin": "Cominhos",
"chili_flakes": "Malagueta em flocos",
"oregano_dried": "Orégãos secos",
"thyme_dried": "Tomilho seco",
"curry_powder": "Caril em pó",
"garam_masala": "Garam masala",
"turmeric": "Curcuma",
"cinnamon": "Canela",
"allspice": "Pimenta-da-jamaica"
}
}
@@ -0,0 +1,947 @@
import type {
Allergen,
NutritionDeclaration,
NutritionProvenance,
Season,
StorageLocationType,
Unit,
} from "@app/shared-types";
/**
* Seed: kanoniska ingredienser med schablonvärden per 100 g.
*
* VIKTIGT (spec §21, §61.1): värdena här är standardiserade uppskattningar
* ("seed_estimate") för utveckling. Produktionsvägen är import från
* Livsmedelsverkets öppna livsmedelsdatabas via connectorn jobbet byter då
* provenance till "livsmedelsverket". AI hittar ALDRIG på näringsvärden.
*/
export interface SeedIngredient {
id: string;
nameSv: string;
nameEn: string;
aliases: string[];
category: string;
defaultUnit: Unit;
densityGPerMl?: number;
gramsPerPiece?: number;
allergens: Allergen[];
isVegan: boolean;
isVegetarian: boolean;
containsGluten: boolean;
containsLactose: boolean;
isPork: boolean;
isBeef: boolean;
isAlcohol: boolean;
nutritionPer100: NutritionDeclaration;
nutritionProvenance: NutritionProvenance;
peakSeasons: Season[];
shelfLifeGuidance?: Partial<Record<StorageLocationType, number>>;
defaultPriceMinorPerKg?: number;
}
const PROVENANCE: NutritionProvenance = {
source: "seed_estimate",
confidence: 0.7,
verifiedByUser: false,
};
interface N {
kcal: number;
p: number;
k: number;
f: number;
mf?: number;
fib?: number;
s?: number;
salt?: number;
}
function per100(n: N): NutritionDeclaration {
return {
basis: "per_100_g",
values: {
kcal: n.kcal,
proteinG: n.p,
carbsG: n.k,
fatG: n.f,
saturatedFatG: n.mf ?? 0,
fiberG: n.fib ?? 0,
sugarG: n.s ?? 0,
saltG: n.salt ?? 0,
},
};
}
type IngOpts = Partial<
Omit<
SeedIngredient,
"id" | "nameSv" | "nameEn" | "category" | "nutritionPer100" | "nutritionProvenance"
>
> & {
n: N;
};
function ing(
id: string,
nameSv: string,
nameEn: string,
category: string,
opts: IngOpts,
): SeedIngredient {
const { n, ...rest } = opts;
return {
id,
nameSv,
nameEn,
aliases: rest.aliases ?? [],
category,
defaultUnit: rest.defaultUnit ?? "GRAM",
allergens: rest.allergens ?? [],
isVegan: rest.isVegan ?? false,
isVegetarian: rest.isVegetarian ?? false,
containsGluten: rest.containsGluten ?? false,
containsLactose: rest.containsLactose ?? false,
isPork: rest.isPork ?? false,
isBeef: rest.isBeef ?? false,
isAlcohol: rest.isAlcohol ?? false,
nutritionPer100: per100(n),
nutritionProvenance: PROVENANCE,
peakSeasons: rest.peakSeasons ?? [],
...(rest.densityGPerMl !== undefined ? { densityGPerMl: rest.densityGPerMl } : {}),
...(rest.gramsPerPiece !== undefined ? { gramsPerPiece: rest.gramsPerPiece } : {}),
...(rest.shelfLifeGuidance !== undefined ? { shelfLifeGuidance: rest.shelfLifeGuidance } : {}),
...(rest.defaultPriceMinorPerKg !== undefined
? { defaultPriceMinorPerKg: rest.defaultPriceMinorPerKg }
: {}),
};
}
const veg = { isVegan: true, isVegetarian: true };
const vgt = { isVegan: false, isVegetarian: true };
export const SEED_INGREDIENTS: SeedIngredient[] = [
// --- Mejeri & ägg ---
ing("milk_3", "Standardmjölk 3 %", "Whole milk", "mejeri", {
...vgt,
defaultUnit: "DECILITER",
densityGPerMl: 1.03,
allergens: ["milk"],
containsLactose: true,
n: { kcal: 60, p: 3.4, k: 4.7, f: 3, mf: 1.9, s: 4.7, salt: 0.1 },
shelfLifeGuidance: { fridge: 7 },
defaultPriceMinorPerKg: 1600,
aliases: ["mjölk", "helmjölk"],
}),
ing("milk_1_5", "Mellanmjölk 1,5 %", "Semi-skimmed milk", "mejeri", {
...vgt,
defaultUnit: "DECILITER",
densityGPerMl: 1.03,
allergens: ["milk"],
containsLactose: true,
n: { kcal: 46, p: 3.5, k: 4.8, f: 1.5, mf: 1, s: 4.8, salt: 0.1 },
shelfLifeGuidance: { fridge: 7 },
defaultPriceMinorPerKg: 1500,
aliases: ["mellanmjölk"],
}),
ing("oat_drink", "Havredryck", "Oat drink", "mejeri", {
...veg,
defaultUnit: "DECILITER",
densityGPerMl: 1.01,
containsGluten: false,
n: { kcal: 46, p: 1, k: 6.7, f: 1.5, mf: 0.2, s: 4, salt: 0.1 },
shelfLifeGuidance: { fridge: 7 },
defaultPriceMinorPerKg: 2200,
aliases: ["havremjölk"],
}),
ing("cream", "Vispgrädde 40 %", "Whipping cream", "mejeri", {
...vgt,
defaultUnit: "DECILITER",
densityGPerMl: 1,
allergens: ["milk"],
containsLactose: true,
n: { kcal: 380, p: 2, k: 3, f: 40, mf: 26, s: 3, salt: 0.1 },
shelfLifeGuidance: { fridge: 7 },
defaultPriceMinorPerKg: 6500,
}),
ing("cooking_cream", "Matlagningsgrädde 15 %", "Cooking cream", "mejeri", {
...vgt,
defaultUnit: "DECILITER",
densityGPerMl: 1,
allergens: ["milk"],
containsLactose: true,
n: { kcal: 162, p: 2.7, k: 4.2, f: 15, mf: 10, s: 4, salt: 0.1 },
shelfLifeGuidance: { fridge: 7 },
defaultPriceMinorPerKg: 5500,
}),
ing("creme_fraiche", "Crème fraiche", "Crème fraîche", "mejeri", {
...vgt,
defaultUnit: "DECILITER",
densityGPerMl: 1,
allergens: ["milk"],
containsLactose: true,
n: { kcal: 292, p: 2.3, k: 3, f: 30, mf: 20, s: 3, salt: 0.1 },
shelfLifeGuidance: { fridge: 14 },
defaultPriceMinorPerKg: 6000,
}),
ing("quark", "Kvarg naturell", "Quark", "mejeri", {
...vgt,
allergens: ["milk"],
containsLactose: true,
n: { kcal: 62, p: 11, k: 3.8, f: 0.3, mf: 0.2, s: 3.8, salt: 0.1 },
shelfLifeGuidance: { fridge: 10 },
defaultPriceMinorPerKg: 4500,
aliases: ["kesella"],
}),
ing("yoghurt_natural", "Naturell yoghurt 3 %", "Plain yogurt", "mejeri", {
...vgt,
defaultUnit: "DECILITER",
densityGPerMl: 1.03,
allergens: ["milk"],
containsLactose: true,
n: { kcal: 61, p: 3.8, k: 4.9, f: 3, mf: 2, s: 4.9, salt: 0.1 },
shelfLifeGuidance: { fridge: 7 },
defaultPriceMinorPerKg: 3000,
}),
ing("butter", "Smör", "Butter", "mejeri", {
...vgt,
allergens: ["milk"],
containsLactose: true,
n: { kcal: 744, p: 0.6, k: 0.7, f: 82, mf: 52, salt: 1.2 },
shelfLifeGuidance: { fridge: 60 },
defaultPriceMinorPerKg: 11000,
}),
ing("cheese_hard", "Hårdost (t.ex. hushållsost)", "Hard cheese", "mejeri", {
...vgt,
allergens: ["milk"],
containsLactose: true,
n: { kcal: 350, p: 26, k: 0, f: 27, mf: 17, salt: 1.2 },
shelfLifeGuidance: { fridge: 21 },
defaultPriceMinorPerKg: 12000,
aliases: ["ost", "riven ost"],
}),
ing("vasterbotten_cheese", "Västerbottensost", "Västerbotten cheese", "mejeri", {
...vgt,
allergens: ["milk"],
containsLactose: true,
n: { kcal: 390, p: 27, k: 0, f: 31, mf: 20, salt: 1.5 },
shelfLifeGuidance: { fridge: 21 },
defaultPriceMinorPerKg: 22000,
}),
ing("feta", "Fetaost", "Feta cheese", "mejeri", {
...vgt,
allergens: ["milk"],
containsLactose: true,
n: { kcal: 265, p: 14, k: 2, f: 22, mf: 15, salt: 2.7 },
shelfLifeGuidance: { fridge: 14 },
defaultPriceMinorPerKg: 13000,
}),
ing("halloumi", "Halloumi", "Halloumi", "mejeri", {
...vgt,
allergens: ["milk"],
containsLactose: true,
n: { kcal: 321, p: 21, k: 2.2, f: 25, mf: 16, salt: 2.8 },
shelfLifeGuidance: { fridge: 30 },
defaultPriceMinorPerKg: 16000,
}),
ing("egg", "Ägg", "Egg", "mejeri", {
...vgt,
defaultUnit: "COUNT",
gramsPerPiece: 58,
allergens: ["eggs"],
n: { kcal: 143, p: 12.6, k: 0.7, f: 9.5, mf: 3.1, salt: 0.4 },
shelfLifeGuidance: { fridge: 30, pantry: 21 },
defaultPriceMinorPerKg: 6500,
}),
// --- Kött & fågel ---
ing("chicken_breast", "Kycklingfilé", "Chicken breast", "kott_fagel", {
n: { kcal: 106, p: 22, k: 0, f: 2, mf: 0.6, salt: 0.2 },
shelfLifeGuidance: { fridge: 2, freezer: 180 },
defaultPriceMinorPerKg: 13000,
aliases: ["kyckling", "kycklingbröst"],
}),
ing("chicken_thigh", "Kycklinglårfilé", "Chicken thigh", "kott_fagel", {
n: { kcal: 150, p: 19, k: 0, f: 8, mf: 2.2, salt: 0.2 },
shelfLifeGuidance: { fridge: 2, freezer: 180 },
defaultPriceMinorPerKg: 11000,
}),
ing("minced_beef", "Nötfärs 10 %", "Minced beef", "kott_fagel", {
isBeef: true,
n: { kcal: 176, p: 20, k: 0, f: 10, mf: 4.5, salt: 0.2 },
shelfLifeGuidance: { fridge: 1, freezer: 120 },
defaultPriceMinorPerKg: 14000,
aliases: ["nötfärs"],
}),
ing("minced_mixed", "Blandfärs", "Minced pork/beef", "kott_fagel", {
isPork: true,
isBeef: true,
n: { kcal: 220, p: 18, k: 0, f: 16, mf: 7, salt: 0.2 },
shelfLifeGuidance: { fridge: 1, freezer: 120 },
defaultPriceMinorPerKg: 10000,
}),
ing("falukorv", "Falukorv", "Falu sausage", "kott_fagel", {
isPork: true,
isBeef: true,
n: { kcal: 230, p: 10, k: 6, f: 19, mf: 7, salt: 1.9 },
shelfLifeGuidance: { fridge: 10 },
defaultPriceMinorPerKg: 7000,
}),
ing("bacon", "Bacon", "Bacon", "kott_fagel", {
isPork: true,
n: { kcal: 320, p: 14, k: 1, f: 29, mf: 11, salt: 2.5 },
shelfLifeGuidance: { fridge: 7, freezer: 60 },
defaultPriceMinorPerKg: 18000,
}),
ing("pork_loin", "Fläskytterfilé", "Pork loin", "kott_fagel", {
isPork: true,
n: { kcal: 120, p: 21, k: 0, f: 4, mf: 1.4, salt: 0.1 },
shelfLifeGuidance: { fridge: 2, freezer: 150 },
defaultPriceMinorPerKg: 9000,
}),
ing("meatball_pork_beef", "Köttbullar (färdiga)", "Meatballs", "kott_fagel", {
isPork: true,
isBeef: true,
n: { kcal: 230, p: 13, k: 7, f: 17, mf: 6.5, salt: 1.8 },
shelfLifeGuidance: { fridge: 5, freezer: 120 },
defaultPriceMinorPerKg: 9000,
}),
// --- Fisk & skaldjur ---
ing("salmon", "Laxfilé", "Salmon fillet", "fisk", {
allergens: ["fish"],
n: { kcal: 200, p: 20, k: 0, f: 13, mf: 2.5, salt: 0.1 },
shelfLifeGuidance: { fridge: 2, freezer: 90 },
defaultPriceMinorPerKg: 25000,
aliases: ["lax"],
}),
ing("cod", "Torskfilé", "Cod fillet", "fisk", {
allergens: ["fish"],
n: { kcal: 80, p: 18, k: 0, f: 0.7, mf: 0.1, salt: 0.2 },
shelfLifeGuidance: { fridge: 2, freezer: 90 },
defaultPriceMinorPerKg: 30000,
aliases: ["torsk"],
}),
ing("shrimp", "Räkor (skalade)", "Shrimp", "fisk", {
allergens: ["crustaceans"],
n: { kcal: 80, p: 18, k: 0, f: 0.8, mf: 0.2, salt: 1.5 },
shelfLifeGuidance: { fridge: 2, freezer: 90 },
defaultPriceMinorPerKg: 35000,
}),
ing("pickled_herring", "Inlagd sill", "Pickled herring", "fisk", {
allergens: ["fish"],
n: { kcal: 180, p: 12, k: 12, f: 10, mf: 2.5, s: 11, salt: 2.2 },
shelfLifeGuidance: { fridge: 21 },
defaultPriceMinorPerKg: 12000,
aliases: ["sill"],
}),
ing("anchovy_swedish", "Ansjovis (svensk)", "Swedish anchovy sprats", "fisk", {
allergens: ["fish"],
n: { kcal: 170, p: 12, k: 8, f: 10, mf: 2, salt: 6 },
shelfLifeGuidance: { fridge: 30 },
defaultPriceMinorPerKg: 18000,
}),
// --- Grönsaker ---
ing("potato", "Potatis", "Potato", "gronsaker", {
...veg,
defaultUnit: "COUNT",
gramsPerPiece: 180,
n: { kcal: 80, p: 2, k: 17, f: 0.1, fib: 1.8 },
shelfLifeGuidance: { pantry: 30, fridge: 45 },
defaultPriceMinorPerKg: 1500,
peakSeasons: ["autumn"],
}),
ing("new_potato", "Färskpotatis", "New potatoes", "gronsaker", {
...veg,
n: { kcal: 75, p: 1.9, k: 16, f: 0.1, fib: 1.6 },
shelfLifeGuidance: { pantry: 7, fridge: 10 },
defaultPriceMinorPerKg: 3000,
peakSeasons: ["summer"],
}),
ing("onion", "Gul lök", "Yellow onion", "gronsaker", {
...veg,
defaultUnit: "COUNT",
gramsPerPiece: 150,
n: { kcal: 40, p: 1.2, k: 8, f: 0.1, fib: 1.7, s: 5 },
shelfLifeGuidance: { pantry: 30 },
defaultPriceMinorPerKg: 1500,
aliases: ["lök"],
}),
ing("red_onion", "Rödlök", "Red onion", "gronsaker", {
...veg,
defaultUnit: "COUNT",
gramsPerPiece: 120,
n: { kcal: 42, p: 1.2, k: 8.5, f: 0.1, fib: 1.7, s: 5.5 },
shelfLifeGuidance: { pantry: 30 },
defaultPriceMinorPerKg: 2000,
}),
ing("garlic", "Vitlök", "Garlic", "gronsaker", {
...veg,
defaultUnit: "COUNT",
gramsPerPiece: 5,
aliases: ["vitlöksklyfta"],
n: { kcal: 140, p: 6.5, k: 28, f: 0.5, fib: 2 },
shelfLifeGuidance: { pantry: 60 },
defaultPriceMinorPerKg: 9000,
}),
ing("carrot", "Morot", "Carrot", "gronsaker", {
...veg,
defaultUnit: "COUNT",
gramsPerPiece: 125,
n: { kcal: 38, p: 0.7, k: 8, f: 0.2, fib: 2.7, s: 4.5 },
shelfLifeGuidance: { fridge: 21 },
defaultPriceMinorPerKg: 1500,
peakSeasons: ["autumn", "winter"],
}),
ing("tomato", "Tomat", "Tomato", "gronsaker", {
...veg,
defaultUnit: "COUNT",
gramsPerPiece: 125,
n: { kcal: 20, p: 0.9, k: 3.5, f: 0.2, fib: 1.4, s: 2.6 },
shelfLifeGuidance: { pantry: 7, fridge: 10 },
defaultPriceMinorPerKg: 3500,
peakSeasons: ["summer"],
}),
ing("cucumber", "Gurka", "Cucumber", "gronsaker", {
...veg,
defaultUnit: "COUNT",
gramsPerPiece: 350,
n: { kcal: 12, p: 0.7, k: 2, f: 0.1, fib: 0.7 },
shelfLifeGuidance: { fridge: 7 },
defaultPriceMinorPerKg: 2500,
peakSeasons: ["summer"],
}),
ing("bell_pepper", "Paprika", "Bell pepper", "gronsaker", {
...veg,
defaultUnit: "COUNT",
gramsPerPiece: 150,
n: { kcal: 30, p: 1, k: 5, f: 0.3, fib: 1.9, s: 4.5 },
shelfLifeGuidance: { fridge: 10 },
defaultPriceMinorPerKg: 4500,
peakSeasons: ["summer", "autumn"],
}),
ing("spinach", "Spenat (färsk)", "Spinach", "gronsaker", {
...veg,
n: { kcal: 25, p: 2.9, k: 1.5, f: 0.4, fib: 2 },
shelfLifeGuidance: { fridge: 4, freezer: 180 },
defaultPriceMinorPerKg: 9000,
}),
ing("lettuce", "Sallad (huvud)", "Lettuce", "gronsaker", {
...veg,
defaultUnit: "COUNT",
gramsPerPiece: 300,
n: { kcal: 15, p: 1.2, k: 2, f: 0.2, fib: 1.3 },
shelfLifeGuidance: { fridge: 5 },
defaultPriceMinorPerKg: 6000,
peakSeasons: ["summer"],
}),
ing("broccoli", "Broccoli", "Broccoli", "gronsaker", {
...veg,
defaultUnit: "COUNT",
gramsPerPiece: 300,
n: { kcal: 35, p: 3.5, k: 4, f: 0.4, fib: 2.9 },
shelfLifeGuidance: { fridge: 5, freezer: 180 },
defaultPriceMinorPerKg: 4000,
}),
ing("zucchini", "Zucchini", "Zucchini", "gronsaker", {
...veg,
defaultUnit: "COUNT",
gramsPerPiece: 300,
n: { kcal: 17, p: 1.2, k: 3, f: 0.3, fib: 1 },
shelfLifeGuidance: { fridge: 7 },
defaultPriceMinorPerKg: 3500,
peakSeasons: ["summer", "autumn"],
}),
ing("leek", "Purjolök", "Leek", "gronsaker", {
...veg,
defaultUnit: "COUNT",
gramsPerPiece: 200,
n: { kcal: 30, p: 1.5, k: 5.5, f: 0.3, fib: 2.3 },
shelfLifeGuidance: { fridge: 14 },
defaultPriceMinorPerKg: 3500,
peakSeasons: ["autumn", "winter"],
}),
ing("dill", "Dill (färsk)", "Dill", "gronsaker", {
...veg,
n: { kcal: 40, p: 3.5, k: 5, f: 0.8, fib: 2.5 },
shelfLifeGuidance: { fridge: 5 },
defaultPriceMinorPerKg: 30000,
peakSeasons: ["summer"],
}),
ing("parsley", "Persilja (färsk)", "Parsley", "gronsaker", {
...veg,
n: { kcal: 45, p: 3.7, k: 6, f: 0.9, fib: 3.5 },
shelfLifeGuidance: { fridge: 5 },
defaultPriceMinorPerKg: 30000,
}),
ing("basil", "Basilika (färsk)", "Basil", "gronsaker", {
...veg,
n: { kcal: 30, p: 3, k: 2.5, f: 0.6, fib: 1.6 },
shelfLifeGuidance: { fridge: 4 },
defaultPriceMinorPerKg: 40000,
peakSeasons: ["summer"],
}),
ing("ginger", "Ingefära (färsk)", "Ginger", "gronsaker", {
...veg,
n: { kcal: 80, p: 1.8, k: 16, f: 0.8, fib: 2 },
shelfLifeGuidance: { fridge: 21 },
defaultPriceMinorPerKg: 8000,
}),
ing("mushroom", "Champinjoner", "Mushrooms", "gronsaker", {
...veg,
n: { kcal: 25, p: 3, k: 1.5, f: 0.4, fib: 1.5 },
shelfLifeGuidance: { fridge: 5 },
defaultPriceMinorPerKg: 6000,
peakSeasons: ["autumn"],
}),
ing("frozen_peas", "Gröna ärtor (frysta)", "Frozen peas", "gronsaker", {
...veg,
n: { kcal: 78, p: 5.5, k: 11, f: 0.5, fib: 5.5, s: 5 },
shelfLifeGuidance: { freezer: 365 },
defaultPriceMinorPerKg: 3000,
}),
ing("corn", "Majs (konserverad)", "Sweet corn", "gronsaker", {
...veg,
n: { kcal: 90, p: 3, k: 17, f: 1.2, fib: 2.5, s: 5 },
shelfLifeGuidance: { pantry: 365, fridge: 4 },
defaultPriceMinorPerKg: 3000,
}),
ing("olives", "Oliver", "Olives", "gronsaker", {
...veg,
n: { kcal: 150, p: 1, k: 1, f: 15, mf: 2.2, salt: 3.5 },
shelfLifeGuidance: { fridge: 30 },
defaultPriceMinorPerKg: 10000,
}),
ing("avocado", "Avokado", "Avocado", "gronsaker", {
...veg,
defaultUnit: "COUNT",
gramsPerPiece: 200,
n: { kcal: 160, p: 2, k: 2, f: 15, mf: 3.1, fib: 6.7 },
shelfLifeGuidance: { pantry: 4, fridge: 7 },
defaultPriceMinorPerKg: 6000,
}),
// --- Frukt & bär ---
ing("lemon", "Citron", "Lemon", "frukt", {
...veg,
defaultUnit: "COUNT",
gramsPerPiece: 120,
n: { kcal: 30, p: 1, k: 9, f: 0.3, fib: 2.8, s: 2.5 },
shelfLifeGuidance: { pantry: 14, fridge: 30 },
defaultPriceMinorPerKg: 3000,
}),
ing("lime", "Lime", "Lime", "frukt", {
...veg,
defaultUnit: "COUNT",
gramsPerPiece: 70,
n: { kcal: 30, p: 0.7, k: 10, f: 0.2, fib: 2.8, s: 1.7 },
shelfLifeGuidance: { pantry: 14, fridge: 30 },
defaultPriceMinorPerKg: 4000,
}),
ing("apple", "Äpple", "Apple", "frukt", {
...veg,
defaultUnit: "COUNT",
gramsPerPiece: 180,
n: { kcal: 52, p: 0.3, k: 12, f: 0.2, fib: 2.4, s: 10 },
shelfLifeGuidance: { pantry: 14, fridge: 30 },
defaultPriceMinorPerKg: 2500,
peakSeasons: ["autumn"],
}),
ing("banana", "Banan", "Banana", "frukt", {
...veg,
defaultUnit: "COUNT",
gramsPerPiece: 120,
n: { kcal: 89, p: 1.1, k: 20, f: 0.3, fib: 2.6, s: 12 },
shelfLifeGuidance: { pantry: 5 },
defaultPriceMinorPerKg: 2500,
}),
ing("strawberry", "Jordgubbar", "Strawberries", "frukt", {
...veg,
n: { kcal: 33, p: 0.7, k: 6, f: 0.3, fib: 2, s: 4.9 },
shelfLifeGuidance: { fridge: 3 },
defaultPriceMinorPerKg: 6000,
peakSeasons: ["summer"],
}),
// --- Spannmål, pasta, bröd ---
ing("pasta_dry", "Pasta (torr)", "Dry pasta", "spannmal", {
...veg,
containsGluten: true,
allergens: ["gluten"],
n: { kcal: 360, p: 12, k: 72, f: 1.5, fib: 3 },
shelfLifeGuidance: { pantry: 730 },
defaultPriceMinorPerKg: 2500,
aliases: ["spaghetti", "penne", "makaroner"],
}),
ing("pasta_gluten_free", "Glutenfri pasta", "Gluten-free pasta", "spannmal", {
...veg,
n: { kcal: 355, p: 7, k: 76, f: 2, fib: 2.5 },
shelfLifeGuidance: { pantry: 730 },
defaultPriceMinorPerKg: 4500,
}),
ing("rice_white", "Ris (jasmin/basmati, torrt)", "White rice", "spannmal", {
...veg,
densityGPerMl: 0.85,
n: { kcal: 350, p: 7, k: 78, f: 0.6, fib: 1.4 },
shelfLifeGuidance: { pantry: 730 },
defaultPriceMinorPerKg: 3000,
aliases: ["ris", "jasminris", "basmatiris"],
}),
ing("noodles_egg", "Äggnudlar (torra)", "Egg noodles", "spannmal", {
...vgt,
containsGluten: true,
allergens: ["gluten", "eggs"],
n: { kcal: 365, p: 13, k: 70, f: 4, fib: 3 },
shelfLifeGuidance: { pantry: 365 },
defaultPriceMinorPerKg: 4500,
aliases: ["nudlar"],
}),
ing("flour_wheat", "Vetemjöl", "Wheat flour", "spannmal", {
...veg,
containsGluten: true,
allergens: ["gluten"],
densityGPerMl: 0.6,
n: { kcal: 340, p: 10, k: 70, f: 1.5, fib: 3 },
shelfLifeGuidance: { pantry: 365 },
defaultPriceMinorPerKg: 1500,
aliases: ["mjöl"],
}),
ing("oats", "Havregryn", "Rolled oats", "spannmal", {
...veg,
densityGPerMl: 0.37,
n: { kcal: 370, p: 13, k: 58, f: 7, mf: 1.3, fib: 10 },
shelfLifeGuidance: { pantry: 365 },
defaultPriceMinorPerKg: 2000,
}),
ing("breadcrumbs", "Ströbröd", "Breadcrumbs", "spannmal", {
...veg,
containsGluten: true,
allergens: ["gluten"],
densityGPerMl: 0.55,
n: { kcal: 360, p: 11, k: 72, f: 2.5, fib: 4, salt: 1 },
shelfLifeGuidance: { pantry: 365 },
defaultPriceMinorPerKg: 3000,
}),
ing("bread_sourdough", "Surdegsbröd", "Sourdough bread", "brod", {
...veg,
containsGluten: true,
allergens: ["gluten"],
n: { kcal: 250, p: 8.5, k: 48, f: 1.5, fib: 3.5, salt: 1.1 },
shelfLifeGuidance: { pantry: 4, freezer: 90 },
defaultPriceMinorPerKg: 6000,
aliases: ["bröd"],
}),
ing("tortilla", "Tortillabröd", "Tortilla wraps", "brod", {
...veg,
containsGluten: true,
allergens: ["gluten"],
defaultUnit: "COUNT",
gramsPerPiece: 60,
n: { kcal: 300, p: 8, k: 50, f: 7, mf: 3, fib: 3, salt: 1.2 },
shelfLifeGuidance: { pantry: 30 },
defaultPriceMinorPerKg: 6000,
}),
ing("hamburger_bun", "Hamburgerbröd", "Burger buns", "brod", {
...vgt,
containsGluten: true,
allergens: ["gluten", "sesame"],
defaultUnit: "COUNT",
gramsPerPiece: 60,
n: { kcal: 290, p: 9, k: 50, f: 5, mf: 1, fib: 2.5, s: 6, salt: 1 },
shelfLifeGuidance: { pantry: 5, freezer: 90 },
defaultPriceMinorPerKg: 5500,
}),
// --- Baljväxter ---
ing("red_lentils", "Röda linser (torra)", "Red lentils", "baljvaxter", {
...veg,
densityGPerMl: 0.85,
n: { kcal: 340, p: 24, k: 52, f: 1.5, fib: 11 },
shelfLifeGuidance: { pantry: 730 },
defaultPriceMinorPerKg: 4000,
}),
ing("chickpeas_canned", "Kikärtor (kokta)", "Chickpeas", "baljvaxter", {
...veg,
n: { kcal: 120, p: 7, k: 17, f: 2.2, fib: 5.5 },
shelfLifeGuidance: { pantry: 730, fridge: 3 },
defaultPriceMinorPerKg: 3000,
}),
ing("black_beans_canned", "Svarta bönor (kokta)", "Black beans", "baljvaxter", {
...veg,
n: { kcal: 100, p: 7, k: 15, f: 0.5, fib: 7 },
shelfLifeGuidance: { pantry: 730, fridge: 3 },
defaultPriceMinorPerKg: 3000,
}),
ing("kidney_beans_canned", "Kidneybönor (kokta)", "Kidney beans", "baljvaxter", {
...veg,
n: { kcal: 100, p: 7.5, k: 14, f: 0.6, fib: 7 },
shelfLifeGuidance: { pantry: 730, fridge: 3 },
defaultPriceMinorPerKg: 3000,
}),
ing("tofu", "Tofu (naturell)", "Tofu", "baljvaxter", {
...veg,
allergens: ["soy"],
n: { kcal: 120, p: 12, k: 2, f: 7, mf: 1 },
shelfLifeGuidance: { fridge: 7 },
defaultPriceMinorPerKg: 8000,
}),
// --- Skafferi & konserver ---
ing("canned_tomatoes", "Krossade tomater", "Canned crushed tomatoes", "konserver", {
...veg,
defaultUnit: "COUNT",
gramsPerPiece: 400,
n: { kcal: 30, p: 1.3, k: 5, f: 0.2, fib: 1.5, s: 4 },
shelfLifeGuidance: { pantry: 730, fridge: 4 },
defaultPriceMinorPerKg: 2000,
}),
ing("tomato_paste", "Tomatpuré", "Tomato paste", "konserver", {
...veg,
densityGPerMl: 1.05,
n: { kcal: 90, p: 4.3, k: 15, f: 0.5, fib: 4, s: 12 },
shelfLifeGuidance: { pantry: 365, fridge: 14 },
defaultPriceMinorPerKg: 4000,
}),
ing("coconut_milk", "Kokosmjölk", "Coconut milk", "konserver", {
...veg,
defaultUnit: "DECILITER",
densityGPerMl: 0.97,
n: { kcal: 180, p: 1.7, k: 3, f: 18, mf: 16 },
shelfLifeGuidance: { pantry: 730, fridge: 3 },
defaultPriceMinorPerKg: 4500,
}),
ing("olive_oil", "Olivolja", "Olive oil", "skafferi", {
...veg,
defaultUnit: "TABLESPOON",
densityGPerMl: 0.92,
n: { kcal: 884, p: 0, k: 0, f: 100, mf: 14 },
shelfLifeGuidance: { pantry: 540 },
defaultPriceMinorPerKg: 10000,
}),
ing("rapeseed_oil", "Rapsolja", "Rapeseed oil", "skafferi", {
...veg,
defaultUnit: "TABLESPOON",
densityGPerMl: 0.92,
n: { kcal: 884, p: 0, k: 0, f: 100, mf: 7 },
shelfLifeGuidance: { pantry: 540 },
defaultPriceMinorPerKg: 5000,
aliases: ["matolja", "olja"],
}),
ing("sugar", "Strösocker", "Sugar", "skafferi", {
...veg,
densityGPerMl: 0.85,
n: { kcal: 400, p: 0, k: 100, f: 0, s: 100 },
shelfLifeGuidance: { pantry: 3650 },
defaultPriceMinorPerKg: 2000,
aliases: ["socker"],
}),
ing("honey", "Honung", "Honey", "skafferi", {
...vgt,
densityGPerMl: 1.4,
n: { kcal: 320, p: 0.4, k: 80, f: 0, s: 80 },
shelfLifeGuidance: { pantry: 1095 },
defaultPriceMinorPerKg: 12000,
}),
ing("soy_sauce", "Soja (japansk)", "Soy sauce", "skafferi", {
...veg,
allergens: ["soy", "gluten"],
containsGluten: true,
defaultUnit: "TABLESPOON",
densityGPerMl: 1.15,
n: { kcal: 60, p: 8, k: 6, f: 0, salt: 15 },
shelfLifeGuidance: { pantry: 730 },
defaultPriceMinorPerKg: 8000,
}),
ing("fish_sauce", "Fisksås", "Fish sauce", "skafferi", {
allergens: ["fish"],
defaultUnit: "TABLESPOON",
densityGPerMl: 1.2,
n: { kcal: 45, p: 9, k: 3, f: 0, salt: 24 },
shelfLifeGuidance: { pantry: 730 },
defaultPriceMinorPerKg: 9000,
}),
ing("red_curry_paste", "Röd currypasta", "Red curry paste", "skafferi", {
...veg,
allergens: [],
densityGPerMl: 1.05,
n: { kcal: 120, p: 3, k: 15, f: 5, mf: 1, salt: 6 },
shelfLifeGuidance: { pantry: 365, fridge: 30 },
defaultPriceMinorPerKg: 15000,
}),
ing("mustard", "Senap", "Mustard", "skafferi", {
...veg,
allergens: ["mustard"],
densityGPerMl: 1.05,
n: { kcal: 130, p: 6, k: 10, f: 7, mf: 0.5, s: 8, salt: 2.5 },
shelfLifeGuidance: { fridge: 90 },
defaultPriceMinorPerKg: 6000,
}),
ing("mayonnaise", "Majonnäs", "Mayonnaise", "skafferi", {
...vgt,
allergens: ["eggs", "mustard"],
densityGPerMl: 0.95,
n: { kcal: 680, p: 1, k: 2, f: 75, mf: 6, salt: 1 },
shelfLifeGuidance: { fridge: 60 },
defaultPriceMinorPerKg: 7000,
}),
ing("vegetable_stock_cube", "Grönsaksbuljongtärning", "Vegetable stock cube", "skafferi", {
...veg,
allergens: ["celery"],
defaultUnit: "COUNT",
gramsPerPiece: 10,
n: { kcal: 220, p: 8, k: 25, f: 10, mf: 5, salt: 45 },
shelfLifeGuidance: { pantry: 730 },
defaultPriceMinorPerKg: 20000,
aliases: ["buljong"],
}),
ing("chicken_stock_cube", "Kycklingbuljongtärning", "Chicken stock cube", "skafferi", {
allergens: ["celery"],
defaultUnit: "COUNT",
gramsPerPiece: 10,
n: { kcal: 230, p: 9, k: 24, f: 11, mf: 5.5, salt: 45 },
shelfLifeGuidance: { pantry: 730 },
defaultPriceMinorPerKg: 20000,
}),
ing("peanut_butter", "Jordnötssmör", "Peanut butter", "skafferi", {
...veg,
allergens: ["peanuts"],
n: { kcal: 600, p: 25, k: 12, f: 50, mf: 10, fib: 6, salt: 0.8 },
shelfLifeGuidance: { pantry: 365 },
defaultPriceMinorPerKg: 9000,
}),
ing("teriyaki_sauce", "Teriyakisås", "Teriyaki sauce", "skafferi", {
...veg,
allergens: ["soy", "gluten"],
containsGluten: true,
defaultUnit: "TABLESPOON",
densityGPerMl: 1.2,
n: { kcal: 130, p: 4, k: 28, f: 0, s: 22, salt: 8 },
shelfLifeGuidance: { pantry: 365, fridge: 60 },
defaultPriceMinorPerKg: 10000,
}),
ing("taco_spice", "Tacokrydda", "Taco seasoning", "kryddor", {
...veg,
densityGPerMl: 0.5,
n: { kcal: 280, p: 8, k: 45, f: 6, fib: 12, salt: 18 },
shelfLifeGuidance: { pantry: 730 },
defaultPriceMinorPerKg: 25000,
}),
ing("salsa", "Salsa (burk)", "Salsa", "konserver", {
...veg,
n: { kcal: 45, p: 1.5, k: 8, f: 0.3, fib: 1.5, s: 6, salt: 1.5 },
shelfLifeGuidance: { pantry: 365, fridge: 7 },
defaultPriceMinorPerKg: 4500,
}),
ing("sesame_seeds", "Sesamfrön", "Sesame seeds", "skafferi", {
...veg,
allergens: ["sesame"],
defaultUnit: "TABLESPOON",
densityGPerMl: 0.6,
n: { kcal: 570, p: 18, k: 12, f: 50, mf: 7, fib: 12 },
shelfLifeGuidance: { pantry: 365 },
defaultPriceMinorPerKg: 12000,
}),
// --- Kryddor & bas ---
ing("salt", "Salt", "Salt", "kryddor", {
...veg,
defaultUnit: "TEASPOON",
densityGPerMl: 1.2,
n: { kcal: 0, p: 0, k: 0, f: 0, salt: 100 },
shelfLifeGuidance: { pantry: 3650 },
defaultPriceMinorPerKg: 1500,
}),
ing("black_pepper", "Svartpeppar", "Black pepper", "kryddor", {
...veg,
defaultUnit: "MILLILITER",
densityGPerMl: 0.5,
n: { kcal: 250, p: 10, k: 44, f: 3.3, fib: 25 },
shelfLifeGuidance: { pantry: 1095 },
defaultPriceMinorPerKg: 30000,
aliases: ["peppar"],
}),
ing("paprika_powder", "Paprikapulver", "Paprika powder", "kryddor", {
...veg,
defaultUnit: "TEASPOON",
densityGPerMl: 0.45,
n: { kcal: 280, p: 14, k: 34, f: 13, fib: 35 },
shelfLifeGuidance: { pantry: 1095 },
defaultPriceMinorPerKg: 25000,
}),
ing("cumin", "Spiskummin", "Cumin", "kryddor", {
...veg,
defaultUnit: "TEASPOON",
densityGPerMl: 0.5,
n: { kcal: 375, p: 18, k: 44, f: 22, fib: 10 },
shelfLifeGuidance: { pantry: 1095 },
defaultPriceMinorPerKg: 30000,
}),
ing("chili_flakes", "Chiliflakes", "Chili flakes", "kryddor", {
...veg,
defaultUnit: "MILLILITER",
densityGPerMl: 0.4,
n: { kcal: 320, p: 12, k: 50, f: 17, fib: 27 },
shelfLifeGuidance: { pantry: 1095 },
defaultPriceMinorPerKg: 35000,
}),
ing("oregano_dried", "Oregano (torkad)", "Dried oregano", "kryddor", {
...veg,
defaultUnit: "TEASPOON",
densityGPerMl: 0.3,
n: { kcal: 265, p: 9, k: 69, f: 4.3, fib: 42 },
shelfLifeGuidance: { pantry: 1095 },
defaultPriceMinorPerKg: 40000,
}),
ing("thyme_dried", "Timjan (torkad)", "Dried thyme", "kryddor", {
...veg,
defaultUnit: "TEASPOON",
densityGPerMl: 0.3,
n: { kcal: 276, p: 9, k: 64, f: 7.4, fib: 37 },
shelfLifeGuidance: { pantry: 1095 },
defaultPriceMinorPerKg: 40000,
}),
ing("curry_powder", "Currypulver", "Curry powder", "kryddor", {
...veg,
defaultUnit: "TEASPOON",
densityGPerMl: 0.45,
n: { kcal: 325, p: 14, k: 25, f: 14, fib: 33 },
shelfLifeGuidance: { pantry: 1095 },
defaultPriceMinorPerKg: 30000,
}),
ing("garam_masala", "Garam masala", "Garam masala", "kryddor", {
...veg,
defaultUnit: "TEASPOON",
densityGPerMl: 0.45,
n: { kcal: 380, p: 15, k: 45, f: 15, fib: 25 },
shelfLifeGuidance: { pantry: 1095 },
defaultPriceMinorPerKg: 35000,
}),
ing("turmeric", "Gurkmeja", "Turmeric", "kryddor", {
...veg,
defaultUnit: "TEASPOON",
densityGPerMl: 0.5,
n: { kcal: 350, p: 8, k: 65, f: 10, fib: 21 },
shelfLifeGuidance: { pantry: 1095 },
defaultPriceMinorPerKg: 25000,
}),
ing("cinnamon", "Kanel", "Cinnamon", "kryddor", {
...veg,
defaultUnit: "TEASPOON",
densityGPerMl: 0.55,
n: { kcal: 250, p: 4, k: 80, f: 1.2, fib: 53 },
shelfLifeGuidance: { pantry: 1095 },
defaultPriceMinorPerKg: 25000,
}),
ing("allspice", "Kryddpeppar", "Allspice", "kryddor", {
...veg,
defaultUnit: "MILLILITER",
densityGPerMl: 0.5,
n: { kcal: 263, p: 6, k: 72, f: 8.7, fib: 22 },
shelfLifeGuidance: { pantry: 1095 },
defaultPriceMinorPerKg: 35000,
}),
];
export const SEED_INGREDIENT_IDS = new Set(SEED_INGREDIENTS.map((i) => i.id));
+1108
View File
@@ -0,0 +1,1108 @@
import type {
CookingMethod,
Cuisine,
Equipment,
MealType,
RecipeDifficulty,
RecipeTag,
RecipeVariantType,
Season,
Unit,
} from "@app/shared-types";
/**
* Seed: redaktionella originalrecept skrivna för plattformen (sourceType:
* own_editorial egna formuleringar, ingen kopierad text, spec §15).
* Näring + allergener beräknas deterministiskt vid seed ur ingredienserna.
*/
export interface SeedRecipeIngredient {
ing: string; // canonical ingredient id
nameSv: string;
qty: number;
unit: Unit;
note?: string;
optional?: boolean;
group?: string;
}
export interface SeedRecipeStep {
text: string;
timerSeconds?: number;
temperatureC?: number;
tip?: string;
}
export interface SeedRecipe {
slug: string;
titleSv: string;
descriptionSv: string;
cuisine: Cuisine;
country?: string;
mealTypes: MealType[];
tags: RecipeTag[];
methods: CookingMethod[];
equipment: Equipment[];
difficulty: RecipeDifficulty;
prepMin: number;
cookMin: number;
portions: number;
spiceLevel: number;
mealPrepFriendly: boolean;
freezerFriendly: boolean;
peakSeasons: Season[];
holidayTags: string[];
variantType?: RecipeVariantType;
variantOfSlug?: string;
storageGuidanceSv?: string;
dnaProtein?: string;
dnaCarb?: string;
dnaVegetables: string[];
dnaFlavor: string[];
ingredients: SeedRecipeIngredient[];
steps: SeedRecipeStep[];
}
export const SEED_RECIPES: SeedRecipe[] = [
{
slug: "kottbullar-med-potatismos",
titleSv: "Köttbullar med potatismos",
descriptionSv:
"Klassiska svenska köttbullar med krämigt hemlagat potatismos. En rätt som fungerar lika bra en tisdag som på julbordet.",
cuisine: "swedish",
country: "Sverige",
mealTypes: ["dinner", "lunch"],
tags: ["kid_friendly", "meal_prep", "freezer_friendly"],
methods: ["stovetop"],
equipment: ["stove"],
difficulty: "easy",
prepMin: 20,
cookMin: 25,
portions: 4,
spiceLevel: 0,
mealPrepFriendly: true,
freezerFriendly: true,
peakSeasons: [],
holidayTags: ["jul", "midsommar"],
storageGuidanceSv: "Håller 3 dagar i kyl. Köttbullarna kan frysas i 3 månader.",
dnaProtein: "minced_mixed",
dnaCarb: "potato",
dnaVegetables: ["onion"],
dnaFlavor: ["savory", "classic", "allspice"],
ingredients: [
{ ing: "minced_mixed", nameSv: "Blandfärs", qty: 500, unit: "GRAM", group: "Köttbullar" },
{
ing: "onion",
nameSv: "Gul lök",
qty: 1,
unit: "COUNT",
note: "finhackad",
group: "Köttbullar",
},
{ ing: "breadcrumbs", nameSv: "Ströbröd", qty: 0.5, unit: "DECILITER", group: "Köttbullar" },
{ ing: "milk_3", nameSv: "Mjölk", qty: 1, unit: "DECILITER", group: "Köttbullar" },
{ ing: "egg", nameSv: "Ägg", qty: 1, unit: "COUNT", group: "Köttbullar" },
{ ing: "allspice", nameSv: "Kryddpeppar", qty: 2, unit: "MILLILITER", group: "Köttbullar" },
{ ing: "salt", nameSv: "Salt", qty: 1, unit: "TEASPOON", group: "Köttbullar" },
{ ing: "butter", nameSv: "Smör till stekning", qty: 25, unit: "GRAM", group: "Köttbullar" },
{ ing: "potato", nameSv: "Potatis (mjölig)", qty: 8, unit: "COUNT", group: "Potatismos" },
{ ing: "milk_3", nameSv: "Mjölk", qty: 2, unit: "DECILITER", group: "Potatismos" },
{ ing: "butter", nameSv: "Smör", qty: 50, unit: "GRAM", group: "Potatismos" },
],
steps: [
{
text: "Blanda ströbröd och mjölk i en bunke och låt svälla i 5 minuter.",
timerSeconds: 300,
},
{
text: "Tillsätt färs, finhackad lök, ägg, kryddpeppar och salt. Arbeta ihop till en jämn smet.",
},
{
text: "Rulla till jämnstora bullar med fuktade händer.",
tip: "Blöt händerna så fastnar inte smeten.",
},
{
text: "Skala potatisen och koka mjuk i saltat vatten, cirka 20 minuter.",
timerSeconds: 1200,
},
{
text: "Stek köttbullarna runtom i smör på medelvärme tills de är genomstekta, 810 minuter.",
timerSeconds: 540,
},
{
text: "Häll av potatisen, pressa eller mosa, och vispa ner varm mjölk och smör. Smaka av med salt.",
},
{ text: "Servera köttbullarna med moset. Lingonsylt och pressgurka passar fint till." },
],
},
{
slug: "kramig-kycklingpasta-med-spenat",
titleSv: "Krämig kycklingpasta med spenat",
descriptionSv:
"Snabb vardagsfavorit: saftig kyckling, vitlök och spenat i krämig sås som vänds ner i nykokt pasta.",
cuisine: "italian",
mealTypes: ["dinner"],
tags: ["quick", "kid_friendly", "high_protein"],
methods: ["stovetop"],
equipment: ["stove"],
difficulty: "beginner",
prepMin: 10,
cookMin: 15,
portions: 4,
spiceLevel: 1,
mealPrepFriendly: true,
freezerFriendly: false,
peakSeasons: [],
holidayTags: [],
storageGuidanceSv:
"Håller 23 dagar i kyl. Såsen kan tjockna späd med lite mjölk vid uppvärmning.",
dnaProtein: "chicken_breast",
dnaCarb: "pasta_dry",
dnaVegetables: ["spinach"],
dnaFlavor: ["creamy", "garlic"],
ingredients: [
{ ing: "pasta_dry", nameSv: "Pasta", qty: 320, unit: "GRAM" },
{ ing: "chicken_breast", nameSv: "Kycklingfilé", qty: 500, unit: "GRAM", note: "i bitar" },
{ ing: "garlic", nameSv: "Vitlöksklyftor", qty: 2, unit: "COUNT", note: "finhackade" },
{ ing: "spinach", nameSv: "Färsk spenat", qty: 100, unit: "GRAM" },
{ ing: "cooking_cream", nameSv: "Matlagningsgrädde", qty: 3, unit: "DECILITER" },
{ ing: "cheese_hard", nameSv: "Riven ost", qty: 50, unit: "GRAM" },
{ ing: "olive_oil", nameSv: "Olivolja", qty: 1, unit: "TABLESPOON" },
{ ing: "salt", nameSv: "Salt", qty: 1, unit: "TEASPOON" },
{ ing: "black_pepper", nameSv: "Svartpeppar", qty: 2, unit: "MILLILITER" },
{ ing: "chili_flakes", nameSv: "Chiliflakes", qty: 1, unit: "MILLILITER", optional: true },
],
steps: [
{ text: "Koka pastan enligt tiden på paketet i rikligt saltat vatten.", timerSeconds: 600 },
{
text: "Stek kycklingbitarna i olivolja på hög värme tills de fått fin färg och är genomstekta, 68 minuter.",
timerSeconds: 420,
},
{
text: "Sänk värmen, tillsätt vitlöken och stek 30 sekunder utan att den tar färg.",
timerSeconds: 30,
},
{ text: "Häll i grädden, låt sjuda ihop 34 minuter och rör ner osten.", timerSeconds: 210 },
{
text: "Vänd ner spenaten tills den precis sjunker ihop. Smaka av med salt, peppar och ev. chiliflakes.",
},
{ text: "Blanda såsen med den nykokta pastan och servera direkt." },
],
},
{
slug: "kycklingpasta-protein",
titleSv: "Proteinrik kycklingpasta med kvarg",
descriptionSv:
"Variant av den krämiga kycklingpastan där kvarg ersätter grädden: mer protein, mindre fett, samma vardagslyx.",
cuisine: "italian",
mealTypes: ["dinner"],
tags: ["quick", "high_protein", "low_fat"],
methods: ["stovetop"],
equipment: ["stove"],
difficulty: "beginner",
prepMin: 10,
cookMin: 15,
portions: 4,
spiceLevel: 1,
mealPrepFriendly: true,
freezerFriendly: false,
peakSeasons: [],
holidayTags: [],
variantType: "high_protein",
variantOfSlug: "kramig-kycklingpasta-med-spenat",
dnaProtein: "chicken_breast",
dnaCarb: "pasta_dry",
dnaVegetables: ["spinach"],
dnaFlavor: ["creamy", "garlic", "light"],
ingredients: [
{ ing: "pasta_dry", nameSv: "Pasta", qty: 320, unit: "GRAM" },
{ ing: "chicken_breast", nameSv: "Kycklingfilé", qty: 600, unit: "GRAM", note: "i bitar" },
{ ing: "garlic", nameSv: "Vitlöksklyftor", qty: 2, unit: "COUNT" },
{ ing: "spinach", nameSv: "Färsk spenat", qty: 100, unit: "GRAM" },
{ ing: "quark", nameSv: "Kvarg", qty: 250, unit: "GRAM" },
{ ing: "milk_1_5", nameSv: "Mellanmjölk", qty: 1, unit: "DECILITER" },
{ ing: "olive_oil", nameSv: "Olivolja", qty: 1, unit: "TABLESPOON" },
{ ing: "salt", nameSv: "Salt", qty: 1, unit: "TEASPOON" },
{ ing: "black_pepper", nameSv: "Svartpeppar", qty: 2, unit: "MILLILITER" },
],
steps: [
{ text: "Koka pastan enligt paketets anvisning.", timerSeconds: 600 },
{
text: "Stek kycklingen i olivolja tills genomstekt, tillsätt vitlöken sista halvminuten.",
timerSeconds: 450,
},
{
text: "Sänk värmen till låg. Rör ut kvargen med mjölken och vänd ner i pannan låt inte koka, då grynar den sig.",
tip: "Kvarg tillsätts alltid på slutet på låg värme.",
},
{ text: "Vänd ner spenaten och pastan, smaka av med salt och peppar. Servera direkt." },
],
},
{
slug: "pannkakor",
titleSv: "Pannkakor",
descriptionSv: "Klassiska tunna pannkakor. Barnens favorit och de vuxnas.",
cuisine: "swedish",
mealTypes: ["dinner", "lunch", "dessert"],
tags: ["kid_friendly", "budget", "quick"],
methods: ["stovetop"],
equipment: ["stove"],
difficulty: "beginner",
prepMin: 5,
cookMin: 20,
portions: 4,
spiceLevel: 0,
mealPrepFriendly: false,
freezerFriendly: true,
peakSeasons: [],
holidayTags: [],
dnaCarb: "flour_wheat",
dnaVegetables: [],
dnaFlavor: ["sweet", "classic"],
ingredients: [
{ ing: "flour_wheat", nameSv: "Vetemjöl", qty: 2.5, unit: "DECILITER" },
{ ing: "salt", nameSv: "Salt", qty: 0.5, unit: "TEASPOON" },
{ ing: "milk_3", nameSv: "Mjölk", qty: 6, unit: "DECILITER" },
{ ing: "egg", nameSv: "Ägg", qty: 3, unit: "COUNT" },
{ ing: "butter", nameSv: "Smör till stekning", qty: 25, unit: "GRAM" },
],
steps: [
{ text: "Vispa mjöl och salt med hälften av mjölken till en slät smet." },
{ text: "Vispa i resten av mjölken och äggen." },
{
text: "Låt smeten svälla 10 minuter om du hinner.",
timerSeconds: 600,
tip: "Svälld smet ger jämnare pannkakor.",
},
{
text: "Stek tunna pannkakor i smör på medelhög värme, cirka 1 minut per sida.",
timerSeconds: 60,
},
{ text: "Servera med sylt och grädde, eller vänd ner bär." },
],
},
{
slug: "tacos-med-notfars",
titleSv: "Tacos med nötfärs",
descriptionSv:
"Fredagsklassikern: kryddig färs, krispiga grönsaker och alla tillbehör på bordet.",
cuisine: "mexican",
mealTypes: ["dinner"],
tags: ["kid_friendly", "quick"],
methods: ["stovetop"],
equipment: ["stove"],
difficulty: "beginner",
prepMin: 15,
cookMin: 10,
portions: 4,
spiceLevel: 1,
mealPrepFriendly: false,
freezerFriendly: false,
peakSeasons: [],
holidayTags: ["fredagsmys"],
dnaProtein: "minced_beef",
dnaCarb: "tortilla",
dnaVegetables: ["tomato", "lettuce", "corn"],
dnaFlavor: ["spiced", "fresh"],
ingredients: [
{ ing: "minced_beef", nameSv: "Nötfärs", qty: 500, unit: "GRAM" },
{ ing: "taco_spice", nameSv: "Tacokrydda", qty: 2, unit: "TABLESPOON" },
{ ing: "tortilla", nameSv: "Tortillabröd", qty: 8, unit: "COUNT" },
{ ing: "tomato", nameSv: "Tomater", qty: 2, unit: "COUNT", note: "tärnade" },
{ ing: "lettuce", nameSv: "Sallad", qty: 0.5, unit: "COUNT", note: "strimlad" },
{ ing: "corn", nameSv: "Majs", qty: 200, unit: "GRAM" },
{ ing: "cheese_hard", nameSv: "Riven ost", qty: 100, unit: "GRAM" },
{ ing: "creme_fraiche", nameSv: "Crème fraiche", qty: 2, unit: "DECILITER" },
{ ing: "salsa", nameSv: "Salsa", qty: 200, unit: "GRAM" },
{ ing: "rapeseed_oil", nameSv: "Olja till stekning", qty: 1, unit: "TABLESPOON" },
],
steps: [
{ text: "Bryn färsen i olja på hög värme tills den fått färg." },
{ text: "Rör i tacokryddan och 1 dl vatten, låt puttra 5 minuter.", timerSeconds: 300 },
{ text: "Tärna tomat, strimla sallad och riv osten. Ställ fram allt i skålar." },
{ text: "Värm tortillabröden enligt paketet." },
{ text: "Låt alla bygga sina egna tacos vid bordet." },
],
},
{
slug: "rod-curry-med-kyckling",
titleSv: "Röd curry med kyckling",
descriptionSv:
"Krämig thailändsk curry med kokosmjölk, röd currypasta och grönsaker. Värmande och full av smak.",
cuisine: "thai",
mealTypes: ["dinner"],
tags: ["quick", "high_protein"],
methods: ["wok", "stovetop"],
equipment: ["stove", "wok_pan"],
difficulty: "easy",
prepMin: 15,
cookMin: 15,
portions: 4,
spiceLevel: 3,
mealPrepFriendly: true,
freezerFriendly: true,
peakSeasons: ["autumn", "winter"],
holidayTags: [],
storageGuidanceSv: "Håller 3 dagar i kyl och kan frysas. Riset kokas bäst färskt.",
dnaProtein: "chicken_thigh",
dnaCarb: "rice_white",
dnaVegetables: ["bell_pepper", "broccoli"],
dnaFlavor: ["creamy", "spicy", "coconut"],
ingredients: [
{ ing: "chicken_thigh", nameSv: "Kycklinglårfilé", qty: 500, unit: "GRAM", note: "i bitar" },
{ ing: "red_curry_paste", nameSv: "Röd currypasta", qty: 2, unit: "TABLESPOON" },
{ ing: "coconut_milk", nameSv: "Kokosmjölk", qty: 4, unit: "DECILITER" },
{ ing: "bell_pepper", nameSv: "Paprika", qty: 1, unit: "COUNT", note: "strimlad" },
{ ing: "broccoli", nameSv: "Broccoli", qty: 250, unit: "GRAM", note: "i buketter" },
{ ing: "fish_sauce", nameSv: "Fisksås", qty: 1, unit: "TABLESPOON" },
{ ing: "lime", nameSv: "Lime", qty: 0.5, unit: "COUNT", note: "saften" },
{ ing: "rice_white", nameSv: "Jasminris", qty: 3, unit: "DECILITER" },
{ ing: "rapeseed_oil", nameSv: "Olja", qty: 1, unit: "TABLESPOON" },
{ ing: "sugar", nameSv: "Socker", qty: 1, unit: "TEASPOON" },
],
steps: [
{ text: "Koka riset enligt paketets anvisning.", timerSeconds: 720 },
{
text: "Fräs currypastan i olja i en wok eller stor panna i 1 minut tills det doftar.",
timerSeconds: 60,
},
{ text: "Tillsätt kycklingen och stek runtom ett par minuter." },
{ text: "Häll i kokosmjölken och låt sjuda 5 minuter.", timerSeconds: 300 },
{
text: "Lägg i paprika och broccoli och sjud ytterligare 45 minuter tills kycklingen är genomstekt.",
timerSeconds: 270,
},
{ text: "Smaka av med fisksås, limesaft och socker. Servera med riset." },
],
},
{
slug: "ugnsbakad-lax-med-citron-och-dill",
titleSv: "Ugnsbakad lax med citron och dill",
descriptionSv: "Lax i ugn med citron, dill och kokt potatis enkel nordisk vardagslyx.",
cuisine: "nordic",
mealTypes: ["dinner"],
tags: ["high_protein", "quick"],
methods: ["oven"],
equipment: ["oven", "stove"],
difficulty: "beginner",
prepMin: 10,
cookMin: 20,
portions: 4,
spiceLevel: 0,
mealPrepFriendly: true,
freezerFriendly: false,
peakSeasons: ["summer"],
holidayTags: ["midsommar"],
dnaProtein: "salmon",
dnaCarb: "potato",
dnaVegetables: ["dill", "lemon"],
dnaFlavor: ["fresh", "lemon", "dill"],
ingredients: [
{ ing: "salmon", nameSv: "Laxfilé", qty: 600, unit: "GRAM" },
{ ing: "lemon", nameSv: "Citron", qty: 1, unit: "COUNT", note: "i skivor" },
{ ing: "dill", nameSv: "Färsk dill", qty: 20, unit: "GRAM" },
{ ing: "potato", nameSv: "Potatis", qty: 8, unit: "COUNT" },
{ ing: "butter", nameSv: "Smör", qty: 25, unit: "GRAM" },
{ ing: "salt", nameSv: "Salt", qty: 1, unit: "TEASPOON" },
{ ing: "black_pepper", nameSv: "Svartpeppar", qty: 2, unit: "MILLILITER" },
],
steps: [
{ text: "Sätt ugnen på 200 °C.", temperatureC: 200 },
{ text: "Koka potatisen i saltat vatten, 1820 minuter.", timerSeconds: 1140 },
{
text: "Lägg laxen i en smord form, salta, peppra och toppa med citronskivor och halva dillen.",
},
{
text: "Baka i ugnen 1518 minuter tills laxen precis går att dela i mitten.",
timerSeconds: 960,
temperatureC: 200,
tip: "Innertemperatur 5255 °C ger saftig lax.",
},
{ text: "Servera med potatis, smör och resten av dillen." },
],
},
{
slug: "vegetarisk-linsgryta",
titleSv: "Vegetarisk linsgryta med kokos",
descriptionSv:
"Mustig gryta på röda linser, tomat och kokosmjölk med värmande indiska kryddor. Vegansk, billig och mättande.",
cuisine: "indian",
mealTypes: ["dinner", "lunch"],
tags: ["vegan", "vegetarian", "budget", "meal_prep", "freezer_friendly"],
methods: ["stovetop"],
equipment: ["stove"],
difficulty: "beginner",
prepMin: 10,
cookMin: 25,
portions: 4,
spiceLevel: 2,
mealPrepFriendly: true,
freezerFriendly: true,
peakSeasons: ["autumn", "winter"],
holidayTags: [],
storageGuidanceSv: "Blir bara godare dagen efter. Håller 4 dagar i kyl, fryser utmärkt.",
dnaProtein: "red_lentils",
dnaCarb: "rice_white",
dnaVegetables: ["onion", "carrot", "canned_tomatoes"],
dnaFlavor: ["spiced", "coconut", "warming"],
ingredients: [
{ ing: "red_lentils", nameSv: "Röda linser", qty: 3, unit: "DECILITER" },
{ ing: "onion", nameSv: "Gul lök", qty: 1, unit: "COUNT", note: "hackad" },
{ ing: "garlic", nameSv: "Vitlöksklyftor", qty: 2, unit: "COUNT" },
{ ing: "ginger", nameSv: "Färsk ingefära", qty: 15, unit: "GRAM", note: "riven" },
{ ing: "carrot", nameSv: "Morötter", qty: 2, unit: "COUNT", note: "tärnade" },
{ ing: "canned_tomatoes", nameSv: "Krossade tomater", qty: 1, unit: "COUNT" },
{ ing: "coconut_milk", nameSv: "Kokosmjölk", qty: 4, unit: "DECILITER" },
{ ing: "curry_powder", nameSv: "Currypulver", qty: 1, unit: "TABLESPOON" },
{ ing: "cumin", nameSv: "Spiskummin", qty: 1, unit: "TEASPOON" },
{ ing: "vegetable_stock_cube", nameSv: "Grönsaksbuljongtärning", qty: 1, unit: "COUNT" },
{ ing: "rapeseed_oil", nameSv: "Olja", qty: 1, unit: "TABLESPOON" },
{ ing: "rice_white", nameSv: "Ris till servering", qty: 3, unit: "DECILITER" },
],
steps: [
{ text: "Fräs lök, vitlök och ingefära mjuka i olja på medelvärme." },
{ text: "Rör i curry och spiskummin och fräs 30 sekunder.", timerSeconds: 30 },
{
text: "Tillsätt linser, morot, krossade tomater, kokosmjölk, buljongtärning och 3 dl vatten.",
},
{ text: "Låt sjuda under lock i 20 minuter, rör om då och då.", timerSeconds: 1200 },
{ text: "Koka riset under tiden.", timerSeconds: 720 },
{ text: "Smaka av grytan med salt. Servera med ris och gärna färsk koriander." },
],
},
{
slug: "korv-stroganoff",
titleSv: "Korv stroganoff",
descriptionSv: "Snabb svensk klassiker med falukorv i krämig tomatsås. Serveras med ris.",
cuisine: "swedish",
mealTypes: ["dinner", "lunch"],
tags: ["kid_friendly", "budget", "quick"],
methods: ["stovetop"],
equipment: ["stove"],
difficulty: "beginner",
prepMin: 10,
cookMin: 15,
portions: 4,
spiceLevel: 0,
mealPrepFriendly: true,
freezerFriendly: false,
peakSeasons: [],
holidayTags: [],
dnaProtein: "falukorv",
dnaCarb: "rice_white",
dnaVegetables: ["onion"],
dnaFlavor: ["creamy", "tomato", "classic"],
ingredients: [
{ ing: "falukorv", nameSv: "Falukorv", qty: 400, unit: "GRAM", note: "i strimlor" },
{ ing: "onion", nameSv: "Gul lök", qty: 1, unit: "COUNT", note: "skivad" },
{ ing: "tomato_paste", nameSv: "Tomatpuré", qty: 2, unit: "TABLESPOON" },
{ ing: "cooking_cream", nameSv: "Matlagningsgrädde", qty: 3, unit: "DECILITER" },
{ ing: "mustard", nameSv: "Senap", qty: 1, unit: "TEASPOON" },
{ ing: "paprika_powder", nameSv: "Paprikapulver", qty: 1, unit: "TEASPOON" },
{ ing: "rice_white", nameSv: "Ris", qty: 3, unit: "DECILITER" },
{ ing: "rapeseed_oil", nameSv: "Olja", qty: 1, unit: "TABLESPOON" },
],
steps: [
{ text: "Koka riset enligt paketets anvisning.", timerSeconds: 720 },
{ text: "Stek korvstrimlor och lök i olja tills de fått lite färg." },
{ text: "Rör i tomatpuré och paprikapulver, fräs 1 minut.", timerSeconds: 60 },
{ text: "Häll i grädden och senapen, låt sjuda 5 minuter.", timerSeconds: 300 },
{ text: "Smaka av med svartpeppar och servera med riset." },
],
},
{
slug: "grekisk-sallad",
titleSv: "Grekisk sallad med fetaost",
descriptionSv:
"Solmogen tomat, gurka, rödlök, oliver och fetaost med olivolja och oregano. Somrig, snabb och helt utan spis.",
cuisine: "greek",
mealTypes: ["lunch", "dinner", "starter"],
tags: ["vegetarian", "quick", "low_carb", "gluten_free"],
methods: ["no_cook"],
equipment: [],
difficulty: "beginner",
prepMin: 15,
cookMin: 0,
portions: 4,
spiceLevel: 0,
mealPrepFriendly: false,
freezerFriendly: false,
peakSeasons: ["summer"],
holidayTags: [],
dnaProtein: "feta",
dnaVegetables: ["tomato", "cucumber", "red_onion", "olives"],
dnaFlavor: ["fresh", "salty", "mediterranean"],
ingredients: [
{ ing: "tomato", nameSv: "Tomater", qty: 4, unit: "COUNT", note: "i klyftor" },
{ ing: "cucumber", nameSv: "Gurka", qty: 0.5, unit: "COUNT", note: "i bitar" },
{ ing: "red_onion", nameSv: "Rödlök", qty: 0.5, unit: "COUNT", note: "tunt skivad" },
{ ing: "olives", nameSv: "Oliver", qty: 100, unit: "GRAM" },
{ ing: "feta", nameSv: "Fetaost", qty: 200, unit: "GRAM" },
{ ing: "olive_oil", nameSv: "Olivolja", qty: 3, unit: "TABLESPOON" },
{ ing: "oregano_dried", nameSv: "Torkad oregano", qty: 1, unit: "TEASPOON" },
{ ing: "black_pepper", nameSv: "Svartpeppar", qty: 2, unit: "MILLILITER" },
],
steps: [
{ text: "Skär tomat, gurka och rödlök och lägg i en vid skål." },
{ text: "Toppa med oliver och fetaost i stora bitar." },
{
text: "Ringla över olivolja och strö över oregano och svartpeppar. Servera direkt.",
tip: "Salta lite fetaosten och oliverna är redan sälta nog.",
},
],
},
{
slug: "kycklingwok-med-nudlar",
titleSv: "Kycklingwok med nudlar",
descriptionSv: "Snabb wok med kyckling, grönsaker och nudlar i sojabaserad sås.",
cuisine: "chinese",
mealTypes: ["dinner"],
tags: ["quick", "high_protein"],
methods: ["wok"],
equipment: ["stove", "wok_pan"],
difficulty: "easy",
prepMin: 15,
cookMin: 10,
portions: 4,
spiceLevel: 1,
mealPrepFriendly: false,
freezerFriendly: false,
peakSeasons: [],
holidayTags: [],
dnaProtein: "chicken_breast",
dnaCarb: "noodles_egg",
dnaVegetables: ["broccoli", "bell_pepper", "carrot"],
dnaFlavor: ["umami", "soy", "ginger"],
ingredients: [
{ ing: "chicken_breast", nameSv: "Kycklingfilé", qty: 500, unit: "GRAM", note: "i strimlor" },
{ ing: "noodles_egg", nameSv: "Äggnudlar", qty: 250, unit: "GRAM" },
{ ing: "broccoli", nameSv: "Broccoli", qty: 200, unit: "GRAM" },
{ ing: "bell_pepper", nameSv: "Paprika", qty: 1, unit: "COUNT" },
{ ing: "carrot", nameSv: "Morot", qty: 1, unit: "COUNT", note: "i tunna stavar" },
{ ing: "garlic", nameSv: "Vitlöksklyftor", qty: 2, unit: "COUNT" },
{ ing: "ginger", nameSv: "Färsk ingefära", qty: 15, unit: "GRAM" },
{ ing: "soy_sauce", nameSv: "Soja", qty: 3, unit: "TABLESPOON" },
{ ing: "honey", nameSv: "Honung", qty: 1, unit: "TABLESPOON" },
{ ing: "rapeseed_oil", nameSv: "Olja", qty: 2, unit: "TABLESPOON" },
],
steps: [
{ text: "Koka nudlarna enligt paketet, skölj i kallt vatten och låt rinna av." },
{
text: "Hetta upp oljan i wok. Woka kycklingen tills den fått färg, 34 minuter.",
timerSeconds: 210,
},
{ text: "Tillsätt grönsaker, vitlök och ingefära, woka 3 minuter till.", timerSeconds: 180 },
{
text: "Blanda i nudlar, soja och honung, woka ihop en sista minut och servera.",
timerSeconds: 60,
},
],
},
{
slug: "tomatsoppa-med-basilika",
titleSv: "Tomatsoppa med basilika",
descriptionSv:
"Len tomatsoppa på krossade tomater, toppad med färsk basilika. Gott med bröd till.",
cuisine: "italian",
mealTypes: ["lunch", "dinner", "starter"],
tags: ["vegetarian", "budget", "quick", "low_calorie"],
methods: ["stovetop"],
equipment: ["stove", "blender"],
difficulty: "beginner",
prepMin: 5,
cookMin: 15,
portions: 4,
spiceLevel: 0,
mealPrepFriendly: true,
freezerFriendly: true,
peakSeasons: ["autumn", "winter"],
holidayTags: [],
dnaVegetables: ["canned_tomatoes", "onion", "basil"],
dnaFlavor: ["tomato", "herby", "comforting"],
ingredients: [
{ ing: "canned_tomatoes", nameSv: "Krossade tomater", qty: 2, unit: "COUNT" },
{ ing: "onion", nameSv: "Gul lök", qty: 1, unit: "COUNT" },
{ ing: "garlic", nameSv: "Vitlöksklyftor", qty: 2, unit: "COUNT" },
{ ing: "vegetable_stock_cube", nameSv: "Grönsaksbuljongtärning", qty: 1, unit: "COUNT" },
{ ing: "basil", nameSv: "Färsk basilika", qty: 15, unit: "GRAM" },
{ ing: "olive_oil", nameSv: "Olivolja", qty: 2, unit: "TABLESPOON" },
{ ing: "sugar", nameSv: "Socker", qty: 1, unit: "TEASPOON" },
{
ing: "cooking_cream",
nameSv: "Matlagningsgrädde",
qty: 1,
unit: "DECILITER",
optional: true,
},
],
steps: [
{ text: "Fräs hackad lök och vitlök mjuka i olivolja." },
{
text: "Tillsätt krossade tomater, 3 dl vatten, buljongtärning och socker. Sjud 10 minuter.",
timerSeconds: 600,
},
{ text: "Mixa soppan slät med stavmixer. Rör ev. i grädden." },
{ text: "Smaka av med salt och peppar, toppa med basilika och servera med gott bröd." },
],
},
{
slug: "chili-con-carne",
titleSv: "Chili con carne",
descriptionSv:
"Mustig färsgryta med bönor, tomat och rökig hetta. Perfekt att laga i stor sats.",
cuisine: "mexican",
mealTypes: ["dinner"],
tags: ["meal_prep", "freezer_friendly", "batch_cooking", "high_protein"],
methods: ["stovetop"],
equipment: ["stove"],
difficulty: "easy",
prepMin: 15,
cookMin: 40,
portions: 6,
spiceLevel: 2,
mealPrepFriendly: true,
freezerFriendly: true,
peakSeasons: ["autumn", "winter"],
holidayTags: [],
storageGuidanceSv: "Håller 4 dagar i kyl, fryser utmärkt i portionslådor.",
dnaProtein: "minced_beef",
dnaCarb: "rice_white",
dnaVegetables: ["onion", "bell_pepper", "kidney_beans_canned"],
dnaFlavor: ["smoky", "spicy", "tomato"],
ingredients: [
{ ing: "minced_beef", nameSv: "Nötfärs", qty: 600, unit: "GRAM" },
{ ing: "onion", nameSv: "Gul lök", qty: 2, unit: "COUNT" },
{ ing: "garlic", nameSv: "Vitlöksklyftor", qty: 3, unit: "COUNT" },
{ ing: "bell_pepper", nameSv: "Paprika", qty: 1, unit: "COUNT" },
{ ing: "kidney_beans_canned", nameSv: "Kidneybönor", qty: 400, unit: "GRAM" },
{ ing: "canned_tomatoes", nameSv: "Krossade tomater", qty: 2, unit: "COUNT" },
{ ing: "tomato_paste", nameSv: "Tomatpuré", qty: 2, unit: "TABLESPOON" },
{ ing: "cumin", nameSv: "Spiskummin", qty: 2, unit: "TEASPOON" },
{ ing: "paprika_powder", nameSv: "Paprikapulver", qty: 2, unit: "TEASPOON" },
{ ing: "chili_flakes", nameSv: "Chiliflakes", qty: 1, unit: "TEASPOON" },
{ ing: "rapeseed_oil", nameSv: "Olja", qty: 1, unit: "TABLESPOON" },
{ ing: "rice_white", nameSv: "Ris till servering", qty: 4, unit: "DECILITER" },
],
steps: [
{
text: "Bryn färsen i olja i en stor gryta. Tillsätt hackad lök, vitlök och paprika och fräs mjukt.",
},
{ text: "Rör i tomatpuré och alla kryddor, fräs 1 minut.", timerSeconds: 60 },
{
text: "Tillsätt krossade tomater och 2 dl vatten. Sjud under lock 30 minuter.",
timerSeconds: 1800,
},
{
text: "Rör i avsköljda bönor och sjud 5 minuter till. Smaka av med salt.",
timerSeconds: 300,
},
{ text: "Servera med ris, och gärna crème fraiche och riven ost." },
],
},
{
slug: "teriyakilax-med-ris",
titleSv: "Teriyakilax med ris",
descriptionSv: "Glaserad lax med sötsalt teriyaki, ångat ris och broccoli.",
cuisine: "japanese",
mealTypes: ["dinner"],
tags: ["quick", "high_protein"],
methods: ["stovetop", "oven"],
equipment: ["stove"],
difficulty: "easy",
prepMin: 10,
cookMin: 15,
portions: 4,
spiceLevel: 0,
mealPrepFriendly: true,
freezerFriendly: false,
peakSeasons: [],
holidayTags: [],
dnaProtein: "salmon",
dnaCarb: "rice_white",
dnaVegetables: ["broccoli"],
dnaFlavor: ["umami", "sweet", "glazed"],
ingredients: [
{ ing: "salmon", nameSv: "Laxfilé", qty: 600, unit: "GRAM", note: "i portionsbitar" },
{ ing: "teriyaki_sauce", nameSv: "Teriyakisås", qty: 4, unit: "TABLESPOON" },
{ ing: "rice_white", nameSv: "Ris", qty: 3, unit: "DECILITER" },
{ ing: "broccoli", nameSv: "Broccoli", qty: 300, unit: "GRAM" },
{ ing: "rapeseed_oil", nameSv: "Olja", qty: 1, unit: "TABLESPOON" },
{ ing: "sesame_seeds", nameSv: "Sesamfrön", qty: 1, unit: "TABLESPOON", optional: true },
],
steps: [
{ text: "Koka riset. Ånga eller koka broccolin de sista 4 minuterna.", timerSeconds: 720 },
{
text: "Stek laxen i olja med skinnsidan ner 34 minuter, vänd och stek 2 minuter till.",
timerSeconds: 330,
},
{
text: "Häll teriyakisåsen över laxen och låt den glasera på svag värme 12 minuter.",
timerSeconds: 90,
},
{ text: "Servera laxen på ris med broccoli, toppa gärna med sesamfrön." },
],
},
{
slug: "spaghetti-med-kottfarssas",
titleSv: "Spaghetti med köttfärssås",
descriptionSv:
"Vardagens trotjänare: mustig köttfärssås som fått puttra, serverad med spaghetti.",
cuisine: "italian",
mealTypes: ["dinner", "lunch"],
tags: ["kid_friendly", "budget", "meal_prep", "freezer_friendly"],
methods: ["stovetop"],
equipment: ["stove"],
difficulty: "beginner",
prepMin: 10,
cookMin: 30,
portions: 4,
spiceLevel: 0,
mealPrepFriendly: true,
freezerFriendly: true,
peakSeasons: [],
holidayTags: [],
storageGuidanceSv: "Såsen håller 3 dagar i kyl och fryser utmärkt.",
dnaProtein: "minced_beef",
dnaCarb: "pasta_dry",
dnaVegetables: ["onion", "carrot", "canned_tomatoes"],
dnaFlavor: ["tomato", "savory", "classic"],
ingredients: [
{ ing: "minced_beef", nameSv: "Nötfärs", qty: 500, unit: "GRAM" },
{ ing: "onion", nameSv: "Gul lök", qty: 1, unit: "COUNT" },
{ ing: "garlic", nameSv: "Vitlöksklyftor", qty: 2, unit: "COUNT" },
{ ing: "carrot", nameSv: "Morot", qty: 1, unit: "COUNT", note: "finriven" },
{ ing: "canned_tomatoes", nameSv: "Krossade tomater", qty: 2, unit: "COUNT" },
{ ing: "tomato_paste", nameSv: "Tomatpuré", qty: 2, unit: "TABLESPOON" },
{ ing: "oregano_dried", nameSv: "Torkad oregano", qty: 1, unit: "TEASPOON" },
{ ing: "pasta_dry", nameSv: "Spaghetti", qty: 320, unit: "GRAM" },
{ ing: "olive_oil", nameSv: "Olivolja", qty: 1, unit: "TABLESPOON" },
],
steps: [
{
text: "Fräs hackad lök och vitlök i olivolja. Tillsätt färsen och bryn tills den fått färg.",
},
{ text: "Rör i tomatpuré, riven morot och oregano." },
{
text: "Tillsätt krossade tomater och 1 dl vatten. Låt sjuda minst 20 minuter.",
timerSeconds: 1200,
tip: "Längre puttertid = rundare smak.",
},
{ text: "Koka spaghettin enligt paketet.", timerSeconds: 540 },
{ text: "Smaka av såsen med salt och peppar och servera med pastan." },
],
},
{
slug: "pytt-i-panna",
titleSv: "Pytt i panna med stekt ägg",
descriptionSv:
"Klassisk restmat på tärnad potatis, lök och korv eller kött toppad med stekt ägg.",
cuisine: "swedish",
mealTypes: ["dinner", "lunch"],
tags: ["budget", "leftover_friendly", "quick"],
methods: ["stovetop"],
equipment: ["stove"],
difficulty: "beginner",
prepMin: 15,
cookMin: 15,
portions: 4,
spiceLevel: 0,
mealPrepFriendly: false,
freezerFriendly: false,
peakSeasons: [],
holidayTags: [],
dnaProtein: "falukorv",
dnaCarb: "potato",
dnaVegetables: ["onion"],
dnaFlavor: ["savory", "fried", "classic"],
ingredients: [
{
ing: "potato",
nameSv: "Kokt potatis",
qty: 8,
unit: "COUNT",
note: "tärnad perfekt för gårdagens potatis",
},
{ ing: "falukorv", nameSv: "Falukorv", qty: 300, unit: "GRAM", note: "tärnad" },
{ ing: "onion", nameSv: "Gul lök", qty: 2, unit: "COUNT", note: "hackade" },
{ ing: "egg", nameSv: "Ägg", qty: 4, unit: "COUNT" },
{ ing: "butter", nameSv: "Smör", qty: 40, unit: "GRAM" },
],
steps: [
{
text: "Stek potatistärningarna i hälften av smöret på hög värme tills gyllene och krispiga.",
},
{ text: "Tillsätt lök och korv och stek tills allt fått fin färg." },
{ text: "Stek äggen i resten av smöret." },
{ text: "Salta, peppra och servera pytten med stekt ägg och gärna rödbetor." },
],
},
{
slug: "halloumiburgare",
titleSv: "Halloumiburgare med avokado",
descriptionSv:
"Vegetarisk burgare med stekt halloumi, avokado och syrlig rödlök i briochebröd.",
cuisine: "american",
mealTypes: ["dinner"],
tags: ["vegetarian", "quick"],
methods: ["stovetop", "grill"],
equipment: ["stove"],
difficulty: "beginner",
prepMin: 15,
cookMin: 10,
portions: 4,
spiceLevel: 0,
mealPrepFriendly: false,
freezerFriendly: false,
peakSeasons: ["summer"],
holidayTags: ["grillsäsong"],
dnaProtein: "halloumi",
dnaCarb: "hamburger_bun",
dnaVegetables: ["avocado", "tomato", "red_onion", "lettuce"],
dnaFlavor: ["salty", "fresh", "grilled"],
ingredients: [
{ ing: "halloumi", nameSv: "Halloumi", qty: 400, unit: "GRAM", note: "i skivor" },
{ ing: "hamburger_bun", nameSv: "Hamburgerbröd", qty: 4, unit: "COUNT" },
{ ing: "avocado", nameSv: "Avokado", qty: 2, unit: "COUNT" },
{ ing: "tomato", nameSv: "Tomat", qty: 2, unit: "COUNT", note: "skivade" },
{ ing: "red_onion", nameSv: "Rödlök", qty: 0.5, unit: "COUNT", note: "tunt skivad" },
{ ing: "lettuce", nameSv: "Sallad", qty: 0.25, unit: "COUNT" },
{ ing: "mayonnaise", nameSv: "Majonnäs", qty: 4, unit: "TABLESPOON" },
{ ing: "rapeseed_oil", nameSv: "Olja", qty: 1, unit: "TABLESPOON" },
],
steps: [
{
text: "Stek eller grilla halloumiskivorna tills de är gyllene på båda sidor, 23 minuter per sida.",
timerSeconds: 300,
},
{ text: "Rosta bröden snabbt i pannan eller på grillen." },
{ text: "Mosa avokadon grovt med lite salt." },
{ text: "Bygg burgarna: majonnäs, sallad, halloumi, avokado, tomat och rödlök." },
],
},
{
slug: "kikartscurry",
titleSv: "Kikärtscurry med spenat",
descriptionSv:
"Snabb vegansk curry på kikärtor, tomat, kokosmjölk och spenat. Klar på 20 minuter.",
cuisine: "indian",
mealTypes: ["dinner", "lunch"],
tags: ["vegan", "vegetarian", "budget", "quick", "meal_prep"],
methods: ["stovetop"],
equipment: ["stove"],
difficulty: "beginner",
prepMin: 5,
cookMin: 15,
portions: 4,
spiceLevel: 2,
mealPrepFriendly: true,
freezerFriendly: true,
peakSeasons: [],
holidayTags: [],
dnaProtein: "chickpeas_canned",
dnaCarb: "rice_white",
dnaVegetables: ["spinach", "onion", "canned_tomatoes"],
dnaFlavor: ["spiced", "coconut", "warming"],
ingredients: [
{ ing: "chickpeas_canned", nameSv: "Kikärtor", qty: 500, unit: "GRAM", note: "avsköljda" },
{ ing: "onion", nameSv: "Gul lök", qty: 1, unit: "COUNT" },
{ ing: "garlic", nameSv: "Vitlöksklyftor", qty: 2, unit: "COUNT" },
{ ing: "canned_tomatoes", nameSv: "Krossade tomater", qty: 1, unit: "COUNT" },
{ ing: "coconut_milk", nameSv: "Kokosmjölk", qty: 2, unit: "DECILITER" },
{ ing: "spinach", nameSv: "Spenat", qty: 100, unit: "GRAM" },
{ ing: "garam_masala", nameSv: "Garam masala", qty: 2, unit: "TEASPOON" },
{ ing: "turmeric", nameSv: "Gurkmeja", qty: 1, unit: "TEASPOON" },
{ ing: "rice_white", nameSv: "Ris", qty: 3, unit: "DECILITER" },
{ ing: "rapeseed_oil", nameSv: "Olja", qty: 1, unit: "TABLESPOON" },
],
steps: [
{ text: "Koka riset.", timerSeconds: 720 },
{ text: "Fräs hackad lök och vitlök i olja, rör i kryddorna sista halvminuten." },
{
text: "Tillsätt kikärtor, krossade tomater och kokosmjölk. Sjud 10 minuter.",
timerSeconds: 600,
},
{ text: "Vänd ner spenaten, smaka av med salt och servera med ris." },
],
},
{
slug: "vasterbottensostpaj",
titleSv: "Västerbottensostpaj",
descriptionSv:
"Midsommarklassikern framför andra: knaprigt pajskal fyllt med krämig äggstanning och rejält med Västerbottensost.",
cuisine: "swedish",
mealTypes: ["lunch", "buffet", "starter"],
tags: ["vegetarian", "meal_prep"],
methods: ["oven"],
equipment: ["oven"],
difficulty: "medium",
prepMin: 25,
cookMin: 40,
portions: 8,
spiceLevel: 0,
mealPrepFriendly: true,
freezerFriendly: true,
peakSeasons: ["summer"],
holidayTags: ["midsommar", "jul"],
storageGuidanceSv: "Håller 3 dagar i kyl. Kan bakas dagen innan och värmas.",
dnaProtein: "vasterbotten_cheese",
dnaCarb: "flour_wheat",
dnaVegetables: [],
dnaFlavor: ["rich", "cheesy", "buttery"],
ingredients: [
{ ing: "flour_wheat", nameSv: "Vetemjöl", qty: 3, unit: "DECILITER", group: "Pajdeg" },
{ ing: "butter", nameSv: "Smör (kallt)", qty: 125, unit: "GRAM", group: "Pajdeg" },
{
ing: "vasterbotten_cheese",
nameSv: "Västerbottensost (riven)",
qty: 300,
unit: "GRAM",
group: "Fyllning",
},
{ ing: "egg", nameSv: "Ägg", qty: 3, unit: "COUNT", group: "Fyllning" },
{ ing: "cream", nameSv: "Vispgrädde", qty: 2, unit: "DECILITER", group: "Fyllning" },
{ ing: "black_pepper", nameSv: "Svartpeppar", qty: 2, unit: "MILLILITER", group: "Fyllning" },
],
steps: [
{
text: "Nyp ihop mjöl, smör och 1 msk kallt vatten till en deg. Tryck ut i en pajform och vila kallt 30 minuter.",
timerSeconds: 1800,
},
{
text: "Sätt ugnen på 200 °C. Förgrädda skalet 10 minuter.",
timerSeconds: 600,
temperatureC: 200,
},
{ text: "Vispa ihop ägg, grädde och peppar. Rör i den rivna osten." },
{
text: "Häll fyllningen i skalet och grädda 2530 minuter tills stanningen stelnat och fått gyllene yta.",
timerSeconds: 1650,
temperatureC: 200,
},
{
text: "Låt svalna något före servering god ljummen med löjrom, rödlök och crème fraiche.",
},
],
},
{
slug: "jansons-frestelse",
titleSv: "Janssons frestelse",
descriptionSv:
"Julbordets krämiga potatisgratäng med svensk ansjovis, lök och grädde gyllene och knaprig på ytan.",
cuisine: "swedish",
mealTypes: ["dinner", "buffet"],
tags: ["meal_prep"],
methods: ["oven"],
equipment: ["oven"],
difficulty: "easy",
prepMin: 25,
cookMin: 55,
portions: 6,
spiceLevel: 0,
mealPrepFriendly: true,
freezerFriendly: false,
peakSeasons: ["winter"],
holidayTags: ["jul", "påsk", "midsommar"],
dnaProtein: "anchovy_swedish",
dnaCarb: "potato",
dnaVegetables: ["onion"],
dnaFlavor: ["rich", "salty", "creamy"],
ingredients: [
{ ing: "potato", nameSv: "Potatis (fast)", qty: 10, unit: "COUNT", note: "i tunna stavar" },
{ ing: "onion", nameSv: "Gul lök", qty: 2, unit: "COUNT", note: "tunt skivad" },
{ ing: "anchovy_swedish", nameSv: "Ansjovisfiléer med spad", qty: 125, unit: "GRAM" },
{ ing: "cream", nameSv: "Vispgrädde", qty: 3, unit: "DECILITER" },
{ ing: "breadcrumbs", nameSv: "Ströbröd", qty: 2, unit: "TABLESPOON" },
{ ing: "butter", nameSv: "Smör", qty: 25, unit: "GRAM" },
],
steps: [
{ text: "Sätt ugnen på 200 °C.", temperatureC: 200 },
{ text: "Stek löken mjuk i smör utan att den tar färg." },
{ text: "Varva potatisstavar, lök och ansjovis i en smord form. Avsluta med potatis." },
{
text: "Häll över hälften av grädden och lite ansjovisspad. Strö över ströbröd och klicka på smör.",
},
{
text: "Grädda 30 minuter, häll på resten av grädden och grädda 2025 minuter till tills potatisen är mjuk.",
timerSeconds: 3300,
temperatureC: 200,
},
],
},
{
slug: "sill-och-farskpotatis",
titleSv: "Sill och färskpotatis",
descriptionSv:
"Midsommarens självklara lunch: inlagd sill, nykokt färskpotatis med dill, gräddfil och gräslök.",
cuisine: "swedish",
mealTypes: ["lunch", "buffet"],
tags: ["quick", "gluten_free"],
methods: ["stovetop", "no_cook"],
equipment: ["stove"],
difficulty: "beginner",
prepMin: 10,
cookMin: 20,
portions: 4,
spiceLevel: 0,
mealPrepFriendly: false,
freezerFriendly: false,
peakSeasons: ["summer"],
holidayTags: ["midsommar"],
dnaProtein: "pickled_herring",
dnaCarb: "new_potato",
dnaVegetables: ["dill"],
dnaFlavor: ["fresh", "pickled", "summer"],
ingredients: [
{ ing: "pickled_herring", nameSv: "Inlagd sill", qty: 400, unit: "GRAM" },
{ ing: "new_potato", nameSv: "Färskpotatis", qty: 800, unit: "GRAM" },
{ ing: "dill", nameSv: "Färsk dill", qty: 15, unit: "GRAM" },
{ ing: "creme_fraiche", nameSv: "Crème fraiche eller gräddfil", qty: 2, unit: "DECILITER" },
{ ing: "red_onion", nameSv: "Rödlök", qty: 0.5, unit: "COUNT", note: "finhackad" },
{ ing: "butter", nameSv: "Smör", qty: 25, unit: "GRAM" },
],
steps: [
{
text: "Skrubba färskpotatisen och koka med en dillkvist i saltat vatten, 1518 minuter.",
timerSeconds: 1020,
},
{ text: "Lägg upp sillen och strö över finhackad rödlök." },
{
text: "Servera potatisen med smör och dill, tillsammans med sill och crème fraiche.",
tip: "Ett hårdkokt ägg och knäckebröd gör midsommartallriken komplett.",
},
],
},
{
slug: "havregrynsgrot",
titleSv: "Havregrynsgröt med äpple och kanel",
descriptionSv: "Vardagsfrukostens bas: krämig havregrynsgröt toppad med rivet äpple och kanel.",
cuisine: "swedish",
mealTypes: ["breakfast"],
tags: ["budget", "quick", "vegan"],
methods: ["stovetop", "microwave"],
equipment: ["stove"],
difficulty: "beginner",
prepMin: 2,
cookMin: 5,
portions: 2,
spiceLevel: 0,
mealPrepFriendly: false,
freezerFriendly: false,
peakSeasons: [],
holidayTags: [],
dnaCarb: "oats",
dnaVegetables: [],
dnaFlavor: ["warm", "cinnamon", "simple"],
ingredients: [
{ ing: "oats", nameSv: "Havregryn", qty: 2, unit: "DECILITER" },
{ ing: "salt", nameSv: "Salt", qty: 0.5, unit: "MILLILITER" },
{ ing: "apple", nameSv: "Äpple", qty: 1, unit: "COUNT", note: "rivet" },
{ ing: "cinnamon", nameSv: "Kanel", qty: 1, unit: "TEASPOON" },
{ ing: "milk_3", nameSv: "Mjölk till servering", qty: 2, unit: "DECILITER", optional: true },
],
steps: [
{ text: "Koka upp 4 dl vatten med havregryn och salt." },
{ text: "Sjud under omrörning 3 minuter.", timerSeconds: 180 },
{ text: "Toppa med rivet äpple, kanel och mjölk." },
],
},
];
@@ -0,0 +1,188 @@
/**
* Seed: datadriven Season & Events Engine (spec §28).
* Datumregler beräknas av recommendation-engine (midsommar, påsk, advent).
*/
export interface SeedSeasonEvent {
id: string;
slug: string;
nameSv: string;
market: string;
dateRule:
| { kind: "fixed"; monthDay: string }
| { kind: "range"; startMonthDay: string; endMonthDay: string }
| { kind: "computed"; algorithm: "midsummer" | "easter" | "advent" | "custom" };
leadDays: number;
foodTags: string[];
recipeSlugs: string[];
priority: number;
}
export const SEED_SEASON_EVENTS: SeedSeasonEvent[] = [
{
id: "se-midsommar",
slug: "midsommar",
nameSv: "Midsommar",
market: "SE",
dateRule: { kind: "computed", algorithm: "midsummer" },
leadDays: 10,
foodTags: ["sill", "färskpotatis", "jordgubbar", "grillat", "västerbottensostpaj"],
recipeSlugs: ["sill-och-farskpotatis", "vasterbottensostpaj", "kottbullar-med-potatismos"],
priority: 100,
},
{
id: "se-jul",
slug: "jul",
nameSv: "Jul",
market: "SE",
dateRule: { kind: "range", startMonthDay: "12-20", endMonthDay: "12-26" },
leadDays: 21,
foodTags: ["julbord", "köttbullar", "janssons", "sill", "skinka"],
recipeSlugs: ["jansons-frestelse", "kottbullar-med-potatismos"],
priority: 100,
},
{
id: "se-pask",
slug: "påsk",
nameSv: "Påsk",
market: "SE",
dateRule: { kind: "computed", algorithm: "easter" },
leadDays: 10,
foodTags: ["ägg", "sill", "lamm", "lax"],
recipeSlugs: ["sill-och-farskpotatis", "ugnsbakad-lax-med-citron-och-dill"],
priority: 90,
},
{
id: "se-nyar",
slug: "nyår",
nameSv: "Nyårsafton",
market: "SE",
dateRule: { kind: "fixed", monthDay: "12-31" },
leadDays: 7,
foodTags: ["fest", "skaldjur", "lyx"],
recipeSlugs: [],
priority: 80,
},
{
id: "se-kraftskiva",
slug: "kraftskiva",
nameSv: "Kräftskivepremiär",
market: "SE",
dateRule: { kind: "range", startMonthDay: "08-01", endMonthDay: "08-31" },
leadDays: 7,
foodTags: ["kräftor", "västerbottensostpaj", "knäckebröd"],
recipeSlugs: ["vasterbottensostpaj"],
priority: 70,
},
{
id: "se-surstromming",
slug: "surstromming",
nameSv: "Surströmmingspremiär",
market: "SE",
dateRule: { kind: "fixed", monthDay: "08-15" },
leadDays: 5,
foodTags: ["surströmming", "tunnbröd", "mandelpotatis"],
recipeSlugs: [],
priority: 40,
},
{
id: "se-lucia",
slug: "lucia",
nameSv: "Lucia",
market: "SE",
dateRule: { kind: "fixed", monthDay: "12-13" },
leadDays: 7,
foodTags: ["lussekatter", "pepparkakor", "glögg"],
recipeSlugs: [],
priority: 60,
},
{
id: "se-valborg",
slug: "valborg",
nameSv: "Valborg",
market: "SE",
dateRule: { kind: "fixed", monthDay: "04-30" },
leadDays: 5,
foodTags: ["grillat", "vårmat"],
recipeSlugs: ["halloumiburgare"],
priority: 50,
},
{
id: "se-grillsasong",
slug: "grillsasong",
nameSv: "Grillsäsong",
market: "SE",
dateRule: { kind: "range", startMonthDay: "05-15", endMonthDay: "08-31" },
leadDays: 0,
foodTags: ["grillat", "sallad", "sommarmat"],
recipeSlugs: ["halloumiburgare", "grekisk-sallad"],
priority: 30,
},
{
id: "se-skolstart",
slug: "skolstart",
nameSv: "Skolstart",
market: "SE",
dateRule: { kind: "range", startMonthDay: "08-10", endMonthDay: "08-31" },
leadDays: 7,
foodTags: ["matlåda", "vardagsmat", "snabbt"],
recipeSlugs: ["spaghetti-med-kottfarssas", "chili-con-carne"],
priority: 40,
},
{
id: "se-alla-hjartans",
slug: "alla-hjartans-dag",
nameSv: "Alla hjärtans dag",
market: "SE",
dateRule: { kind: "fixed", monthDay: "02-14" },
leadDays: 5,
foodTags: ["middag för två", "lyx", "dessert"],
recipeSlugs: ["teriyakilax-med-ris"],
priority: 50,
},
{
id: "int-halloween",
slug: "halloween",
nameSv: "Halloween",
market: "SE",
dateRule: { kind: "fixed", monthDay: "10-31" },
leadDays: 7,
foodTags: ["pumpa", "barnkalas", "höstmat"],
recipeSlugs: ["tomatsoppa-med-basilika"],
priority: 40,
},
{
id: "int-oktoberfest",
slug: "oktoberfest",
nameSv: "Oktoberfest",
market: "SE",
dateRule: { kind: "range", startMonthDay: "09-20", endMonthDay: "10-05" },
leadDays: 5,
foodTags: ["korv", "surkål", "öl"],
recipeSlugs: [],
priority: 20,
},
{
id: "int-thanksgiving",
slug: "thanksgiving",
nameSv: "Thanksgiving",
market: "US",
dateRule: { kind: "range", startMonthDay: "11-20", endMonthDay: "11-28" },
leadDays: 10,
foodTags: ["kalkon", "pumpapaj"],
recipeSlugs: [],
priority: 90,
},
{
id: "int-ramadan-eid",
slug: "eid",
nameSv: "Eid al-Fitr",
market: "SE",
// Rörligt datum (månkalender) uppdateras årligen av admin tills kalenderconnector finns.
dateRule: { kind: "computed", algorithm: "custom" },
leadDays: 14,
foodTags: ["fest", "lamm", "dadlar", "sötsaker"],
recipeSlugs: [],
priority: 90,
},
];
@@ -0,0 +1,176 @@
/**
* Seed: substitutionsregler (spec §20). Kuraterade byten med mängdfaktor,
* instruktioner och begränsningar. Näring räknas alltid om av nutrition-engine.
*/
export interface SeedSubstitution {
id: string;
from: string;
to: string;
ratio: number;
instructionsSv?: string;
bestFor: string[];
notRecommendedFor: string[];
flavorImpactSv?: string;
textureImpactSv?: string;
priority?: number;
}
export const SEED_SUBSTITUTIONS: SeedSubstitution[] = [
{
id: "cream-to-quark",
from: "cream",
to: "quark",
ratio: 0.8,
instructionsSv: "Tillsätt kvargen mot slutet på låg värme den får inte koka.",
bestFor: ["sauces", "pasta"],
notRecommendedFor: ["whipping"],
flavorImpactSv: "Syrligare och lättare smak.",
textureImpactSv: "Mindre fyllig, kan gryna sig vid hög värme.",
priority: 10,
},
{
id: "cooking-cream-to-quark",
from: "cooking_cream",
to: "quark",
ratio: 0.8,
instructionsSv: "Rör ut kvargen med lite vätska och vänd ner på låg värme sist.",
bestFor: ["sauces", "pasta", "stews"],
notRecommendedFor: ["whipping", "baking"],
flavorImpactSv: "Syrligare, mer protein, mindre fett.",
},
{
id: "cooking-cream-to-coconut",
from: "cooking_cream",
to: "coconut_milk",
ratio: 1,
instructionsSv: "Byt rakt av. Ger tydlig kokossmak.",
bestFor: ["curry", "soups", "stews"],
notRecommendedFor: ["swedish_classics"],
flavorImpactSv: "Kokossmak passar asiatiska rätter.",
priority: 5,
},
{
id: "creme-fraiche-to-yoghurt",
from: "creme_fraiche",
to: "yoghurt_natural",
ratio: 1,
instructionsSv: "Fungerar kallt rakt av. I varma rätter: tillsätt på slutet utan att koka.",
bestFor: ["dips", "cold_sauces", "toppings"],
notRecommendedFor: ["long_simmering"],
flavorImpactSv: "Syrligare och lättare.",
},
{
id: "milk-to-oat",
from: "milk_3",
to: "oat_drink",
ratio: 1,
instructionsSv: "Byt rakt av i de flesta recept.",
bestFor: ["pancakes", "porridge", "baking", "sauces"],
notRecommendedFor: [],
flavorImpactSv: "Lätt havresmak, något sötare.",
},
{
id: "butter-to-oil",
from: "butter",
to: "rapeseed_oil",
ratio: 0.8,
instructionsSv: "Använd 80 % av mängden vid stekning.",
bestFor: ["frying", "sauteing"],
notRecommendedFor: ["baking_pastry", "pie_dough"],
flavorImpactSv: "Neutralare smak utan smörton.",
},
{
id: "chicken-to-tofu",
from: "chicken_breast",
to: "tofu",
ratio: 1,
instructionsSv: "Pressa tofun, tärna och stek på hög värme tills gyllene. Krydda generöst.",
bestFor: ["wok", "curry", "bowls"],
notRecommendedFor: ["whole_roast"],
flavorImpactSv: "Mildare tar upp marinadens smak.",
textureImpactSv: "Mjukare än kyckling.",
},
{
id: "chicken-to-chickpeas",
from: "chicken_breast",
to: "chickpeas_canned",
ratio: 1.2,
instructionsSv: "Skölj kikärtorna och lägg i mot slutet de behöver bara bli varma.",
bestFor: ["curry", "stews", "salads"],
notRecommendedFor: ["frying_strips"],
flavorImpactSv: "Nötigare, vegetariskt.",
},
{
id: "minced-beef-to-lentils",
from: "minced_beef",
to: "red_lentils",
ratio: 0.5,
instructionsSv: "Använd hälften så mycket torra linser och sjud dem i såsen 15 minuter.",
bestFor: ["bolognese", "chili", "stews"],
notRecommendedFor: ["meatballs", "burgers"],
flavorImpactSv: "Mildare, mer fiber.",
textureImpactSv: "Mjukare konsistens än färs.",
},
{
id: "pasta-to-glutenfree",
from: "pasta_dry",
to: "pasta_gluten_free",
ratio: 1,
instructionsSv: "Koka enligt paketets tid glutenfri pasta blir snabbt överkokt.",
bestFor: ["all_pasta_dishes"],
notRecommendedFor: [],
flavorImpactSv: "I princip likvärdig i såsrätter.",
textureImpactSv: "Något känsligare konsistens.",
},
{
id: "falukorv-to-halloumi",
from: "falukorv",
to: "halloumi",
ratio: 0.9,
instructionsSv: "Stek halloumin gyllene i stället för korven. Salta inte extra.",
bestFor: ["stroganoff", "pytt"],
notRecommendedFor: [],
flavorImpactSv: "Saltare, vegetariskt.",
},
{
id: "fishsauce-to-soy",
from: "fish_sauce",
to: "soy_sauce",
ratio: 1,
instructionsSv: "Byt rakt av för vegetariskt alternativ.",
bestFor: ["wok", "curry", "dressings"],
notRecommendedFor: [],
flavorImpactSv: "Mindre fisksälta, mer sojaumami.",
},
{
id: "cod-to-salmon",
from: "cod",
to: "salmon",
ratio: 1,
instructionsSv: "Samma tillagningstid per centimeter tjocklek.",
bestFor: ["oven_baking", "frying"],
notRecommendedFor: [],
flavorImpactSv: "Fetare och rundare smak.",
},
{
id: "creme-fraiche-to-quark",
from: "creme_fraiche",
to: "quark",
ratio: 1,
instructionsSv: "Rör om kvargen slät. I varma rätter: sist, på låg värme.",
bestFor: ["toppings", "dips", "sauces"],
notRecommendedFor: ["long_simmering"],
flavorImpactSv: "Lättare, mer protein.",
},
{
id: "onion-to-leek",
from: "onion",
to: "leek",
ratio: 1.3,
instructionsSv: "Använd den vita och ljusgröna delen, fräs mjuk.",
bestFor: ["soups", "stews", "pies"],
notRecommendedFor: ["raw_salads"],
flavorImpactSv: "Mildare och sötare löksmak.",
},
];
+4
View File
@@ -0,0 +1,4 @@
export { SEED_INGREDIENTS, SEED_INGREDIENT_IDS } from "./data/ingredients.js";
export { SEED_RECIPES } from "./data/recipes.js";
export { SEED_SUBSTITUTIONS } from "./data/substitutions.js";
export { SEED_SEASON_EVENTS } from "./data/seasonEvents.js";
+750
View File
@@ -0,0 +1,750 @@
import { config as loadDotenv } from "dotenv";
import { existsSync } from "node:fs";
import path from "node:path";
// Ladda .env från paketet ELLER monorepo-roten (pnpm --filter sätter cwd till paketet).
for (const candidate of [".env", "../.env", "../../.env"]) {
const p = path.resolve(process.cwd(), candidate);
if (existsSync(p)) {
loadDotenv({ path: p });
break;
}
}
import {
computeRecipeNutrition,
toGrams,
type IngredientNutritionSource,
} from "@app/nutrition-engine";
import { BRAND, type Allergen, type RecipeDNA } from "@app/shared-types";
import { createDatabase } from "../client.js";
import * as schema from "../schema/index.js";
import { SEED_INGREDIENTS } from "./data/ingredients.js";
import INGREDIENT_TRANSLATIONS from "./data/ingredient-translations.json" with { type: "json" };
import { SEED_RECIPES, type SeedRecipe } from "./data/recipes.js";
import { SEED_SUBSTITUTIONS } from "./data/substitutions.js";
import { SEED_SEASON_EVENTS } from "./data/seasonEvents.js";
/**
* Seed-körning. Idempotent: onConflictDoNothing/Update där det är säkert.
*
* Näring, allergener och kostnad för recepten beräknas HÄR, deterministiskt,
* ur ingredienserna (spec §61.12) aldrig hårdkodade och aldrig från AI.
*/
async function main() {
const { db, pool } = createDatabase();
console.log("[seed] Startar …");
// 1. Kanoniska ingredienser
for (const ing of SEED_INGREDIENTS) {
await db
.insert(schema.canonicalIngredients)
.values({
id: ing.id,
nameSv: ing.nameSv,
nameEn: ing.nameEn,
aliases: ing.aliases,
category: ing.category,
defaultUnit: ing.defaultUnit,
densityGPerMl: ing.densityGPerMl ?? null,
gramsPerPiece: ing.gramsPerPiece ?? null,
allergens: ing.allergens,
isVegan: ing.isVegan,
isVegetarian: ing.isVegetarian,
containsGluten: ing.containsGluten,
containsLactose: ing.containsLactose,
isPork: ing.isPork,
isBeef: ing.isBeef,
isAlcohol: ing.isAlcohol,
nutritionPer100: ing.nutritionPer100,
nutritionProvenance: ing.nutritionProvenance,
peakSeasons: ing.peakSeasons,
shelfLifeGuidance: ing.shelfLifeGuidance ?? null,
defaultPriceMinorPerKg: ing.defaultPriceMinorPerKg ?? null,
})
.onConflictDoUpdate({
target: schema.canonicalIngredients.id,
set: {
nameSv: ing.nameSv,
nutritionPer100: ing.nutritionPer100,
updatedAt: new Date(),
},
});
}
console.log(`[seed] ${SEED_INGREDIENTS.length} ingredienser`);
// 1b. Ingrediensöversättningar (i18n M2 + D-031): en ur nameEn; es/it/de/fr
// ur ingredient-translations.json. Rader per språk aldrig nya kolumner.
// Seed-källa = publicerad direkt; native-granskning rekommenderas före lansering.
const translationRows: { ingredientId: string; languageTag: string; name: string }[] =
SEED_INGREDIENTS.map((ing) => ({ ingredientId: ing.id, languageTag: "en", name: ing.nameEn }));
for (const [languageTag, names] of Object.entries(
INGREDIENT_TRANSLATIONS as Record<string, Record<string, string>>,
)) {
for (const [ingredientId, name] of Object.entries(names)) {
translationRows.push({ ingredientId, languageTag, name });
}
}
for (const row of translationRows) {
await db
.insert(schema.ingredientTranslations)
.values({ ...row, aliases: [], source: "seed", status: "published" })
.onConflictDoUpdate({
target: [
schema.ingredientTranslations.ingredientId,
schema.ingredientTranslations.languageTag,
],
set: { name: row.name, updatedAt: new Date() },
});
}
console.log(`[seed] ${translationRows.length} ingrediensöversättningar (11 språk)`);
// 1c. Enhetsetiketter per språk (i18n M2). Visning lagring är alltid koderna.
const UNIT_LABELS: Record<string, Record<string, { abbr: string; name: string }>> = {
sv: {
GRAM: { abbr: "g", name: "gram" },
KILOGRAM: { abbr: "kg", name: "kilogram" },
MILLILITER: { abbr: "ml", name: "milliliter" },
DECILITER: { abbr: "dl", name: "deciliter" },
LITER: { abbr: "l", name: "liter" },
TEASPOON: { abbr: "tsk", name: "tesked" },
TABLESPOON: { abbr: "msk", name: "matsked" },
CUP_US: { abbr: "cup", name: "cup (US)" },
FLUID_OUNCE_US: { abbr: "fl oz", name: "fluid ounce (US)" },
OUNCE: { abbr: "oz", name: "ounce" },
POUND: { abbr: "lb", name: "pound" },
COUNT: { abbr: "st", name: "styck" },
PORTION: { abbr: "portion", name: "portion" },
PINCH: { abbr: "krm", name: "kryddmått" },
SLICE: { abbr: "skiva", name: "skiva" },
CLOVE: { abbr: "klyfta", name: "klyfta" },
CAN: { abbr: "burk", name: "burk" },
PACKAGE: { abbr: "paket", name: "paket" },
},
en: {
GRAM: { abbr: "g", name: "gram" },
KILOGRAM: { abbr: "kg", name: "kilogram" },
MILLILITER: { abbr: "ml", name: "milliliter" },
DECILITER: { abbr: "dl", name: "deciliter" },
LITER: { abbr: "l", name: "liter" },
TEASPOON: { abbr: "tsp", name: "teaspoon" },
TABLESPOON: { abbr: "tbsp", name: "tablespoon" },
CUP_US: { abbr: "cup", name: "cup (US)" },
FLUID_OUNCE_US: { abbr: "fl oz", name: "fluid ounce (US)" },
OUNCE: { abbr: "oz", name: "ounce" },
POUND: { abbr: "lb", name: "pound" },
COUNT: { abbr: "pcs", name: "pieces" },
PORTION: { abbr: "serving", name: "serving" },
PINCH: { abbr: "pinch", name: "pinch" },
SLICE: { abbr: "slice", name: "slice" },
CLOVE: { abbr: "clove", name: "clove" },
CAN: { abbr: "can", name: "can" },
PACKAGE: { abbr: "pack", name: "package" },
},
es: {
GRAM: { abbr: "g", name: "gramo" },
KILOGRAM: { abbr: "kg", name: "kilogramo" },
MILLILITER: { abbr: "ml", name: "mililitro" },
DECILITER: { abbr: "dl", name: "decilitro" },
LITER: { abbr: "l", name: "litro" },
TEASPOON: { abbr: "cdta", name: "cucharadita" },
TABLESPOON: { abbr: "cda", name: "cucharada" },
CUP_US: { abbr: "taza", name: "taza (US)" },
FLUID_OUNCE_US: { abbr: "fl oz", name: "onza líquida (US)" },
OUNCE: { abbr: "oz", name: "onza" },
POUND: { abbr: "lb", name: "libra" },
COUNT: { abbr: "ud", name: "unidad" },
PORTION: { abbr: "ración", name: "ración" },
PINCH: { abbr: "pizca", name: "pizca" },
SLICE: { abbr: "rebanada", name: "rebanada" },
CLOVE: { abbr: "diente", name: "diente" },
CAN: { abbr: "lata", name: "lata" },
PACKAGE: { abbr: "paquete", name: "paquete" },
},
it: {
GRAM: { abbr: "g", name: "grammo" },
KILOGRAM: { abbr: "kg", name: "chilogrammo" },
MILLILITER: { abbr: "ml", name: "millilitro" },
DECILITER: { abbr: "dl", name: "decilitro" },
LITER: { abbr: "l", name: "litro" },
TEASPOON: { abbr: "cucchiaino", name: "cucchiaino" },
TABLESPOON: { abbr: "cucchiaio", name: "cucchiaio" },
CUP_US: { abbr: "tazza", name: "tazza (US)" },
FLUID_OUNCE_US: { abbr: "fl oz", name: "oncia liquida (US)" },
OUNCE: { abbr: "oz", name: "oncia" },
POUND: { abbr: "lb", name: "libbra" },
COUNT: { abbr: "pz", name: "pezzo" },
PORTION: { abbr: "porzione", name: "porzione" },
PINCH: { abbr: "pizzico", name: "pizzico" },
SLICE: { abbr: "fetta", name: "fetta" },
CLOVE: { abbr: "spicchio", name: "spicchio" },
CAN: { abbr: "lattina", name: "lattina" },
PACKAGE: { abbr: "confezione", name: "confezione" },
},
de: {
GRAM: { abbr: "g", name: "Gramm" },
KILOGRAM: { abbr: "kg", name: "Kilogramm" },
MILLILITER: { abbr: "ml", name: "Milliliter" },
DECILITER: { abbr: "dl", name: "Deziliter" },
LITER: { abbr: "l", name: "Liter" },
TEASPOON: { abbr: "TL", name: "Teelöffel" },
TABLESPOON: { abbr: "EL", name: "Esslöffel" },
CUP_US: { abbr: "Cup", name: "Cup (US)" },
FLUID_OUNCE_US: { abbr: "fl oz", name: "Flüssigunze (US)" },
OUNCE: { abbr: "oz", name: "Unze" },
POUND: { abbr: "lb", name: "Pfund" },
COUNT: { abbr: "Stk", name: "Stück" },
PORTION: { abbr: "Portion", name: "Portion" },
PINCH: { abbr: "Prise", name: "Prise" },
SLICE: { abbr: "Scheibe", name: "Scheibe" },
CLOVE: { abbr: "Zehe", name: "Zehe" },
CAN: { abbr: "Dose", name: "Dose" },
PACKAGE: { abbr: "Packung", name: "Packung" },
},
fr: {
GRAM: { abbr: "g", name: "gramme" },
KILOGRAM: { abbr: "kg", name: "kilogramme" },
MILLILITER: { abbr: "ml", name: "millilitre" },
DECILITER: { abbr: "dl", name: "décilitre" },
LITER: { abbr: "l", name: "litre" },
TEASPOON: { abbr: "c. à c.", name: "cuillère à café" },
TABLESPOON: { abbr: "c. à s.", name: "cuillère à soupe" },
CUP_US: { abbr: "cup", name: "cup (US)" },
FLUID_OUNCE_US: { abbr: "fl oz", name: "once liquide (US)" },
OUNCE: { abbr: "oz", name: "once" },
POUND: { abbr: "lb", name: "livre" },
COUNT: { abbr: "pcs", name: "pièce" },
PORTION: { abbr: "portion", name: "portion" },
PINCH: { abbr: "pincée", name: "pincée" },
SLICE: { abbr: "tranche", name: "tranche" },
CLOVE: { abbr: "gousse", name: "gousse" },
CAN: { abbr: "boîte", name: "boîte" },
PACKAGE: { abbr: "paquet", name: "paquet" },
},
da: {
GRAM: { abbr: "g", name: "gram" },
KILOGRAM: { abbr: "kg", name: "kilogram" },
MILLILITER: { abbr: "ml", name: "milliliter" },
DECILITER: { abbr: "dl", name: "deciliter" },
LITER: { abbr: "l", name: "liter" },
TEASPOON: { abbr: "tsk", name: "teskefuld" },
TABLESPOON: { abbr: "spsk", name: "spiseskefuld" },
CUP_US: { abbr: "cup", name: "cup (US)" },
FLUID_OUNCE_US: { abbr: "fl oz", name: "fluid ounce (US)" },
OUNCE: { abbr: "oz", name: "ounce" },
POUND: { abbr: "lb", name: "pund" },
COUNT: { abbr: "stk", name: "styk" },
PORTION: { abbr: "portion", name: "portion" },
PINCH: { abbr: "knsp", name: "knivspids" },
SLICE: { abbr: "skive", name: "skive" },
CLOVE: { abbr: "fed", name: "fed" },
CAN: { abbr: "dåse", name: "dåse" },
PACKAGE: { abbr: "pakke", name: "pakke" },
},
nb: {
GRAM: { abbr: "g", name: "gram" },
KILOGRAM: { abbr: "kg", name: "kilogram" },
MILLILITER: { abbr: "ml", name: "milliliter" },
DECILITER: { abbr: "dl", name: "desiliter" },
LITER: { abbr: "l", name: "liter" },
TEASPOON: { abbr: "ts", name: "teskje" },
TABLESPOON: { abbr: "ss", name: "spiseskje" },
CUP_US: { abbr: "cup", name: "cup (US)" },
FLUID_OUNCE_US: { abbr: "fl oz", name: "fluid ounce (US)" },
OUNCE: { abbr: "oz", name: "unse" },
POUND: { abbr: "lb", name: "pund" },
COUNT: { abbr: "stk", name: "stykk" },
PORTION: { abbr: "porsjon", name: "porsjon" },
PINCH: { abbr: "knivsodd", name: "knivsodd" },
SLICE: { abbr: "skive", name: "skive" },
CLOVE: { abbr: "båt", name: "båt" },
CAN: { abbr: "boks", name: "boks" },
PACKAGE: { abbr: "pakke", name: "pakke" },
},
fi: {
GRAM: { abbr: "g", name: "gramma" },
KILOGRAM: { abbr: "kg", name: "kilogramma" },
MILLILITER: { abbr: "ml", name: "millilitra" },
DECILITER: { abbr: "dl", name: "desilitra" },
LITER: { abbr: "l", name: "litra" },
TEASPOON: { abbr: "tl", name: "teelusikka" },
TABLESPOON: { abbr: "rkl", name: "ruokalusikka" },
CUP_US: { abbr: "cup", name: "cup (US)" },
FLUID_OUNCE_US: { abbr: "fl oz", name: "nesteunssi (US)" },
OUNCE: { abbr: "oz", name: "unssi" },
POUND: { abbr: "lb", name: "pauna" },
COUNT: { abbr: "kpl", name: "kappale" },
PORTION: { abbr: "annos", name: "annos" },
PINCH: { abbr: "hyppysellinen", name: "hyppysellinen" },
SLICE: { abbr: "viipale", name: "viipale" },
CLOVE: { abbr: "kynsi", name: "kynsi" },
CAN: { abbr: "tölkki", name: "tölkki" },
PACKAGE: { abbr: "paketti", name: "paketti" },
},
nl: {
GRAM: { abbr: "g", name: "gram" },
KILOGRAM: { abbr: "kg", name: "kilogram" },
MILLILITER: { abbr: "ml", name: "milliliter" },
DECILITER: { abbr: "dl", name: "deciliter" },
LITER: { abbr: "l", name: "liter" },
TEASPOON: { abbr: "tl", name: "theelepel" },
TABLESPOON: { abbr: "el", name: "eetlepel" },
CUP_US: { abbr: "cup", name: "cup (US)" },
FLUID_OUNCE_US: { abbr: "fl oz", name: "fluid ounce (US)" },
OUNCE: { abbr: "oz", name: "ounce" },
POUND: { abbr: "lb", name: "pond" },
COUNT: { abbr: "st", name: "stuk" },
PORTION: { abbr: "portie", name: "portie" },
PINCH: { abbr: "snufje", name: "snufje" },
SLICE: { abbr: "plak", name: "plak" },
CLOVE: { abbr: "teentje", name: "teentje" },
CAN: { abbr: "blik", name: "blik" },
PACKAGE: { abbr: "pak", name: "pak" },
},
pl: {
GRAM: { abbr: "g", name: "gram" },
KILOGRAM: { abbr: "kg", name: "kilogram" },
MILLILITER: { abbr: "ml", name: "mililitr" },
DECILITER: { abbr: "dl", name: "decylitr" },
LITER: { abbr: "l", name: "litr" },
TEASPOON: { abbr: "łyżeczka", name: "łyżeczka" },
TABLESPOON: { abbr: "łyżka", name: "łyżka" },
CUP_US: { abbr: "cup", name: "cup (US)" },
FLUID_OUNCE_US: { abbr: "fl oz", name: "uncja płynu (US)" },
OUNCE: { abbr: "oz", name: "uncja" },
POUND: { abbr: "lb", name: "funt" },
COUNT: { abbr: "szt.", name: "sztuka" },
PORTION: { abbr: "porcja", name: "porcja" },
PINCH: { abbr: "szczypta", name: "szczypta" },
SLICE: { abbr: "plaster", name: "plaster" },
CLOVE: { abbr: "ząbek", name: "ząbek" },
CAN: { abbr: "puszka", name: "puszka" },
PACKAGE: { abbr: "opakowanie", name: "opakowanie" },
},
pt: {
GRAM: { abbr: "g", name: "grama" },
KILOGRAM: { abbr: "kg", name: "quilograma" },
MILLILITER: { abbr: "ml", name: "mililitro" },
DECILITER: { abbr: "dl", name: "decilitro" },
LITER: { abbr: "l", name: "litro" },
TEASPOON: { abbr: "c. chá", name: "colher de chá" },
TABLESPOON: { abbr: "c. sopa", name: "colher de sopa" },
CUP_US: { abbr: "cup", name: "cup (US)" },
FLUID_OUNCE_US: { abbr: "fl oz", name: "onça líquida (US)" },
OUNCE: { abbr: "oz", name: "onça" },
POUND: { abbr: "lb", name: "libra" },
COUNT: { abbr: "un", name: "unidade" },
PORTION: { abbr: "dose", name: "dose" },
PINCH: { abbr: "pitada", name: "pitada" },
SLICE: { abbr: "fatia", name: "fatia" },
CLOVE: { abbr: "dente", name: "dente" },
CAN: { abbr: "lata", name: "lata" },
PACKAGE: { abbr: "embalagem", name: "embalagem" },
},
};
let unitLabelCount = 0;
for (const [languageTag, labels] of Object.entries(UNIT_LABELS)) {
for (const [unitCode, label] of Object.entries(labels)) {
await db
.insert(schema.unitTranslations)
.values({
unitCode: unitCode as (typeof schema.unitTranslations.$inferInsert)["unitCode"],
languageTag,
abbreviation: label.abbr,
name: label.name,
})
.onConflictDoUpdate({
target: [schema.unitTranslations.unitCode, schema.unitTranslations.languageTag],
set: { abbreviation: label.abbr, name: label.name },
});
unitLabelCount++;
}
}
console.log(`[seed] ${unitLabelCount} enhetsetiketter (12 språk)`);
// 1d. Marknadsprofiler för näringsvisning + allergenframhävning (i18n M6).
const NUTRITION_PROFILES = [
{
regionCode: "EU",
energyDisplay: "both" as const,
saltDisplay: "salt" as const,
energyLabelKey: "nutrition.energy",
},
{
regionCode: "SE",
energyDisplay: "both" as const,
saltDisplay: "salt" as const,
energyLabelKey: "nutrition.energy",
},
{
regionCode: "GB",
energyDisplay: "both" as const,
saltDisplay: "salt" as const,
energyLabelKey: "nutrition.energy",
},
{
regionCode: "US",
energyDisplay: "kcal" as const,
saltDisplay: "sodium" as const,
energyLabelKey: "nutrition.calories",
},
{
regionCode: "CA",
energyDisplay: "kcal" as const,
saltDisplay: "sodium" as const,
energyLabelKey: "nutrition.calories",
},
];
for (const p of NUTRITION_PROFILES) {
await db
.insert(schema.nutritionDisplayProfiles)
.values(p)
.onConflictDoUpdate({
target: schema.nutritionDisplayProfiles.regionCode,
set: { ...p, updatedAt: new Date() },
});
}
// EU/EES + GB: 14 deklarationspliktiga. US (FDA Big 9): utan selleri/senap/lupin/
// sulfiter/blötdjur. CA (Health Canada): som EU utan selleri och lupin.
const EU14 = [
"gluten",
"crustaceans",
"eggs",
"fish",
"peanuts",
"soy",
"milk",
"tree_nuts",
"celery",
"mustard",
"sesame",
"sulphites",
"lupin",
"molluscs",
] as const;
const US9 = [
"gluten",
"crustaceans",
"eggs",
"fish",
"peanuts",
"soy",
"milk",
"tree_nuts",
"sesame",
] as const;
const CA = [
"gluten",
"crustaceans",
"eggs",
"fish",
"peanuts",
"soy",
"milk",
"tree_nuts",
"mustard",
"sesame",
"sulphites",
"molluscs",
] as const;
const MARKET_ALLERGENS: Record<string, readonly string[]> = {
EU: EU14,
SE: EU14,
GB: EU14,
US: US9,
CA,
};
let ruleCount = 0;
for (const [regionCode, allergens] of Object.entries(MARKET_ALLERGENS)) {
for (const allergen of allergens) {
await db
.insert(schema.allergenMarketRules)
.values({ regionCode, allergen: allergen as (typeof EU14)[number], mustHighlight: true })
.onConflictDoNothing();
ruleCount++;
}
}
console.log(`[seed] ${NUTRITION_PROFILES.length} näringsprofiler + ${ruleCount} allergenregler`);
// Uppslagskarta för beräkningar
const ingredientMap = new Map(
SEED_INGREDIENTS.map((i) => [
i.id,
{
nutritionPer100: i.nutritionPer100,
densityGPerMl: i.densityGPerMl ?? null,
gramsPerPiece: i.gramsPerPiece ?? null,
allergens: i.allergens,
priceMinorPerKg: i.defaultPriceMinorPerKg ?? null,
},
]),
);
// 2. Source registry-post för redaktionella recept (spec §15)
const [registry] = await db
.insert(schema.recipeSourceRegistry)
.values({
sourceName: `${BRAND.name} Redaktion`,
license: "proprietary",
rightToStore: true,
rightToModify: true,
rightToDisplay: true,
attributionRequired: false,
commercialUse: true,
notes: "Egna originalrecept. Fullständiga rättigheter.",
})
.returning();
// 3. Recept i två pass (varianter behöver grundreceptets id)
const slugToId = new Map<string, string>();
const basePass = SEED_RECIPES.filter((r) => !r.variantOfSlug);
const variantPass = SEED_RECIPES.filter((r) => r.variantOfSlug);
for (const recipe of [...basePass, ...variantPass]) {
const id = await insertRecipe(db, recipe, ingredientMap, registry?.id ?? null, slugToId);
slugToId.set(recipe.slug, id);
}
console.log(`[seed] ${SEED_RECIPES.length} recept`);
// 4. Substitutioner
for (const sub of SEED_SUBSTITUTIONS) {
await db
.insert(schema.substitutions)
.values({
id: sub.id,
fromIngredientId: sub.from,
toIngredientId: sub.to,
ratio: sub.ratio,
instructionsSv: sub.instructionsSv ?? null,
bestFor: sub.bestFor,
notRecommendedFor: sub.notRecommendedFor,
flavorImpactSv: sub.flavorImpactSv ?? null,
textureImpactSv: sub.textureImpactSv ?? null,
priority: sub.priority ?? 0,
})
.onConflictDoNothing();
}
console.log(`[seed] ${SEED_SUBSTITUTIONS.length} substitutioner`);
// 5. Säsongsevents
for (const ev of SEED_SEASON_EVENTS) {
await db
.insert(schema.seasonEvents)
.values({
id: ev.id,
slug: ev.slug,
nameSv: ev.nameSv,
market: ev.market,
dateRule: ev.dateRule,
leadDays: ev.leadDays,
foodTags: ev.foodTags,
recipeSlugs: ev.recipeSlugs,
priority: ev.priority,
active: true,
})
.onConflictDoNothing();
}
console.log(`[seed] ${SEED_SEASON_EVENTS.length} säsongsevents`);
// 6. Feature flags Launch Core på, Advanced bakom flaggor (spec §1, Del 3)
const flags: Array<{ key: string; enabled: boolean; descriptionSv: string }> = [
{ key: "community_publishing", enabled: false, descriptionSv: "Publicering av användarrecept" },
{ key: "week_plan_ai", enabled: true, descriptionSv: "AI-assisterad veckoplan" },
{
key: "plate_photo_analysis",
enabled: true,
descriptionSv: "Tallriksfoto och portionsuppskattning",
},
{ key: "receipt_scanning", enabled: true, descriptionSv: "Kvittoskanning" },
{ key: "pantry_forecast", enabled: false, descriptionSv: "Pantry Forecast-notiser" },
{ key: "health_integration", enabled: false, descriptionSv: "Apple Health / Health Connect" },
{ key: "weather_context", enabled: false, descriptionSv: "Väderbaserade förslag" },
{ key: "creator_rankings", enabled: false, descriptionSv: "Topplistor och gamification" },
{ key: "food_memories", enabled: false, descriptionSv: "Långsiktiga matminnen" },
{ key: "voice_input", enabled: false, descriptionSv: "Röstinmatning" },
{ key: "ai_rerank", enabled: false, descriptionSv: "AAMOS-omrankning av rekommendationer" },
];
for (const flag of flags) {
await db
.insert(schema.featureFlags)
.values({
key: flag.key,
enabled: flag.enabled,
descriptionSv: flag.descriptionSv,
rolloutPercent: 100,
})
.onConflictDoNothing();
}
console.log(`[seed] ${flags.length} feature flags`);
console.log("[seed] Klart.");
await pool.end();
}
type IngredientCalcInfo = {
nutritionPer100: IngredientNutritionSource["nutritionPer100"];
densityGPerMl: number | null;
gramsPerPiece: number | null;
allergens: Allergen[];
priceMinorPerKg: number | null;
};
async function insertRecipe(
db: ReturnType<typeof createDatabase>["db"],
recipe: SeedRecipe,
ingredientMap: Map<string, IngredientCalcInfo>,
sourceRegistryId: string | null,
slugToId: Map<string, string>,
): Promise<string> {
// Deterministisk näringsberäkning
const sources = new Map<string, IngredientNutritionSource>();
for (const ri of recipe.ingredients) {
const info = ingredientMap.get(ri.ing);
if (!info) throw new Error(`Recept ${recipe.slug}: okänd ingrediens ${ri.ing}`);
sources.set(ri.ing, {
nutritionPer100: info.nutritionPer100,
densityGPerMl: info.densityGPerMl,
gramsPerPiece: info.gramsPerPiece,
});
}
const calcIngredients = recipe.ingredients.map((ri) => ({
canonicalIngredientId: ri.ing,
quantity: ri.qty,
unit: ri.unit,
optional: ri.optional ?? false,
}));
const nutrition = computeRecipeNutrition(calcIngredients, recipe.portions, sources);
if (nutrition.uncomputableIngredientIds.length > 0) {
throw new Error(
`Recept ${recipe.slug}: kunde inte beräkna näring för ${nutrition.uncomputableIngredientIds.join(", ")}`,
);
}
// Deterministisk allergenhärledning
const allergens = new Set<Allergen>();
for (const ri of recipe.ingredients) {
if (ri.optional) continue;
for (const a of ingredientMap.get(ri.ing)?.allergens ?? []) allergens.add(a);
}
// Kostnadsuppskattning ur schablonpriser
let costTotal = 0;
let costComputable = true;
for (const ri of recipe.ingredients) {
if (ri.optional) continue;
const info = ingredientMap.get(ri.ing)!;
const grams = toGrams(ri.qty, ri.unit, {
densityGPerMl: info.densityGPerMl,
gramsPerPiece: info.gramsPerPiece,
});
if (grams == null || info.priceMinorPerKg == null) {
costComputable = false;
continue;
}
costTotal += (grams / 1000) * info.priceMinorPerKg;
}
// costTotal är i minor units (priceMinorPerKg) avrunda till heltal per portion.
const costPerPortion =
costComputable && recipe.portions > 0 ? Math.round(costTotal / recipe.portions) : null;
const dna: RecipeDNA = {
cuisine: recipe.cuisine,
...(recipe.dnaProtein ? { protein: recipe.dnaProtein } : {}),
...(recipe.dnaCarb ? { carbohydrate: recipe.dnaCarb } : {}),
vegetables: recipe.dnaVegetables,
flavorProfile: recipe.dnaFlavor,
spiceLevel: recipe.spiceLevel,
method: recipe.methods[0] ?? "stovetop",
timeMinutes: recipe.prepMin + recipe.cookMin,
calories: nutrition.perPortion.kcal,
proteinGrams: Math.round(nutrition.perPortion.proteinG),
};
const variantOfRecipeId = recipe.variantOfSlug
? (slugToId.get(recipe.variantOfSlug) ?? null)
: null;
const [row] = await db
.insert(schema.recipes)
.values({
slug: recipe.slug,
titleSv: recipe.titleSv,
descriptionSv: recipe.descriptionSv,
country: recipe.country ?? null,
cuisine: recipe.cuisine,
mealTypes: recipe.mealTypes,
tags: recipe.tags,
methods: recipe.methods,
equipment: recipe.equipment,
difficulty: recipe.difficulty,
prepTimeMinutes: recipe.prepMin,
cookTimeMinutes: recipe.cookMin,
totalTimeMinutes: recipe.prepMin + recipe.cookMin,
portions: recipe.portions,
nutritionPerPortion: nutrition.perPortion,
allergens: [...allergens].sort(),
spiceLevel: recipe.spiceLevel,
estimatedCostMinorPerPortion: costPerPortion,
storageGuidanceSv: recipe.storageGuidanceSv ?? null,
mealPrepFriendly: recipe.mealPrepFriendly,
freezerFriendly: recipe.freezerFriendly,
peakSeasons: recipe.peakSeasons,
holidayTags: recipe.holidayTags,
dna,
variantType: recipe.variantType ?? "standard",
variantOfRecipeId,
status: "published",
verificationStatus: "editorial",
sourceType: "own_editorial",
sourceRegistryId,
creatorDisplayName: `${BRAND.name} Redaktion`,
})
.onConflictDoUpdate({
target: schema.recipes.slug,
set: {
nutritionPerPortion: nutrition.perPortion,
allergens: [...allergens].sort(),
dna,
updatedAt: new Date(),
},
})
.returning();
const recipeId = row!.id;
// Ingredienser + steg: rensa och skriv om (idempotent seed)
const { eq } = await import("drizzle-orm");
await db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, recipeId));
await db.delete(schema.recipeSteps).where(eq(schema.recipeSteps.recipeId, recipeId));
await db.insert(schema.recipeIngredients).values(
recipe.ingredients.map((ri, idx) => ({
recipeId,
canonicalIngredientId: ri.ing,
displayNameSv: ri.nameSv,
quantity: ri.qty,
unit: ri.unit,
note: ri.note ?? null,
optional: ri.optional ?? false,
groupName: ri.group ?? null,
sortOrder: idx,
})),
);
await db.insert(schema.recipeSteps).values(
recipe.steps.map((step, idx) => ({
recipeId,
stepNumber: idx + 1,
instructionSv: step.text,
timerSeconds: step.timerSeconds ?? null,
temperatureC: step.temperatureC ?? null,
tip: step.tip ?? null,
})),
);
return recipeId;
}
main().catch((err) => {
console.error("[seed] MISSLYCKADES:", err);
process.exit(1);
});
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src", "drizzle.config.ts"],
"compilerOptions": {
"types": ["node"]
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@app/events",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Typad eventkatalog (spec §55) används av outbox, analytics, memory och training",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@app/shared-types": "workspace:*"
}
}
+67
View File
@@ -0,0 +1,67 @@
import type { EventType, Unit } from "@app/shared-types";
/**
* Typade payloads för domänhändelser (spec §55).
* Events skrivs i outbox-tabellen (domain_events) i samma transaktion som
* affärsdatan och konsumeras asynkront av worker (memory, analytics, training).
*/
export interface EventPayloadMap {
PRODUCT_ADDED: {
inventoryItemId: string;
canonicalIngredientId?: string | null;
quantity: number;
unit: Unit;
source: string;
};
PRODUCT_UPDATED: { inventoryItemId: string; changes: Record<string, unknown> };
PRODUCT_CONSUMED: {
inventoryItemId: string;
quantity: number;
unit: Unit;
refType?: string | null;
};
PRODUCT_DISCARDED: {
inventoryItemId: string;
quantity: number;
unit: Unit;
valueMinor?: number | null;
reason?: string | null;
};
RECIPE_COOKED: { recipeId: string; portions: number; mealBoxPortions: number };
RECIPE_RATED: { recipeId: string; stars: number; feedbackTags: string[] };
RECIPE_CREATED: { recipeId: string; sourceType: string };
RECIPE_FORKED: { recipeId: string; forkedFromRecipeId: string };
MEAL_LOGGED: { mealId: string; mealType: string; kcal: number; source: string };
MEAL_PHOTO_ANALYZED: { scanJobId: string; matched: boolean; kcalMostLikely?: number | null };
HOUSEHOLD_MEMBER_ADDED: { householdId: string; newUserId: string; role: string };
SUBSCRIPTION_STARTED: { subscriptionId: string; plan: string; provider: string };
SUBSCRIPTION_CHANGED: { subscriptionId: string; status: string };
AI_CORRECTED: { scanJobId?: string | null; taskType: string; field: string };
MEMORY_UPDATED: { memoryItemId: string; kind: string; origin: string };
SHOPPING_COMPLETED: { shoppingListId: string; itemsAdded: number };
WEEK_PLAN_UPDATED: { weekPlanId: string; reason?: string | null };
MEAL_BOX_CREATED: { mealBoxId: string; portions: number };
MEAL_BOX_CONSUMED: { mealBoxId: string; portions: number };
}
/** Kontroll i kompileringstid att kartan täcker alla EventType. */
type AssertCoversAll = EventType extends keyof EventPayloadMap ? true : never;
const _coversAll: AssertCoversAll = true;
void _coversAll;
export interface NewDomainEvent<T extends EventType = EventType> {
type: T;
payload: T extends keyof EventPayloadMap ? EventPayloadMap[T] : never;
userId?: string | undefined;
householdId?: string | undefined;
correlationId?: string | undefined;
}
/** Hjälpare med full typinferens: makeEvent("RECIPE_COOKED", {...}). */
export function makeEvent<T extends EventType>(
type: T,
payload: T extends keyof EventPayloadMap ? EventPayloadMap[T] : never,
scope: { userId?: string; householdId?: string; correlationId?: string } = {},
): NewDomainEvent<T> {
return { type, payload, ...scope };
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src"],
"compilerOptions": {
"types": ["node"]
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@app/feature-flags",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Feature flags med DB-lagring, env-override och rollout-procent (spec §1: bygg modulärt med feature flags)",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
}
}
+93
View File
@@ -0,0 +1,93 @@
import { createHash } from "node:crypto";
export interface FlagRow {
key: string;
enabled: boolean;
rolloutPercent: number;
}
/** Kända flaggor centralt register så att döda flaggor syns i kodgranskning. */
export const KNOWN_FLAGS = {
COMMUNITY_PUBLISHING: "community_publishing",
WEEK_PLAN_AI: "week_plan_ai",
PLATE_PHOTO_ANALYSIS: "plate_photo_analysis",
RECEIPT_SCANNING: "receipt_scanning",
PANTRY_FORECAST: "pantry_forecast",
HEALTH_INTEGRATION: "health_integration",
WEATHER_CONTEXT: "weather_context",
CREATOR_RANKINGS: "creator_rankings",
FOOD_MEMORIES: "food_memories",
VOICE_INPUT: "voice_input",
AI_RERANK: "ai_rerank",
} as const;
export type KnownFlagKey = (typeof KNOWN_FLAGS)[keyof typeof KNOWN_FLAGS];
export interface FeatureFlagServiceOptions {
/** Laddar alla flaggor från databasen (injiceras för att undvika DB-beroende här). */
loadAll: () => Promise<FlagRow[]>;
/** Cache-TTL i ms (default 30 s). */
ttlMs?: number;
/** Miljövariabler för override: APP_FLAG_<KEY>=true/false. */
env?: Record<string, string | undefined>;
}
/**
* Flaggtjänst med tre lager:
* 1. env-override (drift/incident: stäng av utan deploy),
* 2. databasvärde med rollout-procent (gradvis utrullning per användare),
* 3. default false (fail closed för ny funktionalitet).
*/
export class FeatureFlagService {
private cache = new Map<string, FlagRow>();
private cachedAt = 0;
private readonly ttlMs: number;
private readonly env: Record<string, string | undefined>;
private readonly loadAll: () => Promise<FlagRow[]>;
constructor(options: FeatureFlagServiceOptions) {
this.loadAll = options.loadAll;
this.ttlMs = options.ttlMs ?? 30_000;
this.env = options.env ?? process.env;
}
async isEnabled(key: string, userId?: string): Promise<boolean> {
const envOverride = this.env[`APP_FLAG_${key.toUpperCase()}`];
if (envOverride === "true") return true;
if (envOverride === "false") return false;
await this.refreshIfStale();
const row = this.cache.get(key);
if (!row || !row.enabled) return false;
if (row.rolloutPercent >= 100) return true;
if (row.rolloutPercent <= 0) return false;
if (!userId) return false;
return bucketFor(key, userId) < row.rolloutPercent;
}
async all(): Promise<FlagRow[]> {
await this.refreshIfStale();
return [...this.cache.values()];
}
invalidate(): void {
this.cachedAt = 0;
}
private async refreshIfStale(): Promise<void> {
if (Date.now() - this.cachedAt < this.ttlMs) return;
try {
const rows = await this.loadAll();
this.cache = new Map(rows.map((r) => [r.key, r]));
this.cachedAt = Date.now();
} catch {
// Behåll gammal cache vid DB-fel flaggor får aldrig fälla en request.
this.cachedAt = Date.now() - this.ttlMs + 5_000;
}
}
}
/** Stabil hash → 099: samma användare hamnar alltid i samma bucket per flagga. */
export function bucketFor(flagKey: string, userId: string): number {
const hash = createHash("sha256").update(`${flagKey}:${userId}`).digest();
return ((hash[0]! << 8) | hash[1]!) % 100;
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src"],
"compilerOptions": {
"types": ["node"]
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@app/inventory-engine",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Food Twin-logik: transaktionsbaserat lager, FEFO, bäst före-klassning, dubblettkandidater (spec §8, §13)",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@app/nutrition-engine": "workspace:*",
"@app/shared-types": "workspace:*"
}
}
+64
View File
@@ -0,0 +1,64 @@
import type { InventoryTransactionType, Unit } from "@app/shared-types";
export interface TransactionLike {
type: InventoryTransactionType;
quantityDelta: number;
unit: Unit;
}
/** Transaktionstyper som ska vara negativa (uttag). */
const OUTFLOW_TYPES: ReadonlySet<InventoryTransactionType> = new Set([
"consume",
"discard",
"cook_use",
"leftover_consumed",
]);
/** Transaktionstyper som ska vara positiva (inflöde). */
const INFLOW_TYPES: ReadonlySet<InventoryTransactionType> = new Set([
"purchase",
"leftover_created",
]);
export interface BalanceResult {
balance: number;
valid: boolean;
errors: string[];
}
/**
* Beräkna saldo ur transaktionshistorik (spec §8: transaktioner är sanningen).
* Saldo tillåts aldrig bli negativt i så fall flaggas historiken som
* inkonsistent så att en correction kan föreslås, i stället för tyst clamp.
*/
export function computeBalance(transactions: TransactionLike[]): BalanceResult {
let balance = 0;
const errors: string[] = [];
for (const [i, tx] of transactions.entries()) {
if (OUTFLOW_TYPES.has(tx.type) && tx.quantityDelta > 0) {
errors.push(`Transaktion ${i} (${tx.type}) borde vara negativ men är +${tx.quantityDelta}`);
}
if (INFLOW_TYPES.has(tx.type) && tx.quantityDelta < 0) {
errors.push(`Transaktion ${i} (${tx.type}) borde vara positiv men är ${tx.quantityDelta}`);
}
balance += tx.quantityDelta;
if (balance < -1e-9) {
errors.push(
`Saldo blev negativt (${balance.toFixed(3)}) efter transaktion ${i} (${tx.type})`,
);
balance = 0;
}
}
return { balance: round3(balance), valid: errors.length === 0, errors };
}
/** Normalisera ett uttag: rätt tecken oavsett hur anroparen skickade mängden. */
export function normalizeDelta(type: InventoryTransactionType, quantity: number): number {
const magnitude = Math.abs(quantity);
if (OUTFLOW_TYPES.has(type)) return -magnitude;
if (INFLOW_TYPES.has(type)) return magnitude;
return quantity; // adjust/correction/move/freeze/thaw får vara valfritt tecken
}
function round3(v: number): number {
return Math.round(v * 1000) / 1000;
}
+109
View File
@@ -0,0 +1,109 @@
import type { InventorySource } from "@app/shared-types";
export interface DedupCandidateInput {
canonicalIngredientId?: string | null;
displayName: string;
brand?: string | null;
quantity: number;
source: InventorySource;
createdAt: string;
}
export interface DedupExistingItem extends DedupCandidateInput {
id: string;
}
export interface DedupCandidate {
itemId: string;
score: number;
reasons: string[];
}
const WINDOW_MS = 3 * 86_400_000; // 3 dygn
/**
* Dubblettkandidater mellan kvitto, streckkod och bilder (spec §9).
* Ren heuristik användaren fattar alltid beslutet. AI-baserad
* dedup (DEDUPLICATE_INVENTORY-jobbet) kan förfina men aldrig auto-slå ihop
* utan bekräftelse.
*/
export function findDuplicateCandidates(
incoming: DedupCandidateInput,
existing: DedupExistingItem[],
): DedupCandidate[] {
const incomingTime = Date.parse(incoming.createdAt);
const results: DedupCandidate[] = [];
for (const item of existing) {
const reasons: string[] = [];
let score = 0;
const sameCanonical =
incoming.canonicalIngredientId != null &&
incoming.canonicalIngredientId === item.canonicalIngredientId;
const nameMatch = normalizedEquals(incoming.displayName, item.displayName);
if (sameCanonical) {
score += 0.45;
reasons.push("samma ingrediens");
} else if (nameMatch) {
score += 0.3;
reasons.push("liknande namn");
} else {
continue;
}
const dt = Math.abs(Date.parse(item.createdAt) - incomingTime);
if (dt <= WINDOW_MS) {
score += 0.25;
reasons.push("registrerad inom 3 dygn");
}
if (incoming.source !== item.source) {
score += 0.15;
reasons.push(`olika källor (${incoming.source} + ${item.source})`);
}
const qtyRatio =
item.quantity > 0 && incoming.quantity > 0
? Math.min(incoming.quantity, item.quantity) / Math.max(incoming.quantity, item.quantity)
: 0;
if (qtyRatio >= 0.7) {
score += 0.15;
reasons.push("liknande mängd");
}
if (
incoming.brand &&
item.brand &&
incoming.brand.toLowerCase().trim() === item.brand.toLowerCase().trim()
) {
score += 0.1;
reasons.push("samma varumärke");
}
if (score >= 0.5) {
results.push({ itemId: item.id, score: Math.min(1, round2(score)), reasons });
}
}
return results.sort((a, b) => b.score - a.score);
}
function normalizedEquals(a: string, b: string): boolean {
const na = normalize(a);
const nb = normalize(b);
if (na === nb) return true;
return na.length > 3 && nb.length > 3 && (na.includes(nb) || nb.includes(na));
}
function normalize(s: string): string {
return s
.toLowerCase()
.replace(/[^a-zåäö0-9 ]/gi, "")
.replace(/\s+/g, " ")
.trim();
}
function round2(v: number): number {
return Math.round(v * 100) / 100;
}
+127
View File
@@ -0,0 +1,127 @@
import { EXPIRY_THRESHOLDS, type ExpiryStatus, type StorageLocationType } from "@app/shared-types";
export interface ExpiryInput {
bestBeforeDate?: string | null;
useByDate?: string | null;
openedAt?: string | null;
frozenAt?: string | null;
thawedAt?: string | null;
storageLocationType?: StorageLocationType | null;
/** Riktvärde i dagar efter öppning/inköp per plats (från canonical ingredient). */
shelfLifeGuidance?: Partial<Record<StorageLocationType, number>> | null;
purchasedAt?: string | null;
}
export interface ExpiryResult {
status: ExpiryStatus;
/** Dagar kvar till den mest bindande gränsen. Negativt = passerad. */
daysLeft: number | null;
/** Vilken gräns som styr klassningen. */
limitingFactor: "use_by" | "best_before" | "opened_guidance" | "purchased_guidance" | "none";
/** Bäst före passerad men troligen ätbar kommunicera skillnaden (spec §13). */
pastBestBefore: boolean;
}
const DAY_MS = 86_400_000;
function daysBetween(from: Date, to: Date): number {
return Math.floor((startOfDay(to).getTime() - startOfDay(from).getTime()) / DAY_MS);
}
/**
* All kalenderaritmetik sker i UTC (deterministiskt oavsett serverns tidszon).
* Datumsträngar ("2026-08-05") är kalenderdatum och tolkas som UTC-midnatt;
* resultatet blir identiskt på en server i Stockholm, Auckland eller UTC.
*/
function startOfDay(d: Date): Date {
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
}
function parse(dateStr: string | null | undefined): Date | null {
if (!dateStr) return null;
const d = new Date(`${dateStr}T00:00:00Z`);
return Number.isNaN(d.getTime()) ? null : d;
}
/**
* Deterministisk bäst före-klassning (spec §13).
*
* Regler:
* - Fryst vara ("frozenAt" satt, ej upptinad): datumklockan pausad → "fresh"
* (kvalitetsförsämring hanteras som vägledning i UI, inte som utgång).
* - "Sista förbrukningsdag" är hård gräns → efter den: "expired".
* - "Bäst före" passerad: "expired"-klass används INTE automatiskt varan
* flaggas "use_soon/expiring" med pastBestBefore=true. Appen får aldrig
* garantera säkerhet (spec §61.3) användaren avgör.
* - Öppnad vara: öppningsdatum + riktvärde per förvaringsplats kan vara mer
* bindande än tryckt datum.
*/
export function classifyExpiry(input: ExpiryInput, today: Date = new Date()): ExpiryResult {
const isFrozen = Boolean(input.frozenAt) && !input.thawedAt;
if (isFrozen) {
return { status: "fresh", daysLeft: null, limitingFactor: "none", pastBestBefore: false };
}
const candidates: Array<{ factor: ExpiryResult["limitingFactor"]; date: Date }> = [];
const useBy = parse(input.useByDate);
if (useBy) candidates.push({ factor: "use_by", date: useBy });
const bestBefore = parse(input.bestBeforeDate);
if (bestBefore) candidates.push({ factor: "best_before", date: bestBefore });
const guidanceDays = input.storageLocationType
? input.shelfLifeGuidance?.[input.storageLocationType]
: undefined;
const opened = parse(input.openedAt);
if (opened && guidanceDays != null) {
candidates.push({
factor: "opened_guidance",
date: new Date(opened.getTime() + guidanceDays * DAY_MS),
});
} else if (!useBy && !bestBefore && guidanceDays != null) {
const purchased = parse(input.purchasedAt);
if (purchased) {
candidates.push({
factor: "purchased_guidance",
date: new Date(purchased.getTime() + guidanceDays * DAY_MS),
});
}
}
if (candidates.length === 0) {
return { status: "unknown", daysLeft: null, limitingFactor: "none", pastBestBefore: false };
}
// Mest bindande = tidigaste datum.
candidates.sort((a, b) => a.date.getTime() - b.date.getTime());
const limiting = candidates[0]!;
const daysLeft = daysBetween(today, limiting.date);
const pastBestBefore = bestBefore ? daysBetween(today, bestBefore) < 0 : false;
let status: ExpiryStatus;
if (daysLeft < 0) {
// Hård gräns endast för sista förbrukningsdag; annars "expiring" + flagga.
status = limiting.factor === "use_by" ? "expired" : "expiring";
} else if (daysLeft <= EXPIRY_THRESHOLDS.EXPIRING_DAYS) {
status = "expiring";
} else if (daysLeft <= EXPIRY_THRESHOLDS.USE_SOON_DAYS) {
status = "use_soon";
} else {
status = "fresh";
}
return { status, daysLeft, limitingFactor: limiting.factor, pastBestBefore };
}
/** Sorteringsnyckel: mest brådskande först, okända sist. */
export function urgencyRank(result: ExpiryResult): number {
const order: Record<ExpiryStatus, number> = {
expired: 0,
expiring: 1,
use_soon: 2,
fresh: 3,
unknown: 4,
};
return order[result.status] * 10_000 + (result.daysLeft ?? 9_000);
}
+67
View File
@@ -0,0 +1,67 @@
import type { Unit } from "@app/shared-types";
import { convert, type IngredientUnitInfo } from "@app/nutrition-engine";
import { classifyExpiry, urgencyRank, type ExpiryInput } from "./expiry.js";
export interface StockItemLike extends ExpiryInput {
id: string;
canonicalIngredientId?: string | null;
quantity: number;
unit: Unit;
purchasedAt?: string | null;
}
export interface Allocation {
itemId: string;
quantity: number;
unit: Unit;
}
export interface FefoResult {
allocations: Allocation[];
/** Hur mycket som saknas i efterfrågad enhet (0 om täckt). */
shortfall: number;
covered: boolean;
}
/**
* FEFO First Expire, First Out (spec §1718: prioritera utgångsdatum).
* Plockar från de poster som går ut först; vid lika datum äldst inköp först.
*/
export function allocateFefo(
required: number,
unit: Unit,
items: StockItemLike[],
info: IngredientUnitInfo = {},
today: Date = new Date(),
): FefoResult {
const sorted = [...items].sort((a, b) => {
const rank = urgencyRank(classifyExpiry(a, today)) - urgencyRank(classifyExpiry(b, today));
if (rank !== 0) return rank;
const pa = a.purchasedAt ?? "9999-12-31";
const pb = b.purchasedAt ?? "9999-12-31";
return pa.localeCompare(pb);
});
let remaining = required;
const allocations: Allocation[] = [];
for (const item of sorted) {
if (remaining <= 1e-9) break;
const available = convert(item.quantity, item.unit, unit, info);
if (available == null || available <= 0) continue;
const take = Math.min(available, remaining);
const takeInItemUnit = convert(take, unit, item.unit, info);
if (takeInItemUnit == null) continue;
allocations.push({ itemId: item.id, quantity: round3(takeInItemUnit), unit: item.unit });
remaining -= take;
}
return {
allocations,
shortfall: round3(Math.max(0, remaining)),
covered: remaining <= 1e-9,
};
}
function round3(v: number): number {
return Math.round(v * 1000) / 1000;
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Pantry Forecast (spec §40): "Ni brukar använda mjölk var sjätte dag."
* Enkel deterministisk konsumtionstakt ur transaktionshistorik.
* Prognoser märks ALLTID som prognoser i UI.
*/
export interface ConsumptionEvent {
/** ISO-datum */
date: string;
quantity: number;
}
export interface ForecastResult {
/** Genomsnittlig förbrukning per dag (i postens enhet). */
dailyRate: number;
/** Dagar tills nuvarande saldo beräknas ta slut. */
daysUntilEmpty: number | null;
/** Föreslaget inköpsdatum (2 dagars marginal). */
suggestedRestockDate: string | null;
confidence: "low" | "medium" | "high";
}
export function forecastDepletion(
currentBalance: number,
consumption: ConsumptionEvent[],
today: Date = new Date(),
): ForecastResult {
if (consumption.length < 2) {
return { dailyRate: 0, daysUntilEmpty: null, suggestedRestockDate: null, confidence: "low" };
}
const sorted = [...consumption].sort((a, b) => a.date.localeCompare(b.date));
const first = Date.parse(sorted[0]!.date);
const last = Date.parse(sorted[sorted.length - 1]!.date);
const spanDays = Math.max(1, (last - first) / 86_400_000);
const total = sorted.reduce((sum, e) => sum + Math.abs(e.quantity), 0);
const dailyRate = total / spanDays;
if (dailyRate <= 0) {
return { dailyRate: 0, daysUntilEmpty: null, suggestedRestockDate: null, confidence: "low" };
}
const daysUntilEmpty = currentBalance / dailyRate;
const restock = new Date(today.getTime() + Math.max(0, daysUntilEmpty - 2) * 86_400_000);
const confidence = consumption.length >= 6 ? "high" : consumption.length >= 4 ? "medium" : "low";
return {
dailyRate: Math.round(dailyRate * 1000) / 1000,
daysUntilEmpty: Math.round(daysUntilEmpty * 10) / 10,
suggestedRestockDate: restock.toISOString().slice(0, 10),
confidence,
};
}
+5
View File
@@ -0,0 +1,5 @@
export * from "./expiry.js";
export * from "./balance.js";
export * from "./fefo.js";
export * from "./dedup.js";
export * from "./forecast.js";
@@ -0,0 +1,209 @@
import { describe, expect, it } from "vitest";
import {
allocateFefo,
classifyExpiry,
computeBalance,
findDuplicateCandidates,
forecastDepletion,
normalizeDelta,
} from "../src/index.js";
const TODAY = new Date("2026-08-02T00:00:00Z"); // UTC testet ska vara sant i alla tidszoner
describe("bäst före-klassning (spec §13)", () => {
it("sista förbrukningsdag passerad → expired (hård gräns)", () => {
const result = classifyExpiry({ useByDate: "2026-08-01" }, TODAY);
expect(result.status).toBe("expired");
expect(result.limitingFactor).toBe("use_by");
});
it("bäst före passerad → expiring + pastBestBefore, ALDRIG auto-expired", () => {
const result = classifyExpiry({ bestBeforeDate: "2026-07-30" }, TODAY);
expect(result.status).toBe("expiring");
expect(result.pastBestBefore).toBe(true);
});
it("trösklar: ≤2 dagar expiring, ≤5 use_soon, annars fresh", () => {
expect(classifyExpiry({ bestBeforeDate: "2026-08-04" }, TODAY).status).toBe("expiring");
expect(classifyExpiry({ bestBeforeDate: "2026-08-06" }, TODAY).status).toBe("use_soon");
expect(classifyExpiry({ bestBeforeDate: "2026-08-20" }, TODAY).status).toBe("fresh");
});
it("fryst vara pausar klockan", () => {
const result = classifyExpiry({ bestBeforeDate: "2026-07-01", frozenAt: "2026-06-20" }, TODAY);
expect(result.status).toBe("fresh");
});
it("upptinad vara räknas igen", () => {
const result = classifyExpiry(
{ bestBeforeDate: "2026-07-01", frozenAt: "2026-06-20", thawedAt: "2026-08-01" },
TODAY,
);
expect(result.status).not.toBe("fresh");
});
it("öppnad vara: riktvärde per plats kan vara mest bindande", () => {
const result = classifyExpiry(
{
bestBeforeDate: "2026-09-01",
openedAt: "2026-07-30",
storageLocationType: "fridge",
shelfLifeGuidance: { fridge: 5 },
},
TODAY,
);
expect(result.limitingFactor).toBe("opened_guidance");
expect(result.daysLeft).toBe(2);
expect(result.status).toBe("expiring");
});
it("ingen data → unknown, aldrig gissning", () => {
expect(classifyExpiry({}, TODAY).status).toBe("unknown");
});
it("mjölkprincipen: gick ut igår → använd sinnena, inte soptunnan", () => {
// Bäst före är en KVALITETSgräns: mjölken som gick ut igår är inte
// automatiskt dålig användaren ska lukta/smaka. Appen får aldrig
// klassa den som "expired" eller föreslå att den slängs.
const result = classifyExpiry({ bestBeforeDate: "2026-08-01" }, TODAY);
expect(result.status).toBe("expiring"); // synlig och prioriterad inte dömd
expect(result.pastBestBefore).toBe(true); // UI: "lukta och smaka"
expect(result.daysLeft).toBe(-1);
expect(result.status).not.toBe("expired");
});
});
describe("transaktionssaldo (spec §8)", () => {
it("summerar spec-exemplet korrekt", () => {
const result = computeBalance([
{ type: "purchase", quantityDelta: 1000, unit: "GRAM" },
{ type: "cook_use", quantityDelta: -300, unit: "GRAM" },
{ type: "discard", quantityDelta: -150, unit: "GRAM" },
]);
expect(result.balance).toBe(550);
expect(result.valid).toBe(true);
});
it("flaggar negativt saldo som inkonsistens", () => {
const result = computeBalance([
{ type: "purchase", quantityDelta: 100, unit: "GRAM" },
{ type: "consume", quantityDelta: -200, unit: "GRAM" },
]);
expect(result.valid).toBe(false);
expect(result.balance).toBe(0);
});
it("normaliserar tecken per transaktionstyp", () => {
expect(normalizeDelta("consume", 300)).toBe(-300);
expect(normalizeDelta("purchase", -300)).toBe(300);
expect(normalizeDelta("adjust", -50)).toBe(-50);
});
});
describe("FEFO-allokering (spec §17)", () => {
it("plockar från den som går ut först", () => {
const result = allocateFefo(
400,
"GRAM",
[
{ id: "fresh", quantity: 500, unit: "GRAM", bestBeforeDate: "2026-08-20" },
{ id: "old", quantity: 300, unit: "GRAM", bestBeforeDate: "2026-08-03" },
],
{},
TODAY,
);
expect(result.covered).toBe(true);
expect(result.allocations[0]?.itemId).toBe("old");
expect(result.allocations[0]?.quantity).toBe(300);
expect(result.allocations[1]?.itemId).toBe("fresh");
expect(result.allocations[1]?.quantity).toBe(100);
});
it("rapporterar shortfall ärligt", () => {
const result = allocateFefo(
1000,
"GRAM",
[{ id: "a", quantity: 300, unit: "GRAM", bestBeforeDate: "2026-08-10" }],
{},
TODAY,
);
expect(result.covered).toBe(false);
expect(result.shortfall).toBe(700);
});
it("mjölkprincipen: passerat bäst före utesluts ALDRIG räddas först", () => {
const result = allocateFefo(
400,
"GRAM",
[
{ id: "fresh", quantity: 500, unit: "GRAM", bestBeforeDate: "2026-08-20" },
{ id: "past", quantity: 300, unit: "GRAM", bestBeforeDate: "2026-08-01" }, // gick ut igår
],
{},
TODAY,
);
// Varan som passerat bäst före plockas FÖRST (rädda den), inte bort.
expect(result.covered).toBe(true);
expect(result.allocations[0]?.itemId).toBe("past");
expect(result.allocations[0]?.quantity).toBe(300);
});
});
describe("dubblettkandidater (spec §9)", () => {
it("hittar kvitto+bild-dubblett inom fönstret", () => {
const candidates = findDuplicateCandidates(
{
canonicalIngredientId: "milk_3",
displayName: "Mjölk 3%",
quantity: 1,
source: "receipt",
createdAt: "2026-08-02T10:00:00Z",
},
[
{
id: "existing",
canonicalIngredientId: "milk_3",
displayName: "mjölk",
quantity: 1,
source: "fridge_photo",
createdAt: "2026-08-01T18:00:00Z",
},
],
);
expect(candidates).toHaveLength(1);
expect(candidates[0]?.score).toBeGreaterThanOrEqual(0.7);
});
it("matchar inte helt olika varor", () => {
const candidates = findDuplicateCandidates(
{
canonicalIngredientId: "milk_3",
displayName: "Mjölk",
quantity: 1,
source: "receipt",
createdAt: "2026-08-02T10:00:00Z",
},
[
{
id: "x",
canonicalIngredientId: "chicken_breast",
displayName: "Kyckling",
quantity: 500,
source: "barcode",
createdAt: "2026-08-02T09:00:00Z",
},
],
);
expect(candidates).toHaveLength(0);
});
});
describe("pantry forecast (spec §40)", () => {
it("beräknar förbrukningstakt och påfyllnadsdatum", () => {
const result = forecastDepletion(
1,
[
{ date: "2026-07-20", quantity: 1 },
{ date: "2026-07-26", quantity: 1 },
{ date: "2026-08-01", quantity: 1 },
],
TODAY,
);
expect(result.dailyRate).toBeCloseTo(0.25, 2);
expect(result.daysUntilEmpty).toBeCloseTo(4, 0);
expect(result.suggestedRestockDate).toBe("2026-08-04");
});
it("låg konfidens vid för lite data", () => {
expect(forecastDepletion(1, [{ date: "2026-08-01", quantity: 1 }], TODAY).confidence).toBe(
"low",
);
});
});
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src", "test"],
"compilerOptions": {
"types": ["node"]
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@app/memory-client",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "AAMOS Memory-integration: minneslager, samtyckesregler och \"Vad appen vet om mig\" (spec §32)",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@app/ai-contracts": "workspace:*",
"@app/shared-types": "workspace:*"
}
}
+238
View File
@@ -0,0 +1,238 @@
import type { AamosClient } from "@app/ai-contracts";
import type { MemoryItem, MemoryKind, SignalOrigin } from "@app/shared-types";
/**
* Minnesarkitektur (spec §32):
*
* - plattformens databas (memory_items) är den ANVÄNDARSYNLIGA sanningen:
* allt som visas i "Vad plattformen vet om mig" och allt som kan korrigeras,
* pausas, raderas och exporteras finns där.
* - AAMOS får härleda nya minnesposter (UPDATE_USER_MEMORY-jobbet), men
* förslagen skrivs alltid in i plattformens tabell där användaren äger dem.
* - Personligt minne är ALDRIG automatiskt träningsdata (spec §3233);
* consentSnapshot följer med varje härledning.
*/
export interface MemoryUpdateProposal {
key: string;
kind: MemoryKind;
summarySv: string;
value: unknown;
origin: Exclude<SignalOrigin, "user_stated">;
confidence: number;
expiresAt?: string | null;
}
export interface MemorySyncInput {
scope: "user" | "household";
scopeId: string;
events: Array<{ type: string; occurredAt: string; payload: unknown }>;
existingMemoryKeys: string[];
consentFlags: {
personalization: boolean;
anonymizedImprovement: boolean;
imageTraining: boolean;
};
correlationId?: string;
localeContext?: import("@app/shared-types").LocaleContext;
}
/**
* Kör minnesuppdatering via AAMOS. Returnerar förslag anroparen (workern)
* persisterar dem i memory_items och publicerar MEMORY_UPDATED-event.
* Utan personaliseringssamtycke körs ingenting.
*/
export async function deriveMemoryUpdates(
aamos: AamosClient,
input: MemorySyncInput,
): Promise<MemoryUpdateProposal[]> {
if (!input.consentFlags.personalization) return [];
const result = await aamos.runTask(
"UPDATE_USER_MEMORY",
{
scope: input.scope,
scopeId: input.scopeId,
events: input.events,
existingMemoryKeys: input.existingMemoryKeys,
},
{
subjectRef: pseudonymize(input.scopeId),
consentFlags: input.consentFlags,
...(input.localeContext ? { localeContext: input.localeContext } : {}),
...(input.correlationId ? { correlationId: input.correlationId } : {}),
},
);
if (result.status !== "ok" || !result.output) return [];
return result.output.memoryUpdates.map((u) => ({
key: u.key,
kind: u.kind,
summarySv: u.summarySv,
value: u.value,
origin: u.origin,
confidence: u.confidence,
expiresAt: u.expiresAt,
}));
}
/** Gruppera minnesposter för "Vad plattformen vet om mig"-vyn. */
export interface MemoryOverview {
language: string;
sections: Array<{
kind: MemoryKind;
/** Rubrik på begärt språk (i18n M10). titleSv behålls för bakåtkompatibilitet. */
title: string;
titleSv: string;
items: Array<MemoryItem & { summary: string }>;
}>;
totalCount: number;
pausedCount: number;
}
const KIND_TITLES: Record<string, Record<MemoryKind, string>> = {
sv: {
structured_fact: "Fakta om dig och hushållet",
event: "Händelser vi kommer ihåg",
semantic: "Mönster vi har observerat",
profile_summary: "Din profil i korthet",
recipe_memory: "Recept och måltider",
},
en: {
structured_fact: "Facts about you and your household",
event: "Events we remember",
semantic: "Patterns we've observed",
profile_summary: "Your profile at a glance",
recipe_memory: "Recipes and meals",
},
es: {
structured_fact: "Datos sobre ti y tu hogar",
event: "Eventos que recordamos",
semantic: "Patrones observados",
profile_summary: "Tu perfil de un vistazo",
recipe_memory: "Recetas y comidas",
},
it: {
structured_fact: "Informazioni su di te e la tua famiglia",
event: "Eventi che ricordiamo",
semantic: "Schemi osservati",
profile_summary: "Il tuo profilo in breve",
recipe_memory: "Ricette e pasti",
},
de: {
structured_fact: "Fakten über dich und deinen Haushalt",
event: "Ereignisse, die wir uns merken",
semantic: "Beobachtete Muster",
profile_summary: "Dein Profil auf einen Blick",
recipe_memory: "Rezepte und Mahlzeiten",
},
fr: {
structured_fact: "Infos sur vous et votre foyer",
event: "Événements mémorisés",
semantic: "Habitudes observées",
profile_summary: "Votre profil en un coup d'œil",
recipe_memory: "Recettes et repas",
},
da: {
structured_fact: "Fakta om dig og husstanden",
event: "Hændelser vi husker",
semantic: "Mønstre vi har observeret",
profile_summary: "Din profil i korte træk",
recipe_memory: "Opskrifter og måltider",
},
nb: {
structured_fact: "Fakta om deg og husstanden",
event: "Hendelser vi husker",
semantic: "Mønstre vi har observert",
profile_summary: "Profilen din i korte trekk",
recipe_memory: "Oppskrifter og måltider",
},
fi: {
structured_fact: "Tietoa sinusta ja kotitaloudestasi",
event: "Muistamamme tapahtumat",
semantic: "Havaitut tavat",
profile_summary: "Profiilisi lyhyesti",
recipe_memory: "Reseptit ja ateriat",
},
nl: {
structured_fact: "Feiten over jou en je huishouden",
event: "Gebeurtenissen die we onthouden",
semantic: "Waargenomen patronen",
profile_summary: "Je profiel in het kort",
recipe_memory: "Recepten en maaltijden",
},
pl: {
structured_fact: "Fakty o Tobie i Twoim gospodarstwie",
event: "Zdarzenia, które pamiętamy",
semantic: "Zaobserwowane wzorce",
profile_summary: "Twój profil w skrócie",
recipe_memory: "Przepisy i posiłki",
},
pt: {
structured_fact: "Factos sobre si e o seu agregado",
event: "Eventos que recordamos",
semantic: "Padrões observados",
profile_summary: "O seu perfil em resumo",
recipe_memory: "Receitas e refeições",
},
};
/**
* Deterministisk rendering av summary ur strukturerad value (i18n-spec §23, M10).
* Kända value-former renderas per språk; okända faller tillbaka på summarySv
* (nya minnen får lokaliserad summary direkt från AAMOS via localeContext).
*/
export function renderMemorySummary(
item: Pick<MemoryItem, "summarySv" | "value">,
languageTag: string,
): string {
const lang = languageTag.split("-")[0] ?? "sv";
if (lang === "sv") return item.summarySv;
const v = item.value as Record<string, unknown> | null | undefined;
if (v && typeof v === "object") {
if (typeof v.favoriteCuisine === "string") {
return `Favorite cuisine: ${String(v.favoriteCuisine)}`;
}
if (typeof v.dislikedIngredient === "string") {
return `Avoids: ${String(v.dislikedIngredient)}`;
}
if (typeof v.typicalPortions === "number") {
return `Usually cooks ${v.typicalPortions} servings`;
}
if (typeof v.spicePreference === "string") {
return `Spice preference: ${String(v.spicePreference)}`;
}
}
return item.summarySv;
}
export function buildMemoryOverview(items: MemoryItem[], languageTag = "sv"): MemoryOverview {
const lang = languageTag.split("-")[0] ?? "sv";
const titles = KIND_TITLES[lang] ?? KIND_TITLES.sv!;
const byKind = new Map<MemoryKind, MemoryItem[]>();
for (const item of items) {
const list = byKind.get(item.kind) ?? [];
list.push(item);
byKind.set(item.kind, list);
}
const sections = [...byKind.entries()].map(([kind, list]) => ({
kind,
title: titles[kind],
titleSv: KIND_TITLES.sv![kind],
items: list
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.map((i) => ({ ...i, summary: renderMemorySummary(i, languageTag) })),
}));
return {
language: lang,
sections,
totalCount: items.length,
pausedCount: items.filter((i) => i.paused).length,
};
}
/** Pseudonymisera id innan det skickas till AAMOS (spec §56: minimization). */
export function pseudonymize(id: string): string {
// Enkel stabil pseudonym riktiga miljöer använder HMAC med rotationsbar nyckel.
return `subj_${Buffer.from(id).toString("base64url").slice(0, 16)}`;
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src"],
"compilerOptions": {
"types": ["node"]
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@app/nutrition-engine",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Deterministisk näringsberäkning (spec §21, §61.1) AI hittar ALDRIG på näringsvärden",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@app/shared-types": "workspace:*"
}
}
+119
View File
@@ -0,0 +1,119 @@
import {
EMPTY_NUTRITION,
type NutritionDeclaration,
type NutritionValues,
type Unit,
} from "@app/shared-types";
import { toBase, toGrams, type IngredientUnitInfo } from "./units.js";
/** Skala näringsvärden med en faktor. */
export function scaleNutrition(values: NutritionValues, factor: number): NutritionValues {
const scaled: NutritionValues = {
kcal: values.kcal * factor,
proteinG: values.proteinG * factor,
carbsG: values.carbsG * factor,
fatG: values.fatG * factor,
saturatedFatG: values.saturatedFatG * factor,
fiberG: values.fiberG * factor,
sugarG: values.sugarG * factor,
saltG: values.saltG * factor,
};
if (values.micro) {
scaled.micro = Object.fromEntries(
Object.entries(values.micro).map(([k, v]) => [k, v == null ? v : v * factor]),
);
}
return scaled;
}
/** Summera två näringsvärden. */
export function addNutrition(a: NutritionValues, b: NutritionValues): NutritionValues {
const sum: NutritionValues = {
kcal: a.kcal + b.kcal,
proteinG: a.proteinG + b.proteinG,
carbsG: a.carbsG + b.carbsG,
fatG: a.fatG + b.fatG,
saturatedFatG: a.saturatedFatG + b.saturatedFatG,
fiberG: a.fiberG + b.fiberG,
sugarG: a.sugarG + b.sugarG,
saltG: a.saltG + b.saltG,
};
const micros = { ...(a.micro ?? {}) } as Record<string, number | undefined>;
if (b.micro) {
for (const [k, v] of Object.entries(b.micro)) {
if (v == null) continue;
micros[k] = (micros[k] ?? 0) + v;
}
}
if (Object.keys(micros).length > 0) sum.micro = micros;
return sum;
}
export function sumNutrition(items: NutritionValues[]): NutritionValues {
return items.reduce(addNutrition, { ...EMPTY_NUTRITION });
}
/** Avrunda för presentation (heltal kcal, en decimal på gram). */
export function roundNutrition(values: NutritionValues): NutritionValues {
const r1 = (v: number) => Math.round(v * 10) / 10;
const rounded: NutritionValues = {
kcal: Math.round(values.kcal),
proteinG: r1(values.proteinG),
carbsG: r1(values.carbsG),
fatG: r1(values.fatG),
saturatedFatG: r1(values.saturatedFatG),
fiberG: r1(values.fiberG),
sugarG: r1(values.sugarG),
saltG: r1(values.saltG),
};
if (values.micro) rounded.micro = values.micro;
return rounded;
}
/**
* Beräkna näring för en given mängd av en ingrediens/produkt utifrån dess
* deklaration. Returnerar null när beräkningen inte kan göras säkert
* anroparen ansvarar då för att visa osäkerheten (spec §61.4), aldrig gissa.
*/
export function computeItemNutrition(
quantity: number,
unit: Unit,
declaration: NutritionDeclaration,
info: IngredientUnitInfo = {},
): NutritionValues | null {
if (quantity < 0) return null;
switch (declaration.basis) {
case "per_100_g": {
const grams = toGrams(quantity, unit, info);
if (grams == null) return null;
return scaleNutrition(declaration.values, grams / 100);
}
case "per_100_ml": {
const base = toBase(quantity, unit);
let ml: number | null = null;
if (base.kind === "volume") ml = base.amount;
else {
// massa/antal → gram → ml via densitet
const grams = toGrams(quantity, unit, info);
const density = info.densityGPerMl;
if (grams != null && density != null && density > 0) ml = grams / density;
}
if (ml == null) return null;
return scaleNutrition(declaration.values, ml / 100);
}
case "per_piece": {
const base = toBase(quantity, unit);
if (base.kind === "count") return scaleNutrition(declaration.values, base.amount);
// Vikt angiven: räkna om via referensvikt.
const grams = toGrams(quantity, unit, info);
const ref = declaration.referenceWeightG;
if (grams == null || ref == null || ref <= 0) return null;
return scaleNutrition(declaration.values, grams / ref);
}
case "per_portion": {
const base = toBase(quantity, unit);
if (base.kind === "count") return scaleNutrition(declaration.values, base.amount);
return null;
}
}
}
+51
View File
@@ -0,0 +1,51 @@
import type { DailyTargets, NutritionValues } from "@app/shared-types";
import { roundNutrition, sumNutrition } from "./calc.js";
export interface DaySummary {
consumed: NutritionValues;
targets: DailyTargets;
remaining: {
kcal: number;
proteinG: number;
carbsG: number;
fatG: number;
fiberG: number;
};
/** Andel av dagsmålet (01+), för progressvisning. */
progress: {
kcal: number;
proteinG: number;
carbsG: number;
fatG: number;
fiberG: number;
saltOfMax: number;
};
saltWarning: boolean;
}
/** "Min dag" (spec §4.3): summera loggade måltider mot dagsmål. */
export function summarizeDay(meals: NutritionValues[], targets: DailyTargets): DaySummary {
const consumed = roundNutrition(sumNutrition(meals));
const remaining = {
kcal: Math.round(targets.kcal - consumed.kcal),
proteinG: Math.round((targets.proteinG - consumed.proteinG) * 10) / 10,
carbsG: Math.round((targets.carbsG - consumed.carbsG) * 10) / 10,
fatG: Math.round((targets.fatG - consumed.fatG) * 10) / 10,
fiberG: Math.round((targets.fiberG - consumed.fiberG) * 10) / 10,
};
const safeDiv = (a: number, b: number) => (b > 0 ? a / b : 0);
return {
consumed,
targets,
remaining,
progress: {
kcal: safeDiv(consumed.kcal, targets.kcal),
proteinG: safeDiv(consumed.proteinG, targets.proteinG),
carbsG: safeDiv(consumed.carbsG, targets.carbsG),
fatG: safeDiv(consumed.fatG, targets.fatG),
fiberG: safeDiv(consumed.fiberG, targets.fiberG),
saltOfMax: safeDiv(consumed.saltG, targets.saltMaxG),
},
saltWarning: consumed.saltG > targets.saltMaxG,
};
}
+107
View File
@@ -0,0 +1,107 @@
import type { ActivityLevel, DailyTargets, GoalType, Sex } from "@app/shared-types";
/**
* Energiberäkning enligt MifflinSt Jeor. Detta är vägledning baserad på
* officiella rekommendationer Appen är inte medicinsk rådgivning (spec §6, §21).
*/
export interface EnergyProfile {
sex: Sex;
age: number;
heightCm: number;
weightKg: number;
activityLevel: ActivityLevel;
primaryGoal?: GoalType | undefined;
}
const ACTIVITY_FACTORS: Record<ActivityLevel, number> = {
sedentary: 1.2,
light: 1.375,
moderate: 1.55,
active: 1.725,
very_active: 1.9,
};
/** Justering per mål (kcal/dag). Konservativa, väldokumenterade nivåer. */
const GOAL_ADJUSTMENTS: Partial<Record<GoalType, number>> = {
lose_weight: -500,
gain_weight: 400,
build_muscle: 250,
maintain_weight: 0,
};
/** Lägsta rekommenderade energiintag under detta kapas aldrig målet (säkerhetsgolv). */
const MIN_KCAL = 1200;
export function bmrMifflinStJeor(
p: Pick<EnergyProfile, "sex" | "age" | "heightCm" | "weightKg">,
): number {
const base = 10 * p.weightKg + 6.25 * p.heightCm - 5 * p.age;
if (p.sex === "male") return base + 5;
if (p.sex === "female") return base - 161;
// Ospecificerat: medelvärde av formlerna, transparent redovisat i UI.
return base - 78;
}
export function tdee(p: EnergyProfile): number {
return bmrMifflinStJeor(p) * ACTIVITY_FACTORS[p.activityLevel];
}
export interface DailyTargetsResult {
targets: DailyTargets;
basis: {
bmrKcal: number;
tdeeKcal: number;
goalAdjustmentKcal: number;
activityLevel: ActivityLevel;
};
}
/**
* Dagsmål: energi via TDEE + måljustering; protein per kg kroppsvikt;
* fett som andel av energi; kolhydrater = resten; fiber 3 g/MJ (nordisk rekommendation);
* salt max 6 g/dag.
*/
export function computeDailyTargets(p: EnergyProfile): DailyTargetsResult {
const bmr = bmrMifflinStJeor(p);
const maintenance = tdee(p);
const adjustment = GOAL_ADJUSTMENTS[p.primaryGoal ?? "maintain_weight"] ?? 0;
const kcal = Math.max(MIN_KCAL, Math.round(maintenance + adjustment));
const proteinPerKg =
p.primaryGoal === "build_muscle" || p.primaryGoal === "more_protein"
? 1.8
: p.primaryGoal === "lose_weight"
? 1.6
: 1.2;
const proteinG = Math.round(p.weightKg * proteinPerKg);
const fatShare = p.primaryGoal === "less_fat" ? 0.25 : 0.3;
const fatG = Math.round((kcal * fatShare) / 9);
const carbsKcal = Math.max(0, kcal - proteinG * 4 - fatG * 9);
const carbsG = Math.round(carbsKcal / 4);
// 3 g fiber per MJ (1 MJ ≈ 239 kcal)
const fiberG = Math.round((kcal / 239) * 3);
return {
targets: { kcal, proteinG, carbsG, fatG, fiberG, saltMaxG: 6 },
basis: {
bmrKcal: Math.round(bmr),
tdeeKcal: Math.round(maintenance),
goalAdjustmentKcal: adjustment,
activityLevel: p.activityLevel,
},
};
}
/** Standardmål när profil saknas (visas tydligt som schablon i appen). */
export const DEFAULT_TARGETS: DailyTargets = {
kcal: 2000,
proteinG: 80,
carbsG: 220,
fatG: 67,
fiberG: 25,
saltMaxG: 6,
};
+5
View File
@@ -0,0 +1,5 @@
export * from "./units.js";
export * from "./calc.js";
export * from "./energy.js";
export * from "./day.js";
export * from "./recipe.js";
+53
View File
@@ -0,0 +1,53 @@
import type { NutritionValues, Unit } from "@app/shared-types";
import { computeItemNutrition, roundNutrition, scaleNutrition, sumNutrition } from "./calc.js";
import type { IngredientUnitInfo } from "./units.js";
import type { NutritionDeclaration } from "@app/shared-types";
export interface RecipeIngredientForCalc {
canonicalIngredientId: string;
quantity: number;
unit: Unit;
optional?: boolean;
}
export interface IngredientNutritionSource extends IngredientUnitInfo {
nutritionPer100: NutritionDeclaration;
}
export interface RecipeNutritionResult {
perPortion: NutritionValues;
total: NutritionValues;
/** Ingredienser som inte kunde beräknas (saknad densitet etc.) redovisas öppet. */
uncomputableIngredientIds: string[];
}
/**
* Beräkna ett recepts näringsvärden ur dess ingredienser deterministiskt.
* Valfria ingredienser exkluderas ur grundberäkningen.
*/
export function computeRecipeNutrition(
ingredients: RecipeIngredientForCalc[],
portions: number,
sources: Map<string, IngredientNutritionSource>,
): RecipeNutritionResult {
const parts: NutritionValues[] = [];
const uncomputable: string[] = [];
for (const ing of ingredients) {
if (ing.optional) continue;
const source = sources.get(ing.canonicalIngredientId);
if (!source) {
uncomputable.push(ing.canonicalIngredientId);
continue;
}
const values = computeItemNutrition(ing.quantity, ing.unit, source.nutritionPer100, source);
if (values == null) {
uncomputable.push(ing.canonicalIngredientId);
continue;
}
parts.push(values);
}
const total = sumNutrition(parts);
const perPortion =
portions > 0 ? roundNutrition(scaleNutrition(total, 1 / portions)) : roundNutrition(total);
return { perPortion, total: roundNutrition(total), uncomputableIngredientIds: uncomputable };
}
+70
View File
@@ -0,0 +1,70 @@
import { UNIT_INFO, type Unit, type UnitKind } from "@app/shared-types";
export interface IngredientUnitInfo {
/** g per ml krävs för volym → massa. */
densityGPerMl?: number | null;
/** g per styck krävs för antal → massa. */
gramsPerPiece?: number | null;
}
export function unitKind(unit: Unit): UnitKind {
return UNIT_INFO[unit].kind;
}
/** Konvertera till basenhet inom samma slag (g, ml eller st). */
export function toBase(quantity: number, unit: Unit): { kind: UnitKind; amount: number } {
const info = UNIT_INFO[unit];
return { kind: info.kind, amount: quantity * info.toBase };
}
/**
* Konvertera valfri mängd till gram. Returnerar null när konvertering inte är
* möjlig utan mer information hellre ärlig osäkerhet än gissning (spec §61.4).
*/
export function toGrams(
quantity: number,
unit: Unit,
info: IngredientUnitInfo = {},
): number | null {
const base = toBase(quantity, unit);
switch (base.kind) {
case "mass":
return base.amount;
case "volume": {
const density = info.densityGPerMl;
if (density == null || density <= 0) return null;
return base.amount * density;
}
case "count": {
const perPiece = info.gramsPerPiece;
if (perPiece == null || perPiece <= 0) return null;
return base.amount * perPiece;
}
}
}
/** Konvertera en mängd mellan enheter (samma slag, eller via densitet/styckvikt). */
export function convert(
quantity: number,
from: Unit,
to: Unit,
info: IngredientUnitInfo = {},
): number | null {
const fromInfo = UNIT_INFO[from];
const toInfo = UNIT_INFO[to];
if (fromInfo.kind === toInfo.kind) {
return (quantity * fromInfo.toBase) / toInfo.toBase;
}
// Olika slag: gå via gram.
const grams = toGrams(quantity, from, info);
if (grams == null) return null;
if (toInfo.kind === "mass") return grams / toInfo.toBase;
if (toInfo.kind === "volume") {
const density = info.densityGPerMl;
if (density == null || density <= 0) return null;
return grams / density / toInfo.toBase;
}
const perPiece = info.gramsPerPiece;
if (perPiece == null || perPiece <= 0) return null;
return grams / perPiece / toInfo.toBase;
}
@@ -0,0 +1,214 @@
import { describe, expect, it } from "vitest";
import type { NutritionDeclaration } from "@app/shared-types";
import {
bmrMifflinStJeor,
computeDailyTargets,
computeItemNutrition,
computeRecipeNutrition,
convert,
scaleNutrition,
summarizeDay,
sumNutrition,
toGrams,
} from "../src/index.js";
const chicken: NutritionDeclaration = {
basis: "per_100_g",
values: {
kcal: 106,
proteinG: 22,
carbsG: 0,
fatG: 2,
saturatedFatG: 0.6,
fiberG: 0,
sugarG: 0,
saltG: 0.2,
},
};
const milk: NutritionDeclaration = {
basis: "per_100_ml",
values: {
kcal: 60,
proteinG: 3.4,
carbsG: 4.7,
fatG: 3,
saturatedFatG: 1.9,
fiberG: 0,
sugarG: 4.7,
saltG: 0.1,
},
};
describe("enhetskonvertering", () => {
it("konverterar massa", () => {
expect(toGrams(1, "KILOGRAM")).toBe(1000);
expect(toGrams(250, "GRAM")).toBe(250);
});
it("konverterar volym via densitet", () => {
expect(toGrams(1, "LITER", { densityGPerMl: 1.03 })).toBeCloseTo(1030);
expect(toGrams(1, "DECILITER", { densityGPerMl: 1 })).toBe(100);
expect(toGrams(1, "TABLESPOON", { densityGPerMl: 0.92 })).toBeCloseTo(13.8);
});
it("konverterar styck via styckvikt", () => {
expect(toGrams(2, "COUNT", { gramsPerPiece: 58 })).toBe(116);
});
it("vägrar gissa: null utan densitet/styckvikt (spec §61.4)", () => {
expect(toGrams(1, "DECILITER")).toBeNull();
expect(toGrams(1, "COUNT")).toBeNull();
expect(convert(1, "DECILITER", "GRAM")).toBeNull();
});
});
describe("näringsberäkning", () => {
it("räknar per 100 g", () => {
const result = computeItemNutrition(300, "GRAM", chicken);
expect(result?.kcal).toBeCloseTo(318);
expect(result?.proteinG).toBeCloseTo(66);
});
it("räknar per 100 ml med volymenhet", () => {
const result = computeItemNutrition(2, "DECILITER", milk, { densityGPerMl: 1.03 });
expect(result?.kcal).toBeCloseTo(120);
});
it("returnerar null när data saknas aldrig påhitt", () => {
expect(computeItemNutrition(1, "COUNT", chicken)).toBeNull();
});
it("summerar och skalar", () => {
const a = computeItemNutrition(100, "GRAM", chicken)!;
const total = sumNutrition([a, a]);
expect(total.kcal).toBeCloseTo(212);
expect(scaleNutrition(total, 0.5).kcal).toBeCloseTo(106);
});
});
describe("receptnäring", () => {
it("beräknar per portion och redovisar oberäkneliga", () => {
const sources = new Map([
["chicken", { nutritionPer100: chicken }],
["milk", { nutritionPer100: milk, densityGPerMl: 1.03 }],
]);
const result = computeRecipeNutrition(
[
{ canonicalIngredientId: "chicken", quantity: 400, unit: "GRAM" },
{ canonicalIngredientId: "milk", quantity: 2, unit: "DECILITER" },
{ canonicalIngredientId: "unknown", quantity: 1, unit: "COUNT" },
],
4,
sources,
);
expect(result.uncomputableIngredientIds).toEqual(["unknown"]);
expect(result.perPortion.kcal).toBeGreaterThan(100);
expect(result.perPortion.kcal * 4).toBeCloseTo(result.total.kcal, 0);
});
it("exkluderar valfria ingredienser", () => {
const sources = new Map([["chicken", { nutritionPer100: chicken }]]);
const withOptional = computeRecipeNutrition(
[
{ canonicalIngredientId: "chicken", quantity: 400, unit: "GRAM" },
{ canonicalIngredientId: "chicken", quantity: 400, unit: "GRAM", optional: true },
],
4,
sources,
);
expect(withOptional.total.kcal).toBeCloseTo(424);
});
});
describe("energi & dagsmål", () => {
it("MifflinSt Jeor referensvärde (man 80 kg, 180 cm, 30 år)", () => {
const bmr = bmrMifflinStJeor({ sex: "male", weightKg: 80, heightCm: 180, age: 30 });
expect(bmr).toBeCloseTo(10 * 80 + 6.25 * 180 - 5 * 30 + 5); // 1780
});
it("viktnedgång ger underskott men aldrig under golvet", () => {
const normal = computeDailyTargets({
sex: "female",
age: 35,
heightCm: 165,
weightKg: 60,
activityLevel: "moderate",
primaryGoal: "lose_weight",
});
expect(normal.targets.kcal).toBeLessThan(normal.basis.tdeeKcal);
const extreme = computeDailyTargets({
sex: "female",
age: 80,
heightCm: 145,
weightKg: 40,
activityLevel: "sedentary",
primaryGoal: "lose_weight",
});
expect(extreme.targets.kcal).toBeGreaterThanOrEqual(1200);
});
it("proteinmål skalar med kroppsvikt och mål", () => {
const base = computeDailyTargets({
sex: "male",
age: 30,
heightCm: 180,
weightKg: 80,
activityLevel: "moderate",
});
const muscle = computeDailyTargets({
sex: "male",
age: 30,
heightCm: 180,
weightKg: 80,
activityLevel: "moderate",
primaryGoal: "build_muscle",
});
expect(muscle.targets.proteinG).toBeGreaterThan(base.targets.proteinG);
expect(muscle.targets.proteinG).toBe(Math.round(80 * 1.8));
});
});
describe("dagsöversikt", () => {
it("summerar måltider och flaggar salt", () => {
const meal = {
kcal: 700,
proteinG: 40,
carbsG: 60,
fatG: 25,
saturatedFatG: 8,
fiberG: 6,
sugarG: 5,
saltG: 4,
};
const summary = summarizeDay([meal, meal], {
kcal: 2000,
proteinG: 100,
carbsG: 220,
fatG: 65,
fiberG: 25,
saltMaxG: 6,
});
expect(summary.consumed.kcal).toBe(1400);
expect(summary.remaining.kcal).toBe(600);
expect(summary.saltWarning).toBe(true);
expect(summary.progress.proteinG).toBeCloseTo(0.8);
});
});
describe("i18n-enheter (i18n-spec §9–§11)", () => {
it("amerikanska volymer konverterar enligt dokumenterad standard", () => {
expect(toGrams(1, "CUP_US", { densityGPerMl: 1 })).toBeCloseTo(236.59);
expect(toGrams(2, "FLUID_OUNCE_US", { densityGPerMl: 1 })).toBeCloseTo(59.14);
expect(toGrams(1, "TEASPOON", { densityGPerMl: 1 })).toBe(5);
expect(toGrams(1, "TABLESPOON", { densityGPerMl: 1 })).toBe(15);
expect(toGrams(2, "PINCH", { densityGPerMl: 1 })).toBe(1);
});
it("amerikanska massor är massa ingen densitet behövs", () => {
expect(toGrams(1, "OUNCE")).toBeCloseTo(28.35);
expect(toGrams(1, "POUND")).toBeCloseTo(453.59);
expect(convert(1, "POUND", "OUNCE")).toBeCloseTo(16, 1);
});
it("DECILITER och cup↔dl-konvertering utan förlust", () => {
expect(convert(1, "CUP_US", "DECILITER")).toBeCloseTo(2.3659);
expect(convert(2.3659, "DECILITER", "CUP_US")).toBeCloseTo(1, 4);
});
it("volym↔massa vägrar fortfarande utan ingrediensdensitet (i18n-spec §10)", () => {
expect(toGrams(1, "CUP_US")).toBeNull();
expect(convert(100, "GRAM", "CUP_US")).toBeNull();
});
it("count-enheter (CLOVE, CAN …) kräver styckvikt", () => {
expect(toGrams(2, "CLOVE", { gramsPerPiece: 5 })).toBe(10);
expect(toGrams(1, "CAN")).toBeNull();
});
});
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src", "test"],
"compilerOptions": {
"types": ["node"]
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@app/recipe-engine",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Receptmatchning, deterministisk allergi-/dietfiltrering, substitution och skalning (spec §17, §20, §61.2)",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@app/inventory-engine": "workspace:*",
"@app/nutrition-engine": "workspace:*",
"@app/shared-types": "workspace:*"
}
}
+59
View File
@@ -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],
};
}
+5
View File
@@ -0,0 +1,5 @@
export * from "./safety.js";
export * from "./matching.js";
export * from "./scaling.js";
export * from "./substitution.js";
export * from "./cost.js";
+103
View File
@@ -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 (01). */
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 §1718).
* 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),
};
}
+207
View File
@@ -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();
}
+40
View File
@@ -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,
},
};
}
+299
View File
@@ -0,0 +1,299 @@
import { describe, expect, it } from "vitest";
import {
applySubstitution,
checkRecipeSafety,
computeCoverage,
deriveRecipeAllergens,
estimateCost,
isRecipeSafe,
scaleIngredients,
type IngredientSafetyInfo,
} from "../src/index.js";
const INFO = new Map<string, IngredientSafetyInfo>([
[
"chicken",
{
id: "chicken",
allergens: [],
isVegan: false,
isVegetarian: false,
containsGluten: false,
containsLactose: false,
isPork: false,
isBeef: false,
isAlcohol: false,
},
],
[
"milk",
{
id: "milk",
allergens: ["milk"],
isVegan: false,
isVegetarian: true,
containsGluten: false,
containsLactose: true,
isPork: false,
isBeef: false,
isAlcohol: false,
},
],
[
"pasta",
{
id: "pasta",
allergens: ["gluten"],
mayContainAllergens: ["eggs"],
isVegan: true,
isVegetarian: true,
containsGluten: true,
containsLactose: false,
isPork: false,
isBeef: false,
isAlcohol: false,
},
],
[
"bacon",
{
id: "bacon",
allergens: [],
isVegan: false,
isVegetarian: false,
containsGluten: false,
containsLactose: false,
isPork: true,
isBeef: false,
isAlcohol: false,
},
],
[
"tofu",
{
id: "tofu",
allergens: ["soy"],
isVegan: true,
isVegetarian: true,
containsGluten: false,
containsLactose: false,
isPork: false,
isBeef: false,
isAlcohol: false,
},
],
]);
describe("KRITISKT: deterministisk allergisäkerhet (spec §61.2)", () => {
it("blockerar recept med användarens allergen", () => {
const violations = checkRecipeSafety(
{ ingredients: [{ canonicalIngredientId: "milk", optional: false }], spiceLevel: 0 },
{ allergens: ["milk"] },
INFO,
);
expect(isRecipeSafe(violations)).toBe(false);
expect(violations[0]?.code).toBe("allergen");
expect(violations[0]?.severity).toBe("blocker");
});
it("'kan innehålla spår' ger varning när användaren har allergi", () => {
const violations = checkRecipeSafety(
{ ingredients: [{ canonicalIngredientId: "pasta", optional: false }], spiceLevel: 0 },
{ allergens: ["eggs"] },
INFO,
);
expect(violations.some((v) => v.code === "allergen_may_contain")).toBe(true);
});
it("okänd ingrediens ger varning aldrig tyst OK (försiktighetsprincipen)", () => {
const violations = checkRecipeSafety(
{ ingredients: [{ canonicalIngredientId: "mystery", optional: false }], spiceLevel: 0 },
{ allergens: [] },
INFO,
);
expect(violations.some((v) => v.code === "unverified_data")).toBe(true);
});
it("valfri ingrediens med allergen → varning, inte blocker", () => {
const violations = checkRecipeSafety(
{ ingredients: [{ canonicalIngredientId: "milk", optional: true }], spiceLevel: 0 },
{ allergens: ["milk"] },
INFO,
);
expect(isRecipeSafe(violations)).toBe(true);
expect(violations[0]?.severity).toBe("warning");
});
it("vegan blockerar kött och mjölk", () => {
const violations = checkRecipeSafety(
{
ingredients: [
{ canonicalIngredientId: "chicken", optional: false },
{ canonicalIngredientId: "milk", optional: false },
],
spiceLevel: 0,
},
{ allergens: [], dietPattern: "vegan" },
INFO,
);
expect(violations.filter((v) => v.code === "diet_pattern")).toHaveLength(2);
});
it("halal blockerar fläsk", () => {
const violations = checkRecipeSafety(
{ ingredients: [{ canonicalIngredientId: "bacon", optional: false }], spiceLevel: 0 },
{ allergens: [], religiousRule: "halal" },
INFO,
);
expect(isRecipeSafe(violations)).toBe(false);
});
it("härleder receptallergener ur ingredienserna", () => {
expect(deriveRecipeAllergens(["pasta", "milk", "chicken"], INFO)).toEqual(["gluten", "milk"]);
});
});
describe("lagermatchning (spec §17)", () => {
it("beräknar täckning, saknat och utgående", () => {
const result = computeCoverage(
[
{
canonicalIngredientId: "chicken",
displayNameSv: "Kyckling",
quantity: 500,
unit: "GRAM",
optional: false,
},
{
canonicalIngredientId: "pasta",
displayNameSv: "Pasta",
quantity: 300,
unit: "GRAM",
optional: false,
},
],
[
{
id: "1",
canonicalIngredientId: "chicken",
quantity: 600,
unit: "GRAM",
bestBeforeDate: "2026-08-03",
},
],
new Map(),
new Date("2026-08-02T00:00:00Z"), // UTC testet ska vara sant i alla tidszoner
);
expect(result.coverage).toBe(0.5);
expect(result.missing.map((m) => m.canonicalIngredientId)).toEqual(["pasta"]);
expect(result.expiringUsed[0]?.canonicalIngredientId).toBe("chicken");
});
it("mjölkprincipen: passerat bäst före räknas som tillgängligt, inte som borta", () => {
// Mjölken som gick ut igår är inte automatiskt dålig recepten ska
// fortsätta föreslå den (användaren luktar/smakar) i stället för att
// tyst behandla varan som obefintlig och driva nyinköp/svinn.
const result = computeCoverage(
[
{
canonicalIngredientId: "milk",
displayNameSv: "Mjölk",
quantity: 500,
unit: "MILLILITER",
optional: false,
},
],
[
{
id: "1",
canonicalIngredientId: "milk",
quantity: 1000,
unit: "MILLILITER",
bestBeforeDate: "2026-08-01", // gick ut igår
},
],
new Map(),
new Date("2026-08-02T00:00:00Z"), // UTC testet ska vara sant i alla tidszoner
);
expect(result.coverage).toBe(1);
expect(result.missing).toHaveLength(0);
expect(result.expiringUsed[0]?.canonicalIngredientId).toBe("milk"); // prioriteras "räddar mjölken"
});
});
describe("skalning", () => {
it("skalar mängder och avrundar köksvänligt", () => {
const scaled = scaleIngredients(
[
{
canonicalIngredientId: "chicken",
displayNameSv: "Kyckling",
quantity: 500,
unit: "GRAM",
optional: false,
},
{
canonicalIngredientId: "salt",
displayNameSv: "Salt",
quantity: 1,
unit: "TEASPOON",
optional: false,
},
],
4,
6,
);
expect(scaled[0]?.quantity).toBe(750);
expect(scaled[1]?.quantity).toBe(1.5);
});
it("kastar på ogiltiga portioner", () => {
expect(() => scaleIngredients([], 0, 4)).toThrow();
});
});
describe("substitution (spec §20)", () => {
it("byter ingrediens med mängdfaktor och varnar för fel kontext", () => {
const result = applySubstitution(
[
{
canonicalIngredientId: "milk",
displayNameSv: "Mjölk",
quantity: 200,
unit: "MILLILITER",
optional: false,
},
],
{
fromIngredientId: "milk",
toIngredientId: "tofu",
ratio: 0.8,
instructionsSv: "Tillsätt sist.",
notRecommendedFor: ["whipping"],
},
"Tofu",
"whipping",
);
expect(result.ingredients[0]?.canonicalIngredientId).toBe("tofu");
expect(result.ingredients[0]?.quantity).toBe(160);
expect(result.applied.warningSv).toContain("rekommenderas inte");
});
it("kastar när ingrediensen inte finns i receptet", () => {
expect(() =>
applySubstitution(
[],
{ fromIngredientId: "x", toIngredientId: "y", ratio: 1, notRecommendedFor: [] },
"Y",
),
).toThrow();
});
});
describe("kostnadsuppskattning (spec §26)", () => {
it("beräknar pris per portion med täckningsgrad", () => {
const result = estimateCost(
[
{ canonicalIngredientId: "chicken", quantity: 500, unit: "GRAM", optional: false },
{ canonicalIngredientId: "unknown", quantity: 100, unit: "GRAM", optional: false },
],
4,
// 130 kr/kg = 13000 minor/kg; 500 g -> 6500 minor totalt, 1625 minor/portion.
new Map([["chicken", { pricePerKgMinor: 13_000, source: "default" as const }]]),
);
expect(result.totalMinor).toBe(6500);
expect(result.perPortionMinor).toBe(1625);
expect(result.priceCoverage).toBe(0.5);
});
});
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src", "test"],
"compilerOptions": {
"types": ["node"]
}
}
@@ -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 (0100-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;
}
// 67. 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 1925 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"]
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@app/shared-types",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Domäntyper, enums och konstanter som delas av hela plattformen",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
}
}
+22
View File
@@ -0,0 +1,22 @@
import brandConfig from "../../../brand.config.json";
/**
* Varumärket läses från brand.config.json i repo-roten kodbasen är i övrigt
* varumärkesneutral så att ett namnbyte aldrig kräver kodändringar.
* Runbook för namnbyte: docs/namnbyte.md.
*/
export interface BrandConfig {
name: string;
slug: string;
urlScheme: string;
iosBundleId: string;
androidPackage: string;
apiDomain: string;
adminDomain: string;
supportEmail: string;
}
export const BRAND: BrandConfig = brandConfig as BrandConfig;
/** Delat könamn för BullMQ (API producerar, workern konsumerar). */
export const JOB_QUEUE_NAME = `${BRAND.slug}-jobs`;
+182
View File
@@ -0,0 +1,182 @@
/**
* Plattformskonstanter: planer, gratisnivå, enhetskonvertering, allergen-etiketter.
*/
import type { Allergen, SubscriptionPlan, Unit, UnitKind } from "./enums.js";
import { BRAND } from "./brand.js";
// ---------------------------------------------------------------------------
// Prenumerationsplaner (spec §4546)
// ---------------------------------------------------------------------------
export interface PlanDefinition {
plan: SubscriptionPlan;
nameSv: string;
priceMinorPerMonth: number;
/** ISO 4217 för fallback-visning; riktiga priser per marknad kommer från butikerna (M8). */
currency: string;
maxHouseholdMembers: number;
/** Fair use inte "obegränsad AI" (spec §45). */
aiScansPerMonth: number;
weekPlanning: boolean;
advancedNutrition: boolean;
communityPublish: boolean;
appleProductId: string;
googleProductId: string;
}
export const TRIAL_DAYS = 7;
export const PLAN_DEFINITIONS: Record<SubscriptionPlan, PlanDefinition> = {
free: {
plan: "free",
nameSv: "Gratis",
priceMinorPerMonth: 0,
currency: "SEK",
maxHouseholdMembers: 1,
aiScansPerMonth: 10,
weekPlanning: false,
advancedNutrition: false,
communityPublish: false,
appleProductId: "",
googleProductId: "",
},
household: {
plan: "household",
nameSv: "Household",
priceMinorPerMonth: 7900,
currency: "SEK",
maxHouseholdMembers: 3,
aiScansPerMonth: 300,
weekPlanning: true,
advancedNutrition: true,
communityPublish: true,
appleProductId: `${BRAND.iosBundleId}.household_monthly`,
googleProductId: "household_monthly",
},
family: {
plan: "family",
nameSv: "Family",
priceMinorPerMonth: 12900,
currency: "SEK",
maxHouseholdMembers: 6,
aiScansPerMonth: 600,
weekPlanning: true,
advancedNutrition: true,
communityPublish: true,
appleProductId: `${BRAND.iosBundleId}.family_monthly`,
googleProductId: "family_monthly",
},
large_household: {
plan: "large_household",
nameSv: "Large Household",
priceMinorPerMonth: 16900,
currency: "SEK",
maxHouseholdMembers: 12,
aiScansPerMonth: 1000,
weekPlanning: true,
advancedNutrition: true,
communityPublish: true,
appleProductId: `${BRAND.iosBundleId}.large_household_monthly`,
googleProductId: "large_household_monthly",
},
};
// ---------------------------------------------------------------------------
// Enheter
// ---------------------------------------------------------------------------
export const UNIT_INFO: Record<Unit, { kind: UnitKind; toBase: number }> = {
GRAM: { kind: "mass", toBase: 1 },
KILOGRAM: { kind: "mass", toBase: 1000 },
OUNCE: { kind: "mass", toBase: 28.35 },
POUND: { kind: "mass", toBase: 453.59 },
MILLILITER: { kind: "volume", toBase: 1 },
DECILITER: { kind: "volume", toBase: 100 },
LITER: { kind: "volume", toBase: 1000 },
TEASPOON: { kind: "volume", toBase: 5 },
TABLESPOON: { kind: "volume", toBase: 15 },
CUP_US: { kind: "volume", toBase: 236.59 },
FLUID_OUNCE_US: { kind: "volume", toBase: 29.57 },
PINCH: { kind: "volume", toBase: 0.5 },
COUNT: { kind: "count", toBase: 1 },
PORTION: { kind: "count", toBase: 1 },
SLICE: { kind: "count", toBase: 1 },
CLOVE: { kind: "count", toBase: 1 },
CAN: { kind: "count", toBase: 1 },
PACKAGE: { kind: "count", toBase: 1 },
};
// ---------------------------------------------------------------------------
// Allergener svenska etiketter (EU:s 14)
// ---------------------------------------------------------------------------
export const ALLERGEN_LABELS_SV: Record<Allergen, string> = {
gluten: "Gluten",
crustaceans: "Kräftdjur",
eggs: "Ägg",
fish: "Fisk",
peanuts: "Jordnötter",
soy: "Soja",
milk: "Mjölk (laktos)",
tree_nuts: "Nötter",
celery: "Selleri",
mustard: "Senap",
sesame: "Sesamfrön",
sulphites: "Sulfiter",
lupin: "Lupin",
molluscs: "Blötdjur",
};
// ---------------------------------------------------------------------------
// Bäst före-klassning (deterministisk, spec §13)
// ---------------------------------------------------------------------------
/** Dagar kvar → status. use_soon: ≤ USE_SOON_DAYS, expiring: ≤ EXPIRING_DAYS. */
export const EXPIRY_THRESHOLDS = {
EXPIRING_DAYS: 2,
USE_SOON_DAYS: 5,
} as const;
// ---------------------------------------------------------------------------
// Butiksavdelningar för inköpslistans sortering (spec §27)
// ---------------------------------------------------------------------------
export const STORE_SECTIONS = [
"frukt_gront",
"brod",
"mejeri",
"kott_fagel",
"fisk",
"chark",
"frys",
"skafferi",
"konserver",
"kryddor_bak",
"dryck",
"snacks",
"hygien_ovrigt",
] as const;
export type StoreSection = (typeof STORE_SECTIONS)[number];
export const STORE_SECTION_LABELS_SV: Record<StoreSection, string> = {
frukt_gront: "Frukt & grönt",
brod: "Bröd",
mejeri: "Mejeri",
kott_fagel: "Kött & fågel",
fisk: "Fisk & skaldjur",
chark: "Chark",
frys: "Frys",
skafferi: "Skafferi",
konserver: "Konserver",
kryddor_bak: "Kryddor & bakning",
dryck: "Dryck",
snacks: "Snacks & godis",
hygien_ovrigt: "Övrigt",
};
// ---------------------------------------------------------------------------
// Versionsmärkning för AI-kontrakt
// ---------------------------------------------------------------------------
export const AI_CONTRACT_VERSION = "1.0.0";
export const API_VERSION = "v1";
+730
View File
@@ -0,0 +1,730 @@
/**
* Domänentiteter. Speglar databasschemat (packages/database) men i camelCase
* och utan persistensdetaljer. API:t serialiserar datum som ISO-strängar.
*/
import type {
ActivityLevel,
Allergen,
ConsentKind,
ConsentStatus,
CookingMethod,
CreatorLevel,
Cuisine,
DateKind,
DietPattern,
Equipment,
EventType,
ExpiryStatus,
FeedbackTag,
GoalType,
HouseholdRole,
InventorySource,
InventoryTransactionType,
JobStatus,
JobType,
MealLogSource,
MealType,
MemoryKind,
NotificationType,
PrecisionMode,
ProfileVisibility,
RecipeDifficulty,
RecipeSourceType,
RecipeStatus,
RecipeTag,
RecipeVariantType,
RecipeVerificationStatus,
ReligiousRule,
ScanType,
Season,
Sex,
SignalOrigin,
StorageLocationType,
SubscriptionPlan,
SubscriptionProvider,
SubscriptionStatus,
TasteAxis,
Unit,
UserRole,
VerificationStatus,
} from "./enums.js";
import type {
DailyTargets,
NutritionDeclaration,
NutritionProvenance,
NutritionValues,
} from "./nutrition.js";
// ---------------------------------------------------------------------------
// Gemensamma byggstenar
// ---------------------------------------------------------------------------
/** Spårbarhet för varje AI-härledd datapunkt (spec §9). */
export interface DataProvenance {
source: InventorySource | "ai" | "system";
confidence: number;
verifiedByUser: boolean;
createdAt: string;
updatedAt: string;
lastVerifiedAt?: string;
modelVersion?: string;
promptVersion?: string;
}
// ---------------------------------------------------------------------------
// Användare & profil (spec §6) hälsodata hålls logiskt separerad (spec §7, §56)
// ---------------------------------------------------------------------------
export interface User {
id: string;
email: string;
displayName: string;
role: UserRole;
locale: string;
precisionMode: PrecisionMode;
onboardingCompleted: boolean;
createdAt: string;
updatedAt: string;
}
/** Hälsorelaterad profil åtkomstskyddad separat från hushållsdata. */
export interface UserHealthProfile {
userId: string;
birthYear?: number;
sex?: Sex;
heightCm?: number;
weightKg?: number;
targetWeightKg?: number;
activityLevel: ActivityLevel;
trainingSessionsPerWeek?: number;
trainingTypes?: string[];
updatedAt: string;
}
export interface UserPreferences {
userId: string;
primaryGoal?: GoalType;
goals: GoalType[];
dietPattern: DietPattern;
religiousRule: ReligiousRule;
allergens: Allergen[];
intolerances: string[];
/** canonical ingredient-id:n som ska undvikas */
avoidIngredientIds: string[];
favoriteCuisines: Cuisine[];
dislikedDishes: string[];
spiceLevelMax: number;
weeklyBudgetMinor?: number;
maxCookingMinutesWeekday?: number;
equipment: Equipment[];
defaultPortions: number;
updatedAt: string;
}
export interface UserConsent {
userId: string;
kind: ConsentKind;
status: ConsentStatus;
grantedAt?: string;
revokedAt?: string;
updatedAt: string;
}
export interface UserDailyTargetsSnapshot {
userId: string;
date: string;
targets: DailyTargets;
/** Hur målen beräknades transparens i "Min dag". */
basis: {
bmrKcal: number;
tdeeKcal: number;
goalAdjustmentKcal: number;
activityLevel: ActivityLevel;
};
}
// ---------------------------------------------------------------------------
// Hushåll (spec §7)
// ---------------------------------------------------------------------------
export interface Household {
id: string;
name: string;
inviteCode: string;
weeklyBudgetMinor?: number;
/** ISO 4217 alla belopp i hushållet tolkas i denna valuta (i18n-spec §20). */
currencyCode: string;
createdAt: string;
updatedAt: string;
}
export interface HouseholdMember {
householdId: string;
userId: string;
role: HouseholdRole;
/** Portionsfaktor för denna person (t.ex. barn 0.6, tränande 1.3). */
portionFactor: number;
joinedAt: string;
}
export interface StorageLocation {
id: string;
householdId: string;
type: StorageLocationType;
name: string;
/** Underplatser: hyllor, lådor (spec §8). */
sublocations: string[];
sortOrder: number;
}
// ---------------------------------------------------------------------------
// Ingredienser & produkter (spec §11)
// ---------------------------------------------------------------------------
export interface CanonicalIngredient {
id: string; // slug, t.ex. "chicken_breast"
nameSv: string;
nameEn: string;
category: string;
defaultUnit: Unit;
/** g per ml, för volym↔massa-konvertering */
densityGPerMl?: number;
/** g per styck (t.ex. ett ägg ≈ 58 g) */
gramsPerPiece?: number;
allergens: Allergen[];
/** Diet-flaggor för deterministisk filtrering */
isVegan: boolean;
isVegetarian: boolean;
containsGluten: boolean;
containsLactose: boolean;
isPork: boolean;
isBeef: boolean;
isAlcohol: boolean;
nutritionPer100: NutritionDeclaration;
nutritionProvenance: NutritionProvenance;
/** Säsonger då råvaran är som bäst (spec §28) */
peakSeasons: Season[];
/** Riktvärde för hållbarhet efter öppning/inköp, per förvaringsplats (dagar). Vägledning ej garanti (spec §13). */
shelfLifeGuidance?: Partial<Record<StorageLocationType, number>>;
defaultPriceMinorPerKg?: number;
}
export interface Product {
id: string;
gtin?: string;
name: string;
brand?: string;
canonicalIngredientId?: string;
packageSizeValue?: number;
packageSizeUnit?: Unit;
ingredientsText?: string;
allergens: Allergen[];
mayContainAllergens: Allergen[];
nutrition?: NutritionDeclaration;
imageUrls: string[];
language: string;
market: string;
dataSource: string;
verificationStatus: VerificationStatus;
/** Produkter versionshanteras (spec §11, §61.12). */
version: number;
validFrom: string;
validTo?: string;
createdAt: string;
updatedAt: string;
}
// ---------------------------------------------------------------------------
// Food Twin lager (spec §8)
// ---------------------------------------------------------------------------
export interface InventoryItem {
id: string;
householdId: string;
canonicalIngredientId?: string;
productId?: string;
displayName: string;
brand?: string;
/** Aktuellt saldo (härlett ur transaktioner men cachat för snabb läsning). */
quantity: number;
unit: Unit;
storageLocationId: string;
sublocation?: string;
purchasedAt?: string;
openedAt?: string;
bestBeforeDate?: string;
useByDate?: string;
dateKind?: DateKind;
frozenAt?: string;
thawedAt?: string;
priceMinor?: number;
nutritionPer100?: NutritionDeclaration;
source: InventorySource;
confidence: number;
verifiedByUser: boolean;
lastVerifiedAt?: string;
expiryStatus: ExpiryStatus;
createdAt: string;
updatedAt: string;
}
export interface InventoryTransaction {
id: string;
householdId: string;
inventoryItemId: string;
type: InventoryTransactionType;
/** Positiv = in, negativ = ut. Samma enhet som posten. */
quantityDelta: number;
unit: Unit;
/** Referens till recept, måltid, kvitto, matlåda etc. */
refType?: "recipe_cook" | "meal" | "receipt" | "meal_box" | "shopping" | "scan" | "manual";
refId?: string;
actorUserId?: string;
note?: string;
createdAt: string;
}
// ---------------------------------------------------------------------------
// Recept (spec §1416)
// ---------------------------------------------------------------------------
/** Maskinläsbart Recipe DNA (spec §16). */
export interface RecipeDNA {
cuisine: Cuisine;
protein?: string;
carbohydrate?: string;
vegetables: string[];
flavorProfile: string[];
spiceLevel: number;
method: CookingMethod;
timeMinutes: number;
calories: number;
proteinGrams: number;
}
export interface RecipeIngredient {
id: string;
recipeId: string;
canonicalIngredientId: string;
displayNameSv: string;
quantity: number;
unit: Unit;
note?: string;
optional: boolean;
/** Gruppering, t.ex. "Sås", "Topping" */
groupName?: string;
sortOrder: number;
}
export interface RecipeStep {
id: string;
recipeId: string;
stepNumber: number;
instructionSv: string;
/** Timer i sekunder om steget har en naturlig timer (Cooking Mode, spec §41). */
timerSeconds?: number;
temperatureC?: number;
tip?: string;
}
export interface Recipe {
id: string;
slug: string;
titleSv: string;
descriptionSv: string;
country?: string;
region?: string;
cuisine: Cuisine;
mealTypes: MealType[];
tags: RecipeTag[];
methods: CookingMethod[];
equipment: Equipment[];
difficulty: RecipeDifficulty;
prepTimeMinutes: number;
cookTimeMinutes: number;
totalTimeMinutes: number;
portions: number;
/** Deterministiskt beräknad av nutrition-engine utifrån ingredienser (spec §61.1). */
nutritionPerPortion: NutritionValues;
allergens: Allergen[];
spiceLevel: number;
estimatedCostMinorPerPortion?: number;
storageGuidanceSv?: string;
mealPrepFriendly: boolean;
freezerFriendly: boolean;
peakSeasons: Season[];
holidayTags: string[];
dna: RecipeDNA;
variantType: RecipeVariantType;
/** Länk till grundreceptet om detta är en variant (spec §16). */
variantOfRecipeId?: string;
/** Fork-ursprung (spec §35): "Baserat på recept av X". */
forkedFromRecipeId?: string;
status: RecipeStatus;
verificationStatus: RecipeVerificationStatus;
sourceType: RecipeSourceType;
sourceRegistryId?: string;
creatorUserId?: string;
creatorDisplayName?: string;
imageUrls: string[];
version: number;
ratingAverage?: number;
ratingCount: number;
cookCount: number;
favoriteCount: number;
createdAt: string;
updatedAt: string;
}
export interface RecipeRating {
id: string;
recipeId: string;
userId: string;
stars: number;
feedbackTags: FeedbackTag[];
comment?: string;
createdAt: string;
}
export interface Substitution {
id: string;
fromIngredientId: string;
toIngredientId: string;
ratio: number;
instructionsSv?: string;
bestFor: string[];
notRecommendedFor: string[];
flavorImpactSv?: string;
textureImpactSv?: string;
}
/** Source registry för juridisk spårbarhet (spec §15). */
export interface RecipeSourceRegistryEntry {
id: string;
sourceName: string;
license: string;
rightToStore: boolean;
rightToModify: boolean;
rightToDisplay: boolean;
attributionRequired: boolean;
attributionText?: string;
commercialUse: boolean;
validFrom: string;
validTo?: string;
notes?: string;
}
// ---------------------------------------------------------------------------
// Måltider, matlådor (spec §2224)
// ---------------------------------------------------------------------------
export interface Meal {
id: string;
userId: string;
householdId?: string;
date: string; // YYYY-MM-DD
mealType: MealType;
source: MealLogSource;
recipeId?: string;
titleSv: string;
portionFraction: number;
nutrition: NutritionValues;
nutritionIsEstimate: boolean;
estimateRangeKcal?: { min: number; max: number };
photoUrl?: string;
scanJobId?: string;
loggedAt: string;
}
export interface MealBox {
id: string;
householdId: string;
recipeId?: string;
titleSv: string;
portions: number;
portionsRemaining: number;
kcalPerPortion?: number;
nutritionPerPortion?: NutritionValues;
cookedAt: string;
storageLocationId: string;
frozen: boolean;
/** Rekommenderad senaste användning (vägledning, ej garanti spec §13). */
recommendedUseBy: string;
reservedForUserId?: string;
status: "available" | "reserved" | "consumed" | "discarded";
createdAt: string;
}
// ---------------------------------------------------------------------------
// Planering, inköp, budget (spec §2527)
// ---------------------------------------------------------------------------
export interface WeekPlan {
id: string;
householdId: string;
/** Måndag i ISO-vecka, YYYY-MM-DD */
weekStartDate: string;
status: "draft" | "active" | "completed";
generatedBy: "user" | "engine";
notes?: string;
createdAt: string;
updatedAt: string;
}
export interface WeekPlanEntry {
id: string;
weekPlanId: string;
date: string;
mealType: MealType;
recipeId?: string;
mealBoxId?: string;
titleSv: string;
portions: number;
status: "planned" | "cooked" | "skipped" | "moved";
/** Förklaring vid dynamisk omplanering (spec §25). */
rescheduleReasonSv?: string;
sortOrder: number;
}
export interface ShoppingList {
id: string;
householdId: string;
name: string;
status: "active" | "completed" | "archived";
weekPlanId?: string;
createdAt: string;
updatedAt: string;
}
export interface ShoppingListItem {
id: string;
shoppingListId: string;
canonicalIngredientId?: string;
displayName: string;
quantity: number;
unit: Unit;
/** Butiksavdelning för sortering (spec §27). */
storeSection: string;
suggestedPackageSize?: string;
estimatedPriceMinor?: number;
checked: boolean;
addedByUserId?: string;
/** Härledd från recept/plan eller manuellt tillagd. */
origin: "plan" | "recipe" | "manual" | "forecast" | "restock";
sortOrder: number;
}
export interface Receipt {
id: string;
householdId: string;
storeName?: string;
purchaseDate?: string;
totalMinor?: number;
discountMinor?: number;
imageUrl?: string;
scanJobId?: string;
status: "pending" | "confirmed" | "rejected";
createdAt: string;
}
export interface ReceiptLine {
id: string;
receiptId: string;
rawText: string;
normalizedName?: string;
canonicalIngredientId?: string;
productId?: string;
quantity?: number;
unit?: Unit;
unitPriceMinor?: number;
totalPriceMinor?: number;
confidence: number;
verifiedByUser: boolean;
addedToInventory: boolean;
}
// ---------------------------------------------------------------------------
// Skanningsjobb (spec §50, §54)
// ---------------------------------------------------------------------------
export interface ScanJob {
id: string;
userId: string;
householdId?: string;
scanType: ScanType;
jobType: JobType;
status: JobStatus;
s3Keys: string[];
/** Strukturerat AI-resultat, validerat mot ai-contracts. */
result?: unknown;
error?: string;
modelVersion?: string;
promptVersion?: string;
latencyMs?: number;
costUsd?: number;
createdAt: string;
updatedAt: string;
completedAt?: string;
}
// ---------------------------------------------------------------------------
// Minne (spec §32)
// ---------------------------------------------------------------------------
export interface MemoryItem {
id: string;
userId?: string;
householdId?: string;
kind: MemoryKind;
key: string;
/** Läsbar sammanfattning som visas i "Vad plattformen vet om mig". */
summarySv: string;
value: unknown;
origin: SignalOrigin;
confidence: number;
verifiedByUser: boolean;
paused: boolean;
createdAt: string;
updatedAt: string;
lastUsedAt?: string;
expiresAt?: string;
}
export interface TasteSignal {
id: string;
userId: string;
axis: TasteAxis;
/** -1 (mindre) … +1 (mer) */
direction: number;
strength: number;
origin: SignalOrigin;
refRecipeId?: string;
createdAt: string;
}
// ---------------------------------------------------------------------------
// Säsong & event (spec §28)
// ---------------------------------------------------------------------------
export interface SeasonEvent {
id: string;
slug: string;
nameSv: string;
market: string;
/** Datumregel: fast datum (MM-DD), beräknad (t.ex. midsommar) eller intervall. */
dateRule:
| { kind: "fixed"; monthDay: string }
| { kind: "range"; startMonthDay: string; endMonthDay: string }
| { kind: "computed"; algorithm: "midsummer" | "easter" | "advent" | "custom" };
/** Hur många dagar före eventet det ska börja påverka rekommendationer. */
leadDays: number;
foodTags: string[];
recipeSlugs: string[];
priority: number;
active: boolean;
}
// ---------------------------------------------------------------------------
// Prenumerationer & entitlements (spec §4547)
// ---------------------------------------------------------------------------
export interface Subscription {
id: string;
userId: string;
householdId?: string;
provider: SubscriptionProvider;
productId: string;
plan: SubscriptionPlan;
originalTransactionId?: string;
status: SubscriptionStatus;
purchasedAt?: string;
expiresAt?: string;
gracePeriodExpiresAt?: string;
canceledAt?: string;
lastVerifiedAt?: string;
createdAt: string;
updatedAt: string;
}
export interface Entitlements {
plan: SubscriptionPlan;
status: SubscriptionStatus | "free";
maxHouseholdMembers: number;
aiScansPerMonth: number;
aiScansUsedThisMonth: number;
weekPlanning: boolean;
advancedNutrition: boolean;
communityPublish: boolean;
expiresAt?: string;
graceUntil?: string;
/** Signerad token för offline-verifiering med begränsad giltighet (spec §44). */
signedToken?: string;
}
// ---------------------------------------------------------------------------
// Events, flags, audit (spec §5557)
// ---------------------------------------------------------------------------
export interface DomainEvent<TPayload = unknown> {
id: string;
type: EventType;
occurredAt: string;
userId?: string;
householdId?: string;
payload: TPayload;
correlationId?: string;
}
export interface FeatureFlag {
key: string;
enabled: boolean;
descriptionSv?: string;
/** Procent av användare (0100) vid gradvis utrullning. */
rolloutPercent: number;
updatedAt: string;
}
export interface AuditLogEntry {
id: string;
actorUserId?: string;
actorType: "user" | "admin" | "system" | "worker";
action: string;
targetType?: string;
targetId?: string;
metadata?: Record<string, unknown>;
ip?: string;
correlationId?: string;
createdAt: string;
}
export interface AppNotification {
id: string;
userId: string;
type: NotificationType;
titleSv: string;
bodySv: string;
data?: Record<string, unknown>;
scheduledFor?: string;
sentAt?: string;
readAt?: string;
createdAt: string;
}
// ---------------------------------------------------------------------------
// Creator (spec §37)
// ---------------------------------------------------------------------------
export interface CreatorStats {
userId: string;
displayName: string;
visibility: ProfileVisibility;
level: CreatorLevel;
publishedRecipes: number;
followers: number;
totalCooks: number;
totalFavorites: number;
averageRating?: number;
verifiedRecipes: number;
badges: string[];
updatedAt: string;
}
+584
View File
@@ -0,0 +1,584 @@
/**
* Centrala enums för hela plattformen.
*
* Mönster: `as const`-array + härledd unionstyp. Arrayerna återanvänds av
* Zod (`z.enum`) och Drizzle (`pgEnum`) så att databas, API och mobil alltid
* delar exakt samma värdemängder.
*/
// ---------------------------------------------------------------------------
// Förvaring & lager (spec §8)
// ---------------------------------------------------------------------------
export const STORAGE_LOCATION_TYPES = [
"fridge",
"freezer",
"pantry",
"garage_freezer",
"wine_fridge",
"cellar",
"meal_box",
"custom",
] as const;
export type StorageLocationType = (typeof STORAGE_LOCATION_TYPES)[number];
/** Datakällor för lagerposter (spec §9). */
export const INVENTORY_SOURCES = [
"fridge_photo",
"freezer_photo",
"pantry_photo",
"ingredient_photo",
"barcode",
"receipt",
"digital_receipt",
"label_photo",
"manual_search",
"free_text",
"voice",
"cooked_recipe",
"connector",
"seed",
] as const;
export type InventorySource = (typeof INVENTORY_SOURCES)[number];
/** Lagret är transaktionsbaserat (spec §8): varje förändring är en transaktion. */
export const INVENTORY_TRANSACTION_TYPES = [
"purchase",
"consume",
"discard",
"adjust",
"cook_use",
"leftover_created",
"leftover_consumed",
"correction",
"freeze",
"thaw",
"move",
] as const;
export type InventoryTransactionType = (typeof INVENTORY_TRANSACTION_TYPES)[number];
/** Klassning av hur bråttom en vara är (deterministisk, spec §13). */
export const EXPIRY_STATUSES = ["fresh", "use_soon", "expiring", "expired", "unknown"] as const;
export type ExpiryStatus = (typeof EXPIRY_STATUSES)[number];
/** Bäst före ≠ sista förbrukningsdag (spec §13). */
export const DATE_KINDS = ["best_before", "use_by"] as const;
export type DateKind = (typeof DATE_KINDS)[number];
// ---------------------------------------------------------------------------
// Enheter
// ---------------------------------------------------------------------------
/**
* Språkneutrala enhetskoder (i18n-spec §11). Canonical bas: massa=GRAM,
* volym=MILLILITER, antal=COUNT. Visningsnamn/förkortningar ("msk", "tbsp")
* lever i översättningslagret ALDRIG här. Dokumenterad standard (i18n-spec §10):
* TEASPOON=5 ml, TABLESPOON=15 ml (metrisk), CUP_US=236.59 ml,
* FLUID_OUNCE_US=29.57 ml, OUNCE=28.35 g, POUND=453.59 g, PINCH≈0.5 ml.
* Svenska "krm" (=1 ml) lagras som MILLILITER och visas lokalt som "krm".
*/
export const UNITS = [
"GRAM",
"KILOGRAM",
"MILLILITER",
"DECILITER",
"LITER",
"TEASPOON",
"TABLESPOON",
"CUP_US",
"FLUID_OUNCE_US",
"OUNCE",
"POUND",
"COUNT",
"PORTION",
"PINCH",
"SLICE",
"CLOVE",
"CAN",
"PACKAGE",
] as const;
export type Unit = (typeof UNITS)[number];
export const UNIT_KINDS = ["mass", "volume", "count"] as const;
export type UnitKind = (typeof UNIT_KINDS)[number];
// ---------------------------------------------------------------------------
// Användare, profil & mål (spec §6)
// ---------------------------------------------------------------------------
export const USER_ROLES = ["user", "moderator", "admin"] as const;
export type UserRole = (typeof USER_ROLES)[number];
export const SEXES = ["female", "male", "unspecified"] as const;
export type Sex = (typeof SEXES)[number];
export const ACTIVITY_LEVELS = ["sedentary", "light", "moderate", "active", "very_active"] as const;
export type ActivityLevel = (typeof ACTIVITY_LEVELS)[number];
export const GOAL_TYPES = [
"lose_weight",
"gain_weight",
"build_muscle",
"maintain_weight",
"more_protein",
"less_fat",
"more_fiber",
"more_variety",
"less_waste",
"lower_cost",
"cook_more",
] as const;
export type GoalType = (typeof GOAL_TYPES)[number];
export const DIET_PATTERNS = [
"omnivore",
"flexitarian",
"pescatarian",
"vegetarian",
"vegan",
"low_carb",
"keto",
"high_protein",
"mediterranean",
] as const;
export type DietPattern = (typeof DIET_PATTERNS)[number];
export const RELIGIOUS_RULES = [
"none",
"halal",
"kosher",
"hindu_no_beef",
"buddhist_vegetarian",
] as const;
export type ReligiousRule = (typeof RELIGIOUS_RULES)[number];
/** EU:s 14 deklarationspliktiga allergener. Allergikontroll är ALLTID deterministisk (spec §61.2). */
export const ALLERGENS = [
"gluten",
"crustaceans",
"eggs",
"fish",
"peanuts",
"soy",
"milk",
"tree_nuts",
"celery",
"mustard",
"sesame",
"sulphites",
"lupin",
"molluscs",
] as const;
export type Allergen = (typeof ALLERGENS)[number];
/** Enkelt läge vs exakt läge (spec §5) kan kombineras, lagras som preferens. */
export const PRECISION_MODES = ["simple", "exact"] as const;
export type PrecisionMode = (typeof PRECISION_MODES)[number];
// ---------------------------------------------------------------------------
// Hushåll (spec §7)
// ---------------------------------------------------------------------------
export const HOUSEHOLD_ROLES = ["owner", "adult", "member", "child"] as const;
export type HouseholdRole = (typeof HOUSEHOLD_ROLES)[number];
// ---------------------------------------------------------------------------
// Recept (spec §1416)
// ---------------------------------------------------------------------------
export const CUISINES = [
"swedish",
"nordic",
"italian",
"french",
"spanish",
"greek",
"thai",
"chinese",
"japanese",
"korean",
"vietnamese",
"indian",
"mexican",
"american",
"turkish",
"lebanese",
"moroccan",
"middle_eastern",
"international",
] as const;
export type Cuisine = (typeof CUISINES)[number];
export const MEAL_TYPES = [
"breakfast",
"lunch",
"dinner",
"snack",
"dessert",
"starter",
"buffet",
"party",
] as const;
export type MealType = (typeof MEAL_TYPES)[number];
/** Attribut-taggar från spec §14 (utöver måltidstyp och metod). */
export const RECIPE_TAGS = [
"kid_friendly",
"meal_prep",
"quick",
"high_protein",
"low_fat",
"low_calorie",
"low_carb",
"vegetarian",
"vegan",
"gluten_free",
"lactose_free",
"budget",
"luxury",
"freezer_friendly",
"leftover_friendly",
"one_pot",
"batch_cooking",
] as const;
export type RecipeTag = (typeof RECIPE_TAGS)[number];
export const COOKING_METHODS = [
"stovetop",
"oven",
"grill",
"airfryer",
"wok",
"slow_cooker",
"sous_vide",
"microwave",
"no_cook",
"pressure_cooker",
"deep_fry",
"steam",
] as const;
export type CookingMethod = (typeof COOKING_METHODS)[number];
export const EQUIPMENT = [
"stove",
"oven",
"microwave",
"airfryer",
"grill",
"slow_cooker",
"sous_vide",
"pressure_cooker",
"blender",
"food_processor",
"hand_mixer",
"stand_mixer",
"wok_pan",
"kitchen_scale",
"thermometer",
] as const;
export type Equipment = (typeof EQUIPMENT)[number];
export const RECIPE_DIFFICULTIES = ["beginner", "easy", "medium", "advanced", "expert"] as const;
export type RecipeDifficulty = (typeof RECIPE_DIFFICULTIES)[number];
/** Publiceringsflöde för recept (spec §35): submission → AI-kontroll → dubblett → moderation → publicering. */
export const RECIPE_STATUSES = [
"draft",
"submitted",
"ai_checked",
"in_moderation",
"published",
"rejected",
"archived",
] as const;
export type RecipeStatus = (typeof RECIPE_STATUSES)[number];
export const RECIPE_VERIFICATION_STATUSES = [
"unverified",
"community",
"verified",
"editorial",
] as const;
export type RecipeVerificationStatus = (typeof RECIPE_VERIFICATION_STATUSES)[number];
/** Varianter länkas till grundrecept (spec §16). */
export const RECIPE_VARIANT_TYPES = [
"standard",
"high_protein",
"low_calorie",
"low_fat",
"vegetarian",
"vegan",
"gluten_free",
"lactose_free",
"kid_friendly",
"airfryer",
"budget",
] as const;
export type RecipeVariantType = (typeof RECIPE_VARIANT_TYPES)[number];
/** Dubblettklassning (spec §36). */
export const RECIPE_SIMILARITY_CLASSES = [
"duplicate",
"variant",
"inspired",
"independent",
] as const;
export type RecipeSimilarityClass = (typeof RECIPE_SIMILARITY_CLASSES)[number];
/** Juridiskt spårbara källtyper för recept (spec §15). */
export const RECIPE_SOURCE_TYPES = [
"own_editorial",
"ai_assisted_reviewed",
"open_license",
"public_domain",
"licensed_database",
"creator_agreement",
"user_generated",
] as const;
export type RecipeSourceType = (typeof RECIPE_SOURCE_TYPES)[number];
// ---------------------------------------------------------------------------
// Skanning & AI-jobb (spec §4.2, §54)
// ---------------------------------------------------------------------------
export const SCAN_TYPES = [
"fridge",
"freezer",
"pantry",
"ingredients",
"plate",
"receipt",
"barcode",
"expiry_date",
"nutrition_label",
"product_package",
] as const;
export type ScanType = (typeof SCAN_TYPES)[number];
/** Jobbtyper, exakt enligt spec §54. */
export const JOB_TYPES = [
"ANALYZE_FRIDGE_IMAGE",
"ANALYZE_PANTRY_IMAGE",
"ANALYZE_MEAL_IMAGE",
"READ_RECEIPT",
"READ_NUTRITION_LABEL",
"READ_EXPIRY_DATE",
"NORMALIZE_PRODUCTS",
"DEDUPLICATE_INVENTORY",
"CALCULATE_NUTRITION",
"GENERATE_RECIPE_OPTIONS",
"RANK_RECIPES",
"UPDATE_USER_MEMORY",
"GENERATE_WEEK_PLAN",
"SEND_EXPIRY_NOTIFICATION",
"VERIFY_SUBSCRIPTION",
"PROCESS_STORE_NOTIFICATION",
"BUILD_TRAINING_SAMPLE",
"RUN_AI_EVALUATION",
] as const;
export type JobType = (typeof JOB_TYPES)[number];
export const JOB_STATUSES = [
"queued",
"running",
"awaiting_confirmation",
"completed",
"failed",
"canceled",
] as const;
export type JobStatus = (typeof JOB_STATUSES)[number];
// ---------------------------------------------------------------------------
// Events (spec §55)
// ---------------------------------------------------------------------------
export const EVENT_TYPES = [
"PRODUCT_ADDED",
"PRODUCT_UPDATED",
"PRODUCT_CONSUMED",
"PRODUCT_DISCARDED",
"RECIPE_COOKED",
"RECIPE_RATED",
"RECIPE_CREATED",
"RECIPE_FORKED",
"MEAL_LOGGED",
"MEAL_PHOTO_ANALYZED",
"HOUSEHOLD_MEMBER_ADDED",
"SUBSCRIPTION_STARTED",
"SUBSCRIPTION_CHANGED",
"AI_CORRECTED",
"MEMORY_UPDATED",
"SHOPPING_COMPLETED",
"WEEK_PLAN_UPDATED",
"MEAL_BOX_CREATED",
"MEAL_BOX_CONSUMED",
] as const;
export type EventType = (typeof EVENT_TYPES)[number];
// ---------------------------------------------------------------------------
// Måltider & loggning (spec §23)
// ---------------------------------------------------------------------------
export const MEAL_LOG_SOURCES = [
"cooked_recipe",
"plate_photo",
"barcode",
"product",
"free_text",
"voice",
"previous_meal",
"meal_box",
"manual",
] as const;
export type MealLogSource = (typeof MEAL_LOG_SOURCES)[number];
// ---------------------------------------------------------------------------
// Smakprofil & feedback (spec §30)
// ---------------------------------------------------------------------------
export const FEEDBACK_TAGS = [
"too_spicy",
"too_mild",
"too_dry",
"too_salty",
"too_sour",
"too_little_sauce",
"too_difficult",
"make_again",
] as const;
export type FeedbackTag = (typeof FEEDBACK_TAGS)[number];
export const TASTE_AXES = [
"spice",
"salt",
"acid",
"creaminess",
"garlic",
"sweetness",
"herbs",
"umami",
] as const;
export type TasteAxis = (typeof TASTE_AXES)[number];
/** Skilj explicit preferens, observerat mönster och AI-antagande (spec §30, §32). */
export const SIGNAL_ORIGINS = ["user_stated", "observed", "ai_inferred"] as const;
export type SignalOrigin = (typeof SIGNAL_ORIGINS)[number];
// ---------------------------------------------------------------------------
// Minne & samtycke (spec §3233)
// ---------------------------------------------------------------------------
export const MEMORY_KINDS = [
"structured_fact",
"event",
"semantic",
"profile_summary",
"recipe_memory",
] as const;
export type MemoryKind = (typeof MEMORY_KINDS)[number];
export const CONSENT_KINDS = [
"personalization",
"anonymized_improvement",
"image_training",
"health_integration",
"location_weather",
"push_notifications",
] as const;
export type ConsentKind = (typeof CONSENT_KINDS)[number];
export const CONSENT_STATUSES = ["granted", "denied", "revoked"] as const;
export type ConsentStatus = (typeof CONSENT_STATUSES)[number];
// ---------------------------------------------------------------------------
// Säsong, högtid, väder (spec §2829)
// ---------------------------------------------------------------------------
export const SEASONS = ["spring", "summer", "autumn", "winter"] as const;
export type Season = (typeof SEASONS)[number];
export const WEATHER_HINTS = ["hot", "warm", "mild", "cold", "rain", "snow", "unknown"] as const;
export type WeatherHint = (typeof WEATHER_HINTS)[number];
// ---------------------------------------------------------------------------
// Prenumerationer (spec §4547)
// ---------------------------------------------------------------------------
export const SUBSCRIPTION_PLANS = ["free", "household", "family", "large_household"] as const;
export type SubscriptionPlan = (typeof SUBSCRIPTION_PLANS)[number];
export const SUBSCRIPTION_STATUSES = [
"trial",
"active",
"in_grace",
"on_hold",
"paused",
"canceled",
"expired",
] as const;
export type SubscriptionStatus = (typeof SUBSCRIPTION_STATUSES)[number];
export const SUBSCRIPTION_PROVIDERS = ["apple", "google", "promo", "none"] as const;
export type SubscriptionProvider = (typeof SUBSCRIPTION_PROVIDERS)[number];
// ---------------------------------------------------------------------------
// Creator & community (spec §3538)
// ---------------------------------------------------------------------------
export const CREATOR_LEVELS = ["beginner", "sous_chef", "chef", "master_chef", "legend"] as const;
export type CreatorLevel = (typeof CREATOR_LEVELS)[number];
export const MODERATION_ACTIONS = ["approve", "reject", "request_changes", "escalate"] as const;
export type ModerationAction = (typeof MODERATION_ACTIONS)[number];
export const PROFILE_VISIBILITIES = ["private", "friends", "national", "global"] as const;
export type ProfileVisibility = (typeof PROFILE_VISIBILITIES)[number];
// ---------------------------------------------------------------------------
// Notiser (spec §40)
// ---------------------------------------------------------------------------
export const NOTIFICATION_TYPES = [
"expiry_warning",
"meal_box_reminder",
"quick_dinner_suggestion",
"holiday_upcoming",
"creator_new_recipe",
"week_plan_change",
"pantry_forecast",
"subscription_status",
] as const;
export type NotificationType = (typeof NOTIFICATION_TYPES)[number];
// ---------------------------------------------------------------------------
// Datakvalitet varje AI-datapunkt bär källa + confidence (spec §9)
// ---------------------------------------------------------------------------
export const VERIFICATION_STATUSES = [
"unverified",
"user_verified",
"editorially_verified",
] as const;
export type VerificationStatus = (typeof VERIFICATION_STATUSES)[number];
// ---------------------------------------------------------------------------
// Översättningar (i18n-spec §1114): AI-utkast -> granskning -> publicerad.
// Svensk källtext är alltid sanningen; översättningar är vyer av den.
// ---------------------------------------------------------------------------
export const TRANSLATION_STATUSES = ["draft_ai", "in_review", "published"] as const;
export type TranslationStatus = (typeof TRANSLATION_STATUSES)[number];
export const TRANSLATION_SOURCES = ["seed", "ai", "human"] as const;
export type TranslationSource = (typeof TRANSLATION_SOURCES)[number];
/**
* Hjälpfunktion för Drizzle pgEnum som kräver en icke-tom tuple.
* Användning: pgEnum("unit", tuple(UNITS))
*/
export function tuple<T extends readonly [string, ...string[]]>(
values: T,
): [T[number], ...T[number][]] {
return [...values] as [T[number], ...T[number][]];
}
+8
View File
@@ -0,0 +1,8 @@
export * from "./enums.js";
export * from "./nutrition.js";
export * from "./entities.js";
export * from "./constants.js";
export * from "./brand.js";
export * from "./locale.js";
export * from "./money.js";
export * from "./measurement.js";
+103
View File
@@ -0,0 +1,103 @@
/**
* Locale-preferenser (i18n-spec §6): språk ≠ region ≠ måttsystem ≠ valuta.
* En svensk i USA kan ha svensk UI-text, amerikansk region, USD och Fahrenheit.
* Standarder: BCP 47 (språk), ISO 3166-1 alpha-2 (land), IANA (tidszon), ISO 4217 (valuta).
*/
export const MEASUREMENT_SYSTEMS = ["METRIC", "US_CUSTOMARY", "MIXED"] as const;
export type MeasurementSystem = (typeof MEASUREMENT_SYSTEMS)[number];
export const TEMPERATURE_UNITS = ["CELSIUS", "FAHRENHEIT"] as const;
export type TemperatureUnit = (typeof TEMPERATURE_UNITS)[number];
export interface UserLocalePreferences {
languageTag: string; // BCP 47, t.ex. "sv-SE"
regionCode: string; // ISO 3166-1 alpha-2, t.ex. "SE"
timeZone: string; // IANA, t.ex. "Europe/Stockholm"
measurementSystem: MeasurementSystem;
temperatureUnit: TemperatureUnit;
currencyCode: string; // ISO 4217, t.ex. "SEK"
firstDayOfWeek: number; // 0 = söndag … 6 = lördag
use24HourTime: boolean;
}
/** Kontext som följer med varje AAMOS-anrop (i18n-spec §22). */
export interface LocaleContext {
languageTag: string;
regionCode: string;
timeZone: string;
measurementSystem: MeasurementSystem;
temperatureUnit: TemperatureUnit;
currencyCode: string;
}
export const DEFAULT_LOCALE_PREFERENCES: UserLocalePreferences = {
languageTag: "sv-SE",
regionCode: "SE",
timeZone: "Europe/Stockholm",
measurementSystem: "METRIC",
temperatureUnit: "CELSIUS",
currencyCode: "SEK",
firstDayOfWeek: 1,
use24HourTime: true,
};
/** Rimliga regiondefaults användaren kan alltid ändra varje fält separat. */
const REGION_DEFAULTS: Record<string, Partial<UserLocalePreferences>> = {
SE: {},
NO: { languageTag: "nb-NO", timeZone: "Europe/Oslo", currencyCode: "NOK" },
DK: { languageTag: "da-DK", timeZone: "Europe/Copenhagen", currencyCode: "DKK" },
FI: { languageTag: "fi-FI", timeZone: "Europe/Helsinki", currencyCode: "EUR" },
IS: { languageTag: "is-IS", timeZone: "Atlantic/Reykjavik", currencyCode: "ISK" },
ES: { languageTag: "es-ES", timeZone: "Europe/Madrid", currencyCode: "EUR" },
NL: { languageTag: "nl-NL", timeZone: "Europe/Amsterdam", currencyCode: "EUR" },
PL: { languageTag: "pl-PL", timeZone: "Europe/Warsaw", currencyCode: "PLN" },
PT: { languageTag: "pt-PT", timeZone: "Europe/Lisbon", currencyCode: "EUR" },
IT: { languageTag: "it-IT", timeZone: "Europe/Rome", currencyCode: "EUR" },
GB: {
languageTag: "en-GB",
timeZone: "Europe/London",
currencyCode: "GBP",
measurementSystem: "MIXED",
},
US: {
languageTag: "en-US",
timeZone: "America/New_York",
currencyCode: "USD",
measurementSystem: "US_CUSTOMARY",
temperatureUnit: "FAHRENHEIT",
firstDayOfWeek: 0,
use24HourTime: false,
},
CA: {
languageTag: "en-CA",
timeZone: "America/Toronto",
currencyCode: "CAD",
measurementSystem: "MIXED",
firstDayOfWeek: 0,
use24HourTime: false,
},
DE: { languageTag: "de-DE", timeZone: "Europe/Berlin", currencyCode: "EUR" },
FR: { languageTag: "fr-FR", timeZone: "Europe/Paris", currencyCode: "EUR" },
};
export function localeDefaultsForRegion(regionCode: string): UserLocalePreferences {
const overrides = REGION_DEFAULTS[regionCode.toUpperCase()] ?? {};
return { ...DEFAULT_LOCALE_PREFERENCES, regionCode: regionCode.toUpperCase(), ...overrides };
}
export function toLocaleContext(prefs: UserLocalePreferences): LocaleContext {
return {
languageTag: prefs.languageTag,
regionCode: prefs.regionCode,
timeZone: prefs.timeZone,
measurementSystem: prefs.measurementSystem,
temperatureUnit: prefs.temperatureUnit,
currencyCode: prefs.currencyCode,
};
}
/** Temperaturkonvertering för visning (canonical lagras alltid i Celsius). */
export function celsiusToDisplay(celsius: number, unit: TemperatureUnit): number {
return unit === "FAHRENHEIT" ? Math.round((celsius * 9) / 5 + 32) : celsius;
}
+102
View File
@@ -0,0 +1,102 @@
import type { MeasurementSystem } from "./locale.js";
import { UNIT_INFO } from "./constants.js";
import type { Unit } from "./enums.js";
/**
* Måttvisningstjänst (i18n-spec §910, §30 M5).
*
* Lagring är ALLTID kanonisk (GRAM/MILLILITER/COUNT …). Denna modul väljer
* hur en kanonisk mängd VISAS för användarens measurementSystem förlustfritt:
* originalvärdet rör vi aldrig, vi räknar bara fram en visningsrepresentation.
*
* METRIC g/kg, ml/dl/l som i dag.
* US_CUSTOMARY vikt -> oz/lb, volym -> tsp/tbsp/fl oz/cup.
* MIXED (GB/CA-stil) metriskt kök som METRIC tills marknadsdata säger annat.
*/
export interface DisplayQuantity {
value: number;
unit: Unit;
/** Ex "0.25" -> visas som ¼ i UI:t om klienten vill. */
approximate: boolean;
}
const OZ_IN_GRAMS = 28.349523125;
const LB_IN_GRAMS = 453.59237;
const TSP_ML = 5;
const TBSP_ML = 15;
const FLOZ_ML = 29.5735295625;
const CUP_ML = 236.5882365;
/** Avrunda till närmaste kvarts (för cups/tsp så recepten ser naturliga ut). */
function roundQuarter(v: number): number {
return Math.round(v * 4) / 4;
}
function round1(v: number): number {
return Math.round(v * 10) / 10;
}
/** Kanonisk mängd -> visningsmängd för användarens måttsystem. */
export function displayQuantity(
quantity: number,
unit: Unit,
system: MeasurementSystem,
): DisplayQuantity {
const info = UNIT_INFO[unit];
if (!info) return { value: quantity, unit, approximate: false };
if (system !== "US_CUSTOMARY") {
// METRIC/MIXED: uppgradera bara till läsbara metriska enheter.
if (info.kind === "mass") {
const grams = quantity * info.toBase;
if (grams >= 1000)
return { value: round1(grams / 1000), unit: "KILOGRAM", approximate: false };
return { value: Math.round(grams), unit: "GRAM", approximate: false };
}
if (info.kind === "volume") {
const ml = quantity * info.toBase;
// Behåll kökstypiska enheter som de är (tsk/msk/krm visas bäst oförändrade).
if (unit === "TEASPOON" || unit === "TABLESPOON" || unit === "PINCH") {
return { value: quantity, unit, approximate: false };
}
if (ml >= 1000) return { value: round1(ml / 1000), unit: "LITER", approximate: false };
if (ml >= 100) return { value: round1(ml / 100), unit: "DECILITER", approximate: false };
return { value: Math.round(ml), unit: "MILLILITER", approximate: false };
}
return { value: quantity, unit, approximate: false };
}
// US_CUSTOMARY
if (info.kind === "mass") {
const grams = quantity * info.toBase;
if (grams >= LB_IN_GRAMS)
return { value: round1(grams / LB_IN_GRAMS), unit: "POUND", approximate: true };
return { value: round1(grams / OZ_IN_GRAMS), unit: "OUNCE", approximate: true };
}
if (info.kind === "volume") {
const ml = quantity * info.toBase;
if (ml >= CUP_ML / 2)
return { value: roundQuarter(ml / CUP_ML), unit: "CUP_US", approximate: true };
if (ml >= FLOZ_ML)
return { value: roundQuarter(ml / FLOZ_ML), unit: "FLUID_OUNCE_US", approximate: true };
if (ml >= TBSP_ML)
return { value: roundQuarter(ml / TBSP_ML), unit: "TABLESPOON", approximate: true };
return { value: roundQuarter(ml / TSP_ML), unit: "TEASPOON", approximate: true };
}
return { value: quantity, unit, approximate: false };
}
/** kcal -> kJ (i18n-spec §18: EU visar båda, kJ = kcal × 4.184). */
export function kcalToKilojoules(kcal: number): number {
return Math.round(kcal * 4.184);
}
/** Salt (g) <-> natrium (mg): salt = natrium × 2.5 (spec §1819). */
export function sodiumMgToSaltGrams(sodiumMg: number): number {
return Math.round(sodiumMg * 2.5) / 1000;
}
export function saltGramsToSodiumMg(saltGrams: number): number {
return Math.round((saltGrams * 1000) / 2.5);
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Pengar (i18n-spec §20): minor units + ISO 4217, aldrig floating point för lagring.
* Historiska priser behåller originalvaluta; omräkning märks alltid som omräknad.
*
* M1 GENOMFÖRD: alla pengakolumner lagras som *_minor (heltal) och hushållet bär
* currency_code (ISO 4217). Denna modul är den gemensamma modellen för visning/aritmetik.
*/
export interface Money {
/** Minsta enhet: öre, cent, pence … */
amountMinor: number;
/** ISO 4217, t.ex. "SEK", "EUR", "USD". */
currency: string;
}
/** Valutor med annat antal decimaler än 2 (ISO 4217). */
const MINOR_DIGITS: Record<string, number> = { ISK: 0, JPY: 0, KWD: 3 };
export function minorDigits(currency: string): number {
return MINOR_DIGITS[currency.toUpperCase()] ?? 2;
}
export function toMinor(amount: number, currency: string): number {
return Math.round(amount * 10 ** minorDigits(currency));
}
export function fromMinor(money: Money): number {
return money.amountMinor / 10 ** minorDigits(money.currency);
}
export function money(amountMinor: number, currency: string): Money {
if (!Number.isInteger(amountMinor)) {
throw new Error(`amountMinor måste vara ett heltal, fick ${amountMinor}`);
}
return { amountMinor, currency: currency.toUpperCase() };
}
export function addMoney(a: Money, b: Money): Money {
if (a.currency !== b.currency) {
throw new Error(`Kan inte addera ${a.currency} och ${b.currency} utan växelkurs`);
}
return { amountMinor: a.amountMinor + b.amountMinor, currency: a.currency };
}
/** Locale-medveten formattering via Intl (finns i Node, Hermes/RN och webbläsare). */
export function formatMoney(value: Money, languageTag: string): string {
try {
return new Intl.NumberFormat(languageTag, {
style: "currency",
currency: value.currency,
}).format(fromMinor(value));
} catch {
return `${fromMinor(value)} ${value.currency}`;
}
}
+95
View File
@@ -0,0 +1,95 @@
/**
* Näringstyper. All beräkning sker i deterministisk kod (spec §21, §61.1)
* AI får aldrig hitta på näringsvärden. Värden presenteras alltid som
* uppskattningar i UI.
*/
/** Makro- och mikronäringsvärden. Alla värden ≥ 0. */
export interface NutritionValues {
/** kcal */
kcal: number;
/** gram */
proteinG: number;
/** gram */
carbsG: number;
/** gram */
fatG: number;
/** gram */
saturatedFatG: number;
/** gram */
fiberG: number;
/** gram */
sugarG: number;
/** gram salt (NaCl). natrium_mg = saltG * 400 */
saltG: number;
/** Utvalda mikronäringsämnen, valfria (spec §21) */
micro?: MicroNutrients;
}
export interface MicroNutrients {
vitaminDUg?: number;
vitaminB12Ug?: number;
vitaminCMg?: number;
folateUg?: number;
ironMg?: number;
calciumMg?: number;
zincMg?: number;
magnesiumMg?: number;
potassiumMg?: number;
iodineUg?: number;
}
export type NutritionBasis = "per_100_g" | "per_100_ml" | "per_piece" | "per_portion";
/** Näringsdeklaration knuten till en bas (per 100 g/ml, per styck eller per portion). */
export interface NutritionDeclaration {
basis: NutritionBasis;
/** Vikt i gram för "per_piece"/"per_portion" så att omräkning är möjlig. */
referenceWeightG?: number;
values: NutritionValues;
}
/** Datakvalitet för näringsdata källan ska alltid vara spårbar (spec §9, §21). */
export interface NutritionProvenance {
source:
| "livsmedelsverket"
| "product_label"
| "licensed_database"
| "user_entered"
| "seed_estimate"
| "computed_from_ingredients";
confidence: number;
verifiedByUser: boolean;
lastVerifiedAt?: string;
}
/** Intervallvisning för uppskattningar, t.ex. tallriksfoto (spec §22). */
export interface NutritionEstimateRange {
minKcal: number;
maxKcal: number;
mostLikelyKcal: number;
values: NutritionValues;
confidence: number;
}
/** Dagsmål per användare, beräknade deterministiskt av nutrition-engine. */
export interface DailyTargets {
kcal: number;
proteinG: number;
carbsG: number;
fatG: number;
fiberG: number;
/** Max rekommenderat salt (g) */
saltMaxG: number;
}
export const EMPTY_NUTRITION: NutritionValues = {
kcal: 0,
proteinG: 0,
carbsG: 0,
fatG: 0,
saturatedFatG: 0,
fiberG: 0,
sugarG: 0,
saltG: 0,
};
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import {
celsiusToDisplay,
formatMoney,
fromMinor,
localeDefaultsForRegion,
money,
toMinor,
} from "../src/index.js";
describe("locale-preferenser (i18n-spec §6)", () => {
it("regiondefaults: USA får US_CUSTOMARY + Fahrenheit + USD + söndagsstart", () => {
const us = localeDefaultsForRegion("us");
expect(us.measurementSystem).toBe("US_CUSTOMARY");
expect(us.temperatureUnit).toBe("FAHRENHEIT");
expect(us.currencyCode).toBe("USD");
expect(us.firstDayOfWeek).toBe(0);
expect(us.use24HourTime).toBe(false);
});
it("okänd region faller tillbaka till metriska defaults", () => {
const xx = localeDefaultsForRegion("XX");
expect(xx.measurementSystem).toBe("METRIC");
expect(xx.regionCode).toBe("XX");
});
it("temperatur: canonical Celsius → Fahrenheit-visning", () => {
expect(celsiusToDisplay(200, "FAHRENHEIT")).toBe(392);
expect(celsiusToDisplay(200, "CELSIUS")).toBe(200);
});
});
describe("pengar i minor units (i18n-spec §20)", () => {
it("SEK: 79 kr = 7900 öre, tillbaka utan förlust", () => {
expect(toMinor(79, "SEK")).toBe(7900);
expect(fromMinor(money(7900, "SEK"))).toBe(79);
});
it("valutor utan decimaler (ISK) hanteras", () => {
expect(toMinor(500, "ISK")).toBe(500);
expect(fromMinor(money(500, "ISK"))).toBe(500);
});
it("amountMinor måste vara heltal aldrig floating point", () => {
expect(() => money(79.5, "SEK")).toThrow();
});
it("formattering är locale-medveten", () => {
const sv = formatMoney(money(7900, "SEK"), "sv-SE");
const us = formatMoney(money(7900, "USD"), "en-US");
expect(sv).toContain("79");
expect(us).toContain("79");
});
});
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import {
displayQuantity,
kcalToKilojoules,
saltGramsToSodiumMg,
sodiumMgToSaltGrams,
} from "../src/measurement.js";
describe("måttvisning (i18n-spec §910, M5)", () => {
it("METRIC: gram uppgraderas läsbart, kökenheter behålls", () => {
expect(displayQuantity(500, "GRAM", "METRIC")).toEqual({
value: 500,
unit: "GRAM",
approximate: false,
});
expect(displayQuantity(1500, "GRAM", "METRIC")).toEqual({
value: 1.5,
unit: "KILOGRAM",
approximate: false,
});
expect(displayQuantity(2, "TABLESPOON", "METRIC")).toEqual({
value: 2,
unit: "TABLESPOON",
approximate: false,
});
expect(displayQuantity(250, "MILLILITER", "METRIC")).toEqual({
value: 2.5,
unit: "DECILITER",
approximate: false,
});
});
it("US_CUSTOMARY: vikt -> oz/lb", () => {
const oz = displayQuantity(100, "GRAM", "US_CUSTOMARY");
expect(oz.unit).toBe("OUNCE");
expect(oz.value).toBeCloseTo(3.5, 1);
const lb = displayQuantity(1, "KILOGRAM", "US_CUSTOMARY");
expect(lb.unit).toBe("POUND");
expect(lb.value).toBeCloseTo(2.2, 1);
});
it("US_CUSTOMARY: volym -> tsp/tbsp/cup med kvartsavrundning", () => {
const cup = displayQuantity(2.5, "DECILITER", "US_CUSTOMARY");
expect(cup.unit).toBe("CUP_US");
expect(cup.value).toBeCloseTo(1, 1);
const tsp = displayQuantity(5, "MILLILITER", "US_CUSTOMARY");
expect(tsp.unit).toBe("TEASPOON");
expect(tsp.value).toBe(1);
const tbsp = displayQuantity(15, "MILLILITER", "US_CUSTOMARY");
expect(tbsp.unit).toBe("TABLESPOON");
expect(tbsp.value).toBe(1);
});
it("MIXED beter sig metriskt (GB/CA-kök)", () => {
expect(displayQuantity(500, "GRAM", "MIXED").unit).toBe("GRAM");
});
it("styck-enheter konverteras aldrig", () => {
expect(displayQuantity(3, "COUNT", "US_CUSTOMARY")).toEqual({
value: 3,
unit: "COUNT",
approximate: false,
});
});
it("lagring påverkas aldrig ren visningsfunktion utan sidoeffekter", () => {
const before = { q: 500, u: "GRAM" as const };
displayQuantity(before.q, before.u, "US_CUSTOMARY");
expect(before).toEqual({ q: 500, u: "GRAM" });
});
});
describe("energi & natrium (i18n-spec §1819, M6)", () => {
it("kcal -> kJ med 4.184", () => {
expect(kcalToKilojoules(100)).toBe(418);
expect(kcalToKilojoules(650)).toBe(2720);
});
it("salt <-> natrium med faktor 2.5", () => {
expect(sodiumMgToSaltGrams(400)).toBe(1);
expect(saltGramsToSodiumMg(1)).toBe(400);
expect(saltGramsToSodiumMg(sodiumMgToSaltGrams(1234))).toBeCloseTo(1234, -1);
});
});
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src", "test"],
"compilerOptions": {
"types": ["node"]
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@app/subscriptions",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Entitlements, signerade offline-tokens och StoreKit/Play-verifiering (spec §4447)",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@app/shared-types": "workspace:*"
}
}
+107
View File
@@ -0,0 +1,107 @@
import {
PLAN_DEFINITIONS,
TRIAL_DAYS,
type Entitlements,
type SubscriptionPlan,
type SubscriptionStatus,
} from "@app/shared-types";
export interface SubscriptionSnapshot {
plan: SubscriptionPlan;
status: SubscriptionStatus;
expiresAt?: Date | null;
gracePeriodExpiresAt?: Date | null;
}
export interface TrialSnapshot {
startedAt: Date;
endsAt: Date;
}
export interface UsageSnapshot {
aiScansUsedThisMonth: number;
}
/**
* Backend är source of truth (spec §47, §61.14). Denna funktion är den enda
* platsen som avgör vad en användare får göra.
*
* Prioritet: aktiv betald prenumeration (inkl. grace) > pågående trial > free.
*/
export function computeEntitlements(
subscription: SubscriptionSnapshot | null,
trial: TrialSnapshot | null,
usage: UsageSnapshot,
now: Date = new Date(),
): Entitlements {
// 1. Betald prenumeration
if (subscription) {
const withinPeriod = subscription.expiresAt == null || subscription.expiresAt > now;
const withinGrace =
subscription.gracePeriodExpiresAt != null && subscription.gracePeriodExpiresAt > now;
const active =
(subscription.status === "active" || subscription.status === "trial") && withinPeriod;
const inGrace = subscription.status === "in_grace" && withinGrace;
if (active || inGrace) {
const def = PLAN_DEFINITIONS[subscription.plan];
return {
plan: subscription.plan,
status: inGrace ? "in_grace" : subscription.status,
maxHouseholdMembers: def.maxHouseholdMembers,
aiScansPerMonth: def.aiScansPerMonth,
aiScansUsedThisMonth: usage.aiScansUsedThisMonth,
weekPlanning: def.weekPlanning,
advancedNutrition: def.advancedNutrition,
communityPublish: def.communityPublish,
expiresAt: subscription.expiresAt?.toISOString(),
graceUntil: subscription.gracePeriodExpiresAt?.toISOString(),
};
}
}
// 2. Trial: 7 dagar full tillgång utan kort (spec §46). Family-nivå under trial.
if (trial && trial.endsAt > now) {
const def = PLAN_DEFINITIONS.family;
return {
plan: "family",
status: "trial",
maxHouseholdMembers: def.maxHouseholdMembers,
aiScansPerMonth: def.aiScansPerMonth,
aiScansUsedThisMonth: usage.aiScansUsedThisMonth,
weekPlanning: true,
advancedNutrition: true,
communityPublish: true,
expiresAt: trial.endsAt.toISOString(),
};
}
// 3. Gratisnivå: användbar men begränsad (spec §46).
const free = PLAN_DEFINITIONS.free;
return {
plan: "free",
status: "free",
maxHouseholdMembers: free.maxHouseholdMembers,
aiScansPerMonth: free.aiScansPerMonth,
aiScansUsedThisMonth: usage.aiScansUsedThisMonth,
weekPlanning: free.weekPlanning,
advancedNutrition: free.advancedNutrition,
communityPublish: free.communityPublish,
};
}
export function trialEndsAt(startedAt: Date): Date {
return new Date(startedAt.getTime() + TRIAL_DAYS * 86_400_000);
}
/** Har användaren AI-skanningar kvar denna månad? (fair use, spec §45) */
export function canUseAiScan(entitlements: Entitlements): boolean {
return entitlements.aiScansUsedThisMonth < entitlements.aiScansPerMonth;
}
export function resolvePlanFromProductId(productId: string): SubscriptionPlan | null {
for (const def of Object.values(PLAN_DEFINITIONS)) {
if (def.appleProductId === productId || def.googleProductId === productId) return def.plan;
}
return null;
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./entitlements.js";
export * from "./token.js";
export * from "./stores.js";
+110
View File
@@ -0,0 +1,110 @@
import type { SubscriptionPlan, SubscriptionStatus } from "@app/shared-types";
import { resolvePlanFromProductId } from "./entitlements.js";
/**
* Butiksverifiering (spec §47, Del 13). Backend verifierar ALLTID köpet mot
* butiken klientens kvitto är bara ett påstående (spec §61.14).
*
* PRODUKTIONSINTEGRATION (implementeras i fas 7, se docs/utvecklingsfaser.md):
* - Apple: App Store Server API v2 verifiera JWS-signaturen i signedTransaction
* mot Apples publika nycklar, kontrollera bundleId + environment.
* - Google: Play Developer API purchases.subscriptionsv2.get med service account.
*
* Dev/CI: APP_STORE_MODE=sandbox accepterar särskilt formaterade
* sandbox-payloads så att hela flödet kan testas end-to-end utan butikskonton.
*/
export interface VerifiedPurchase {
provider: "apple" | "google";
productId: string;
plan: SubscriptionPlan;
originalTransactionId: string;
status: SubscriptionStatus;
purchasedAt: Date;
expiresAt: Date;
}
export type StoreVerificationResult =
{ ok: true; purchase: VerifiedPurchase } | { ok: false; error: string };
export interface StoreVerifierConfig {
mode: "production" | "sandbox";
appleBundleId?: string | undefined;
googlePackageName?: string | undefined;
}
export class StoreVerifier {
constructor(private readonly cfg: StoreVerifierConfig) {}
async verifyApple(signedTransaction: string): Promise<StoreVerificationResult> {
if (this.cfg.mode === "sandbox") {
return parseSandboxPayload("apple", signedTransaction);
}
// TODO(fas 7): App Store Server API v2 JWS-verifiering.
return {
ok: false,
error:
"Apple-verifiering i produktionsläge är inte konfigurerad ännu. " +
"Kräver APPLE_ISSUER_ID/KEY_ID/PRIVATE_KEY (se docs/subscriptions.md).",
};
}
async verifyGoogle(
packageName: string,
productId: string,
purchaseToken: string,
): Promise<StoreVerificationResult> {
if (this.cfg.mode === "sandbox") {
return parseSandboxPayload("google", purchaseToken, productId);
}
if (this.cfg.googlePackageName && packageName !== this.cfg.googlePackageName) {
return { ok: false, error: `Fel paketnamn: ${packageName}` };
}
// TODO(fas 7): Play Developer API purchases.subscriptionsv2.
return {
ok: false,
error:
"Google-verifiering i produktionsläge är inte konfigurerad ännu. " +
"Kräver GOOGLE_SERVICE_ACCOUNT_JSON (se docs/subscriptions.md).",
};
}
}
/**
* Sandbox-payload: JSON i klartext:
* {"productId":"<iosBundleId>.family_monthly","originalTransactionId":"sandbox-1","expiresInDays":30}
*/
function parseSandboxPayload(
provider: "apple" | "google",
payload: string,
fallbackProductId?: string,
): StoreVerificationResult {
try {
const data = JSON.parse(payload) as {
productId?: string;
originalTransactionId?: string;
expiresInDays?: number;
};
const productId = data.productId ?? fallbackProductId;
if (!productId) return { ok: false, error: "sandbox-payload saknar productId" };
const plan = resolvePlanFromProductId(productId);
if (!plan || plan === "free") {
return { ok: false, error: `Okänt productId: ${productId}` };
}
const now = new Date();
return {
ok: true,
purchase: {
provider,
productId,
plan,
originalTransactionId: data.originalTransactionId ?? `sandbox-${Date.now()}`,
status: "active",
purchasedAt: now,
expiresAt: new Date(now.getTime() + (data.expiresInDays ?? 30) * 86_400_000),
},
};
} catch {
return { ok: false, error: "Ogiltig sandbox-payload (förväntar JSON)" };
}
}
+94
View File
@@ -0,0 +1,94 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import type { Entitlements } from "@app/shared-types";
/**
* Signerad entitlement-token för offline-läge (spec §44):
* "Premium får inte vara permanent lokal boolean. Använd signerad
* entitlement-token med begränsad giltighet och grace period."
*
* Kompakt HS256-JWT implementerad med node:crypto (inga extra beroenden).
* Appen cachar token och litar på den offline tills exp + grace har passerat.
*/
export interface EntitlementTokenPayload {
sub: string; // userId
plan: Entitlements["plan"];
status: string;
maxMembers: number;
scansPerMonth: number;
weekPlanning: boolean;
advancedNutrition: boolean;
communityPublish: boolean;
iat: number;
exp: number;
/** Unix-sekunder: sista tidpunkt token accepteras offline (grace). */
graceExp: number;
}
const b64url = (buf: Buffer): string =>
buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const fromB64url = (s: string): Buffer =>
Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/"), "base64");
export function signEntitlementToken(
userId: string,
entitlements: Entitlements,
secret: string,
options: { ttlHours?: number; graceDays?: number; now?: Date } = {},
): string {
const now = options.now ?? new Date();
const ttlHours = options.ttlHours ?? 24;
const graceDays = options.graceDays ?? 7;
const iat = Math.floor(now.getTime() / 1000);
const exp = iat + ttlHours * 3600;
const payload: EntitlementTokenPayload = {
sub: userId,
plan: entitlements.plan,
status: entitlements.status,
maxMembers: entitlements.maxHouseholdMembers,
scansPerMonth: entitlements.aiScansPerMonth,
weekPlanning: entitlements.weekPlanning,
advancedNutrition: entitlements.advancedNutrition,
communityPublish: entitlements.communityPublish,
iat,
exp,
graceExp: exp + graceDays * 86_400,
};
const header = b64url(Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })));
const body = b64url(Buffer.from(JSON.stringify(payload)));
const signature = b64url(createHmac("sha256", secret).update(`${header}.${body}`).digest());
return `${header}.${body}.${signature}`;
}
export type TokenVerification =
| { valid: true; payload: EntitlementTokenPayload; withinGrace: boolean }
| { valid: false; reason: "malformed" | "bad_signature" | "expired_beyond_grace" };
export function verifyEntitlementToken(
token: string,
secret: string,
now: Date = new Date(),
): TokenVerification {
const parts = token.split(".");
if (parts.length !== 3) return { valid: false, reason: "malformed" };
const [header, body, signature] = parts as [string, string, string];
const expected = createHmac("sha256", secret).update(`${header}.${body}`).digest();
const actual = fromB64url(signature);
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
return { valid: false, reason: "bad_signature" };
}
let payload: EntitlementTokenPayload;
try {
payload = JSON.parse(fromB64url(body).toString("utf8")) as EntitlementTokenPayload;
} catch {
return { valid: false, reason: "malformed" };
}
const nowSec = Math.floor(now.getTime() / 1000);
if (nowSec > payload.graceExp) return { valid: false, reason: "expired_beyond_grace" };
return { valid: true, payload, withinGrace: nowSec > payload.exp };
}
@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import { PLAN_DEFINITIONS } from "@app/shared-types";
import {
canUseAiScan,
computeEntitlements,
resolvePlanFromProductId,
signEntitlementToken,
trialEndsAt,
verifyEntitlementToken,
} from "../src/index.js";
const NOW = new Date("2026-08-02T12:00:00Z");
const SECRET = "test-secret-with-length";
describe("entitlements (spec §4547)", () => {
it("aktiv prenumeration ger planens förmåner", () => {
const ent = computeEntitlements(
{ plan: "family", status: "active", expiresAt: new Date("2026-09-01") },
null,
{ aiScansUsedThisMonth: 10 },
NOW,
);
expect(ent.plan).toBe("family");
expect(ent.maxHouseholdMembers).toBe(6);
expect(ent.weekPlanning).toBe(true);
});
it("grace period behåller åtkomst (spec §44)", () => {
const ent = computeEntitlements(
{
plan: "household",
status: "in_grace",
expiresAt: new Date("2026-08-01"),
gracePeriodExpiresAt: new Date("2026-08-10"),
},
null,
{ aiScansUsedThisMonth: 0 },
NOW,
);
expect(ent.status).toBe("in_grace");
expect(ent.weekPlanning).toBe(true);
});
it("utgången prenumeration utan grace → free", () => {
const ent = computeEntitlements(
{ plan: "family", status: "active", expiresAt: new Date("2026-07-01") },
null,
{ aiScansUsedThisMonth: 0 },
NOW,
);
expect(ent.plan).toBe("free");
});
it("trial ger full tillgång i 7 dagar (spec §46)", () => {
const start = new Date("2026-08-01T00:00:00Z");
const ent = computeEntitlements(
null,
{ startedAt: start, endsAt: trialEndsAt(start) },
{ aiScansUsedThisMonth: 3 },
NOW,
);
expect(ent.status).toBe("trial");
expect(ent.weekPlanning).toBe(true);
});
it("free tier: 10 skanningar och användbar grund (spec §46)", () => {
const ent = computeEntitlements(null, null, { aiScansUsedThisMonth: 9 }, NOW);
expect(ent.plan).toBe("free");
expect(ent.aiScansPerMonth).toBe(10);
expect(canUseAiScan(ent)).toBe(true);
expect(canUseAiScan({ ...ent, aiScansUsedThisMonth: 10 })).toBe(false);
});
it("mappar productId → plan (härlett ur brand-konfig, aldrig hårdkodat)", () => {
expect(resolvePlanFromProductId(PLAN_DEFINITIONS.family.appleProductId)).toBe("family");
expect(resolvePlanFromProductId(PLAN_DEFINITIONS.household.googleProductId)).toBe("household");
expect(resolvePlanFromProductId("okänd")).toBeNull();
});
});
describe("signerad entitlement-token (spec §44)", () => {
const ent = computeEntitlements(
{ plan: "family", status: "active", expiresAt: new Date("2026-09-01") },
null,
{ aiScansUsedThisMonth: 0 },
NOW,
);
it("signerar och verifierar", () => {
const token = signEntitlementToken("user-1", ent, SECRET, { now: NOW });
const result = verifyEntitlementToken(token, SECRET, NOW);
expect(result.valid).toBe(true);
if (result.valid) {
expect(result.payload.sub).toBe("user-1");
expect(result.payload.plan).toBe("family");
expect(result.withinGrace).toBe(false);
}
});
it("avvisar manipulerad token", () => {
const token = signEntitlementToken("user-1", ent, SECRET, { now: NOW });
const tampered = token.slice(0, -4) + "AAAA";
expect(verifyEntitlementToken(tampered, SECRET, NOW).valid).toBe(false);
});
it("avvisar fel secret", () => {
const token = signEntitlementToken("user-1", ent, SECRET, { now: NOW });
expect(verifyEntitlementToken(token, "annan-secret-123", NOW).valid).toBe(false);
});
it("grace: giltig efter exp men inom graceExp, ogiltig därefter ALDRIG permanent boolean", () => {
const token = signEntitlementToken("user-1", ent, SECRET, {
now: NOW,
ttlHours: 24,
graceDays: 7,
});
const afterExp = new Date(NOW.getTime() + 3 * 86_400_000);
const inGrace = verifyEntitlementToken(token, SECRET, afterExp);
expect(inGrace.valid).toBe(true);
if (inGrace.valid) expect(inGrace.withinGrace).toBe(true);
const afterGrace = new Date(NOW.getTime() + 9 * 86_400_000);
const expired = verifyEntitlementToken(token, SECRET, afterGrace);
expect(expired.valid).toBe(false);
if (!expired.valid) expect(expired.reason).toBe("expired_beyond_grace");
});
});
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src", "test"],
"compilerOptions": {
"types": ["node"]
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@app/validation",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Zod-scheman för API-kontrakten delas av api, worker, admin och mobil",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@app/shared-types": "workspace:*",
"zod": "^4.4.0"
}
}
+66
View File
@@ -0,0 +1,66 @@
import { z } from "zod";
export const registerInputSchema = z.object({
email: z.string().trim().toLowerCase().max(255).pipe(z.email()),
password: z.string().min(10, "Lösenordet måste vara minst 10 tecken").max(200),
displayName: z.string().min(1).max(80).trim(),
locale: z.string().min(2).max(10).default("sv-SE"),
});
export type RegisterInput = z.infer<typeof registerInputSchema>;
export const loginInputSchema = z.object({
email: z.string().trim().toLowerCase().pipe(z.email()),
password: z.string().min(1).max(200),
});
export type LoginInput = z.infer<typeof loginInputSchema>;
export const refreshInputSchema = z.object({
refreshToken: z.string().min(10),
});
export type RefreshInput = z.infer<typeof refreshInputSchema>;
export const authTokensSchema = z.object({
accessToken: z.string(),
refreshToken: z.string(),
accessTokenExpiresIn: z.number(),
});
export type AuthTokens = z.infer<typeof authTokensSchema>;
export const changePasswordInputSchema = z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(10).max(200),
});
export type ChangePasswordInput = z.infer<typeof changePasswordInputSchema>;
export const forgotPasswordInputSchema = z.object({
email: z.string().trim().toLowerCase().pipe(z.email()),
});
export type ForgotPasswordInput = z.infer<typeof forgotPasswordInputSchema>;
export const resetPasswordInputSchema = z.object({
token: z.string().min(32).max(200),
newPassword: z.string().min(10).max(200),
});
export type ResetPasswordInput = z.infer<typeof resetPasswordInputSchema>;
export const verifyEmailInputSchema = z.object({
token: z.string().min(32).max(200),
});
export type VerifyEmailInput = z.infer<typeof verifyEmailInputSchema>;
export const totpVerifyInputSchema = z.object({
preAuthToken: z.string().min(10),
code: z
.string()
.trim()
.regex(/^\d{6}$/),
});
export type TotpVerifyInput = z.infer<typeof totpVerifyInputSchema>;
export const totpCodeInputSchema = z.object({
code: z
.string()
.trim()
.regex(/^\d{6}$/),
});
export type TotpCodeInput = z.infer<typeof totpCodeInputSchema>;
+33
View File
@@ -0,0 +1,33 @@
import { z } from "zod";
import { UNITS } from "@app/shared-types";
export const uuidSchema = z.uuid();
export const idParamSchema = z.object({ id: uuidSchema });
export const dateStringSchema = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Datum måste vara YYYY-MM-DD");
export const paginationQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(30),
offset: z.coerce.number().int().min(0).default(0),
});
export type PaginationQuery = z.infer<typeof paginationQuerySchema>;
export const unitSchema = z.enum(UNITS);
export const quantitySchema = z.number().positive().max(1_000_000);
export const confidenceSchema = z.number().min(0).max(1);
/** Standardiserat felsvar från API:t. */
export const apiErrorSchema = z.object({
error: z.object({
code: z.string(),
message: z.string(),
details: z.unknown().optional(),
correlationId: z.string().optional(),
}),
});
export type ApiError = z.infer<typeof apiErrorSchema>;
+45
View File
@@ -0,0 +1,45 @@
import { z } from "zod";
import { HOUSEHOLD_ROLES, STORAGE_LOCATION_TYPES } from "@app/shared-types";
import { uuidSchema } from "./common.js";
export const createHouseholdInputSchema = z.object({
name: z.string().min(1).max(80).trim(),
weeklyBudgetMinor: z.number().int().min(0).max(10_000_000).optional(),
/** ISO 4217 hushållets valuta (i18n-spec §20). Default SEK i databasen. */
currencyCode: z
.string()
.length(3)
.regex(/^[A-Za-z]{3}$/)
.transform((v) => v.toUpperCase())
.optional(),
});
export type CreateHouseholdInput = z.infer<typeof createHouseholdInputSchema>;
export const updateHouseholdInputSchema = createHouseholdInputSchema.partial();
export type UpdateHouseholdInput = z.infer<typeof updateHouseholdInputSchema>;
export const joinHouseholdInputSchema = z.object({
inviteCode: z.string().min(4).max(20).trim(),
});
export type JoinHouseholdInput = z.infer<typeof joinHouseholdInputSchema>;
export const updateMemberInputSchema = z.object({
role: z.enum(HOUSEHOLD_ROLES).optional(),
portionFactor: z.number().min(0.1).max(3).optional(),
});
export type UpdateMemberInput = z.infer<typeof updateMemberInputSchema>;
export const memberParamSchema = z.object({
id: uuidSchema,
userId: uuidSchema,
});
export const createStorageLocationInputSchema = z.object({
type: z.enum(STORAGE_LOCATION_TYPES),
name: z.string().min(1).max(60).trim(),
sublocations: z.array(z.string().min(1).max(60)).max(20).default([]),
});
export type CreateStorageLocationInput = z.infer<typeof createStorageLocationInputSchema>;
export const updateStorageLocationInputSchema = createStorageLocationInputSchema.partial();
export type UpdateStorageLocationInput = z.infer<typeof updateStorageLocationInputSchema>;
+14
View File
@@ -0,0 +1,14 @@
export * from "./common.js";
export * from "./auth.js";
export * from "./profile.js";
export * from "./household.js";
export * from "./inventory.js";
export * from "./scans.js";
export * from "./recipes.js";
export * from "./meals.js";
export * from "./shopping.js";
export * from "./planning.js";
export * from "./recommendations.js";
export * from "./memory.js";
export * from "./subscriptions.js";
export * from "./locale.js";
+45
View File
@@ -0,0 +1,45 @@
import { z } from "zod";
import { DATE_KINDS, INVENTORY_SOURCES, INVENTORY_TRANSACTION_TYPES } from "@app/shared-types";
import { dateStringSchema, quantitySchema, unitSchema, uuidSchema } from "./common.js";
export const createInventoryItemInputSchema = z.object({
canonicalIngredientId: z.string().max(80).optional(),
productId: uuidSchema.optional(),
displayName: z.string().min(1).max(120).trim(),
brand: z.string().max(80).optional(),
quantity: quantitySchema,
unit: unitSchema,
storageLocationId: uuidSchema,
sublocation: z.string().max(60).optional(),
purchasedAt: dateStringSchema.optional(),
openedAt: dateStringSchema.optional(),
bestBeforeDate: dateStringSchema.optional(),
useByDate: dateStringSchema.optional(),
dateKind: z.enum(DATE_KINDS).optional(),
frozenAt: dateStringSchema.optional(),
priceMinor: z.number().int().min(0).max(10_000_000).optional(),
source: z.enum(INVENTORY_SOURCES).default("manual_search"),
});
export type CreateInventoryItemInput = z.infer<typeof createInventoryItemInputSchema>;
export const updateInventoryItemInputSchema = createInventoryItemInputSchema.partial().extend({
verifiedByUser: z.boolean().optional(),
});
export type UpdateInventoryItemInput = z.infer<typeof updateInventoryItemInputSchema>;
/** Manuell lagertransaktion, t.ex. "använde 300 g" eller "slängde resten". */
export const inventoryTransactionInputSchema = z.object({
type: z.enum(INVENTORY_TRANSACTION_TYPES),
quantityDelta: z.number().refine((v) => v !== 0, "Delta får inte vara 0"),
note: z.string().max(200).optional(),
});
export type InventoryTransactionInput = z.infer<typeof inventoryTransactionInputSchema>;
export const inventoryQuerySchema = z.object({
storageLocationId: uuidSchema.optional(),
expiryStatus: z.enum(["fresh", "use_soon", "expiring", "expired", "unknown"]).optional(),
search: z.string().max(80).optional(),
limit: z.coerce.number().int().min(1).max(200).default(100),
offset: z.coerce.number().int().min(0).default(0),
});
export type InventoryQuery = z.infer<typeof inventoryQuerySchema>;
+26
View File
@@ -0,0 +1,26 @@
import { z } from "zod";
import { MEASUREMENT_SYSTEMS, TEMPERATURE_UNITS } from "@app/shared-types";
/** i18n-spec §6: alla fält oberoende och valfria vid uppdatering. */
export const updateLocalePreferencesInputSchema = z.object({
languageTag: z
.string()
.regex(/^[a-z]{2,3}(-[A-Za-z]{2,4})?(-[A-Za-z0-9]{2,8})?$/, "Ogiltig BCP 47-tagg")
.optional(),
regionCode: z
.string()
.regex(/^[A-Za-z]{2}$/, "Ogiltig ISO 3166-1 alpha-2-kod")
.transform((v) => v.toUpperCase())
.optional(),
timeZone: z.string().min(1).max(64).optional(),
measurementSystem: z.enum(MEASUREMENT_SYSTEMS).optional(),
temperatureUnit: z.enum(TEMPERATURE_UNITS).optional(),
currencyCode: z
.string()
.regex(/^[A-Za-z]{3}$/, "Ogiltig ISO 4217-kod")
.transform((v) => v.toUpperCase())
.optional(),
firstDayOfWeek: z.number().int().min(0).max(6).optional(),
use24HourTime: z.boolean().optional(),
});
export type UpdateLocalePreferencesInput = z.infer<typeof updateLocalePreferencesInputSchema>;
+70
View File
@@ -0,0 +1,70 @@
import { z } from "zod";
import { MEAL_LOG_SOURCES, MEAL_TYPES } from "@app/shared-types";
import { dateStringSchema, quantitySchema, unitSchema, uuidSchema } from "./common.js";
const nutritionValuesInputSchema = z.object({
kcal: z.number().min(0).max(20000),
proteinG: z.number().min(0).max(1000).default(0),
carbsG: z.number().min(0).max(2000).default(0),
fatG: z.number().min(0).max(1000).default(0),
saturatedFatG: z.number().min(0).max(500).default(0),
fiberG: z.number().min(0).max(300).default(0),
sugarG: z.number().min(0).max(1000).default(0),
saltG: z.number().min(0).max(100).default(0),
});
/**
* Måltidsloggning (spec §23). Näringsvärden får ALDRIG hittas på av AI:
* de kommer från recept (deterministiskt), produkt/streckkod, eller
* användarens egen inmatning. Tallriksfoto ger intervall som användaren bekräftar.
*/
export const logMealInputSchema = z.object({
date: dateStringSchema,
mealType: z.enum(MEAL_TYPES),
source: z.enum(MEAL_LOG_SOURCES),
titleSv: z.string().min(1).max(150),
recipeId: uuidSchema.optional(),
mealBoxId: uuidSchema.optional(),
scanJobId: uuidSchema.optional(),
portionFraction: z.number().min(0.1).max(5).default(1),
/** Vid produkt/fritext: ange mängd + per-100-värden eller direkta värden. */
items: z
.array(
z.object({
displayName: z.string().min(1).max(120),
canonicalIngredientId: z.string().max(80).optional(),
productId: uuidSchema.optional(),
quantity: quantitySchema.optional(),
unit: unitSchema.optional(),
nutrition: nutritionValuesInputSchema.optional(),
}),
)
.max(30)
.default([]),
/** Direkta värden när användaren själv anger, eller bekräftat foto-intervall. */
nutritionOverride: nutritionValuesInputSchema.optional(),
});
export type LogMealInput = z.infer<typeof logMealInputSchema>;
export const dayQuerySchema = z.object({
date: dateStringSchema,
});
export const createMealBoxInputSchema = z.object({
recipeId: uuidSchema.optional(),
titleSv: z.string().min(1).max(150),
portions: z.number().int().min(1).max(24),
storageLocationId: uuidSchema,
frozen: z.boolean().default(false),
cookedAt: dateStringSchema.optional(),
reservedForUserId: uuidSchema.optional(),
});
export type CreateMealBoxInput = z.infer<typeof createMealBoxInputSchema>;
export const consumeMealBoxInputSchema = z.object({
portions: z.number().int().min(1).max(24).default(1),
logAsMeal: z.boolean().default(true),
mealType: z.enum(MEAL_TYPES).default("lunch"),
date: dateStringSchema.optional(),
});
export type ConsumeMealBoxInput = z.infer<typeof consumeMealBoxInputSchema>;
+22
View File
@@ -0,0 +1,22 @@
import { z } from "zod";
/**
* "Vad plattformen vet om mig" (spec §32): användaren kan korrigera, pausa och radera.
*/
export const updateMemoryItemInputSchema = z.object({
summarySv: z.string().min(1).max(500).optional(),
value: z.unknown().optional(),
verified: z.boolean().optional(),
paused: z.boolean().optional(),
});
export type UpdateMemoryItemInput = z.infer<typeof updateMemoryItemInputSchema>;
export const memoryQuerySchema = z.object({
kind: z
.enum(["structured_fact", "event", "semantic", "profile_summary", "recipe_memory"])
.optional(),
includePaused: z.coerce.boolean().default(true),
limit: z.coerce.number().int().min(1).max(200).default(100),
offset: z.coerce.number().int().min(0).default(0),
});
export type MemoryQuery = z.infer<typeof memoryQuerySchema>;
+31
View File
@@ -0,0 +1,31 @@
import { z } from "zod";
import { MEAL_TYPES } from "@app/shared-types";
import { dateStringSchema, uuidSchema } from "./common.js";
/** Veckoplan (spec §25). */
export const generateWeekPlanInputSchema = z.object({
weekStartDate: dateStringSchema,
daysToPlann: z.number().int().min(1).max(14).optional(),
mealTypes: z.array(z.enum(MEAL_TYPES)).min(1).default(["dinner"]),
portionsPerMeal: z.number().int().min(1).max(20).optional(),
budgetMinorTotal: z.number().int().min(0).max(5_000_000).optional(),
/** T.ex. "Sju middagar för fyra personer under 1 000 kr" (spec §26). */
noteSv: z.string().max(300).optional(),
preferLeftoversFirst: z.boolean().default(true),
varietyLevel: z.enum(["low", "medium", "high"]).default("medium"),
});
export type GenerateWeekPlanInput = z.infer<typeof generateWeekPlanInputSchema>;
export const updatePlanEntryInputSchema = z.object({
date: dateStringSchema.optional(),
mealType: z.enum(MEAL_TYPES).optional(),
recipeId: uuidSchema.nullable().optional(),
mealBoxId: uuidSchema.nullable().optional(),
portions: z.number().int().min(1).max(24).optional(),
status: z.enum(["planned", "cooked", "skipped", "moved"]).optional(),
});
export type UpdatePlanEntryInput = z.infer<typeof updatePlanEntryInputSchema>;
export const weekPlanQuerySchema = z.object({
weekStartDate: dateStringSchema.optional(),
});
+72
View File
@@ -0,0 +1,72 @@
import { z } from "zod";
import {
ACTIVITY_LEVELS,
ALLERGENS,
CONSENT_KINDS,
CUISINES,
DIET_PATTERNS,
EQUIPMENT,
GOAL_TYPES,
PRECISION_MODES,
RELIGIOUS_RULES,
SEXES,
} from "@app/shared-types";
/** Hälsoprofil hanteras separat från hushållsdata (spec §7, §56). */
export const updateHealthProfileInputSchema = z.object({
birthYear: z.number().int().min(1900).max(2030).optional(),
sex: z.enum(SEXES).optional(),
heightCm: z.number().min(80).max(250).optional(),
weightKg: z.number().min(20).max(400).optional(),
targetWeightKg: z.number().min(20).max(400).optional(),
activityLevel: z.enum(ACTIVITY_LEVELS).optional(),
trainingSessionsPerWeek: z.number().int().min(0).max(21).optional(),
trainingTypes: z.array(z.string().max(50)).max(10).optional(),
});
export type UpdateHealthProfileInput = z.infer<typeof updateHealthProfileInputSchema>;
export const updatePreferencesInputSchema = z.object({
primaryGoal: z.enum(GOAL_TYPES).optional(),
goals: z.array(z.enum(GOAL_TYPES)).max(11).optional(),
dietPattern: z.enum(DIET_PATTERNS).optional(),
religiousRule: z.enum(RELIGIOUS_RULES).optional(),
allergens: z.array(z.enum(ALLERGENS)).optional(),
intolerances: z.array(z.string().max(60)).max(30).optional(),
avoidIngredientIds: z.array(z.string().max(80)).max(100).optional(),
favoriteCuisines: z.array(z.enum(CUISINES)).max(19).optional(),
dislikedDishes: z.array(z.string().max(80)).max(50).optional(),
spiceLevelMax: z.number().int().min(0).max(5).optional(),
weeklyBudgetMinor: z.number().int().min(0).max(10_000_000).nullable().optional(),
maxCookingMinutesWeekday: z.number().int().min(5).max(360).nullable().optional(),
equipment: z.array(z.enum(EQUIPMENT)).optional(),
defaultPortions: z.number().int().min(1).max(20).optional(),
});
export type UpdatePreferencesInput = z.infer<typeof updatePreferencesInputSchema>;
export const updateMeInputSchema = z.object({
displayName: z.string().min(1).max(80).trim().optional(),
locale: z.string().min(2).max(10).optional(),
precisionMode: z.enum(PRECISION_MODES).optional(),
});
export type UpdateMeInput = z.infer<typeof updateMeInputSchema>;
export const consentInputSchema = z.object({
kind: z.enum(CONSENT_KINDS),
granted: z.boolean(),
});
export type ConsentInput = z.infer<typeof consentInputSchema>;
/** Onboarding i ett svep (spec §6): allt är valfritt utom visningsnamnet som redan finns. */
export const onboardingInputSchema = z.object({
healthProfile: updateHealthProfileInputSchema.optional(),
preferences: updatePreferencesInputSchema.optional(),
precisionMode: z.enum(PRECISION_MODES).default("simple"),
householdChoice: z
.discriminatedUnion("kind", [
z.object({ kind: z.literal("create"), name: z.string().min(1).max(80) }),
z.object({ kind: z.literal("join"), inviteCode: z.string().min(4).max(20) }),
z.object({ kind: z.literal("skip") }),
])
.default({ kind: "skip" }),
});
export type OnboardingInput = z.infer<typeof onboardingInputSchema>;
+132
View File
@@ -0,0 +1,132 @@
import { z } from "zod";
import {
ALLERGENS,
COOKING_METHODS,
CUISINES,
EQUIPMENT,
FEEDBACK_TAGS,
MEAL_TYPES,
RECIPE_DIFFICULTIES,
RECIPE_TAGS,
} from "@app/shared-types";
import { quantitySchema, unitSchema, uuidSchema } from "./common.js";
export const recipeQuerySchema = z.object({
search: z.string().max(120).optional(),
cuisine: z.enum(CUISINES).optional(),
mealType: z.enum(MEAL_TYPES).optional(),
tags: z
.union([z.enum(RECIPE_TAGS), z.array(z.enum(RECIPE_TAGS))])
.transform((v) => (Array.isArray(v) ? v : [v]))
.optional(),
method: z.enum(COOKING_METHODS).optional(),
maxTotalMinutes: z.coerce.number().int().min(1).max(600).optional(),
maxKcalPerPortion: z.coerce.number().int().min(50).max(5000).optional(),
minProteinPerPortion: z.coerce.number().int().min(0).max(300).optional(),
maxCostMinorPerPortion: z.coerce.number().int().min(0).max(100_000).optional(),
difficulty: z.enum(RECIPE_DIFFICULTIES).optional(),
excludeAllergens: z
.union([z.enum(ALLERGENS), z.array(z.enum(ALLERGENS))])
.transform((v) => (Array.isArray(v) ? v : [v]))
.optional(),
creatorUserId: uuidSchema.optional(),
sort: z.enum(["relevance", "rating", "cooked", "newest", "time", "cost"]).default("relevance"),
limit: z.coerce.number().int().min(1).max(50).default(20),
offset: z.coerce.number().int().min(0).default(0),
});
export type RecipeQuery = z.infer<typeof recipeQuerySchema>;
const recipeIngredientInputSchema = z.object({
canonicalIngredientId: z.string().min(1).max(80),
displayNameSv: z.string().min(1).max(120),
quantity: quantitySchema,
unit: unitSchema,
note: z.string().max(120).optional(),
optional: z.boolean().default(false),
groupName: z.string().max(60).optional(),
});
const recipeStepInputSchema = z.object({
instructionSv: z.string().min(3).max(1000),
timerSeconds: z.number().int().min(5).max(86_400).optional(),
temperatureC: z.number().int().min(30).max(350).optional(),
tip: z.string().max(300).optional(),
});
/**
* Användarrecept (spec §35): antingen strukturerat direkt, eller fritext som
* AI strukturerar (via AAMOS) och användaren sedan granskar.
*/
export const createUserRecipeInputSchema = z.discriminatedUnion("mode", [
z.object({
mode: z.literal("structured"),
titleSv: z.string().min(3).max(150),
descriptionSv: z.string().max(2000).default(""),
cuisine: z.enum(CUISINES).default("international"),
mealTypes: z.array(z.enum(MEAL_TYPES)).min(1),
tags: z.array(z.enum(RECIPE_TAGS)).default([]),
methods: z.array(z.enum(COOKING_METHODS)).default([]),
equipment: z.array(z.enum(EQUIPMENT)).default([]),
difficulty: z.enum(RECIPE_DIFFICULTIES).default("easy"),
prepTimeMinutes: z.number().int().min(0).max(600),
cookTimeMinutes: z.number().int().min(0).max(1440),
portions: z.number().int().min(1).max(24),
spiceLevel: z.number().int().min(0).max(5).default(0),
ingredients: z.array(recipeIngredientInputSchema).min(1).max(60),
steps: z.array(recipeStepInputSchema).min(1).max(40),
}),
z.object({
mode: z.literal("free_text"),
text: z.string().min(20).max(8000),
}),
]);
export type CreateUserRecipeInput = z.infer<typeof createUserRecipeInputSchema>;
export const rateRecipeInputSchema = z.object({
stars: z.number().int().min(1).max(5),
feedbackTags: z.array(z.enum(FEEDBACK_TAGS)).max(8).default([]),
comment: z.string().max(1000).optional(),
});
export type RateRecipeInput = z.infer<typeof rateRecipeInputSchema>;
/** "Jag har lagat detta" kärnflödet som drar lager och loggar måltid (spec §23). */
export const cookRecipeInputSchema = z.object({
portionsCooked: z.number().int().min(1).max(24),
/** Vilka i hushållet som åt, med portionsandel per person. */
eaters: z
.array(
z.object({
userId: uuidSchema,
portionFraction: z.number().min(0.1).max(3).default(1),
}),
)
.default([]),
/** Portioner som blev matlådor (spec §24). */
mealBoxPortions: z.number().int().min(0).max(24).default(0),
mealBoxStorageLocationId: uuidSchema.optional(),
mealBoxFrozen: z.boolean().default(false),
/** Dra ingredienser från lagret? Förifyllt förslag visas i appen. */
deductInventory: z.boolean().default(true),
/** Justeringar av vad som faktiskt användes. */
inventoryOverrides: z
.array(
z.object({
canonicalIngredientId: z.string().max(80),
quantityUsed: z.number().min(0),
unit: unitSchema,
}),
)
.default([]),
date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
mealType: z.enum(MEAL_TYPES).default("dinner"),
});
export type CookRecipeInput = z.infer<typeof cookRecipeInputSchema>;
export const substitutionQuerySchema = z.object({
fromIngredientId: z.string().min(1).max(80),
context: z.string().max(60).optional(),
});
export type SubstitutionQuery = z.infer<typeof substitutionQuerySchema>;
@@ -0,0 +1,18 @@
import { z } from "zod";
import { MEAL_TYPES } from "@app/shared-types";
/**
* "Vad ska vi äta?" (spec §18) + "Jag är sugen på" (spec §19).
* Alla parametrar är valfria motorn använder hushållets kontext som standard.
*/
export const whatToEatQuerySchema = z.object({
mealType: z.enum(MEAL_TYPES).default("dinner"),
persons: z.coerce.number().int().min(1).max(20).optional(),
maxMinutes: z.coerce.number().int().min(5).max(600).optional(),
maxCostMinorPerPortion: z.coerce.number().int().min(0).max(100_000).optional(),
/** Fritext eller röst-transkription: "krämigt", "asiatiskt", "under 500 kcal" … */
craving: z.string().max(300).optional(),
includeLeftovers: z.coerce.boolean().default(true),
limit: z.coerce.number().int().min(1).max(20).default(5),
});
export type WhatToEatQuery = z.infer<typeof whatToEatQuerySchema>;
+70
View File
@@ -0,0 +1,70 @@
import { z } from "zod";
import { SCAN_TYPES } from "@app/shared-types";
import {
confidenceSchema,
dateStringSchema,
quantitySchema,
unitSchema,
uuidSchema,
} from "./common.js";
/** Steg 1: begär signerad uppladdning + skapa jobb (spec §50). */
export const createScanInputSchema = z.object({
scanType: z.enum(SCAN_TYPES),
imageCount: z.number().int().min(0).max(6).default(1),
contentType: z
.enum(["image/jpeg", "image/png", "image/webp", "image/heic"])
.default("image/jpeg"),
/** För streckkod behövs ingen bild koden skickas direkt. */
barcode: z
.string()
.regex(/^\d{8,14}$/)
.optional(),
/** Kontext som förbättrar analysen, t.ex. recept vid tallriksfoto (spec §22). */
context: z
.object({
recipeId: uuidSchema.optional(),
storageLocationId: uuidSchema.optional(),
note: z.string().max(300).optional(),
})
.optional(),
});
export type CreateScanInput = z.infer<typeof createScanInputSchema>;
/** Ett AI-identifierat objekt som användaren granskar (spec §10). */
export const scanResultItemSchema = z.object({
tempId: z.string(),
detectedName: z.string(),
canonicalIngredientId: z.string().nullable(),
brand: z.string().nullable().optional(),
estimatedQuantity: z.number().nullable(),
unit: unitSchema.nullable(),
bestBeforeDate: dateStringSchema.nullable().optional(),
confidence: confidenceSchema,
requiresConfirmation: z.boolean(),
});
export type ScanResultItem = z.infer<typeof scanResultItemSchema>;
/** Steg 3: användaren bekräftar/ändrar innan något skrivs till lagret (spec §10, §61.5). */
export const confirmScanInputSchema = z.object({
storageLocationId: uuidSchema.optional(),
items: z
.array(
z.object({
tempId: z.string().optional(),
action: z.enum(["accept", "edit", "reject", "add"]),
canonicalIngredientId: z.string().max(80).optional(),
displayName: z.string().min(1).max(120),
brand: z.string().max(80).optional(),
quantity: quantitySchema,
unit: unitSchema,
bestBeforeDate: dateStringSchema.optional(),
useByDate: dateStringSchema.optional(),
storageLocationId: uuidSchema.optional(),
sublocation: z.string().max(60).optional(),
priceMinor: z.number().int().min(0).optional(),
}),
)
.max(100),
});
export type ConfirmScanInput = z.infer<typeof confirmScanInputSchema>;
+47
View File
@@ -0,0 +1,47 @@
import { z } from "zod";
import { STORE_SECTIONS } from "@app/shared-types";
import { dateStringSchema, quantitySchema, unitSchema, uuidSchema } from "./common.js";
export const createShoppingListInputSchema = z.object({
name: z.string().min(1).max(80).default("Inköpslista"),
weekPlanId: uuidSchema.optional(),
/** Generera från veckoplan: dra av det som redan finns hemma (spec §27). */
generateFromPlan: z.boolean().default(false),
});
export type CreateShoppingListInput = z.infer<typeof createShoppingListInputSchema>;
export const addShoppingItemInputSchema = z.object({
displayName: z.string().min(1).max(120),
canonicalIngredientId: z.string().max(80).optional(),
quantity: quantitySchema.default(1),
unit: unitSchema.default("COUNT"),
storeSection: z.enum(STORE_SECTIONS).optional(),
estimatedPriceMinor: z.number().int().min(0).optional(),
});
export type AddShoppingItemInput = z.infer<typeof addShoppingItemInputSchema>;
export const updateShoppingItemInputSchema = z.object({
displayName: z.string().min(1).max(120).optional(),
quantity: quantitySchema.optional(),
unit: unitSchema.optional(),
storeSection: z.enum(STORE_SECTIONS).optional(),
checked: z.boolean().optional(),
});
export type UpdateShoppingItemInput = z.infer<typeof updateShoppingItemInputSchema>;
/** Avsluta köprundan: bockade varor läggs in i lagret (spec §27). */
export const completeShoppingInputSchema = z.object({
addToInventory: z.boolean().default(true),
storageDefaults: z
.array(
z.object({
shoppingListItemId: uuidSchema,
storageLocationId: uuidSchema,
priceMinor: z.number().int().min(0).optional(),
bestBeforeDate: dateStringSchema.optional(),
}),
)
.default([]),
defaultStorageLocationId: uuidSchema.optional(),
});
export type CompleteShoppingInput = z.infer<typeof completeShoppingInputSchema>;
+40
View File
@@ -0,0 +1,40 @@
import { z } from "zod";
import { SUBSCRIPTION_PLANS } from "@app/shared-types";
/**
* Klienten skickar kvitto/token efter köp; backend verifierar mot butiken
* och är source of truth (spec §47, §61.14).
*/
export const verifyPurchaseInputSchema = z.discriminatedUnion("provider", [
z.object({
provider: z.literal("apple"),
/** App Store Server API: signerad transaktion från StoreKit 2. */
signedTransaction: z.string().min(10),
}),
z.object({
provider: z.literal("google"),
packageName: z.string().min(3),
productId: z.string().min(1),
purchaseToken: z.string().min(10),
}),
]);
export type VerifyPurchaseInput = z.infer<typeof verifyPurchaseInputSchema>;
export const restorePurchasesInputSchema = z.object({
provider: z.enum(["apple", "google"]),
payload: z.string().min(1),
});
export type RestorePurchasesInput = z.infer<typeof restorePurchasesInputSchema>;
/** Store-notiser tas emot rå, signaturverifieras och läggs på kö (spec §47). */
export const storeNotificationSchema = z.object({
raw: z.unknown(),
});
export const adminGrantInputSchema = z.object({
userId: z.uuid(),
plan: z.enum(SUBSCRIPTION_PLANS),
days: z.number().int().min(1).max(3650),
reason: z.string().min(3).max(300),
});
export type AdminGrantInput = z.infer<typeof adminGrantInputSchema>;
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src", "test"],
"compilerOptions": {
"types": ["node"]
}
}