Initial commit (unpacked platform)
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./tasks.js";
|
||||
export * from "./client.js";
|
||||
export { mockOutputFor } from "./mock.js";
|
||||
@@ -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.4–5).
|
||||
*/
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 §13–14): 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>;
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src", "test"],
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./client.js";
|
||||
export * as schema from "./schema/index.js";
|
||||
export * from "./schema/index.js";
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)],
|
||||
);
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Marknadsprofiler (i18n-spec §18–19, 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] })],
|
||||
);
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
@@ -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)],
|
||||
);
|
||||
@@ -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] })],
|
||||
);
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
@@ -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)],
|
||||
);
|
||||
@@ -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 §37–39). */
|
||||
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)],
|
||||
);
|
||||
@@ -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)],
|
||||
);
|
||||
@@ -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 §45–46). */
|
||||
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 §11–14, M2–M3).
|
||||
*
|
||||
* 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 §13–14). */
|
||||
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] })],
|
||||
);
|
||||
@@ -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));
|
||||
@@ -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, 8–10 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 2–3 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, 6–8 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 3–4 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 4–5 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, 18–20 minuter.", timerSeconds: 1140 },
|
||||
{
|
||||
text: "Lägg laxen i en smord form, salta, peppra och toppa med citronskivor och halva dillen.",
|
||||
},
|
||||
{
|
||||
text: "Baka i ugnen 15–18 minuter tills laxen precis går att dela i mitten.",
|
||||
timerSeconds: 960,
|
||||
temperatureC: 200,
|
||||
tip: "Innertemperatur 52–55 °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, 3–4 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 3–4 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 1–2 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, 2–3 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 25–30 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 20–25 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, 15–18 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.",
|
||||
},
|
||||
];
|
||||
@@ -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";
|
||||
@@ -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.1–2) – 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);
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src", "drizzle.config.ts"],
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 → 0–99: 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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 §17–18: 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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src", "test"],
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
@@ -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 §32–33);
|
||||
* 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)}`;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 (0–1+), 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { ActivityLevel, DailyTargets, GoalType, Sex } from "@app/shared-types";
|
||||
|
||||
/**
|
||||
* Energiberäkning enligt Mifflin–St 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,
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./units.js";
|
||||
export * from "./calc.js";
|
||||
export * from "./energy.js";
|
||||
export * from "./day.js";
|
||||
export * from "./recipe.js";
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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("Mifflin–St 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src", "test"],
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Unit } from "@app/shared-types";
|
||||
import { toGrams, type IngredientUnitInfo } from "@app/nutrition-engine";
|
||||
|
||||
export interface CostableIngredient {
|
||||
canonicalIngredientId: string;
|
||||
quantity: number;
|
||||
unit: Unit;
|
||||
optional: boolean;
|
||||
}
|
||||
|
||||
export interface PriceInfo extends IngredientUnitInfo {
|
||||
/** SEK per kg – från kvittohistorik, användardata eller standardvärde (spec §26). */
|
||||
pricePerKgMinor: number;
|
||||
source: "receipt_history" | "user" | "default";
|
||||
}
|
||||
|
||||
export interface CostEstimate {
|
||||
totalMinor: number | null;
|
||||
perPortionMinor: number | null;
|
||||
/** Andel av ingredienserna (viktat) som hade prisdata. */
|
||||
priceCoverage: number;
|
||||
sourcesUsed: PriceInfo["source"][];
|
||||
}
|
||||
|
||||
/** Kostnadsuppskattning per portion – alltid märkt som uppskattning. */
|
||||
export function estimateCost(
|
||||
ingredients: CostableIngredient[],
|
||||
portions: number,
|
||||
prices: Map<string, PriceInfo>,
|
||||
): CostEstimate {
|
||||
let total = 0;
|
||||
let pricedCount = 0;
|
||||
let mandatoryCount = 0;
|
||||
const sources = new Set<PriceInfo["source"]>();
|
||||
|
||||
for (const ing of ingredients) {
|
||||
if (ing.optional) continue;
|
||||
mandatoryCount += 1;
|
||||
const price = prices.get(ing.canonicalIngredientId);
|
||||
if (!price) continue;
|
||||
const grams = toGrams(ing.quantity, ing.unit, price);
|
||||
if (grams == null) continue;
|
||||
total += (grams / 1000) * price.pricePerKgMinor;
|
||||
pricedCount += 1;
|
||||
sources.add(price.source);
|
||||
}
|
||||
|
||||
if (pricedCount === 0 || mandatoryCount === 0) {
|
||||
return { totalMinor: null, perPortionMinor: null, priceCoverage: 0, sourcesUsed: [] };
|
||||
}
|
||||
|
||||
const coverage = pricedCount / mandatoryCount;
|
||||
return {
|
||||
totalMinor: Math.round(total),
|
||||
perPortionMinor: portions > 0 ? Math.round(total / portions) : null,
|
||||
priceCoverage: Math.round(coverage * 100) / 100,
|
||||
sourcesUsed: [...sources],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./safety.js";
|
||||
export * from "./matching.js";
|
||||
export * from "./scaling.js";
|
||||
export * from "./substitution.js";
|
||||
export * from "./cost.js";
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { Unit } from "@app/shared-types";
|
||||
import { convert, type IngredientUnitInfo } from "@app/nutrition-engine";
|
||||
import { classifyExpiry, type ExpiryInput } from "@app/inventory-engine";
|
||||
|
||||
export interface PantryItem extends ExpiryInput {
|
||||
id: string;
|
||||
canonicalIngredientId?: string | null;
|
||||
quantity: number;
|
||||
unit: Unit;
|
||||
}
|
||||
|
||||
export interface RecipeIngredientRequirement {
|
||||
canonicalIngredientId: string;
|
||||
displayNameSv: string;
|
||||
quantity: number;
|
||||
unit: Unit;
|
||||
optional: boolean;
|
||||
}
|
||||
|
||||
export interface IngredientMatch {
|
||||
canonicalIngredientId: string;
|
||||
displayNameSv: string;
|
||||
required: number;
|
||||
unit: Unit;
|
||||
availableInUnit: number;
|
||||
covered: boolean;
|
||||
optional: boolean;
|
||||
/** Mest brådskande status bland matchande lagerposter. */
|
||||
mostUrgentDaysLeft: number | null;
|
||||
usesExpiringItem: boolean;
|
||||
}
|
||||
|
||||
export interface CoverageResult {
|
||||
/** Andel obligatoriska ingredienser som täcks helt (0–1). */
|
||||
coverage: number;
|
||||
matches: IngredientMatch[];
|
||||
missing: IngredientMatch[];
|
||||
/** Ingredienser som finns hemma och snart går ut – guld för rekommendationen. */
|
||||
expiringUsed: IngredientMatch[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Matcha receptets ingredienser mot hushållets lager (spec §17–18).
|
||||
* Enhetskonvertering via nutrition-engine; poster som inte kan konverteras
|
||||
* räknas som otäckta i stället för att gissas.
|
||||
*/
|
||||
export function computeCoverage(
|
||||
requirements: RecipeIngredientRequirement[],
|
||||
pantry: PantryItem[],
|
||||
unitInfo: Map<string, IngredientUnitInfo>,
|
||||
today: Date = new Date(),
|
||||
): CoverageResult {
|
||||
const byIngredient = new Map<string, PantryItem[]>();
|
||||
for (const item of pantry) {
|
||||
if (!item.canonicalIngredientId || item.quantity <= 0) continue;
|
||||
const list = byIngredient.get(item.canonicalIngredientId) ?? [];
|
||||
list.push(item);
|
||||
byIngredient.set(item.canonicalIngredientId, list);
|
||||
}
|
||||
|
||||
const matches: IngredientMatch[] = [];
|
||||
for (const req of requirements) {
|
||||
const stock = byIngredient.get(req.canonicalIngredientId) ?? [];
|
||||
const info = unitInfo.get(req.canonicalIngredientId) ?? {};
|
||||
let available = 0;
|
||||
let mostUrgent: number | null = null;
|
||||
let usesExpiring = false;
|
||||
|
||||
for (const item of stock) {
|
||||
const inReqUnit = convert(item.quantity, item.unit, req.unit, info);
|
||||
if (inReqUnit == null) continue;
|
||||
available += inReqUnit;
|
||||
const expiry = classifyExpiry(item, today);
|
||||
if (expiry.daysLeft != null) {
|
||||
mostUrgent = mostUrgent == null ? expiry.daysLeft : Math.min(mostUrgent, expiry.daysLeft);
|
||||
}
|
||||
if (expiry.status === "expiring" || expiry.status === "use_soon") usesExpiring = true;
|
||||
}
|
||||
|
||||
matches.push({
|
||||
canonicalIngredientId: req.canonicalIngredientId,
|
||||
displayNameSv: req.displayNameSv,
|
||||
required: req.quantity,
|
||||
unit: req.unit,
|
||||
availableInUnit: Math.round(available * 1000) / 1000,
|
||||
covered: available + 1e-9 >= req.quantity,
|
||||
optional: req.optional,
|
||||
mostUrgentDaysLeft: mostUrgent,
|
||||
usesExpiringItem: usesExpiring,
|
||||
});
|
||||
}
|
||||
|
||||
const mandatory = matches.filter((m) => !m.optional);
|
||||
const coveredCount = mandatory.filter((m) => m.covered).length;
|
||||
const coverage = mandatory.length === 0 ? 1 : coveredCount / mandatory.length;
|
||||
|
||||
return {
|
||||
coverage: Math.round(coverage * 100) / 100,
|
||||
matches,
|
||||
missing: matches.filter((m) => !m.covered),
|
||||
expiringUsed: matches.filter((m) => m.covered && m.usesExpiringItem),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import type { Allergen, DietPattern, ReligiousRule } from "@app/shared-types";
|
||||
|
||||
/**
|
||||
* DETERMINISTISK säkerhets- och kostfiltrering (spec §13, §61.2).
|
||||
* Ingen AI får någonsin avgöra allergisäkerhet. Denna modul är enda vägen.
|
||||
*
|
||||
* Försiktighetsprincip: om en ingrediens saknar data ("unknown") behandlas
|
||||
* receptet som EJ säkert för berörda filter och flaggas för granskning.
|
||||
*/
|
||||
|
||||
export interface IngredientSafetyInfo {
|
||||
id: string;
|
||||
allergens: Allergen[];
|
||||
mayContainAllergens?: Allergen[];
|
||||
isVegan: boolean;
|
||||
isVegetarian: boolean;
|
||||
containsGluten: boolean;
|
||||
containsLactose: boolean;
|
||||
isPork: boolean;
|
||||
isBeef: boolean;
|
||||
isAlcohol: boolean;
|
||||
/** true när datan är verifierad; ovverifierad data ger varning i stället för tyst OK. */
|
||||
dataVerified?: boolean;
|
||||
}
|
||||
|
||||
export interface DietaryConstraints {
|
||||
allergens: Allergen[];
|
||||
intolerances?: string[];
|
||||
dietPattern?: DietPattern;
|
||||
religiousRule?: ReligiousRule;
|
||||
avoidIngredientIds?: string[];
|
||||
spiceLevelMax?: number;
|
||||
/** Behandla "kan innehålla spår av" som blockerande (default true vid allergi). */
|
||||
blockMayContain?: boolean;
|
||||
}
|
||||
|
||||
export type ViolationSeverity = "blocker" | "warning";
|
||||
|
||||
export interface SafetyViolation {
|
||||
severity: ViolationSeverity;
|
||||
code:
|
||||
| "allergen"
|
||||
| "allergen_may_contain"
|
||||
| "diet_pattern"
|
||||
| "religious_rule"
|
||||
| "avoided_ingredient"
|
||||
| "spice_level"
|
||||
| "unverified_data";
|
||||
ingredientId?: string;
|
||||
allergen?: Allergen;
|
||||
messageSv: string;
|
||||
}
|
||||
|
||||
export interface RecipeSafetyInput {
|
||||
ingredients: Array<{ canonicalIngredientId: string; optional: boolean }>;
|
||||
spiceLevel: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returnerar ALLA överträdelser (tom lista = säkert enligt tillgänglig data).
|
||||
* Valfria ingredienser ger varning i stället för blocker (kan uteslutas).
|
||||
*/
|
||||
export function checkRecipeSafety(
|
||||
recipe: RecipeSafetyInput,
|
||||
constraints: DietaryConstraints,
|
||||
ingredientInfo: Map<string, IngredientSafetyInfo>,
|
||||
): SafetyViolation[] {
|
||||
const violations: SafetyViolation[] = [];
|
||||
const userAllergens = new Set(constraints.allergens);
|
||||
const avoid = new Set(constraints.avoidIngredientIds ?? []);
|
||||
const blockMayContain = constraints.blockMayContain ?? userAllergens.size > 0;
|
||||
|
||||
for (const ing of recipe.ingredients) {
|
||||
const info = ingredientInfo.get(ing.canonicalIngredientId);
|
||||
const severity: ViolationSeverity = ing.optional ? "warning" : "blocker";
|
||||
|
||||
if (!info) {
|
||||
violations.push({
|
||||
severity: "warning",
|
||||
code: "unverified_data",
|
||||
ingredientId: ing.canonicalIngredientId,
|
||||
messageSv: `Ingrediensen ${ing.canonicalIngredientId} saknar säkerhetsdata – kontrollera manuellt.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const allergen of info.allergens) {
|
||||
if (userAllergens.has(allergen)) {
|
||||
violations.push({
|
||||
severity,
|
||||
code: "allergen",
|
||||
ingredientId: info.id,
|
||||
allergen,
|
||||
messageSv: `Innehåller ${allergen} (${info.id}).`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (blockMayContain) {
|
||||
for (const allergen of info.mayContainAllergens ?? []) {
|
||||
if (userAllergens.has(allergen)) {
|
||||
violations.push({
|
||||
severity: "warning",
|
||||
code: "allergen_may_contain",
|
||||
ingredientId: info.id,
|
||||
allergen,
|
||||
messageSv: `Kan innehålla spår av ${allergen} (${info.id}).`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const diet = constraints.dietPattern;
|
||||
if (diet === "vegan" && !info.isVegan) {
|
||||
violations.push({
|
||||
severity,
|
||||
code: "diet_pattern",
|
||||
ingredientId: info.id,
|
||||
messageSv: `${info.id} är inte veganskt.`,
|
||||
});
|
||||
} else if ((diet === "vegetarian" || diet === "pescatarian") && !info.isVegetarian) {
|
||||
const isFish = info.allergens.includes("fish") || info.allergens.includes("crustaceans");
|
||||
const allowed = diet === "pescatarian" && isFish;
|
||||
if (!allowed) {
|
||||
violations.push({
|
||||
severity,
|
||||
code: "diet_pattern",
|
||||
ingredientId: info.id,
|
||||
messageSv: `${info.id} är inte ${diet === "vegetarian" ? "vegetariskt" : "pescetarianskt"}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const rule = constraints.religiousRule;
|
||||
if (rule === "halal" && (info.isPork || info.isAlcohol)) {
|
||||
violations.push({
|
||||
severity,
|
||||
code: "religious_rule",
|
||||
ingredientId: info.id,
|
||||
messageSv: `${info.id} är inte förenligt med halal (${info.isPork ? "fläsk" : "alkohol"}).`,
|
||||
});
|
||||
}
|
||||
if (
|
||||
rule === "kosher" &&
|
||||
(info.isPork || info.allergens.includes("crustaceans") || info.allergens.includes("molluscs"))
|
||||
) {
|
||||
violations.push({
|
||||
severity,
|
||||
code: "religious_rule",
|
||||
ingredientId: info.id,
|
||||
messageSv: `${info.id} är inte förenligt med kosher.`,
|
||||
});
|
||||
}
|
||||
if (rule === "hindu_no_beef" && info.isBeef) {
|
||||
violations.push({
|
||||
severity,
|
||||
code: "religious_rule",
|
||||
ingredientId: info.id,
|
||||
messageSv: `${info.id} innehåller nötkött.`,
|
||||
});
|
||||
}
|
||||
if (rule === "buddhist_vegetarian" && !info.isVegetarian) {
|
||||
violations.push({
|
||||
severity,
|
||||
code: "religious_rule",
|
||||
ingredientId: info.id,
|
||||
messageSv: `${info.id} är inte vegetariskt.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (avoid.has(info.id)) {
|
||||
violations.push({
|
||||
severity,
|
||||
code: "avoided_ingredient",
|
||||
ingredientId: info.id,
|
||||
messageSv: `${info.id} finns på din undviklista.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (constraints.spiceLevelMax != null && recipe.spiceLevel > constraints.spiceLevelMax) {
|
||||
violations.push({
|
||||
severity: "warning",
|
||||
code: "spice_level",
|
||||
messageSv: `Styrka ${recipe.spiceLevel} överstiger din maxnivå ${constraints.spiceLevelMax}.`,
|
||||
});
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
/** true om receptet är helt fritt från blockers. */
|
||||
export function isRecipeSafe(violations: SafetyViolation[]): boolean {
|
||||
return !violations.some((v) => v.severity === "blocker");
|
||||
}
|
||||
|
||||
/** Härled ett recepts allergener deterministiskt ur ingredienserna (spec §61.2). */
|
||||
export function deriveRecipeAllergens(
|
||||
ingredientIds: string[],
|
||||
ingredientInfo: Map<string, IngredientSafetyInfo>,
|
||||
): Allergen[] {
|
||||
const set = new Set<Allergen>();
|
||||
for (const id of ingredientIds) {
|
||||
const info = ingredientInfo.get(id);
|
||||
for (const a of info?.allergens ?? []) set.add(a);
|
||||
}
|
||||
return [...set].sort();
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { UNIT_INFO, type Unit } from "@app/shared-types";
|
||||
|
||||
export interface ScalableIngredient {
|
||||
canonicalIngredientId: string;
|
||||
displayNameSv: string;
|
||||
quantity: number;
|
||||
unit: Unit;
|
||||
optional: boolean;
|
||||
}
|
||||
|
||||
/** Enheter som avrundas till "köksvänliga" mängder vid skalning. */
|
||||
const SPOON_UNITS: ReadonlySet<Unit> = new Set(["TABLESPOON", "TEASPOON", "PINCH"]);
|
||||
|
||||
/**
|
||||
* Skala recept till annat antal portioner (Cooking Mode: "skala till sex personer").
|
||||
* Kryddmått avrundas till halva mått; styck till kvartar.
|
||||
*/
|
||||
export function scaleIngredients(
|
||||
ingredients: ScalableIngredient[],
|
||||
fromPortions: number,
|
||||
toPortions: number,
|
||||
): ScalableIngredient[] {
|
||||
if (fromPortions <= 0 || toPortions <= 0) {
|
||||
throw new Error("Portioner måste vara > 0");
|
||||
}
|
||||
const factor = toPortions / fromPortions;
|
||||
return ingredients.map((ing) => ({
|
||||
...ing,
|
||||
quantity: roundForUnit(ing.quantity * factor, ing.unit),
|
||||
}));
|
||||
}
|
||||
|
||||
function roundForUnit(value: number, unit: Unit): number {
|
||||
if (SPOON_UNITS.has(unit)) return Math.round(value * 2) / 2;
|
||||
if (UNIT_INFO[unit].kind === "count") return Math.round(value * 4) / 4;
|
||||
if (unit === "KILOGRAM" || unit === "LITER" || unit === "POUND")
|
||||
return Math.round(value * 100) / 100;
|
||||
if (unit === "CUP_US") return Math.round(value * 4) / 4;
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Substitution, Unit } from "@app/shared-types";
|
||||
|
||||
export interface SubstitutableIngredient {
|
||||
canonicalIngredientId: string;
|
||||
displayNameSv: string;
|
||||
quantity: number;
|
||||
unit: Unit;
|
||||
optional: boolean;
|
||||
}
|
||||
|
||||
export interface SubstitutionResult {
|
||||
ingredients: SubstitutableIngredient[];
|
||||
applied: {
|
||||
fromId: string;
|
||||
toId: string;
|
||||
ratio: number;
|
||||
instructionsSv?: string | undefined;
|
||||
warningSv?: string | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitutionsmotor (spec §20): byt ingrediens med mängdfaktor och
|
||||
* instruktionspåverkan. Näringen räknas ALLTID om av nutrition-engine efteråt –
|
||||
* denna modul ändrar bara ingredienslistan.
|
||||
*/
|
||||
export function applySubstitution(
|
||||
ingredients: SubstitutableIngredient[],
|
||||
substitution: Pick<
|
||||
Substitution,
|
||||
"fromIngredientId" | "toIngredientId" | "ratio" | "instructionsSv" | "notRecommendedFor"
|
||||
>,
|
||||
toDisplayNameSv: string,
|
||||
context?: string,
|
||||
): SubstitutionResult {
|
||||
const target = ingredients.find((i) => i.canonicalIngredientId === substitution.fromIngredientId);
|
||||
if (!target) {
|
||||
throw new Error(
|
||||
`Ingrediensen ${substitution.fromIngredientId} finns inte i receptet och kan inte bytas.`,
|
||||
);
|
||||
}
|
||||
|
||||
let warningSv: string | undefined;
|
||||
if (context && substitution.notRecommendedFor.includes(context)) {
|
||||
warningSv = `Observera: detta byte rekommenderas inte för ${context}.`;
|
||||
}
|
||||
|
||||
const next = ingredients.map((ing) =>
|
||||
ing.canonicalIngredientId === substitution.fromIngredientId
|
||||
? {
|
||||
...ing,
|
||||
canonicalIngredientId: substitution.toIngredientId,
|
||||
displayNameSv: toDisplayNameSv,
|
||||
quantity: Math.round(ing.quantity * substitution.ratio * 100) / 100,
|
||||
}
|
||||
: ing,
|
||||
);
|
||||
|
||||
return {
|
||||
ingredients: next,
|
||||
applied: {
|
||||
fromId: substitution.fromIngredientId,
|
||||
toId: substitution.toIngredientId,
|
||||
ratio: substitution.ratio,
|
||||
instructionsSv: substitution.instructionsSv ?? undefined,
|
||||
warningSv,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 (0–100-skala per delkomponent, viktad summa).
|
||||
* Helt deterministisk: samma indata → samma poäng → förklarbar rekommendation.
|
||||
*/
|
||||
export function scoreCandidate(
|
||||
candidate: RecommendationCandidate,
|
||||
ctx: RecommendationContext,
|
||||
weights: ScoringWeights = DEFAULT_WEIGHTS,
|
||||
): ScoredRecommendation {
|
||||
const parts: Record<string, number> = {};
|
||||
|
||||
// 1. Ingredienstäckning – kärnan i "utgå från vad som finns hemma".
|
||||
parts.coverage = candidate.coverage.coverage;
|
||||
|
||||
// 2. Utgångsdatum: störst poäng när receptet räddar varor som snart går ut.
|
||||
const expiring = candidate.coverage.expiringUsed;
|
||||
if (expiring.length === 0) {
|
||||
parts.expiry = 0;
|
||||
} else {
|
||||
const mostUrgent = Math.min(
|
||||
...expiring.map((m) => (m.mostUrgentDaysLeft == null ? 99 : m.mostUrgentDaysLeft)),
|
||||
);
|
||||
parts.expiry = mostUrgent <= 1 ? 1 : mostUrgent <= 3 ? 0.8 : 0.5;
|
||||
}
|
||||
|
||||
// 3. Näringsfit mot återstående dagsmål.
|
||||
parts.nutritionFit = nutritionFit(candidate, ctx);
|
||||
|
||||
// 4. Smak: favoritkök + styrka nära preferens.
|
||||
parts.taste = ctx.favoriteCuisines.includes(candidate.cuisine) ? 1 : 0.4;
|
||||
|
||||
// 5. Betyg: hushållets egna betyg väger tyngst, annars community (kräver volym).
|
||||
if (candidate.householdRating != null) {
|
||||
parts.rating = clamp01((candidate.householdRating - 2.5) / 2.5);
|
||||
} else if (candidate.ratingAverage != null && candidate.ratingCount >= 5) {
|
||||
parts.rating = clamp01((candidate.ratingAverage - 3) / 2);
|
||||
} else {
|
||||
parts.rating = 0.5;
|
||||
}
|
||||
|
||||
// 6–7. Säsong & högtid.
|
||||
parts.season = candidate.peakSeasons.includes(ctx.currentSeason) ? 1 : 0.3;
|
||||
parts.holiday =
|
||||
ctx.activeHolidayTags.length > 0 &&
|
||||
candidate.holidayTags.some((t) => ctx.activeHolidayTags.includes(t))
|
||||
? 1
|
||||
: 0;
|
||||
|
||||
// 8. Tid: hård maxgräns om satt, annars vardagsbonus för snabbt.
|
||||
if (ctx.maxMinutes != null && candidate.totalTimeMinutes > ctx.maxMinutes) {
|
||||
parts.time = -1; // diskvalificerande straff hanteras i rankAll
|
||||
} else if (ctx.isWeekday) {
|
||||
parts.time =
|
||||
candidate.totalTimeMinutes <= 25 ? 1 : candidate.totalTimeMinutes <= 45 ? 0.6 : 0.2;
|
||||
} else {
|
||||
parts.time = candidate.totalTimeMinutes <= 90 ? 0.7 : 0.5;
|
||||
}
|
||||
|
||||
// 9. Budget.
|
||||
const cost = candidate.estimatedCostMinorPerPortion;
|
||||
if (ctx.maxCostMinorPerPortion != null && cost != null) {
|
||||
parts.budget = cost <= ctx.maxCostMinorPerPortion ? 1 : -0.5;
|
||||
} else if (cost != null) {
|
||||
parts.budget = cost <= 25 ? 1 : cost <= 45 ? 0.6 : 0.3;
|
||||
} else {
|
||||
parts.budget = 0.4;
|
||||
}
|
||||
|
||||
// 10. Variation: nyligen lagat straffas (spec §25: variation).
|
||||
const days = candidate.daysSinceLastCooked;
|
||||
parts.variety = days == null ? 0.8 : days < 7 ? 0 : days < 14 ? 0.4 : 1;
|
||||
|
||||
// 11. Väder (spec §29): varmt → grill/sallad, kallt → gryta/soppa.
|
||||
parts.weather = weatherFit(candidate, ctx);
|
||||
|
||||
// 12. "Jag är sugen på" (spec §19).
|
||||
parts.craving = cravingFit(candidate, ctx);
|
||||
|
||||
const score = weightedSum(parts, weights);
|
||||
|
||||
return {
|
||||
recipeId: candidate.recipeId,
|
||||
titleSv: candidate.titleSv,
|
||||
score: Math.round(score * 10) / 10,
|
||||
parts,
|
||||
whySv: buildWhySv(candidate, ctx, parts),
|
||||
missingIngredients: candidate.coverage.missing
|
||||
.filter((m) => !m.optional)
|
||||
.map((m) => m.displayNameSv),
|
||||
usesExpiring: expiring.map((m) => ({
|
||||
nameSv: m.displayNameSv,
|
||||
daysLeft: m.mostUrgentDaysLeft,
|
||||
})),
|
||||
coveragePercent: Math.round(candidate.coverage.coverage * 100),
|
||||
};
|
||||
}
|
||||
|
||||
/** Ranka alla kandidater; kandidater över tidsgränsen filtreras bort. */
|
||||
export function rankAll(
|
||||
candidates: RecommendationCandidate[],
|
||||
ctx: RecommendationContext,
|
||||
weights: ScoringWeights = DEFAULT_WEIGHTS,
|
||||
limit = 5,
|
||||
): ScoredRecommendation[] {
|
||||
return candidates
|
||||
.map((c) => scoreCandidate(c, ctx, weights))
|
||||
.filter((s) => (s.parts.time ?? 0) >= 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
function nutritionFit(candidate: RecommendationCandidate, ctx: RecommendationContext): number {
|
||||
let fit = 0.5;
|
||||
const protein = candidate.nutritionPerPortion.proteinG;
|
||||
if (ctx.remainingProteinG != null && ctx.remainingProteinG > 0) {
|
||||
// Ju närmare receptet fyller proteinluckan, desto bättre (upp till 1).
|
||||
fit = clamp01(protein / Math.max(20, ctx.remainingProteinG * 0.6));
|
||||
}
|
||||
if (ctx.isTrainingDay && protein >= 35) fit = Math.min(1, fit + 0.3);
|
||||
if (ctx.remainingKcal != null && ctx.remainingKcal > 0) {
|
||||
const kcal = candidate.nutritionPerPortion.kcal;
|
||||
if (kcal > ctx.remainingKcal * 1.3) fit *= 0.5;
|
||||
}
|
||||
return clamp01(fit);
|
||||
}
|
||||
|
||||
function weatherFit(candidate: RecommendationCandidate, ctx: RecommendationContext): number {
|
||||
if (!ctx.weather || ctx.weather === "unknown") return 0.5;
|
||||
const title = candidate.titleSv.toLowerCase();
|
||||
const has = (words: string[]) =>
|
||||
words.some((w) => title.includes(w)) ||
|
||||
candidate.tags.some((t) => words.includes(t)) ||
|
||||
words.includes(candidate.cuisine);
|
||||
switch (ctx.weather) {
|
||||
case "hot":
|
||||
case "warm":
|
||||
return has(["grill", "sallad", "kall", "bowl", "wrap"]) ? 1 : 0.4;
|
||||
case "cold":
|
||||
case "snow":
|
||||
return has(["soppa", "gryta", "långkok", "ugns", "pytt"]) ? 1 : 0.4;
|
||||
case "rain":
|
||||
return has(["gryta", "långkok", "soppa", "paj"]) ? 0.9 : 0.5;
|
||||
default:
|
||||
return 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
function cravingFit(candidate: RecommendationCandidate, ctx: RecommendationContext): number {
|
||||
const tags = ctx.cravingTags ?? [];
|
||||
if (tags.length === 0 && !ctx.cravingCuisine && ctx.cravingMaxKcal == null) return 0.5;
|
||||
let hits = 0;
|
||||
let checks = 0;
|
||||
|
||||
if (ctx.cravingCuisine) {
|
||||
checks += 1;
|
||||
if (candidate.cuisine === ctx.cravingCuisine) hits += 1;
|
||||
}
|
||||
if (ctx.cravingMaxKcal != null) {
|
||||
checks += 1;
|
||||
if (candidate.nutritionPerPortion.kcal <= ctx.cravingMaxKcal) hits += 1;
|
||||
}
|
||||
if (tags.length > 0) {
|
||||
checks += 1;
|
||||
const title = candidate.titleSv.toLowerCase();
|
||||
const candidateTags = new Set<string>(candidate.tags);
|
||||
const matched = tags.some(
|
||||
(t) =>
|
||||
candidateTags.has(t) ||
|
||||
title.includes(t) ||
|
||||
(t === "spicy" && candidate.spiceLevel >= 3) ||
|
||||
(t === "quick" && candidate.totalTimeMinutes <= 25) ||
|
||||
(t === "high_protein" && candidate.nutritionPerPortion.proteinG >= 35),
|
||||
);
|
||||
if (matched) hits += 1;
|
||||
}
|
||||
|
||||
return checks === 0 ? 0.5 : hits / checks;
|
||||
}
|
||||
|
||||
function weightedSum(parts: Record<string, number>, weights: ScoringWeights): number {
|
||||
let total = 0;
|
||||
for (const [key, value] of Object.entries(parts)) {
|
||||
const weight = (weights as unknown as Record<string, number>)[key] ?? 0;
|
||||
total += clampPart(value) * weight;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function clamp01(v: number): number {
|
||||
return Math.max(0, Math.min(1, v));
|
||||
}
|
||||
function clampPart(v: number): number {
|
||||
return Math.max(-1, Math.min(1, v));
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { Season } from "@app/shared-types";
|
||||
|
||||
/**
|
||||
* All kalenderaritmetik i denna modul sker i UTC (Date.UTC/getUTC*).
|
||||
* Motorn ska ge identiskt resultat oavsett serverns tidszon – och UTC har
|
||||
* ingen sommartid, så dygnsmatte med millisekunder är alltid exakt.
|
||||
*/
|
||||
|
||||
/** Meteorologisk årstid för norra halvklotet (SE-marknad). */
|
||||
export function seasonForDate(date: Date): Season {
|
||||
const m = date.getUTCMonth() + 1;
|
||||
if (m >= 3 && m <= 5) return "spring";
|
||||
if (m >= 6 && m <= 8) return "summer";
|
||||
if (m >= 9 && m <= 11) return "autumn";
|
||||
return "winter";
|
||||
}
|
||||
|
||||
/** Midsommarafton: fredagen mellan 19 och 25 juni. */
|
||||
export function midsummerEve(year: number): Date {
|
||||
for (let day = 19; day <= 25; day++) {
|
||||
const d = new Date(Date.UTC(year, 5, day));
|
||||
if (d.getUTCDay() === 5) return d;
|
||||
}
|
||||
return new Date(Date.UTC(year, 5, 24));
|
||||
}
|
||||
|
||||
/** Påskdagen enligt anonym gregoriansk algoritm. */
|
||||
export function easterSunday(year: number): Date {
|
||||
const a = year % 19;
|
||||
const b = Math.floor(year / 100);
|
||||
const c = year % 100;
|
||||
const d = Math.floor(b / 4);
|
||||
const e = b % 4;
|
||||
const f = Math.floor((b + 8) / 25);
|
||||
const g = Math.floor((b - f + 1) / 3);
|
||||
const h = (19 * a + b - d - g + 15) % 30;
|
||||
const i = Math.floor(c / 4);
|
||||
const k = c % 4;
|
||||
const l = (32 + 2 * e + 2 * i - h - k) % 7;
|
||||
const m = Math.floor((a + 11 * h + 22 * l) / 451);
|
||||
const month = Math.floor((h + l - 7 * m + 114) / 31);
|
||||
const day = ((h + l - 7 * m + 114) % 31) + 1;
|
||||
return new Date(Date.UTC(year, month - 1, day));
|
||||
}
|
||||
|
||||
/** Första advent: fjärde söndagen före juldagen. */
|
||||
export function firstAdvent(year: number): Date {
|
||||
const christmas = new Date(Date.UTC(year, 11, 25));
|
||||
const dayOfWeek = christmas.getUTCDay();
|
||||
return new Date(Date.UTC(year, 11, 25 - (dayOfWeek === 0 ? 7 : dayOfWeek) - 21));
|
||||
}
|
||||
|
||||
export interface SeasonEventRuleInput {
|
||||
dateRule:
|
||||
| { kind: "fixed"; monthDay: string }
|
||||
| { kind: "range"; startMonthDay: string; endMonthDay: string }
|
||||
| { kind: "computed"; algorithm: "midsummer" | "easter" | "advent" | "custom" };
|
||||
leadDays: number;
|
||||
}
|
||||
|
||||
/** Är eventet aktivt (inom leadDays före, till och med eventdagen/perioden)? */
|
||||
export function isEventActive(rule: SeasonEventRuleInput, today: Date): boolean {
|
||||
const year = today.getUTCFullYear();
|
||||
const t = strip(today).getTime();
|
||||
const DAY = 86_400_000;
|
||||
|
||||
const activeAround = (target: Date, tailDays = 1): boolean => {
|
||||
const start = strip(target).getTime() - rule.leadDays * DAY;
|
||||
const end = strip(target).getTime() + tailDays * DAY;
|
||||
return t >= start && t <= end;
|
||||
};
|
||||
|
||||
switch (rule.dateRule.kind) {
|
||||
case "fixed": {
|
||||
const [mm, dd] = rule.dateRule.monthDay.split("-").map(Number);
|
||||
return activeAround(new Date(Date.UTC(year, (mm ?? 1) - 1, dd ?? 1)));
|
||||
}
|
||||
case "range": {
|
||||
const [sm, sd] = rule.dateRule.startMonthDay.split("-").map(Number);
|
||||
const [em, ed] = rule.dateRule.endMonthDay.split("-").map(Number);
|
||||
const start = Date.UTC(year, (sm ?? 1) - 1, sd ?? 1) - rule.leadDays * DAY;
|
||||
let end = Date.UTC(year, (em ?? 1) - 1, ed ?? 1);
|
||||
if (end < start) end = Date.UTC(year + 1, (em ?? 1) - 1, ed ?? 1);
|
||||
return t >= start && t <= end;
|
||||
}
|
||||
case "computed": {
|
||||
switch (rule.dateRule.algorithm) {
|
||||
case "midsummer":
|
||||
return activeAround(midsummerEve(year), 2);
|
||||
case "easter":
|
||||
return activeAround(easterSunday(year), 1);
|
||||
case "advent":
|
||||
return activeAround(firstAdvent(year), 28);
|
||||
case "custom":
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** UTC-dygnets start – oberoende av serverns tidszon. */
|
||||
function strip(d: Date): Date {
|
||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type {
|
||||
Cuisine,
|
||||
MealType,
|
||||
NutritionValues,
|
||||
RecipeTag,
|
||||
Season,
|
||||
WeatherHint,
|
||||
} from "@app/shared-types";
|
||||
import type { CoverageResult } from "@app/recipe-engine";
|
||||
|
||||
/** Kandidat som poängsätts. Säkerhetsfiltrering har redan skett (blockers borta). */
|
||||
export interface RecommendationCandidate {
|
||||
recipeId: string;
|
||||
titleSv: string;
|
||||
cuisine: Cuisine;
|
||||
tags: RecipeTag[];
|
||||
totalTimeMinutes: number;
|
||||
nutritionPerPortion: NutritionValues;
|
||||
estimatedCostMinorPerPortion?: number | null;
|
||||
ratingAverage?: number | null;
|
||||
ratingCount: number;
|
||||
peakSeasons: Season[];
|
||||
holidayTags: string[];
|
||||
spiceLevel: number;
|
||||
coverage: CoverageResult;
|
||||
/** Dagar sedan hushållet senast lagade receptet (null = aldrig). */
|
||||
daysSinceLastCooked?: number | null;
|
||||
/** Hushållets snittbetyg på receptet, om finns. */
|
||||
householdRating?: number | null;
|
||||
}
|
||||
|
||||
export interface RecommendationContext {
|
||||
mealType: MealType;
|
||||
persons: number;
|
||||
maxMinutes?: number | undefined;
|
||||
maxCostMinorPerPortion?: number | undefined;
|
||||
/** Proteinlucka kvar i dag (g) för den som frågar – styr proteinfit. */
|
||||
remainingProteinG?: number | undefined;
|
||||
/** Kalorier kvar i dag – recept långt över straffas mjukt. */
|
||||
remainingKcal?: number | undefined;
|
||||
currentSeason: Season;
|
||||
activeHolidayTags: string[];
|
||||
weather?: WeatherHint | undefined;
|
||||
isWeekday: boolean;
|
||||
isTrainingDay?: boolean | undefined;
|
||||
favoriteCuisines: Cuisine[];
|
||||
/** Tolkade "jag är sugen på"-taggar (spec §19), redan normaliserade. */
|
||||
cravingTags?: string[] | undefined;
|
||||
cravingCuisine?: Cuisine | undefined;
|
||||
cravingMaxKcal?: number | undefined;
|
||||
}
|
||||
|
||||
export interface ScoredRecommendation {
|
||||
recipeId: string;
|
||||
titleSv: string;
|
||||
score: number;
|
||||
/** Delpoäng för transparens och evals. */
|
||||
parts: Record<string, number>;
|
||||
whySv: string;
|
||||
missingIngredients: string[];
|
||||
usesExpiring: Array<{ nameSv: string; daysLeft: number | null }>;
|
||||
coveragePercent: number;
|
||||
}
|
||||
|
||||
/** Vikter – justerbara via feature flags/admin utan koddeploy. */
|
||||
export interface ScoringWeights {
|
||||
coverage: number;
|
||||
expiry: number;
|
||||
nutritionFit: number;
|
||||
taste: number;
|
||||
rating: number;
|
||||
season: number;
|
||||
holiday: number;
|
||||
time: number;
|
||||
budget: number;
|
||||
variety: number;
|
||||
weather: number;
|
||||
craving: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_WEIGHTS: ScoringWeights = {
|
||||
coverage: 30,
|
||||
expiry: 20,
|
||||
nutritionFit: 12,
|
||||
taste: 10,
|
||||
rating: 8,
|
||||
season: 4,
|
||||
holiday: 6,
|
||||
time: 8,
|
||||
budget: 6,
|
||||
variety: 6,
|
||||
weather: 3,
|
||||
craving: 15,
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CoverageResult } from "@app/recipe-engine";
|
||||
import {
|
||||
easterSunday,
|
||||
isEventActive,
|
||||
midsummerEve,
|
||||
parseCraving,
|
||||
rankAll,
|
||||
scoreCandidate,
|
||||
seasonForDate,
|
||||
type RecommendationCandidate,
|
||||
type RecommendationContext,
|
||||
} from "../src/index.js";
|
||||
|
||||
const fullCoverage: CoverageResult = { coverage: 1, matches: [], missing: [], expiringUsed: [] };
|
||||
const nutrition = {
|
||||
kcal: 550,
|
||||
proteinG: 45,
|
||||
carbsG: 50,
|
||||
fatG: 18,
|
||||
saturatedFatG: 6,
|
||||
fiberG: 6,
|
||||
sugarG: 4,
|
||||
saltG: 1.5,
|
||||
};
|
||||
|
||||
function candidate(overrides: Partial<RecommendationCandidate> = {}): RecommendationCandidate {
|
||||
return {
|
||||
recipeId: "r1",
|
||||
titleSv: "Kycklinggryta",
|
||||
cuisine: "swedish",
|
||||
tags: [],
|
||||
totalTimeMinutes: 30,
|
||||
nutritionPerPortion: nutrition,
|
||||
estimatedCostMinorPerPortion: 2200,
|
||||
ratingAverage: null,
|
||||
ratingCount: 0,
|
||||
peakSeasons: [],
|
||||
holidayTags: [],
|
||||
spiceLevel: 1,
|
||||
coverage: fullCoverage,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const ctx: RecommendationContext = {
|
||||
mealType: "dinner",
|
||||
persons: 4,
|
||||
currentSeason: "summer",
|
||||
activeHolidayTags: [],
|
||||
isWeekday: true,
|
||||
favoriteCuisines: ["swedish"],
|
||||
remainingProteinG: 60,
|
||||
remainingKcal: 800,
|
||||
};
|
||||
|
||||
describe("poängsättning (spec §18)", () => {
|
||||
it("utgångsvaror lyfter rekommendationen", () => {
|
||||
const expiring = candidate({
|
||||
recipeId: "expiring",
|
||||
coverage: {
|
||||
coverage: 1,
|
||||
matches: [],
|
||||
missing: [],
|
||||
expiringUsed: [
|
||||
{
|
||||
canonicalIngredientId: "chicken",
|
||||
displayNameSv: "kycklingen",
|
||||
required: 500,
|
||||
unit: "GRAM",
|
||||
availableInUnit: 600,
|
||||
covered: true,
|
||||
optional: false,
|
||||
mostUrgentDaysLeft: 1,
|
||||
usesExpiringItem: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const fresh = candidate({ recipeId: "fresh" });
|
||||
const ranked = rankAll([fresh, expiring], ctx);
|
||||
expect(ranked[0]?.recipeId).toBe("expiring");
|
||||
expect(ranked[0]?.whySv).toContain("senast i morgon");
|
||||
});
|
||||
|
||||
it("förklaringen innehåller täckning och protein (specens exempel)", () => {
|
||||
const scored = scoreCandidate(
|
||||
candidate({
|
||||
coverage: { ...fullCoverage, coverage: 0.92 },
|
||||
nutritionPerPortion: { ...nutrition, proteinG: 58 },
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
expect(scored.whySv).toContain("92 %");
|
||||
expect(scored.whySv).toContain("58 gram protein");
|
||||
});
|
||||
|
||||
it("recept över tidsgränsen filtreras bort", () => {
|
||||
const slow = candidate({ recipeId: "slow", totalTimeMinutes: 90 });
|
||||
const ranked = rankAll([slow], { ...ctx, maxMinutes: 45 });
|
||||
expect(ranked).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("nyligen lagat straffas (variation)", () => {
|
||||
const recent = scoreCandidate(candidate({ daysSinceLastCooked: 2 }), ctx);
|
||||
const old = scoreCandidate(candidate({ daysSinceLastCooked: 30 }), ctx);
|
||||
expect(old.score).toBeGreaterThan(recent.score);
|
||||
});
|
||||
});
|
||||
|
||||
describe("'jag är sugen på' (spec §19)", () => {
|
||||
it("tolkar kök, taggar och kcal-gräns", () => {
|
||||
const parsed = parseCraving("något krämigt och asiatiskt under 500 kcal");
|
||||
expect(parsed.cuisine).toBe("thai");
|
||||
expect(parsed.tags).toContain("creamy");
|
||||
expect(parsed.maxKcal).toBe(500);
|
||||
});
|
||||
it("tolkar svenska uttryck", () => {
|
||||
expect(parseCraving("snabb husmanskost").tags).toEqual(
|
||||
expect.arrayContaining(["quick", "comfort"]),
|
||||
);
|
||||
expect(parseCraving("barnvänliga tacos").cuisine).toBe("mexican");
|
||||
});
|
||||
});
|
||||
|
||||
describe("säsongs- och eventmotor (spec §28)", () => {
|
||||
// Motorn räknar i UTC – testerna använder UTC-datum så de är sanna i alla tidszoner.
|
||||
it("årstider", () => {
|
||||
expect(seasonForDate(new Date("2026-07-15T00:00:00Z"))).toBe("summer");
|
||||
expect(seasonForDate(new Date("2026-01-15T00:00:00Z"))).toBe("winter");
|
||||
});
|
||||
it("midsommarafton är alltid en fredag 19–25 juni", () => {
|
||||
for (const year of [2025, 2026, 2027, 2028]) {
|
||||
const eve = midsummerEve(year);
|
||||
expect(eve.getUTCDay()).toBe(5);
|
||||
expect(eve.getUTCMonth()).toBe(5);
|
||||
expect(eve.getUTCDate()).toBeGreaterThanOrEqual(19);
|
||||
expect(eve.getUTCDate()).toBeLessThanOrEqual(25);
|
||||
}
|
||||
});
|
||||
it("påskdagen: kända referensår", () => {
|
||||
expect(easterSunday(2026).toISOString().slice(0, 10)).toBe("2026-04-05");
|
||||
expect(easterSunday(2027).toISOString().slice(0, 10)).toBe("2027-03-28");
|
||||
});
|
||||
it("event aktiveras inom leadDays", () => {
|
||||
const midsummer = {
|
||||
dateRule: { kind: "computed", algorithm: "midsummer" } as const,
|
||||
leadDays: 10,
|
||||
};
|
||||
expect(isEventActive(midsummer, new Date("2026-06-15T00:00:00Z"))).toBe(true);
|
||||
expect(isEventActive(midsummer, new Date("2026-03-01T00:00:00Z"))).toBe(false);
|
||||
const jul = {
|
||||
dateRule: { kind: "range", startMonthDay: "12-20", endMonthDay: "12-26" } as const,
|
||||
leadDays: 21,
|
||||
};
|
||||
expect(isEventActive(jul, new Date("2026-12-05T00:00:00Z"))).toBe(true);
|
||||
expect(isEventActive(jul, new Date("2026-08-02T00:00:00Z"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src", "test"],
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
@@ -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 §45–46)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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";
|
||||
@@ -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 §14–16)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 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 §22–24)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 §25–27)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 §45–47)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 §55–57)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 (0–100) 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;
|
||||
}
|
||||
@@ -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 §14–16)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 §32–33)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 §28–29)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 §45–47)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 §35–38)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 §11–14): 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][]];
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 §9–10, §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 §18–19). */
|
||||
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);
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 §9–10, 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 §18–19, 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src", "test"],
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
@@ -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 §44–47)",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@app/shared-types": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./entitlements.js";
|
||||
export * from "./token.js";
|
||||
export * from "./stores.js";
|
||||
@@ -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)" };
|
||||
}
|
||||
}
|
||||
@@ -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 §45–47)", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src", "test"],
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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";
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src", "test"],
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user