feat: implement unit conversion utilities and centralize unit definitions for consistency across frontend and backend

This commit is contained in:
Nils-Johan Gynther
2026-04-21 10:51:07 +02:00
parent 8e9b90028f
commit 5345b9e55e
3 changed files with 267 additions and 154 deletions
+152
View File
@@ -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<string, UnitDefinition>();
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;
}
+7 -140
View File
@@ -4,6 +4,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { CreateRecipeDto } from './dto/create-recipe.dto'; import { CreateRecipeDto } from './dto/create-recipe.dto';
import { ParseMarkdownDto } from './dto/parse-markdown.dto'; import { ParseMarkdownDto } from './dto/parse-markdown.dto';
import { downloadAndOptimizeImage } from '../common/utils/download-image'; 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'; const IMAGE_DEST_DIR = process.env.IMAGE_DEST_DIR || '/app/recipe-images';
@@ -24,140 +25,8 @@ interface ParsedRecipe {
@Injectable() @Injectable()
export class RecipesService { 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<string, number> = {
'g': 1,
'kg': 1000,
};
private static readonly VOLUME_CONVERSIONS: Record<string, number> = {
'ml': 1,
'dl': 100,
};
private static readonly PORTION_CONVERSIONS: Record<string, number> = {
'tsk': 1,
'msk': 3, // 1 matsked ≈ 3 teskedar
};
constructor(private readonly prisma: PrismaService) {} 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<string, string> = {
'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<string, number>;
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) { async getInventoryPreview(id: number) {
const recipe = await this.prisma.recipe.findUnique({ const recipe = await this.prisma.recipe.findUnique({
where: { id }, where: { id },
@@ -202,11 +71,10 @@ export class RecipesService {
for (const item of otherUnitItems) { for (const item of otherUnitItems) {
// Konvertera endast om enheter är kompatibla (samma kategori) // Konvertera endast om enheter är kompatibla (samma kategori)
try { try {
const convertedQuantity = this.convertUnit( const convertedQuantity = convertUnit(
Number(item.quantity), Number(item.quantity),
item.unit, item.unit,
ingredient.unit, ingredient.unit,
ingredient.product.name,
); );
availableOtherUnit += convertedQuantity; availableOtherUnit += convertedQuantity;
} catch { } catch {
@@ -245,12 +113,11 @@ export class RecipesService {
})), })),
otherInventoryItems: otherUnitItems.map((item: any) => { otherInventoryItems: otherUnitItems.map((item: any) => {
// Kolla om konvertering är möjlig (samma enhetskategori) // Kolla om konvertering är möjlig (samma enhetskategori)
const canConvert = this.getUnitCategory(item.unit) === this.getUnitCategory(ingredient.unit) const canConvertUnits = canConvert(item.unit, ingredient.unit);
&& this.getUnitCategory(ingredient.unit) !== 'piece';
let convertedQuantity = 0; let convertedQuantity = 0;
if (canConvert) { if (canConvertUnits) {
try { try {
convertedQuantity = this.convertUnit(Number(item.quantity), item.unit, ingredient.unit, ingredient.product.name); convertedQuantity = convertUnit(Number(item.quantity), item.unit, ingredient.unit);
} catch { } catch {
convertedQuantity = 0; convertedQuantity = 0;
} }
@@ -261,8 +128,8 @@ export class RecipesService {
quantity: item.quantity, quantity: item.quantity,
unit: item.unit, unit: item.unit,
location: item.location, location: item.location,
convertedQuantity: canConvert ? convertedQuantity : 0, convertedQuantity: canConvertUnits ? convertedQuantity : 0,
canConvert, canConvert: canConvertUnits,
}; };
}), }),
status, status,
+108 -14
View File
@@ -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 = [ export const UNIT_OPTIONS = [
{ value: '', label: 'Välj enhet' }, { value: '', label: 'Välj enhet' },
{ value: 'g', label: 'g (gram)' }, ...UNIT_DEFINITIONS.map((u) => ({ value: u.value, label: u.labelSv })),
{ 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' },
]; ];
/** 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;
}