Initial commit (unpacked platform)

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