Initial commit (unpacked platform)
This commit is contained in:
@@ -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"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user