Initial commit (unpacked platform)
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@app/shared-types",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Domäntyper, enums och konstanter som delas av hela plattformen",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import brandConfig from "../../../brand.config.json";
|
||||
|
||||
/**
|
||||
* Varumärket läses från brand.config.json i repo-roten – kodbasen är i övrigt
|
||||
* varumärkesneutral så att ett namnbyte aldrig kräver kodändringar.
|
||||
* Runbook för namnbyte: docs/namnbyte.md.
|
||||
*/
|
||||
export interface BrandConfig {
|
||||
name: string;
|
||||
slug: string;
|
||||
urlScheme: string;
|
||||
iosBundleId: string;
|
||||
androidPackage: string;
|
||||
apiDomain: string;
|
||||
adminDomain: string;
|
||||
supportEmail: string;
|
||||
}
|
||||
|
||||
export const BRAND: BrandConfig = brandConfig as BrandConfig;
|
||||
|
||||
/** Delat könamn för BullMQ (API producerar, workern konsumerar). */
|
||||
export const JOB_QUEUE_NAME = `${BRAND.slug}-jobs`;
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Plattformskonstanter: planer, gratisnivå, enhetskonvertering, allergen-etiketter.
|
||||
*/
|
||||
import type { Allergen, SubscriptionPlan, Unit, UnitKind } from "./enums.js";
|
||||
import { BRAND } from "./brand.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prenumerationsplaner (spec §45–46)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PlanDefinition {
|
||||
plan: SubscriptionPlan;
|
||||
nameSv: string;
|
||||
priceMinorPerMonth: number;
|
||||
/** ISO 4217 för fallback-visning; riktiga priser per marknad kommer från butikerna (M8). */
|
||||
currency: string;
|
||||
maxHouseholdMembers: number;
|
||||
/** Fair use – inte "obegränsad AI" (spec §45). */
|
||||
aiScansPerMonth: number;
|
||||
weekPlanning: boolean;
|
||||
advancedNutrition: boolean;
|
||||
communityPublish: boolean;
|
||||
appleProductId: string;
|
||||
googleProductId: string;
|
||||
}
|
||||
|
||||
export const TRIAL_DAYS = 7;
|
||||
|
||||
export const PLAN_DEFINITIONS: Record<SubscriptionPlan, PlanDefinition> = {
|
||||
free: {
|
||||
plan: "free",
|
||||
nameSv: "Gratis",
|
||||
priceMinorPerMonth: 0,
|
||||
currency: "SEK",
|
||||
maxHouseholdMembers: 1,
|
||||
aiScansPerMonth: 10,
|
||||
weekPlanning: false,
|
||||
advancedNutrition: false,
|
||||
communityPublish: false,
|
||||
appleProductId: "",
|
||||
googleProductId: "",
|
||||
},
|
||||
household: {
|
||||
plan: "household",
|
||||
nameSv: "Household",
|
||||
priceMinorPerMonth: 7900,
|
||||
currency: "SEK",
|
||||
maxHouseholdMembers: 3,
|
||||
aiScansPerMonth: 300,
|
||||
weekPlanning: true,
|
||||
advancedNutrition: true,
|
||||
communityPublish: true,
|
||||
appleProductId: `${BRAND.iosBundleId}.household_monthly`,
|
||||
googleProductId: "household_monthly",
|
||||
},
|
||||
family: {
|
||||
plan: "family",
|
||||
nameSv: "Family",
|
||||
priceMinorPerMonth: 12900,
|
||||
currency: "SEK",
|
||||
maxHouseholdMembers: 6,
|
||||
aiScansPerMonth: 600,
|
||||
weekPlanning: true,
|
||||
advancedNutrition: true,
|
||||
communityPublish: true,
|
||||
appleProductId: `${BRAND.iosBundleId}.family_monthly`,
|
||||
googleProductId: "family_monthly",
|
||||
},
|
||||
large_household: {
|
||||
plan: "large_household",
|
||||
nameSv: "Large Household",
|
||||
priceMinorPerMonth: 16900,
|
||||
currency: "SEK",
|
||||
maxHouseholdMembers: 12,
|
||||
aiScansPerMonth: 1000,
|
||||
weekPlanning: true,
|
||||
advancedNutrition: true,
|
||||
communityPublish: true,
|
||||
appleProductId: `${BRAND.iosBundleId}.large_household_monthly`,
|
||||
googleProductId: "large_household_monthly",
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enheter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const UNIT_INFO: Record<Unit, { kind: UnitKind; toBase: number }> = {
|
||||
GRAM: { kind: "mass", toBase: 1 },
|
||||
KILOGRAM: { kind: "mass", toBase: 1000 },
|
||||
OUNCE: { kind: "mass", toBase: 28.35 },
|
||||
POUND: { kind: "mass", toBase: 453.59 },
|
||||
MILLILITER: { kind: "volume", toBase: 1 },
|
||||
DECILITER: { kind: "volume", toBase: 100 },
|
||||
LITER: { kind: "volume", toBase: 1000 },
|
||||
TEASPOON: { kind: "volume", toBase: 5 },
|
||||
TABLESPOON: { kind: "volume", toBase: 15 },
|
||||
CUP_US: { kind: "volume", toBase: 236.59 },
|
||||
FLUID_OUNCE_US: { kind: "volume", toBase: 29.57 },
|
||||
PINCH: { kind: "volume", toBase: 0.5 },
|
||||
COUNT: { kind: "count", toBase: 1 },
|
||||
PORTION: { kind: "count", toBase: 1 },
|
||||
SLICE: { kind: "count", toBase: 1 },
|
||||
CLOVE: { kind: "count", toBase: 1 },
|
||||
CAN: { kind: "count", toBase: 1 },
|
||||
PACKAGE: { kind: "count", toBase: 1 },
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Allergener – svenska etiketter (EU:s 14)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ALLERGEN_LABELS_SV: Record<Allergen, string> = {
|
||||
gluten: "Gluten",
|
||||
crustaceans: "Kräftdjur",
|
||||
eggs: "Ägg",
|
||||
fish: "Fisk",
|
||||
peanuts: "Jordnötter",
|
||||
soy: "Soja",
|
||||
milk: "Mjölk (laktos)",
|
||||
tree_nuts: "Nötter",
|
||||
celery: "Selleri",
|
||||
mustard: "Senap",
|
||||
sesame: "Sesamfrön",
|
||||
sulphites: "Sulfiter",
|
||||
lupin: "Lupin",
|
||||
molluscs: "Blötdjur",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bäst före-klassning (deterministisk, spec §13)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Dagar kvar → status. use_soon: ≤ USE_SOON_DAYS, expiring: ≤ EXPIRING_DAYS. */
|
||||
export const EXPIRY_THRESHOLDS = {
|
||||
EXPIRING_DAYS: 2,
|
||||
USE_SOON_DAYS: 5,
|
||||
} as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Butiksavdelningar för inköpslistans sortering (spec §27)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const STORE_SECTIONS = [
|
||||
"frukt_gront",
|
||||
"brod",
|
||||
"mejeri",
|
||||
"kott_fagel",
|
||||
"fisk",
|
||||
"chark",
|
||||
"frys",
|
||||
"skafferi",
|
||||
"konserver",
|
||||
"kryddor_bak",
|
||||
"dryck",
|
||||
"snacks",
|
||||
"hygien_ovrigt",
|
||||
] as const;
|
||||
export type StoreSection = (typeof STORE_SECTIONS)[number];
|
||||
|
||||
export const STORE_SECTION_LABELS_SV: Record<StoreSection, string> = {
|
||||
frukt_gront: "Frukt & grönt",
|
||||
brod: "Bröd",
|
||||
mejeri: "Mejeri",
|
||||
kott_fagel: "Kött & fågel",
|
||||
fisk: "Fisk & skaldjur",
|
||||
chark: "Chark",
|
||||
frys: "Frys",
|
||||
skafferi: "Skafferi",
|
||||
konserver: "Konserver",
|
||||
kryddor_bak: "Kryddor & bakning",
|
||||
dryck: "Dryck",
|
||||
snacks: "Snacks & godis",
|
||||
hygien_ovrigt: "Övrigt",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Versionsmärkning för AI-kontrakt
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const AI_CONTRACT_VERSION = "1.0.0";
|
||||
export const API_VERSION = "v1";
|
||||
@@ -0,0 +1,730 @@
|
||||
/**
|
||||
* Domänentiteter. Speglar databasschemat (packages/database) men i camelCase
|
||||
* och utan persistensdetaljer. API:t serialiserar datum som ISO-strängar.
|
||||
*/
|
||||
import type {
|
||||
ActivityLevel,
|
||||
Allergen,
|
||||
ConsentKind,
|
||||
ConsentStatus,
|
||||
CookingMethod,
|
||||
CreatorLevel,
|
||||
Cuisine,
|
||||
DateKind,
|
||||
DietPattern,
|
||||
Equipment,
|
||||
EventType,
|
||||
ExpiryStatus,
|
||||
FeedbackTag,
|
||||
GoalType,
|
||||
HouseholdRole,
|
||||
InventorySource,
|
||||
InventoryTransactionType,
|
||||
JobStatus,
|
||||
JobType,
|
||||
MealLogSource,
|
||||
MealType,
|
||||
MemoryKind,
|
||||
NotificationType,
|
||||
PrecisionMode,
|
||||
ProfileVisibility,
|
||||
RecipeDifficulty,
|
||||
RecipeSourceType,
|
||||
RecipeStatus,
|
||||
RecipeTag,
|
||||
RecipeVariantType,
|
||||
RecipeVerificationStatus,
|
||||
ReligiousRule,
|
||||
ScanType,
|
||||
Season,
|
||||
Sex,
|
||||
SignalOrigin,
|
||||
StorageLocationType,
|
||||
SubscriptionPlan,
|
||||
SubscriptionProvider,
|
||||
SubscriptionStatus,
|
||||
TasteAxis,
|
||||
Unit,
|
||||
UserRole,
|
||||
VerificationStatus,
|
||||
} from "./enums.js";
|
||||
import type {
|
||||
DailyTargets,
|
||||
NutritionDeclaration,
|
||||
NutritionProvenance,
|
||||
NutritionValues,
|
||||
} from "./nutrition.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gemensamma byggstenar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Spårbarhet för varje AI-härledd datapunkt (spec §9). */
|
||||
export interface DataProvenance {
|
||||
source: InventorySource | "ai" | "system";
|
||||
confidence: number;
|
||||
verifiedByUser: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastVerifiedAt?: string;
|
||||
modelVersion?: string;
|
||||
promptVersion?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Användare & profil (spec §6) – hälsodata hålls logiskt separerad (spec §7, §56)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: UserRole;
|
||||
locale: string;
|
||||
precisionMode: PrecisionMode;
|
||||
onboardingCompleted: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Hälsorelaterad profil – åtkomstskyddad separat från hushållsdata. */
|
||||
export interface UserHealthProfile {
|
||||
userId: string;
|
||||
birthYear?: number;
|
||||
sex?: Sex;
|
||||
heightCm?: number;
|
||||
weightKg?: number;
|
||||
targetWeightKg?: number;
|
||||
activityLevel: ActivityLevel;
|
||||
trainingSessionsPerWeek?: number;
|
||||
trainingTypes?: string[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UserPreferences {
|
||||
userId: string;
|
||||
primaryGoal?: GoalType;
|
||||
goals: GoalType[];
|
||||
dietPattern: DietPattern;
|
||||
religiousRule: ReligiousRule;
|
||||
allergens: Allergen[];
|
||||
intolerances: string[];
|
||||
/** canonical ingredient-id:n som ska undvikas */
|
||||
avoidIngredientIds: string[];
|
||||
favoriteCuisines: Cuisine[];
|
||||
dislikedDishes: string[];
|
||||
spiceLevelMax: number;
|
||||
weeklyBudgetMinor?: number;
|
||||
maxCookingMinutesWeekday?: number;
|
||||
equipment: Equipment[];
|
||||
defaultPortions: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UserConsent {
|
||||
userId: string;
|
||||
kind: ConsentKind;
|
||||
status: ConsentStatus;
|
||||
grantedAt?: string;
|
||||
revokedAt?: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UserDailyTargetsSnapshot {
|
||||
userId: string;
|
||||
date: string;
|
||||
targets: DailyTargets;
|
||||
/** Hur målen beräknades – transparens i "Min dag". */
|
||||
basis: {
|
||||
bmrKcal: number;
|
||||
tdeeKcal: number;
|
||||
goalAdjustmentKcal: number;
|
||||
activityLevel: ActivityLevel;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hushåll (spec §7)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Household {
|
||||
id: string;
|
||||
name: string;
|
||||
inviteCode: string;
|
||||
weeklyBudgetMinor?: number;
|
||||
/** ISO 4217 – alla belopp i hushållet tolkas i denna valuta (i18n-spec §20). */
|
||||
currencyCode: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface HouseholdMember {
|
||||
householdId: string;
|
||||
userId: string;
|
||||
role: HouseholdRole;
|
||||
/** Portionsfaktor för denna person (t.ex. barn 0.6, tränande 1.3). */
|
||||
portionFactor: number;
|
||||
joinedAt: string;
|
||||
}
|
||||
|
||||
export interface StorageLocation {
|
||||
id: string;
|
||||
householdId: string;
|
||||
type: StorageLocationType;
|
||||
name: string;
|
||||
/** Underplatser: hyllor, lådor (spec §8). */
|
||||
sublocations: string[];
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ingredienser & produkter (spec §11)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CanonicalIngredient {
|
||||
id: string; // slug, t.ex. "chicken_breast"
|
||||
nameSv: string;
|
||||
nameEn: string;
|
||||
category: string;
|
||||
defaultUnit: Unit;
|
||||
/** g per ml, för volym↔massa-konvertering */
|
||||
densityGPerMl?: number;
|
||||
/** g per styck (t.ex. ett ägg ≈ 58 g) */
|
||||
gramsPerPiece?: number;
|
||||
allergens: Allergen[];
|
||||
/** Diet-flaggor för deterministisk filtrering */
|
||||
isVegan: boolean;
|
||||
isVegetarian: boolean;
|
||||
containsGluten: boolean;
|
||||
containsLactose: boolean;
|
||||
isPork: boolean;
|
||||
isBeef: boolean;
|
||||
isAlcohol: boolean;
|
||||
nutritionPer100: NutritionDeclaration;
|
||||
nutritionProvenance: NutritionProvenance;
|
||||
/** Säsonger då råvaran är som bäst (spec §28) */
|
||||
peakSeasons: Season[];
|
||||
/** Riktvärde för hållbarhet efter öppning/inköp, per förvaringsplats (dagar). Vägledning – ej garanti (spec §13). */
|
||||
shelfLifeGuidance?: Partial<Record<StorageLocationType, number>>;
|
||||
defaultPriceMinorPerKg?: number;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: string;
|
||||
gtin?: string;
|
||||
name: string;
|
||||
brand?: string;
|
||||
canonicalIngredientId?: string;
|
||||
packageSizeValue?: number;
|
||||
packageSizeUnit?: Unit;
|
||||
ingredientsText?: string;
|
||||
allergens: Allergen[];
|
||||
mayContainAllergens: Allergen[];
|
||||
nutrition?: NutritionDeclaration;
|
||||
imageUrls: string[];
|
||||
language: string;
|
||||
market: string;
|
||||
dataSource: string;
|
||||
verificationStatus: VerificationStatus;
|
||||
/** Produkter versionshanteras (spec §11, §61.12). */
|
||||
version: number;
|
||||
validFrom: string;
|
||||
validTo?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Food Twin – lager (spec §8)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface InventoryItem {
|
||||
id: string;
|
||||
householdId: string;
|
||||
canonicalIngredientId?: string;
|
||||
productId?: string;
|
||||
displayName: string;
|
||||
brand?: string;
|
||||
/** Aktuellt saldo (härlett ur transaktioner men cachat för snabb läsning). */
|
||||
quantity: number;
|
||||
unit: Unit;
|
||||
storageLocationId: string;
|
||||
sublocation?: string;
|
||||
purchasedAt?: string;
|
||||
openedAt?: string;
|
||||
bestBeforeDate?: string;
|
||||
useByDate?: string;
|
||||
dateKind?: DateKind;
|
||||
frozenAt?: string;
|
||||
thawedAt?: string;
|
||||
priceMinor?: number;
|
||||
nutritionPer100?: NutritionDeclaration;
|
||||
source: InventorySource;
|
||||
confidence: number;
|
||||
verifiedByUser: boolean;
|
||||
lastVerifiedAt?: string;
|
||||
expiryStatus: ExpiryStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface InventoryTransaction {
|
||||
id: string;
|
||||
householdId: string;
|
||||
inventoryItemId: string;
|
||||
type: InventoryTransactionType;
|
||||
/** Positiv = in, negativ = ut. Samma enhet som posten. */
|
||||
quantityDelta: number;
|
||||
unit: Unit;
|
||||
/** Referens till recept, måltid, kvitto, matlåda etc. */
|
||||
refType?: "recipe_cook" | "meal" | "receipt" | "meal_box" | "shopping" | "scan" | "manual";
|
||||
refId?: string;
|
||||
actorUserId?: string;
|
||||
note?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recept (spec §14–16)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Maskinläsbart Recipe DNA (spec §16). */
|
||||
export interface RecipeDNA {
|
||||
cuisine: Cuisine;
|
||||
protein?: string;
|
||||
carbohydrate?: string;
|
||||
vegetables: string[];
|
||||
flavorProfile: string[];
|
||||
spiceLevel: number;
|
||||
method: CookingMethod;
|
||||
timeMinutes: number;
|
||||
calories: number;
|
||||
proteinGrams: number;
|
||||
}
|
||||
|
||||
export interface RecipeIngredient {
|
||||
id: string;
|
||||
recipeId: string;
|
||||
canonicalIngredientId: string;
|
||||
displayNameSv: string;
|
||||
quantity: number;
|
||||
unit: Unit;
|
||||
note?: string;
|
||||
optional: boolean;
|
||||
/** Gruppering, t.ex. "Sås", "Topping" */
|
||||
groupName?: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface RecipeStep {
|
||||
id: string;
|
||||
recipeId: string;
|
||||
stepNumber: number;
|
||||
instructionSv: string;
|
||||
/** Timer i sekunder om steget har en naturlig timer (Cooking Mode, spec §41). */
|
||||
timerSeconds?: number;
|
||||
temperatureC?: number;
|
||||
tip?: string;
|
||||
}
|
||||
|
||||
export interface Recipe {
|
||||
id: string;
|
||||
slug: string;
|
||||
titleSv: string;
|
||||
descriptionSv: string;
|
||||
country?: string;
|
||||
region?: string;
|
||||
cuisine: Cuisine;
|
||||
mealTypes: MealType[];
|
||||
tags: RecipeTag[];
|
||||
methods: CookingMethod[];
|
||||
equipment: Equipment[];
|
||||
difficulty: RecipeDifficulty;
|
||||
prepTimeMinutes: number;
|
||||
cookTimeMinutes: number;
|
||||
totalTimeMinutes: number;
|
||||
portions: number;
|
||||
/** Deterministiskt beräknad av nutrition-engine utifrån ingredienser (spec §61.1). */
|
||||
nutritionPerPortion: NutritionValues;
|
||||
allergens: Allergen[];
|
||||
spiceLevel: number;
|
||||
estimatedCostMinorPerPortion?: number;
|
||||
storageGuidanceSv?: string;
|
||||
mealPrepFriendly: boolean;
|
||||
freezerFriendly: boolean;
|
||||
peakSeasons: Season[];
|
||||
holidayTags: string[];
|
||||
dna: RecipeDNA;
|
||||
variantType: RecipeVariantType;
|
||||
/** Länk till grundreceptet om detta är en variant (spec §16). */
|
||||
variantOfRecipeId?: string;
|
||||
/** Fork-ursprung (spec §35): "Baserat på recept av X". */
|
||||
forkedFromRecipeId?: string;
|
||||
status: RecipeStatus;
|
||||
verificationStatus: RecipeVerificationStatus;
|
||||
sourceType: RecipeSourceType;
|
||||
sourceRegistryId?: string;
|
||||
creatorUserId?: string;
|
||||
creatorDisplayName?: string;
|
||||
imageUrls: string[];
|
||||
version: number;
|
||||
ratingAverage?: number;
|
||||
ratingCount: number;
|
||||
cookCount: number;
|
||||
favoriteCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface RecipeRating {
|
||||
id: string;
|
||||
recipeId: string;
|
||||
userId: string;
|
||||
stars: number;
|
||||
feedbackTags: FeedbackTag[];
|
||||
comment?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Substitution {
|
||||
id: string;
|
||||
fromIngredientId: string;
|
||||
toIngredientId: string;
|
||||
ratio: number;
|
||||
instructionsSv?: string;
|
||||
bestFor: string[];
|
||||
notRecommendedFor: string[];
|
||||
flavorImpactSv?: string;
|
||||
textureImpactSv?: string;
|
||||
}
|
||||
|
||||
/** Source registry för juridisk spårbarhet (spec §15). */
|
||||
export interface RecipeSourceRegistryEntry {
|
||||
id: string;
|
||||
sourceName: string;
|
||||
license: string;
|
||||
rightToStore: boolean;
|
||||
rightToModify: boolean;
|
||||
rightToDisplay: boolean;
|
||||
attributionRequired: boolean;
|
||||
attributionText?: string;
|
||||
commercialUse: boolean;
|
||||
validFrom: string;
|
||||
validTo?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Måltider, matlådor (spec §22–24)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Meal {
|
||||
id: string;
|
||||
userId: string;
|
||||
householdId?: string;
|
||||
date: string; // YYYY-MM-DD
|
||||
mealType: MealType;
|
||||
source: MealLogSource;
|
||||
recipeId?: string;
|
||||
titleSv: string;
|
||||
portionFraction: number;
|
||||
nutrition: NutritionValues;
|
||||
nutritionIsEstimate: boolean;
|
||||
estimateRangeKcal?: { min: number; max: number };
|
||||
photoUrl?: string;
|
||||
scanJobId?: string;
|
||||
loggedAt: string;
|
||||
}
|
||||
|
||||
export interface MealBox {
|
||||
id: string;
|
||||
householdId: string;
|
||||
recipeId?: string;
|
||||
titleSv: string;
|
||||
portions: number;
|
||||
portionsRemaining: number;
|
||||
kcalPerPortion?: number;
|
||||
nutritionPerPortion?: NutritionValues;
|
||||
cookedAt: string;
|
||||
storageLocationId: string;
|
||||
frozen: boolean;
|
||||
/** Rekommenderad senaste användning (vägledning, ej garanti – spec §13). */
|
||||
recommendedUseBy: string;
|
||||
reservedForUserId?: string;
|
||||
status: "available" | "reserved" | "consumed" | "discarded";
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Planering, inköp, budget (spec §25–27)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface WeekPlan {
|
||||
id: string;
|
||||
householdId: string;
|
||||
/** Måndag i ISO-vecka, YYYY-MM-DD */
|
||||
weekStartDate: string;
|
||||
status: "draft" | "active" | "completed";
|
||||
generatedBy: "user" | "engine";
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface WeekPlanEntry {
|
||||
id: string;
|
||||
weekPlanId: string;
|
||||
date: string;
|
||||
mealType: MealType;
|
||||
recipeId?: string;
|
||||
mealBoxId?: string;
|
||||
titleSv: string;
|
||||
portions: number;
|
||||
status: "planned" | "cooked" | "skipped" | "moved";
|
||||
/** Förklaring vid dynamisk omplanering (spec §25). */
|
||||
rescheduleReasonSv?: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface ShoppingList {
|
||||
id: string;
|
||||
householdId: string;
|
||||
name: string;
|
||||
status: "active" | "completed" | "archived";
|
||||
weekPlanId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ShoppingListItem {
|
||||
id: string;
|
||||
shoppingListId: string;
|
||||
canonicalIngredientId?: string;
|
||||
displayName: string;
|
||||
quantity: number;
|
||||
unit: Unit;
|
||||
/** Butiksavdelning för sortering (spec §27). */
|
||||
storeSection: string;
|
||||
suggestedPackageSize?: string;
|
||||
estimatedPriceMinor?: number;
|
||||
checked: boolean;
|
||||
addedByUserId?: string;
|
||||
/** Härledd från recept/plan eller manuellt tillagd. */
|
||||
origin: "plan" | "recipe" | "manual" | "forecast" | "restock";
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface Receipt {
|
||||
id: string;
|
||||
householdId: string;
|
||||
storeName?: string;
|
||||
purchaseDate?: string;
|
||||
totalMinor?: number;
|
||||
discountMinor?: number;
|
||||
imageUrl?: string;
|
||||
scanJobId?: string;
|
||||
status: "pending" | "confirmed" | "rejected";
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ReceiptLine {
|
||||
id: string;
|
||||
receiptId: string;
|
||||
rawText: string;
|
||||
normalizedName?: string;
|
||||
canonicalIngredientId?: string;
|
||||
productId?: string;
|
||||
quantity?: number;
|
||||
unit?: Unit;
|
||||
unitPriceMinor?: number;
|
||||
totalPriceMinor?: number;
|
||||
confidence: number;
|
||||
verifiedByUser: boolean;
|
||||
addedToInventory: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skanningsjobb (spec §50, §54)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ScanJob {
|
||||
id: string;
|
||||
userId: string;
|
||||
householdId?: string;
|
||||
scanType: ScanType;
|
||||
jobType: JobType;
|
||||
status: JobStatus;
|
||||
s3Keys: string[];
|
||||
/** Strukturerat AI-resultat, validerat mot ai-contracts. */
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
modelVersion?: string;
|
||||
promptVersion?: string;
|
||||
latencyMs?: number;
|
||||
costUsd?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minne (spec §32)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MemoryItem {
|
||||
id: string;
|
||||
userId?: string;
|
||||
householdId?: string;
|
||||
kind: MemoryKind;
|
||||
key: string;
|
||||
/** Läsbar sammanfattning som visas i "Vad plattformen vet om mig". */
|
||||
summarySv: string;
|
||||
value: unknown;
|
||||
origin: SignalOrigin;
|
||||
confidence: number;
|
||||
verifiedByUser: boolean;
|
||||
paused: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastUsedAt?: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export interface TasteSignal {
|
||||
id: string;
|
||||
userId: string;
|
||||
axis: TasteAxis;
|
||||
/** -1 (mindre) … +1 (mer) */
|
||||
direction: number;
|
||||
strength: number;
|
||||
origin: SignalOrigin;
|
||||
refRecipeId?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Säsong & event (spec §28)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SeasonEvent {
|
||||
id: string;
|
||||
slug: string;
|
||||
nameSv: string;
|
||||
market: string;
|
||||
/** Datumregel: fast datum (MM-DD), beräknad (t.ex. midsommar) eller intervall. */
|
||||
dateRule:
|
||||
| { kind: "fixed"; monthDay: string }
|
||||
| { kind: "range"; startMonthDay: string; endMonthDay: string }
|
||||
| { kind: "computed"; algorithm: "midsummer" | "easter" | "advent" | "custom" };
|
||||
/** Hur många dagar före eventet det ska börja påverka rekommendationer. */
|
||||
leadDays: number;
|
||||
foodTags: string[];
|
||||
recipeSlugs: string[];
|
||||
priority: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prenumerationer & entitlements (spec §45–47)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Subscription {
|
||||
id: string;
|
||||
userId: string;
|
||||
householdId?: string;
|
||||
provider: SubscriptionProvider;
|
||||
productId: string;
|
||||
plan: SubscriptionPlan;
|
||||
originalTransactionId?: string;
|
||||
status: SubscriptionStatus;
|
||||
purchasedAt?: string;
|
||||
expiresAt?: string;
|
||||
gracePeriodExpiresAt?: string;
|
||||
canceledAt?: string;
|
||||
lastVerifiedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Entitlements {
|
||||
plan: SubscriptionPlan;
|
||||
status: SubscriptionStatus | "free";
|
||||
maxHouseholdMembers: number;
|
||||
aiScansPerMonth: number;
|
||||
aiScansUsedThisMonth: number;
|
||||
weekPlanning: boolean;
|
||||
advancedNutrition: boolean;
|
||||
communityPublish: boolean;
|
||||
expiresAt?: string;
|
||||
graceUntil?: string;
|
||||
/** Signerad token för offline-verifiering med begränsad giltighet (spec §44). */
|
||||
signedToken?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Events, flags, audit (spec §55–57)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DomainEvent<TPayload = unknown> {
|
||||
id: string;
|
||||
type: EventType;
|
||||
occurredAt: string;
|
||||
userId?: string;
|
||||
householdId?: string;
|
||||
payload: TPayload;
|
||||
correlationId?: string;
|
||||
}
|
||||
|
||||
export interface FeatureFlag {
|
||||
key: string;
|
||||
enabled: boolean;
|
||||
descriptionSv?: string;
|
||||
/** Procent av användare (0–100) vid gradvis utrullning. */
|
||||
rolloutPercent: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AuditLogEntry {
|
||||
id: string;
|
||||
actorUserId?: string;
|
||||
actorType: "user" | "admin" | "system" | "worker";
|
||||
action: string;
|
||||
targetType?: string;
|
||||
targetId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
ip?: string;
|
||||
correlationId?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AppNotification {
|
||||
id: string;
|
||||
userId: string;
|
||||
type: NotificationType;
|
||||
titleSv: string;
|
||||
bodySv: string;
|
||||
data?: Record<string, unknown>;
|
||||
scheduledFor?: string;
|
||||
sentAt?: string;
|
||||
readAt?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Creator (spec §37)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CreatorStats {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
visibility: ProfileVisibility;
|
||||
level: CreatorLevel;
|
||||
publishedRecipes: number;
|
||||
followers: number;
|
||||
totalCooks: number;
|
||||
totalFavorites: number;
|
||||
averageRating?: number;
|
||||
verifiedRecipes: number;
|
||||
badges: string[];
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
/**
|
||||
* Centrala enums för hela plattformen.
|
||||
*
|
||||
* Mönster: `as const`-array + härledd unionstyp. Arrayerna återanvänds av
|
||||
* Zod (`z.enum`) och Drizzle (`pgEnum`) så att databas, API och mobil alltid
|
||||
* delar exakt samma värdemängder.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Förvaring & lager (spec §8)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const STORAGE_LOCATION_TYPES = [
|
||||
"fridge",
|
||||
"freezer",
|
||||
"pantry",
|
||||
"garage_freezer",
|
||||
"wine_fridge",
|
||||
"cellar",
|
||||
"meal_box",
|
||||
"custom",
|
||||
] as const;
|
||||
export type StorageLocationType = (typeof STORAGE_LOCATION_TYPES)[number];
|
||||
|
||||
/** Datakällor för lagerposter (spec §9). */
|
||||
export const INVENTORY_SOURCES = [
|
||||
"fridge_photo",
|
||||
"freezer_photo",
|
||||
"pantry_photo",
|
||||
"ingredient_photo",
|
||||
"barcode",
|
||||
"receipt",
|
||||
"digital_receipt",
|
||||
"label_photo",
|
||||
"manual_search",
|
||||
"free_text",
|
||||
"voice",
|
||||
"cooked_recipe",
|
||||
"connector",
|
||||
"seed",
|
||||
] as const;
|
||||
export type InventorySource = (typeof INVENTORY_SOURCES)[number];
|
||||
|
||||
/** Lagret är transaktionsbaserat (spec §8): varje förändring är en transaktion. */
|
||||
export const INVENTORY_TRANSACTION_TYPES = [
|
||||
"purchase",
|
||||
"consume",
|
||||
"discard",
|
||||
"adjust",
|
||||
"cook_use",
|
||||
"leftover_created",
|
||||
"leftover_consumed",
|
||||
"correction",
|
||||
"freeze",
|
||||
"thaw",
|
||||
"move",
|
||||
] as const;
|
||||
export type InventoryTransactionType = (typeof INVENTORY_TRANSACTION_TYPES)[number];
|
||||
|
||||
/** Klassning av hur bråttom en vara är (deterministisk, spec §13). */
|
||||
export const EXPIRY_STATUSES = ["fresh", "use_soon", "expiring", "expired", "unknown"] as const;
|
||||
export type ExpiryStatus = (typeof EXPIRY_STATUSES)[number];
|
||||
|
||||
/** Bäst före ≠ sista förbrukningsdag (spec §13). */
|
||||
export const DATE_KINDS = ["best_before", "use_by"] as const;
|
||||
export type DateKind = (typeof DATE_KINDS)[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enheter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Språkneutrala enhetskoder (i18n-spec §11). Canonical bas: massa=GRAM,
|
||||
* volym=MILLILITER, antal=COUNT. Visningsnamn/förkortningar ("msk", "tbsp")
|
||||
* lever i översättningslagret – ALDRIG här. Dokumenterad standard (i18n-spec §10):
|
||||
* TEASPOON=5 ml, TABLESPOON=15 ml (metrisk), CUP_US=236.59 ml,
|
||||
* FLUID_OUNCE_US=29.57 ml, OUNCE=28.35 g, POUND=453.59 g, PINCH≈0.5 ml.
|
||||
* Svenska "krm" (=1 ml) lagras som MILLILITER och visas lokalt som "krm".
|
||||
*/
|
||||
export const UNITS = [
|
||||
"GRAM",
|
||||
"KILOGRAM",
|
||||
"MILLILITER",
|
||||
"DECILITER",
|
||||
"LITER",
|
||||
"TEASPOON",
|
||||
"TABLESPOON",
|
||||
"CUP_US",
|
||||
"FLUID_OUNCE_US",
|
||||
"OUNCE",
|
||||
"POUND",
|
||||
"COUNT",
|
||||
"PORTION",
|
||||
"PINCH",
|
||||
"SLICE",
|
||||
"CLOVE",
|
||||
"CAN",
|
||||
"PACKAGE",
|
||||
] as const;
|
||||
export type Unit = (typeof UNITS)[number];
|
||||
|
||||
export const UNIT_KINDS = ["mass", "volume", "count"] as const;
|
||||
export type UnitKind = (typeof UNIT_KINDS)[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Användare, profil & mål (spec §6)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const USER_ROLES = ["user", "moderator", "admin"] as const;
|
||||
export type UserRole = (typeof USER_ROLES)[number];
|
||||
|
||||
export const SEXES = ["female", "male", "unspecified"] as const;
|
||||
export type Sex = (typeof SEXES)[number];
|
||||
|
||||
export const ACTIVITY_LEVELS = ["sedentary", "light", "moderate", "active", "very_active"] as const;
|
||||
export type ActivityLevel = (typeof ACTIVITY_LEVELS)[number];
|
||||
|
||||
export const GOAL_TYPES = [
|
||||
"lose_weight",
|
||||
"gain_weight",
|
||||
"build_muscle",
|
||||
"maintain_weight",
|
||||
"more_protein",
|
||||
"less_fat",
|
||||
"more_fiber",
|
||||
"more_variety",
|
||||
"less_waste",
|
||||
"lower_cost",
|
||||
"cook_more",
|
||||
] as const;
|
||||
export type GoalType = (typeof GOAL_TYPES)[number];
|
||||
|
||||
export const DIET_PATTERNS = [
|
||||
"omnivore",
|
||||
"flexitarian",
|
||||
"pescatarian",
|
||||
"vegetarian",
|
||||
"vegan",
|
||||
"low_carb",
|
||||
"keto",
|
||||
"high_protein",
|
||||
"mediterranean",
|
||||
] as const;
|
||||
export type DietPattern = (typeof DIET_PATTERNS)[number];
|
||||
|
||||
export const RELIGIOUS_RULES = [
|
||||
"none",
|
||||
"halal",
|
||||
"kosher",
|
||||
"hindu_no_beef",
|
||||
"buddhist_vegetarian",
|
||||
] as const;
|
||||
export type ReligiousRule = (typeof RELIGIOUS_RULES)[number];
|
||||
|
||||
/** EU:s 14 deklarationspliktiga allergener. Allergikontroll är ALLTID deterministisk (spec §61.2). */
|
||||
export const ALLERGENS = [
|
||||
"gluten",
|
||||
"crustaceans",
|
||||
"eggs",
|
||||
"fish",
|
||||
"peanuts",
|
||||
"soy",
|
||||
"milk",
|
||||
"tree_nuts",
|
||||
"celery",
|
||||
"mustard",
|
||||
"sesame",
|
||||
"sulphites",
|
||||
"lupin",
|
||||
"molluscs",
|
||||
] as const;
|
||||
export type Allergen = (typeof ALLERGENS)[number];
|
||||
|
||||
/** Enkelt läge vs exakt läge (spec §5) – kan kombineras, lagras som preferens. */
|
||||
export const PRECISION_MODES = ["simple", "exact"] as const;
|
||||
export type PrecisionMode = (typeof PRECISION_MODES)[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hushåll (spec §7)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const HOUSEHOLD_ROLES = ["owner", "adult", "member", "child"] as const;
|
||||
export type HouseholdRole = (typeof HOUSEHOLD_ROLES)[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recept (spec §14–16)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const CUISINES = [
|
||||
"swedish",
|
||||
"nordic",
|
||||
"italian",
|
||||
"french",
|
||||
"spanish",
|
||||
"greek",
|
||||
"thai",
|
||||
"chinese",
|
||||
"japanese",
|
||||
"korean",
|
||||
"vietnamese",
|
||||
"indian",
|
||||
"mexican",
|
||||
"american",
|
||||
"turkish",
|
||||
"lebanese",
|
||||
"moroccan",
|
||||
"middle_eastern",
|
||||
"international",
|
||||
] as const;
|
||||
export type Cuisine = (typeof CUISINES)[number];
|
||||
|
||||
export const MEAL_TYPES = [
|
||||
"breakfast",
|
||||
"lunch",
|
||||
"dinner",
|
||||
"snack",
|
||||
"dessert",
|
||||
"starter",
|
||||
"buffet",
|
||||
"party",
|
||||
] as const;
|
||||
export type MealType = (typeof MEAL_TYPES)[number];
|
||||
|
||||
/** Attribut-taggar från spec §14 (utöver måltidstyp och metod). */
|
||||
export const RECIPE_TAGS = [
|
||||
"kid_friendly",
|
||||
"meal_prep",
|
||||
"quick",
|
||||
"high_protein",
|
||||
"low_fat",
|
||||
"low_calorie",
|
||||
"low_carb",
|
||||
"vegetarian",
|
||||
"vegan",
|
||||
"gluten_free",
|
||||
"lactose_free",
|
||||
"budget",
|
||||
"luxury",
|
||||
"freezer_friendly",
|
||||
"leftover_friendly",
|
||||
"one_pot",
|
||||
"batch_cooking",
|
||||
] as const;
|
||||
export type RecipeTag = (typeof RECIPE_TAGS)[number];
|
||||
|
||||
export const COOKING_METHODS = [
|
||||
"stovetop",
|
||||
"oven",
|
||||
"grill",
|
||||
"airfryer",
|
||||
"wok",
|
||||
"slow_cooker",
|
||||
"sous_vide",
|
||||
"microwave",
|
||||
"no_cook",
|
||||
"pressure_cooker",
|
||||
"deep_fry",
|
||||
"steam",
|
||||
] as const;
|
||||
export type CookingMethod = (typeof COOKING_METHODS)[number];
|
||||
|
||||
export const EQUIPMENT = [
|
||||
"stove",
|
||||
"oven",
|
||||
"microwave",
|
||||
"airfryer",
|
||||
"grill",
|
||||
"slow_cooker",
|
||||
"sous_vide",
|
||||
"pressure_cooker",
|
||||
"blender",
|
||||
"food_processor",
|
||||
"hand_mixer",
|
||||
"stand_mixer",
|
||||
"wok_pan",
|
||||
"kitchen_scale",
|
||||
"thermometer",
|
||||
] as const;
|
||||
export type Equipment = (typeof EQUIPMENT)[number];
|
||||
|
||||
export const RECIPE_DIFFICULTIES = ["beginner", "easy", "medium", "advanced", "expert"] as const;
|
||||
export type RecipeDifficulty = (typeof RECIPE_DIFFICULTIES)[number];
|
||||
|
||||
/** Publiceringsflöde för recept (spec §35): submission → AI-kontroll → dubblett → moderation → publicering. */
|
||||
export const RECIPE_STATUSES = [
|
||||
"draft",
|
||||
"submitted",
|
||||
"ai_checked",
|
||||
"in_moderation",
|
||||
"published",
|
||||
"rejected",
|
||||
"archived",
|
||||
] as const;
|
||||
export type RecipeStatus = (typeof RECIPE_STATUSES)[number];
|
||||
|
||||
export const RECIPE_VERIFICATION_STATUSES = [
|
||||
"unverified",
|
||||
"community",
|
||||
"verified",
|
||||
"editorial",
|
||||
] as const;
|
||||
export type RecipeVerificationStatus = (typeof RECIPE_VERIFICATION_STATUSES)[number];
|
||||
|
||||
/** Varianter länkas till grundrecept (spec §16). */
|
||||
export const RECIPE_VARIANT_TYPES = [
|
||||
"standard",
|
||||
"high_protein",
|
||||
"low_calorie",
|
||||
"low_fat",
|
||||
"vegetarian",
|
||||
"vegan",
|
||||
"gluten_free",
|
||||
"lactose_free",
|
||||
"kid_friendly",
|
||||
"airfryer",
|
||||
"budget",
|
||||
] as const;
|
||||
export type RecipeVariantType = (typeof RECIPE_VARIANT_TYPES)[number];
|
||||
|
||||
/** Dubblettklassning (spec §36). */
|
||||
export const RECIPE_SIMILARITY_CLASSES = [
|
||||
"duplicate",
|
||||
"variant",
|
||||
"inspired",
|
||||
"independent",
|
||||
] as const;
|
||||
export type RecipeSimilarityClass = (typeof RECIPE_SIMILARITY_CLASSES)[number];
|
||||
|
||||
/** Juridiskt spårbara källtyper för recept (spec §15). */
|
||||
export const RECIPE_SOURCE_TYPES = [
|
||||
"own_editorial",
|
||||
"ai_assisted_reviewed",
|
||||
"open_license",
|
||||
"public_domain",
|
||||
"licensed_database",
|
||||
"creator_agreement",
|
||||
"user_generated",
|
||||
] as const;
|
||||
export type RecipeSourceType = (typeof RECIPE_SOURCE_TYPES)[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skanning & AI-jobb (spec §4.2, §54)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const SCAN_TYPES = [
|
||||
"fridge",
|
||||
"freezer",
|
||||
"pantry",
|
||||
"ingredients",
|
||||
"plate",
|
||||
"receipt",
|
||||
"barcode",
|
||||
"expiry_date",
|
||||
"nutrition_label",
|
||||
"product_package",
|
||||
] as const;
|
||||
export type ScanType = (typeof SCAN_TYPES)[number];
|
||||
|
||||
/** Jobbtyper, exakt enligt spec §54. */
|
||||
export const JOB_TYPES = [
|
||||
"ANALYZE_FRIDGE_IMAGE",
|
||||
"ANALYZE_PANTRY_IMAGE",
|
||||
"ANALYZE_MEAL_IMAGE",
|
||||
"READ_RECEIPT",
|
||||
"READ_NUTRITION_LABEL",
|
||||
"READ_EXPIRY_DATE",
|
||||
"NORMALIZE_PRODUCTS",
|
||||
"DEDUPLICATE_INVENTORY",
|
||||
"CALCULATE_NUTRITION",
|
||||
"GENERATE_RECIPE_OPTIONS",
|
||||
"RANK_RECIPES",
|
||||
"UPDATE_USER_MEMORY",
|
||||
"GENERATE_WEEK_PLAN",
|
||||
"SEND_EXPIRY_NOTIFICATION",
|
||||
"VERIFY_SUBSCRIPTION",
|
||||
"PROCESS_STORE_NOTIFICATION",
|
||||
"BUILD_TRAINING_SAMPLE",
|
||||
"RUN_AI_EVALUATION",
|
||||
] as const;
|
||||
export type JobType = (typeof JOB_TYPES)[number];
|
||||
|
||||
export const JOB_STATUSES = [
|
||||
"queued",
|
||||
"running",
|
||||
"awaiting_confirmation",
|
||||
"completed",
|
||||
"failed",
|
||||
"canceled",
|
||||
] as const;
|
||||
export type JobStatus = (typeof JOB_STATUSES)[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Events (spec §55)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const EVENT_TYPES = [
|
||||
"PRODUCT_ADDED",
|
||||
"PRODUCT_UPDATED",
|
||||
"PRODUCT_CONSUMED",
|
||||
"PRODUCT_DISCARDED",
|
||||
"RECIPE_COOKED",
|
||||
"RECIPE_RATED",
|
||||
"RECIPE_CREATED",
|
||||
"RECIPE_FORKED",
|
||||
"MEAL_LOGGED",
|
||||
"MEAL_PHOTO_ANALYZED",
|
||||
"HOUSEHOLD_MEMBER_ADDED",
|
||||
"SUBSCRIPTION_STARTED",
|
||||
"SUBSCRIPTION_CHANGED",
|
||||
"AI_CORRECTED",
|
||||
"MEMORY_UPDATED",
|
||||
"SHOPPING_COMPLETED",
|
||||
"WEEK_PLAN_UPDATED",
|
||||
"MEAL_BOX_CREATED",
|
||||
"MEAL_BOX_CONSUMED",
|
||||
] as const;
|
||||
export type EventType = (typeof EVENT_TYPES)[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Måltider & loggning (spec §23)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const MEAL_LOG_SOURCES = [
|
||||
"cooked_recipe",
|
||||
"plate_photo",
|
||||
"barcode",
|
||||
"product",
|
||||
"free_text",
|
||||
"voice",
|
||||
"previous_meal",
|
||||
"meal_box",
|
||||
"manual",
|
||||
] as const;
|
||||
export type MealLogSource = (typeof MEAL_LOG_SOURCES)[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Smakprofil & feedback (spec §30)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const FEEDBACK_TAGS = [
|
||||
"too_spicy",
|
||||
"too_mild",
|
||||
"too_dry",
|
||||
"too_salty",
|
||||
"too_sour",
|
||||
"too_little_sauce",
|
||||
"too_difficult",
|
||||
"make_again",
|
||||
] as const;
|
||||
export type FeedbackTag = (typeof FEEDBACK_TAGS)[number];
|
||||
|
||||
export const TASTE_AXES = [
|
||||
"spice",
|
||||
"salt",
|
||||
"acid",
|
||||
"creaminess",
|
||||
"garlic",
|
||||
"sweetness",
|
||||
"herbs",
|
||||
"umami",
|
||||
] as const;
|
||||
export type TasteAxis = (typeof TASTE_AXES)[number];
|
||||
|
||||
/** Skilj explicit preferens, observerat mönster och AI-antagande (spec §30, §32). */
|
||||
export const SIGNAL_ORIGINS = ["user_stated", "observed", "ai_inferred"] as const;
|
||||
export type SignalOrigin = (typeof SIGNAL_ORIGINS)[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minne & samtycke (spec §32–33)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const MEMORY_KINDS = [
|
||||
"structured_fact",
|
||||
"event",
|
||||
"semantic",
|
||||
"profile_summary",
|
||||
"recipe_memory",
|
||||
] as const;
|
||||
export type MemoryKind = (typeof MEMORY_KINDS)[number];
|
||||
|
||||
export const CONSENT_KINDS = [
|
||||
"personalization",
|
||||
"anonymized_improvement",
|
||||
"image_training",
|
||||
"health_integration",
|
||||
"location_weather",
|
||||
"push_notifications",
|
||||
] as const;
|
||||
export type ConsentKind = (typeof CONSENT_KINDS)[number];
|
||||
|
||||
export const CONSENT_STATUSES = ["granted", "denied", "revoked"] as const;
|
||||
export type ConsentStatus = (typeof CONSENT_STATUSES)[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Säsong, högtid, väder (spec §28–29)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const SEASONS = ["spring", "summer", "autumn", "winter"] as const;
|
||||
export type Season = (typeof SEASONS)[number];
|
||||
|
||||
export const WEATHER_HINTS = ["hot", "warm", "mild", "cold", "rain", "snow", "unknown"] as const;
|
||||
export type WeatherHint = (typeof WEATHER_HINTS)[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prenumerationer (spec §45–47)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const SUBSCRIPTION_PLANS = ["free", "household", "family", "large_household"] as const;
|
||||
export type SubscriptionPlan = (typeof SUBSCRIPTION_PLANS)[number];
|
||||
|
||||
export const SUBSCRIPTION_STATUSES = [
|
||||
"trial",
|
||||
"active",
|
||||
"in_grace",
|
||||
"on_hold",
|
||||
"paused",
|
||||
"canceled",
|
||||
"expired",
|
||||
] as const;
|
||||
export type SubscriptionStatus = (typeof SUBSCRIPTION_STATUSES)[number];
|
||||
|
||||
export const SUBSCRIPTION_PROVIDERS = ["apple", "google", "promo", "none"] as const;
|
||||
export type SubscriptionProvider = (typeof SUBSCRIPTION_PROVIDERS)[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Creator & community (spec §35–38)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const CREATOR_LEVELS = ["beginner", "sous_chef", "chef", "master_chef", "legend"] as const;
|
||||
export type CreatorLevel = (typeof CREATOR_LEVELS)[number];
|
||||
|
||||
export const MODERATION_ACTIONS = ["approve", "reject", "request_changes", "escalate"] as const;
|
||||
export type ModerationAction = (typeof MODERATION_ACTIONS)[number];
|
||||
|
||||
export const PROFILE_VISIBILITIES = ["private", "friends", "national", "global"] as const;
|
||||
export type ProfileVisibility = (typeof PROFILE_VISIBILITIES)[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notiser (spec §40)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const NOTIFICATION_TYPES = [
|
||||
"expiry_warning",
|
||||
"meal_box_reminder",
|
||||
"quick_dinner_suggestion",
|
||||
"holiday_upcoming",
|
||||
"creator_new_recipe",
|
||||
"week_plan_change",
|
||||
"pantry_forecast",
|
||||
"subscription_status",
|
||||
] as const;
|
||||
export type NotificationType = (typeof NOTIFICATION_TYPES)[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Datakvalitet – varje AI-datapunkt bär källa + confidence (spec §9)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const VERIFICATION_STATUSES = [
|
||||
"unverified",
|
||||
"user_verified",
|
||||
"editorially_verified",
|
||||
] as const;
|
||||
export type VerificationStatus = (typeof VERIFICATION_STATUSES)[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Översättningar (i18n-spec §11–14): AI-utkast -> granskning -> publicerad.
|
||||
// Svensk källtext är alltid sanningen; översättningar är vyer av den.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const TRANSLATION_STATUSES = ["draft_ai", "in_review", "published"] as const;
|
||||
export type TranslationStatus = (typeof TRANSLATION_STATUSES)[number];
|
||||
|
||||
export const TRANSLATION_SOURCES = ["seed", "ai", "human"] as const;
|
||||
export type TranslationSource = (typeof TRANSLATION_SOURCES)[number];
|
||||
|
||||
/**
|
||||
* Hjälpfunktion för Drizzle pgEnum som kräver en icke-tom tuple.
|
||||
* Användning: pgEnum("unit", tuple(UNITS))
|
||||
*/
|
||||
export function tuple<T extends readonly [string, ...string[]]>(
|
||||
values: T,
|
||||
): [T[number], ...T[number][]] {
|
||||
return [...values] as [T[number], ...T[number][]];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from "./enums.js";
|
||||
export * from "./nutrition.js";
|
||||
export * from "./entities.js";
|
||||
export * from "./constants.js";
|
||||
export * from "./brand.js";
|
||||
export * from "./locale.js";
|
||||
export * from "./money.js";
|
||||
export * from "./measurement.js";
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Locale-preferenser (i18n-spec §6): språk ≠ region ≠ måttsystem ≠ valuta.
|
||||
* En svensk i USA kan ha svensk UI-text, amerikansk region, USD och Fahrenheit.
|
||||
* Standarder: BCP 47 (språk), ISO 3166-1 alpha-2 (land), IANA (tidszon), ISO 4217 (valuta).
|
||||
*/
|
||||
|
||||
export const MEASUREMENT_SYSTEMS = ["METRIC", "US_CUSTOMARY", "MIXED"] as const;
|
||||
export type MeasurementSystem = (typeof MEASUREMENT_SYSTEMS)[number];
|
||||
|
||||
export const TEMPERATURE_UNITS = ["CELSIUS", "FAHRENHEIT"] as const;
|
||||
export type TemperatureUnit = (typeof TEMPERATURE_UNITS)[number];
|
||||
|
||||
export interface UserLocalePreferences {
|
||||
languageTag: string; // BCP 47, t.ex. "sv-SE"
|
||||
regionCode: string; // ISO 3166-1 alpha-2, t.ex. "SE"
|
||||
timeZone: string; // IANA, t.ex. "Europe/Stockholm"
|
||||
measurementSystem: MeasurementSystem;
|
||||
temperatureUnit: TemperatureUnit;
|
||||
currencyCode: string; // ISO 4217, t.ex. "SEK"
|
||||
firstDayOfWeek: number; // 0 = söndag … 6 = lördag
|
||||
use24HourTime: boolean;
|
||||
}
|
||||
|
||||
/** Kontext som följer med varje AAMOS-anrop (i18n-spec §22). */
|
||||
export interface LocaleContext {
|
||||
languageTag: string;
|
||||
regionCode: string;
|
||||
timeZone: string;
|
||||
measurementSystem: MeasurementSystem;
|
||||
temperatureUnit: TemperatureUnit;
|
||||
currencyCode: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_LOCALE_PREFERENCES: UserLocalePreferences = {
|
||||
languageTag: "sv-SE",
|
||||
regionCode: "SE",
|
||||
timeZone: "Europe/Stockholm",
|
||||
measurementSystem: "METRIC",
|
||||
temperatureUnit: "CELSIUS",
|
||||
currencyCode: "SEK",
|
||||
firstDayOfWeek: 1,
|
||||
use24HourTime: true,
|
||||
};
|
||||
|
||||
/** Rimliga regiondefaults – användaren kan alltid ändra varje fält separat. */
|
||||
const REGION_DEFAULTS: Record<string, Partial<UserLocalePreferences>> = {
|
||||
SE: {},
|
||||
NO: { languageTag: "nb-NO", timeZone: "Europe/Oslo", currencyCode: "NOK" },
|
||||
DK: { languageTag: "da-DK", timeZone: "Europe/Copenhagen", currencyCode: "DKK" },
|
||||
FI: { languageTag: "fi-FI", timeZone: "Europe/Helsinki", currencyCode: "EUR" },
|
||||
IS: { languageTag: "is-IS", timeZone: "Atlantic/Reykjavik", currencyCode: "ISK" },
|
||||
ES: { languageTag: "es-ES", timeZone: "Europe/Madrid", currencyCode: "EUR" },
|
||||
NL: { languageTag: "nl-NL", timeZone: "Europe/Amsterdam", currencyCode: "EUR" },
|
||||
PL: { languageTag: "pl-PL", timeZone: "Europe/Warsaw", currencyCode: "PLN" },
|
||||
PT: { languageTag: "pt-PT", timeZone: "Europe/Lisbon", currencyCode: "EUR" },
|
||||
IT: { languageTag: "it-IT", timeZone: "Europe/Rome", currencyCode: "EUR" },
|
||||
GB: {
|
||||
languageTag: "en-GB",
|
||||
timeZone: "Europe/London",
|
||||
currencyCode: "GBP",
|
||||
measurementSystem: "MIXED",
|
||||
},
|
||||
US: {
|
||||
languageTag: "en-US",
|
||||
timeZone: "America/New_York",
|
||||
currencyCode: "USD",
|
||||
measurementSystem: "US_CUSTOMARY",
|
||||
temperatureUnit: "FAHRENHEIT",
|
||||
firstDayOfWeek: 0,
|
||||
use24HourTime: false,
|
||||
},
|
||||
CA: {
|
||||
languageTag: "en-CA",
|
||||
timeZone: "America/Toronto",
|
||||
currencyCode: "CAD",
|
||||
measurementSystem: "MIXED",
|
||||
firstDayOfWeek: 0,
|
||||
use24HourTime: false,
|
||||
},
|
||||
DE: { languageTag: "de-DE", timeZone: "Europe/Berlin", currencyCode: "EUR" },
|
||||
FR: { languageTag: "fr-FR", timeZone: "Europe/Paris", currencyCode: "EUR" },
|
||||
};
|
||||
|
||||
export function localeDefaultsForRegion(regionCode: string): UserLocalePreferences {
|
||||
const overrides = REGION_DEFAULTS[regionCode.toUpperCase()] ?? {};
|
||||
return { ...DEFAULT_LOCALE_PREFERENCES, regionCode: regionCode.toUpperCase(), ...overrides };
|
||||
}
|
||||
|
||||
export function toLocaleContext(prefs: UserLocalePreferences): LocaleContext {
|
||||
return {
|
||||
languageTag: prefs.languageTag,
|
||||
regionCode: prefs.regionCode,
|
||||
timeZone: prefs.timeZone,
|
||||
measurementSystem: prefs.measurementSystem,
|
||||
temperatureUnit: prefs.temperatureUnit,
|
||||
currencyCode: prefs.currencyCode,
|
||||
};
|
||||
}
|
||||
|
||||
/** Temperaturkonvertering för visning (canonical lagras alltid i Celsius). */
|
||||
export function celsiusToDisplay(celsius: number, unit: TemperatureUnit): number {
|
||||
return unit === "FAHRENHEIT" ? Math.round((celsius * 9) / 5 + 32) : celsius;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { MeasurementSystem } from "./locale.js";
|
||||
import { UNIT_INFO } from "./constants.js";
|
||||
import type { Unit } from "./enums.js";
|
||||
|
||||
/**
|
||||
* Måttvisningstjänst (i18n-spec §9–10, §30 – M5).
|
||||
*
|
||||
* Lagring är ALLTID kanonisk (GRAM/MILLILITER/COUNT …). Denna modul väljer
|
||||
* hur en kanonisk mängd VISAS för användarens measurementSystem – förlustfritt:
|
||||
* originalvärdet rör vi aldrig, vi räknar bara fram en visningsrepresentation.
|
||||
*
|
||||
* METRIC g/kg, ml/dl/l som i dag.
|
||||
* US_CUSTOMARY vikt -> oz/lb, volym -> tsp/tbsp/fl oz/cup.
|
||||
* MIXED (GB/CA-stil) metriskt kök – som METRIC tills marknadsdata säger annat.
|
||||
*/
|
||||
|
||||
export interface DisplayQuantity {
|
||||
value: number;
|
||||
unit: Unit;
|
||||
/** Ex "0.25" -> visas som ¼ i UI:t om klienten vill. */
|
||||
approximate: boolean;
|
||||
}
|
||||
|
||||
const OZ_IN_GRAMS = 28.349523125;
|
||||
const LB_IN_GRAMS = 453.59237;
|
||||
const TSP_ML = 5;
|
||||
const TBSP_ML = 15;
|
||||
const FLOZ_ML = 29.5735295625;
|
||||
const CUP_ML = 236.5882365;
|
||||
|
||||
/** Avrunda till närmaste kvarts (för cups/tsp – så recepten ser naturliga ut). */
|
||||
function roundQuarter(v: number): number {
|
||||
return Math.round(v * 4) / 4;
|
||||
}
|
||||
|
||||
function round1(v: number): number {
|
||||
return Math.round(v * 10) / 10;
|
||||
}
|
||||
|
||||
/** Kanonisk mängd -> visningsmängd för användarens måttsystem. */
|
||||
export function displayQuantity(
|
||||
quantity: number,
|
||||
unit: Unit,
|
||||
system: MeasurementSystem,
|
||||
): DisplayQuantity {
|
||||
const info = UNIT_INFO[unit];
|
||||
if (!info) return { value: quantity, unit, approximate: false };
|
||||
|
||||
if (system !== "US_CUSTOMARY") {
|
||||
// METRIC/MIXED: uppgradera bara till läsbara metriska enheter.
|
||||
if (info.kind === "mass") {
|
||||
const grams = quantity * info.toBase;
|
||||
if (grams >= 1000)
|
||||
return { value: round1(grams / 1000), unit: "KILOGRAM", approximate: false };
|
||||
return { value: Math.round(grams), unit: "GRAM", approximate: false };
|
||||
}
|
||||
if (info.kind === "volume") {
|
||||
const ml = quantity * info.toBase;
|
||||
// Behåll kökstypiska enheter som de är (tsk/msk/krm visas bäst oförändrade).
|
||||
if (unit === "TEASPOON" || unit === "TABLESPOON" || unit === "PINCH") {
|
||||
return { value: quantity, unit, approximate: false };
|
||||
}
|
||||
if (ml >= 1000) return { value: round1(ml / 1000), unit: "LITER", approximate: false };
|
||||
if (ml >= 100) return { value: round1(ml / 100), unit: "DECILITER", approximate: false };
|
||||
return { value: Math.round(ml), unit: "MILLILITER", approximate: false };
|
||||
}
|
||||
return { value: quantity, unit, approximate: false };
|
||||
}
|
||||
|
||||
// US_CUSTOMARY
|
||||
if (info.kind === "mass") {
|
||||
const grams = quantity * info.toBase;
|
||||
if (grams >= LB_IN_GRAMS)
|
||||
return { value: round1(grams / LB_IN_GRAMS), unit: "POUND", approximate: true };
|
||||
return { value: round1(grams / OZ_IN_GRAMS), unit: "OUNCE", approximate: true };
|
||||
}
|
||||
if (info.kind === "volume") {
|
||||
const ml = quantity * info.toBase;
|
||||
if (ml >= CUP_ML / 2)
|
||||
return { value: roundQuarter(ml / CUP_ML), unit: "CUP_US", approximate: true };
|
||||
if (ml >= FLOZ_ML)
|
||||
return { value: roundQuarter(ml / FLOZ_ML), unit: "FLUID_OUNCE_US", approximate: true };
|
||||
if (ml >= TBSP_ML)
|
||||
return { value: roundQuarter(ml / TBSP_ML), unit: "TABLESPOON", approximate: true };
|
||||
return { value: roundQuarter(ml / TSP_ML), unit: "TEASPOON", approximate: true };
|
||||
}
|
||||
return { value: quantity, unit, approximate: false };
|
||||
}
|
||||
|
||||
/** kcal -> kJ (i18n-spec §18: EU visar båda, kJ = kcal × 4.184). */
|
||||
export function kcalToKilojoules(kcal: number): number {
|
||||
return Math.round(kcal * 4.184);
|
||||
}
|
||||
|
||||
/** Salt (g) <-> natrium (mg): salt = natrium × 2.5 (spec §18–19). */
|
||||
export function sodiumMgToSaltGrams(sodiumMg: number): number {
|
||||
return Math.round(sodiumMg * 2.5) / 1000;
|
||||
}
|
||||
|
||||
export function saltGramsToSodiumMg(saltGrams: number): number {
|
||||
return Math.round((saltGrams * 1000) / 2.5);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Pengar (i18n-spec §20): minor units + ISO 4217, aldrig floating point för lagring.
|
||||
* Historiska priser behåller originalvaluta; omräkning märks alltid som omräknad.
|
||||
*
|
||||
* M1 GENOMFÖRD: alla pengakolumner lagras som *_minor (heltal) och hushållet bär
|
||||
* currency_code (ISO 4217). Denna modul är den gemensamma modellen för visning/aritmetik.
|
||||
*/
|
||||
|
||||
export interface Money {
|
||||
/** Minsta enhet: öre, cent, pence … */
|
||||
amountMinor: number;
|
||||
/** ISO 4217, t.ex. "SEK", "EUR", "USD". */
|
||||
currency: string;
|
||||
}
|
||||
|
||||
/** Valutor med annat antal decimaler än 2 (ISO 4217). */
|
||||
const MINOR_DIGITS: Record<string, number> = { ISK: 0, JPY: 0, KWD: 3 };
|
||||
|
||||
export function minorDigits(currency: string): number {
|
||||
return MINOR_DIGITS[currency.toUpperCase()] ?? 2;
|
||||
}
|
||||
|
||||
export function toMinor(amount: number, currency: string): number {
|
||||
return Math.round(amount * 10 ** minorDigits(currency));
|
||||
}
|
||||
|
||||
export function fromMinor(money: Money): number {
|
||||
return money.amountMinor / 10 ** minorDigits(money.currency);
|
||||
}
|
||||
|
||||
export function money(amountMinor: number, currency: string): Money {
|
||||
if (!Number.isInteger(amountMinor)) {
|
||||
throw new Error(`amountMinor måste vara ett heltal, fick ${amountMinor}`);
|
||||
}
|
||||
return { amountMinor, currency: currency.toUpperCase() };
|
||||
}
|
||||
|
||||
export function addMoney(a: Money, b: Money): Money {
|
||||
if (a.currency !== b.currency) {
|
||||
throw new Error(`Kan inte addera ${a.currency} och ${b.currency} utan växelkurs`);
|
||||
}
|
||||
return { amountMinor: a.amountMinor + b.amountMinor, currency: a.currency };
|
||||
}
|
||||
|
||||
/** Locale-medveten formattering via Intl (finns i Node, Hermes/RN och webbläsare). */
|
||||
export function formatMoney(value: Money, languageTag: string): string {
|
||||
try {
|
||||
return new Intl.NumberFormat(languageTag, {
|
||||
style: "currency",
|
||||
currency: value.currency,
|
||||
}).format(fromMinor(value));
|
||||
} catch {
|
||||
return `${fromMinor(value)} ${value.currency}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Näringstyper. All beräkning sker i deterministisk kod (spec §21, §61.1) –
|
||||
* AI får aldrig hitta på näringsvärden. Värden presenteras alltid som
|
||||
* uppskattningar i UI.
|
||||
*/
|
||||
|
||||
/** Makro- och mikronäringsvärden. Alla värden ≥ 0. */
|
||||
export interface NutritionValues {
|
||||
/** kcal */
|
||||
kcal: number;
|
||||
/** gram */
|
||||
proteinG: number;
|
||||
/** gram */
|
||||
carbsG: number;
|
||||
/** gram */
|
||||
fatG: number;
|
||||
/** gram */
|
||||
saturatedFatG: number;
|
||||
/** gram */
|
||||
fiberG: number;
|
||||
/** gram */
|
||||
sugarG: number;
|
||||
/** gram salt (NaCl). natrium_mg = saltG * 400 */
|
||||
saltG: number;
|
||||
/** Utvalda mikronäringsämnen, valfria (spec §21) */
|
||||
micro?: MicroNutrients;
|
||||
}
|
||||
|
||||
export interface MicroNutrients {
|
||||
vitaminDUg?: number;
|
||||
vitaminB12Ug?: number;
|
||||
vitaminCMg?: number;
|
||||
folateUg?: number;
|
||||
ironMg?: number;
|
||||
calciumMg?: number;
|
||||
zincMg?: number;
|
||||
magnesiumMg?: number;
|
||||
potassiumMg?: number;
|
||||
iodineUg?: number;
|
||||
}
|
||||
|
||||
export type NutritionBasis = "per_100_g" | "per_100_ml" | "per_piece" | "per_portion";
|
||||
|
||||
/** Näringsdeklaration knuten till en bas (per 100 g/ml, per styck eller per portion). */
|
||||
export interface NutritionDeclaration {
|
||||
basis: NutritionBasis;
|
||||
/** Vikt i gram för "per_piece"/"per_portion" så att omräkning är möjlig. */
|
||||
referenceWeightG?: number;
|
||||
values: NutritionValues;
|
||||
}
|
||||
|
||||
/** Datakvalitet för näringsdata – källan ska alltid vara spårbar (spec §9, §21). */
|
||||
export interface NutritionProvenance {
|
||||
source:
|
||||
| "livsmedelsverket"
|
||||
| "product_label"
|
||||
| "licensed_database"
|
||||
| "user_entered"
|
||||
| "seed_estimate"
|
||||
| "computed_from_ingredients";
|
||||
confidence: number;
|
||||
verifiedByUser: boolean;
|
||||
lastVerifiedAt?: string;
|
||||
}
|
||||
|
||||
/** Intervallvisning för uppskattningar, t.ex. tallriksfoto (spec §22). */
|
||||
export interface NutritionEstimateRange {
|
||||
minKcal: number;
|
||||
maxKcal: number;
|
||||
mostLikelyKcal: number;
|
||||
values: NutritionValues;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
/** Dagsmål per användare, beräknade deterministiskt av nutrition-engine. */
|
||||
export interface DailyTargets {
|
||||
kcal: number;
|
||||
proteinG: number;
|
||||
carbsG: number;
|
||||
fatG: number;
|
||||
fiberG: number;
|
||||
/** Max rekommenderat salt (g) */
|
||||
saltMaxG: number;
|
||||
}
|
||||
|
||||
export const EMPTY_NUTRITION: NutritionValues = {
|
||||
kcal: 0,
|
||||
proteinG: 0,
|
||||
carbsG: 0,
|
||||
fatG: 0,
|
||||
saturatedFatG: 0,
|
||||
fiberG: 0,
|
||||
sugarG: 0,
|
||||
saltG: 0,
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
celsiusToDisplay,
|
||||
formatMoney,
|
||||
fromMinor,
|
||||
localeDefaultsForRegion,
|
||||
money,
|
||||
toMinor,
|
||||
} from "../src/index.js";
|
||||
|
||||
describe("locale-preferenser (i18n-spec §6)", () => {
|
||||
it("regiondefaults: USA får US_CUSTOMARY + Fahrenheit + USD + söndagsstart", () => {
|
||||
const us = localeDefaultsForRegion("us");
|
||||
expect(us.measurementSystem).toBe("US_CUSTOMARY");
|
||||
expect(us.temperatureUnit).toBe("FAHRENHEIT");
|
||||
expect(us.currencyCode).toBe("USD");
|
||||
expect(us.firstDayOfWeek).toBe(0);
|
||||
expect(us.use24HourTime).toBe(false);
|
||||
});
|
||||
it("okänd region faller tillbaka till metriska defaults", () => {
|
||||
const xx = localeDefaultsForRegion("XX");
|
||||
expect(xx.measurementSystem).toBe("METRIC");
|
||||
expect(xx.regionCode).toBe("XX");
|
||||
});
|
||||
it("temperatur: canonical Celsius → Fahrenheit-visning", () => {
|
||||
expect(celsiusToDisplay(200, "FAHRENHEIT")).toBe(392);
|
||||
expect(celsiusToDisplay(200, "CELSIUS")).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pengar i minor units (i18n-spec §20)", () => {
|
||||
it("SEK: 79 kr = 7900 öre, tillbaka utan förlust", () => {
|
||||
expect(toMinor(79, "SEK")).toBe(7900);
|
||||
expect(fromMinor(money(7900, "SEK"))).toBe(79);
|
||||
});
|
||||
it("valutor utan decimaler (ISK) hanteras", () => {
|
||||
expect(toMinor(500, "ISK")).toBe(500);
|
||||
expect(fromMinor(money(500, "ISK"))).toBe(500);
|
||||
});
|
||||
it("amountMinor måste vara heltal – aldrig floating point", () => {
|
||||
expect(() => money(79.5, "SEK")).toThrow();
|
||||
});
|
||||
it("formattering är locale-medveten", () => {
|
||||
const sv = formatMoney(money(7900, "SEK"), "sv-SE");
|
||||
const us = formatMoney(money(7900, "USD"), "en-US");
|
||||
expect(sv).toContain("79");
|
||||
expect(us).toContain("79");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
displayQuantity,
|
||||
kcalToKilojoules,
|
||||
saltGramsToSodiumMg,
|
||||
sodiumMgToSaltGrams,
|
||||
} from "../src/measurement.js";
|
||||
|
||||
describe("måttvisning (i18n-spec §9–10, M5)", () => {
|
||||
it("METRIC: gram uppgraderas läsbart, kökenheter behålls", () => {
|
||||
expect(displayQuantity(500, "GRAM", "METRIC")).toEqual({
|
||||
value: 500,
|
||||
unit: "GRAM",
|
||||
approximate: false,
|
||||
});
|
||||
expect(displayQuantity(1500, "GRAM", "METRIC")).toEqual({
|
||||
value: 1.5,
|
||||
unit: "KILOGRAM",
|
||||
approximate: false,
|
||||
});
|
||||
expect(displayQuantity(2, "TABLESPOON", "METRIC")).toEqual({
|
||||
value: 2,
|
||||
unit: "TABLESPOON",
|
||||
approximate: false,
|
||||
});
|
||||
expect(displayQuantity(250, "MILLILITER", "METRIC")).toEqual({
|
||||
value: 2.5,
|
||||
unit: "DECILITER",
|
||||
approximate: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("US_CUSTOMARY: vikt -> oz/lb", () => {
|
||||
const oz = displayQuantity(100, "GRAM", "US_CUSTOMARY");
|
||||
expect(oz.unit).toBe("OUNCE");
|
||||
expect(oz.value).toBeCloseTo(3.5, 1);
|
||||
const lb = displayQuantity(1, "KILOGRAM", "US_CUSTOMARY");
|
||||
expect(lb.unit).toBe("POUND");
|
||||
expect(lb.value).toBeCloseTo(2.2, 1);
|
||||
});
|
||||
|
||||
it("US_CUSTOMARY: volym -> tsp/tbsp/cup med kvartsavrundning", () => {
|
||||
const cup = displayQuantity(2.5, "DECILITER", "US_CUSTOMARY");
|
||||
expect(cup.unit).toBe("CUP_US");
|
||||
expect(cup.value).toBeCloseTo(1, 1);
|
||||
const tsp = displayQuantity(5, "MILLILITER", "US_CUSTOMARY");
|
||||
expect(tsp.unit).toBe("TEASPOON");
|
||||
expect(tsp.value).toBe(1);
|
||||
const tbsp = displayQuantity(15, "MILLILITER", "US_CUSTOMARY");
|
||||
expect(tbsp.unit).toBe("TABLESPOON");
|
||||
expect(tbsp.value).toBe(1);
|
||||
});
|
||||
|
||||
it("MIXED beter sig metriskt (GB/CA-kök)", () => {
|
||||
expect(displayQuantity(500, "GRAM", "MIXED").unit).toBe("GRAM");
|
||||
});
|
||||
|
||||
it("styck-enheter konverteras aldrig", () => {
|
||||
expect(displayQuantity(3, "COUNT", "US_CUSTOMARY")).toEqual({
|
||||
value: 3,
|
||||
unit: "COUNT",
|
||||
approximate: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("lagring påverkas aldrig – ren visningsfunktion utan sidoeffekter", () => {
|
||||
const before = { q: 500, u: "GRAM" as const };
|
||||
displayQuantity(before.q, before.u, "US_CUSTOMARY");
|
||||
expect(before).toEqual({ q: 500, u: "GRAM" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("energi & natrium (i18n-spec §18–19, M6)", () => {
|
||||
it("kcal -> kJ med 4.184", () => {
|
||||
expect(kcalToKilojoules(100)).toBe(418);
|
||||
expect(kcalToKilojoules(650)).toBe(2720);
|
||||
});
|
||||
|
||||
it("salt <-> natrium med faktor 2.5", () => {
|
||||
expect(sodiumMgToSaltGrams(400)).toBe(1);
|
||||
expect(saltGramsToSodiumMg(1)).toBe(400);
|
||||
expect(saltGramsToSodiumMg(sodiumMgToSaltGrams(1234))).toBeCloseTo(1234, -1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src", "test"],
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user