diff --git a/backend/src/common/utils/units.ts b/backend/src/common/utils/units.ts new file mode 100644 index 00000000..8b791c6e --- /dev/null +++ b/backend/src/common/utils/units.ts @@ -0,0 +1,152 @@ +/** + * Central enhetsdatabas för Recipe App. + * + * Alla stödda enheter definieras här med: + * - value : kanonisk förkortning (det som lagras i DB) + * - labelSv : svensk visningstext + * - type : enhetstyp (weight | volume | cooking | piece | other) + * - toBaseFactor: multiplicera för att nå SI-basenheten för typen + * (g för vikt, ml för volym och cooking) + * - aliases : alternativa stavningar/namn som normaliseras hit + * + * Konvertering sker alltid via basenheten: + * result = quantity * from.toBaseFactor / to.toBaseFactor + * + * Obs: "cooking"-enheter (tsk, msk, krm) är tekniskt sett volym men + * behandlas som en separat typ för att matcha befintlig datamodell. + * De kan inte konverteras med ml/dl utan vidare konfiguration. + */ + +export type UnitType = 'weight' | 'volume' | 'cooking' | 'piece' | 'other'; + +export interface UnitDefinition { + /** Kanonisk förkortning — det som lagras i databasen */ + value: string; + /** Svensk visningstext */ + labelSv: string; + /** Enhetstyp */ + type: UnitType; + /** + * Faktor för att konvertera till SI-basenheten för typen. + * - weight → gram (g = 1) + * - volume → milliliter (ml = 1) + * - cooking → milliliter (krm = 1 ml, tsk = 5 ml, msk = 15 ml) + * - piece/other → 1 (ej konverterbara) + */ + toBaseFactor: number; + /** Alternativa stavningar/namn som normaliseras till value */ + aliases: string[]; +} + +export const UNIT_DEFINITIONS: UnitDefinition[] = [ + // ── Vikt ────────────────────────────────────────────────── + { value: 'g', labelSv: 'g (gram)', type: 'weight', toBaseFactor: 1, aliases: ['gram'] }, + { value: 'hg', labelSv: 'hg (hektogram)', type: 'weight', toBaseFactor: 100, aliases: ['hektogram'] }, + { value: 'kg', labelSv: 'kg (kilogram)', type: 'weight', toBaseFactor: 1000, aliases: ['kilo', 'kilogram'] }, + { value: 'mg', labelSv: 'mg (milligram)', type: 'weight', toBaseFactor: 0.001, aliases: ['milligram'] }, + + // ── Volym ───────────────────────────────────────────────── + { value: 'ml', labelSv: 'ml (milliliter)', type: 'volume', toBaseFactor: 1, aliases: ['milliliter'] }, + { value: 'cl', labelSv: 'cl (centiliter)', type: 'volume', toBaseFactor: 10, aliases: ['centiliter'] }, + { value: 'dl', labelSv: 'dl (deciliter)', type: 'volume', toBaseFactor: 100, aliases: ['deciliter'] }, + { value: 'l', labelSv: 'l (liter)', type: 'volume', toBaseFactor: 1000, aliases: ['liter'] }, + + // ── Matlagning (svenska köksenheter) ────────────────────── + { value: 'krm', labelSv: 'krm (kryddmått)', type: 'cooking', toBaseFactor: 1, aliases: ['kryddmatt', 'kryddmått'] }, + { value: 'tsk', labelSv: 'tsk (tesked)', type: 'cooking', toBaseFactor: 5, aliases: ['tesked', 'test'] }, + { value: 'msk', labelSv: 'msk (matsked)', type: 'cooking', toBaseFactor: 15, aliases: ['matsked', 'matsled'] }, + + // ── Styckenheter (ej konverterbara) ─────────────────────── + { value: 'st', labelSv: 'st (styck)', type: 'piece', toBaseFactor: 1, aliases: ['stycke', 'styck', 'stk'] }, + { value: 'port', labelSv: 'port (portioner)', type: 'piece', toBaseFactor: 1, aliases: ['portion', 'portioner'] }, + { value: 'förp', labelSv: 'förp (förpackning)',type: 'piece', toBaseFactor: 1, aliases: ['forp', 'förpackning', 'forpackning'] }, + { value: 'klyfta',labelSv: 'klyfta', type: 'piece', toBaseFactor: 1, aliases: [] }, + + // ── Övrigt ──────────────────────────────────────────────── + { value: 'efter smak', labelSv: 'efter smak', type: 'other', toBaseFactor: 1, aliases: ['eftr smak', 'efter smak'] }, +]; + +/** Snabbuppslagning: alias/value → UnitDefinition */ +const _unitLookup = new Map(); +for (const def of UNIT_DEFINITIONS) { + _unitLookup.set(def.value.toLowerCase(), def); + for (const alias of def.aliases) { + _unitLookup.set(alias.toLowerCase(), def); + } +} + +/** + * Normalisera en enhetssträng till kanonisk förkortning. + * T.ex. "Matsked" → "msk", "Kilogram" → "kg". + * Om enheten är okänd returneras den trimmad och lowercasad. + */ +export function normalizeUnit(unit: string): string { + const key = unit.trim().toLowerCase(); + return _unitLookup.get(key)?.value ?? key; +} + +/** + * Hämta UnitDefinition för en enhet (normaliseras automatiskt). + * Returnerar undefined om enheten inte finns i UNIT_DEFINITIONS. + */ +export function getUnitDefinition(unit: string): UnitDefinition | undefined { + const key = unit.trim().toLowerCase(); + return _unitLookup.get(key); +} + +/** + * Hämta enhetstypen för en enhet. + * Returnerar null om enheten är okänd. + */ +export function getUnitType(unit: string): UnitType | null { + return getUnitDefinition(unit)?.type ?? null; +} + +/** + * Kontrollera om konvertering är möjlig mellan två enheter. + * Konvertering kräver att båda tillhör samma typ och inte är 'piece' eller 'other'. + */ +export function canConvert(fromUnit: string, toUnit: string): boolean { + const from = getUnitDefinition(fromUnit); + const to = getUnitDefinition(toUnit); + if (!from || !to) return false; + if (from.type !== to.type) return false; + if (from.type === 'piece' || from.type === 'other') return false; + return true; +} + +/** + * Konverterar en mängd från en enhet till en annan. + * + * @throws Error om quantity ≤ 0 + * @throws Error om en enhet är okänd ("Unknown unit: ...") + * @throws Error om enheterna inte kan konverteras ("Cannot convert between incompatible unit types: ...") + */ +export function convertUnit(quantity: number, fromUnit: string, toUnit: string): number { + if (quantity <= 0) { + throw new Error(`Invalid quantity: ${quantity}. Quantity must be positive.`); + } + + const normalizedFrom = normalizeUnit(fromUnit); + const normalizedTo = normalizeUnit(toUnit); + + if (normalizedFrom === normalizedTo) return quantity; + + const fromDef = getUnitDefinition(normalizedFrom); + const toDef = getUnitDefinition(normalizedTo); + + if (!fromDef) throw new Error(`Unknown unit: "${fromUnit}"`); + if (!toDef) throw new Error(`Unknown unit: "${toUnit}"`); + + if (fromDef.type !== toDef.type) { + throw new Error( + `Cannot convert between incompatible unit types: "${fromUnit}" (${fromDef.type}) and "${toUnit}" (${toDef.type})`, + ); + } + + if (fromDef.type === 'piece' || fromDef.type === 'other') { + return quantity; // Styckenheter konverteras inte + } + + return (quantity * fromDef.toBaseFactor) / toDef.toBaseFactor; +} diff --git a/backend/src/recipes/recipes.service.ts b/backend/src/recipes/recipes.service.ts index 20c6be01..813730ed 100644 --- a/backend/src/recipes/recipes.service.ts +++ b/backend/src/recipes/recipes.service.ts @@ -4,6 +4,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { CreateRecipeDto } from './dto/create-recipe.dto'; import { ParseMarkdownDto } from './dto/parse-markdown.dto'; import { downloadAndOptimizeImage } from '../common/utils/download-image'; +import { normalizeUnit, getUnitType, convertUnit, canConvert } from '../common/utils/units'; const IMAGE_DEST_DIR = process.env.IMAGE_DEST_DIR || '/app/recipe-images'; @@ -24,140 +25,8 @@ interface ParsedRecipe { @Injectable() export class RecipesService { - // Enhetsklassificering - private static readonly WEIGHT_UNITS = ['g', 'kg']; - private static readonly VOLUME_UNITS = ['ml', 'dl']; - private static readonly PORTION_UNITS = ['tsk', 'msk']; // tesked, matsked - private static readonly PIECE_UNITS = ['st']; // stycken - - // Konverteringsregler för varje enhetstyp - private static readonly WEIGHT_CONVERSIONS: Record = { - 'g': 1, - 'kg': 1000, - }; - private static readonly VOLUME_CONVERSIONS: Record = { - 'ml': 1, - 'dl': 100, - }; - private static readonly PORTION_CONVERSIONS: Record = { - 'tsk': 1, - 'msk': 3, // 1 matsked ≈ 3 teskedar - }; - constructor(private readonly prisma: PrismaService) {} - /** Normalisera enheter (t.ex. "tesked" → "tsk", "milliliter" → "ml") */ - private normalizeUnit(unit: string): string { - const normalized = unit.trim().toLowerCase(); - const unitAliases: Record = { - 'tesked': 'tsk', - 'test': 'tsk', - 'matsked': 'msk', - 'matsled': 'msk', - 'milliliter': 'ml', - 'deciliter': 'dl', - 'gram': 'g', - 'kilo': 'kg', - 'kilogram': 'kg', - 'stycke': 'st', - }; - return unitAliases[normalized] || normalized; - } - - /** Bestäm vilken enhetstyp en enhet tillhör */ - private getUnitCategory(unit: string): string | null { - const normalized = this.normalizeUnit(unit); - if (RecipesService.WEIGHT_UNITS.includes(normalized)) return 'weight'; - if (RecipesService.VOLUME_UNITS.includes(normalized)) return 'volume'; - if (RecipesService.PORTION_UNITS.includes(normalized)) return 'portion'; - if (RecipesService.PIECE_UNITS.includes(normalized)) return 'piece'; - return null; - } - - /** Kontrollera om en enhet är viktbaserad */ - private isWeightUnit(unit: string): boolean { - return this.getUnitCategory(unit) === 'weight'; - } - - /** Kontrollera om en enhet är volymbaserad */ - private isVolumeUnit(unit: string): boolean { - return this.getUnitCategory(unit) === 'volume'; - } - - /** - * Konverterar kvantitet mellan enheter för en given produkt. - * Stödjer vikt (g/kg), volym (ml/dl) och svenska måttenheter (tsk/msk). - * Konverterar endast inom samma enhetstyp. - * - * @throws Error om quantity är negativ/noll, enheter är tomma, eller enheter är inkompatibla - */ - private convertUnit(quantity: number, fromUnit: string, toUnit: string, productName: string): number { - // Input validation - if (quantity <= 0) { - throw new Error(`Invalid quantity: ${quantity}. Quantity must be positive.`); - } - if (!fromUnit?.trim()) { - throw new Error('From unit cannot be empty.'); - } - if (!toUnit?.trim()) { - throw new Error('To unit cannot be empty.'); - } - if (!productName?.trim()) { - throw new Error('Product name cannot be empty.'); - } - - // Normalisera och kontrollera enheter - const normalizedFromUnit = this.normalizeUnit(fromUnit); - const normalizedToUnit = this.normalizeUnit(toUnit); - - // Om enheterna är identiska efter normalisering, returnera direkt - if (normalizedFromUnit === normalizedToUnit) { - return quantity; - } - - // Bestäm enhetstyp - const fromCategory = this.getUnitCategory(normalizedFromUnit); - const toCategory = this.getUnitCategory(normalizedToUnit); - - if (!fromCategory) { - throw new Error(`Unknown unit: "${fromUnit}"`); - } - if (!toCategory) { - throw new Error(`Unknown unit: "${toUnit}"`); - } - - // Konvertera endast inom samma enhetstyp - if (fromCategory !== toCategory) { - throw new Error( - `Cannot convert between incompatible unit types: "${fromUnit}" (${fromCategory}) and "${toUnit}" (${toCategory}) for product "${productName}"`, - ); - } - - // Hämta rätt konverteringstabll baserat på enhetstyp - let conversions: Record; - switch (fromCategory) { - case 'weight': - conversions = RecipesService.WEIGHT_CONVERSIONS; - break; - case 'volume': - conversions = RecipesService.VOLUME_CONVERSIONS; - break; - case 'portion': - conversions = RecipesService.PORTION_CONVERSIONS; - break; - case 'piece': - // Kan inte konvertera stycken - return quantity; - default: - throw new Error(`Unknown unit category: ${fromCategory}`); - } - - // Konvertera via basenhet - return (quantity * conversions[normalizedFromUnit]) / conversions[normalizedToUnit]; - } - - // --- ÖVRIGA METODER (findAll, findOne, create) OFÖRÄNDRADE --- - async getInventoryPreview(id: number) { const recipe = await this.prisma.recipe.findUnique({ where: { id }, @@ -202,11 +71,10 @@ export class RecipesService { for (const item of otherUnitItems) { // Konvertera endast om enheter är kompatibla (samma kategori) try { - const convertedQuantity = this.convertUnit( + const convertedQuantity = convertUnit( Number(item.quantity), item.unit, ingredient.unit, - ingredient.product.name, ); availableOtherUnit += convertedQuantity; } catch { @@ -245,12 +113,11 @@ export class RecipesService { })), otherInventoryItems: otherUnitItems.map((item: any) => { // Kolla om konvertering är möjlig (samma enhetskategori) - const canConvert = this.getUnitCategory(item.unit) === this.getUnitCategory(ingredient.unit) - && this.getUnitCategory(ingredient.unit) !== 'piece'; + const canConvertUnits = canConvert(item.unit, ingredient.unit); let convertedQuantity = 0; - if (canConvert) { + if (canConvertUnits) { try { - convertedQuantity = this.convertUnit(Number(item.quantity), item.unit, ingredient.unit, ingredient.product.name); + convertedQuantity = convertUnit(Number(item.quantity), item.unit, ingredient.unit); } catch { convertedQuantity = 0; } @@ -261,8 +128,8 @@ export class RecipesService { quantity: item.quantity, unit: item.unit, location: item.location, - convertedQuantity: canConvert ? convertedQuantity : 0, - canConvert, + convertedQuantity: canConvertUnits ? convertedQuantity : 0, + canConvert: canConvertUnits, }; }), status, diff --git a/frontend/lib/units.ts b/frontend/lib/units.ts index 9390d8fe..a3300a96 100644 --- a/frontend/lib/units.ts +++ b/frontend/lib/units.ts @@ -1,17 +1,111 @@ +/** + * Central enhetsdatabas för Recipe App (frontend-spegel av backend/src/common/utils/units.ts). + * Håll dessa filer i synk vid ändringar. + */ + +export type UnitType = 'weight' | 'volume' | 'cooking' | 'piece' | 'other'; + +export interface UnitDefinition { + value: string; + labelSv: string; + type: UnitType; + toBaseFactor: number; + aliases: string[]; +} + +export const UNIT_DEFINITIONS: UnitDefinition[] = [ + // ── Vikt ────────────────────────────────────────────────── + { value: 'g', labelSv: 'g (gram)', type: 'weight', toBaseFactor: 1, aliases: ['gram'] }, + { value: 'hg', labelSv: 'hg (hektogram)', type: 'weight', toBaseFactor: 100, aliases: ['hektogram'] }, + { value: 'kg', labelSv: 'kg (kilogram)', type: 'weight', toBaseFactor: 1000, aliases: ['kilo', 'kilogram'] }, + { value: 'mg', labelSv: 'mg (milligram)', type: 'weight', toBaseFactor: 0.001, aliases: ['milligram'] }, + // ── Volym ───────────────────────────────────────────────── + { value: 'ml', labelSv: 'ml (milliliter)', type: 'volume', toBaseFactor: 1, aliases: ['milliliter'] }, + { value: 'cl', labelSv: 'cl (centiliter)', type: 'volume', toBaseFactor: 10, aliases: ['centiliter'] }, + { value: 'dl', labelSv: 'dl (deciliter)', type: 'volume', toBaseFactor: 100, aliases: ['deciliter'] }, + { value: 'l', labelSv: 'l (liter)', type: 'volume', toBaseFactor: 1000, aliases: ['liter'] }, + // ── Matlagning ──────────────────────────────────────────── + { value: 'krm', labelSv: 'krm (kryddmått)', type: 'cooking', toBaseFactor: 1, aliases: ['kryddmatt', 'kryddmått'] }, + { value: 'tsk', labelSv: 'tsk (tesked)', type: 'cooking', toBaseFactor: 5, aliases: ['tesked', 'test'] }, + { value: 'msk', labelSv: 'msk (matsked)', type: 'cooking', toBaseFactor: 15, aliases: ['matsked', 'matsled'] }, + // ── Styck ───────────────────────────────────────────────── + { value: 'st', labelSv: 'st (styck)', type: 'piece', toBaseFactor: 1, aliases: ['stycke', 'styck', 'stk'] }, + { value: 'port', labelSv: 'port (portioner)', type: 'piece', toBaseFactor: 1, aliases: ['portion', 'portioner'] }, + { value: 'förp', labelSv: 'förp (förpackning)', type: 'piece', toBaseFactor: 1, aliases: ['forp', 'förpackning', 'forpackning'] }, + { value: 'klyfta', labelSv: 'klyfta', type: 'piece', toBaseFactor: 1, aliases: [] }, + // ── Övrigt ──────────────────────────────────────────────── + { value: 'efter smak', labelSv: 'efter smak', type: 'other', toBaseFactor: 1, aliases: ['eftr smak'] }, +]; + +/** Alla enheter som { value, label } — bakåtkompatibel ersättning för gamla UNIT_OPTIONS */ export const UNIT_OPTIONS = [ { value: '', label: 'Välj enhet' }, - { value: 'g', label: 'g (gram)' }, - { value: 'kg', label: 'kg (kilogram)' }, - { value: 'hg', label: 'hg (hektogram)' }, - { value: 'ml', label: 'ml (milliliter)' }, - { value: 'dl', label: 'dl (deciliter)' }, - { value: 'l', label: 'l (liter)' }, - { value: 'st', label: 'st (styck)' }, - { value: 'tsk', label: 'tsk (tesked)' }, - { value: 'msk', label: 'msk (matsked)' }, - { value: 'krm', label: 'krm (kryddmått)' }, - { value: 'port', label: 'port (portioner)' }, - { value: 'efter smak', label: 'Efter smak' }, - { value: 'förp', label: 'förp (förpackning)' }, - { value: 'klyfta', label: 'klyfta' }, + ...UNIT_DEFINITIONS.map((u) => ({ value: u.value, label: u.labelSv })), ]; + +/** Enheter grupperade per typ — för användning i dropdown med optgroup */ +export const UNIT_OPTIONS_GROUPED: { group: string; options: { value: string; label: string }[] }[] = [ + { + group: 'Vikt', + options: UNIT_DEFINITIONS.filter((u) => u.type === 'weight').map((u) => ({ value: u.value, label: u.labelSv })), + }, + { + group: 'Volym', + options: UNIT_DEFINITIONS.filter((u) => u.type === 'volume').map((u) => ({ value: u.value, label: u.labelSv })), + }, + { + group: 'Matlagning', + options: UNIT_DEFINITIONS.filter((u) => u.type === 'cooking').map((u) => ({ value: u.value, label: u.labelSv })), + }, + { + group: 'Styck', + options: UNIT_DEFINITIONS.filter((u) => u.type === 'piece').map((u) => ({ value: u.value, label: u.labelSv })), + }, + { + group: 'Övrigt', + options: UNIT_DEFINITIONS.filter((u) => u.type === 'other').map((u) => ({ value: u.value, label: u.labelSv })), + }, +]; + +/** Normalisera en enhetssträng till kanonisk förkortning. */ +export function normalizeUnit(unit: string): string { + const key = unit.trim().toLowerCase(); + for (const def of UNIT_DEFINITIONS) { + if (def.value.toLowerCase() === key) return def.value; + if (def.aliases.some((a) => a.toLowerCase() === key)) return def.value; + } + return key; +} + +/** Hämta UnitDefinition för en enhet. */ +export function getUnitDefinition(unit: string): UnitDefinition | undefined { + const key = unit.trim().toLowerCase(); + return UNIT_DEFINITIONS.find( + (d) => d.value.toLowerCase() === key || d.aliases.some((a) => a.toLowerCase() === key), + ); +} + +/** Hämta enhetstypen för en enhet. */ +export function getUnitType(unit: string): UnitType | null { + return getUnitDefinition(unit)?.type ?? null; +} + +/** Kontrollera om konvertering är möjlig mellan två enheter. */ +export function canConvert(fromUnit: string, toUnit: string): boolean { + const from = getUnitDefinition(fromUnit); + const to = getUnitDefinition(toUnit); + if (!from || !to) return false; + if (from.type !== to.type) return false; + if (from.type === 'piece' || from.type === 'other') return false; + return true; +} + +/** Konverterar en mängd från en enhet till en annan. */ +export function convertUnit(quantity: number, fromUnit: string, toUnit: string): number { + const fromDef = getUnitDefinition(fromUnit); + const toDef = getUnitDefinition(toUnit); + if (!fromDef || !toDef || fromDef.type !== toDef.type) return quantity; + if (fromDef.type === 'piece' || fromDef.type === 'other') return quantity; + return (quantity * fromDef.toBaseFactor) / toDef.toBaseFactor; +} +