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
+119
View File
@@ -0,0 +1,119 @@
import {
EMPTY_NUTRITION,
type NutritionDeclaration,
type NutritionValues,
type Unit,
} from "@app/shared-types";
import { toBase, toGrams, type IngredientUnitInfo } from "./units.js";
/** Skala näringsvärden med en faktor. */
export function scaleNutrition(values: NutritionValues, factor: number): NutritionValues {
const scaled: NutritionValues = {
kcal: values.kcal * factor,
proteinG: values.proteinG * factor,
carbsG: values.carbsG * factor,
fatG: values.fatG * factor,
saturatedFatG: values.saturatedFatG * factor,
fiberG: values.fiberG * factor,
sugarG: values.sugarG * factor,
saltG: values.saltG * factor,
};
if (values.micro) {
scaled.micro = Object.fromEntries(
Object.entries(values.micro).map(([k, v]) => [k, v == null ? v : v * factor]),
);
}
return scaled;
}
/** Summera två näringsvärden. */
export function addNutrition(a: NutritionValues, b: NutritionValues): NutritionValues {
const sum: NutritionValues = {
kcal: a.kcal + b.kcal,
proteinG: a.proteinG + b.proteinG,
carbsG: a.carbsG + b.carbsG,
fatG: a.fatG + b.fatG,
saturatedFatG: a.saturatedFatG + b.saturatedFatG,
fiberG: a.fiberG + b.fiberG,
sugarG: a.sugarG + b.sugarG,
saltG: a.saltG + b.saltG,
};
const micros = { ...(a.micro ?? {}) } as Record<string, number | undefined>;
if (b.micro) {
for (const [k, v] of Object.entries(b.micro)) {
if (v == null) continue;
micros[k] = (micros[k] ?? 0) + v;
}
}
if (Object.keys(micros).length > 0) sum.micro = micros;
return sum;
}
export function sumNutrition(items: NutritionValues[]): NutritionValues {
return items.reduce(addNutrition, { ...EMPTY_NUTRITION });
}
/** Avrunda för presentation (heltal kcal, en decimal på gram). */
export function roundNutrition(values: NutritionValues): NutritionValues {
const r1 = (v: number) => Math.round(v * 10) / 10;
const rounded: NutritionValues = {
kcal: Math.round(values.kcal),
proteinG: r1(values.proteinG),
carbsG: r1(values.carbsG),
fatG: r1(values.fatG),
saturatedFatG: r1(values.saturatedFatG),
fiberG: r1(values.fiberG),
sugarG: r1(values.sugarG),
saltG: r1(values.saltG),
};
if (values.micro) rounded.micro = values.micro;
return rounded;
}
/**
* Beräkna näring för en given mängd av en ingrediens/produkt utifrån dess
* deklaration. Returnerar null när beräkningen inte kan göras säkert
* anroparen ansvarar då för att visa osäkerheten (spec §61.4), aldrig gissa.
*/
export function computeItemNutrition(
quantity: number,
unit: Unit,
declaration: NutritionDeclaration,
info: IngredientUnitInfo = {},
): NutritionValues | null {
if (quantity < 0) return null;
switch (declaration.basis) {
case "per_100_g": {
const grams = toGrams(quantity, unit, info);
if (grams == null) return null;
return scaleNutrition(declaration.values, grams / 100);
}
case "per_100_ml": {
const base = toBase(quantity, unit);
let ml: number | null = null;
if (base.kind === "volume") ml = base.amount;
else {
// massa/antal → gram → ml via densitet
const grams = toGrams(quantity, unit, info);
const density = info.densityGPerMl;
if (grams != null && density != null && density > 0) ml = grams / density;
}
if (ml == null) return null;
return scaleNutrition(declaration.values, ml / 100);
}
case "per_piece": {
const base = toBase(quantity, unit);
if (base.kind === "count") return scaleNutrition(declaration.values, base.amount);
// Vikt angiven: räkna om via referensvikt.
const grams = toGrams(quantity, unit, info);
const ref = declaration.referenceWeightG;
if (grams == null || ref == null || ref <= 0) return null;
return scaleNutrition(declaration.values, grams / ref);
}
case "per_portion": {
const base = toBase(quantity, unit);
if (base.kind === "count") return scaleNutrition(declaration.values, base.amount);
return null;
}
}
}
+51
View File
@@ -0,0 +1,51 @@
import type { DailyTargets, NutritionValues } from "@app/shared-types";
import { roundNutrition, sumNutrition } from "./calc.js";
export interface DaySummary {
consumed: NutritionValues;
targets: DailyTargets;
remaining: {
kcal: number;
proteinG: number;
carbsG: number;
fatG: number;
fiberG: number;
};
/** Andel av dagsmålet (01+), för progressvisning. */
progress: {
kcal: number;
proteinG: number;
carbsG: number;
fatG: number;
fiberG: number;
saltOfMax: number;
};
saltWarning: boolean;
}
/** "Min dag" (spec §4.3): summera loggade måltider mot dagsmål. */
export function summarizeDay(meals: NutritionValues[], targets: DailyTargets): DaySummary {
const consumed = roundNutrition(sumNutrition(meals));
const remaining = {
kcal: Math.round(targets.kcal - consumed.kcal),
proteinG: Math.round((targets.proteinG - consumed.proteinG) * 10) / 10,
carbsG: Math.round((targets.carbsG - consumed.carbsG) * 10) / 10,
fatG: Math.round((targets.fatG - consumed.fatG) * 10) / 10,
fiberG: Math.round((targets.fiberG - consumed.fiberG) * 10) / 10,
};
const safeDiv = (a: number, b: number) => (b > 0 ? a / b : 0);
return {
consumed,
targets,
remaining,
progress: {
kcal: safeDiv(consumed.kcal, targets.kcal),
proteinG: safeDiv(consumed.proteinG, targets.proteinG),
carbsG: safeDiv(consumed.carbsG, targets.carbsG),
fatG: safeDiv(consumed.fatG, targets.fatG),
fiberG: safeDiv(consumed.fiberG, targets.fiberG),
saltOfMax: safeDiv(consumed.saltG, targets.saltMaxG),
},
saltWarning: consumed.saltG > targets.saltMaxG,
};
}
+107
View File
@@ -0,0 +1,107 @@
import type { ActivityLevel, DailyTargets, GoalType, Sex } from "@app/shared-types";
/**
* Energiberäkning enligt MifflinSt Jeor. Detta är vägledning baserad på
* officiella rekommendationer Appen är inte medicinsk rådgivning (spec §6, §21).
*/
export interface EnergyProfile {
sex: Sex;
age: number;
heightCm: number;
weightKg: number;
activityLevel: ActivityLevel;
primaryGoal?: GoalType | undefined;
}
const ACTIVITY_FACTORS: Record<ActivityLevel, number> = {
sedentary: 1.2,
light: 1.375,
moderate: 1.55,
active: 1.725,
very_active: 1.9,
};
/** Justering per mål (kcal/dag). Konservativa, väldokumenterade nivåer. */
const GOAL_ADJUSTMENTS: Partial<Record<GoalType, number>> = {
lose_weight: -500,
gain_weight: 400,
build_muscle: 250,
maintain_weight: 0,
};
/** Lägsta rekommenderade energiintag under detta kapas aldrig målet (säkerhetsgolv). */
const MIN_KCAL = 1200;
export function bmrMifflinStJeor(
p: Pick<EnergyProfile, "sex" | "age" | "heightCm" | "weightKg">,
): number {
const base = 10 * p.weightKg + 6.25 * p.heightCm - 5 * p.age;
if (p.sex === "male") return base + 5;
if (p.sex === "female") return base - 161;
// Ospecificerat: medelvärde av formlerna, transparent redovisat i UI.
return base - 78;
}
export function tdee(p: EnergyProfile): number {
return bmrMifflinStJeor(p) * ACTIVITY_FACTORS[p.activityLevel];
}
export interface DailyTargetsResult {
targets: DailyTargets;
basis: {
bmrKcal: number;
tdeeKcal: number;
goalAdjustmentKcal: number;
activityLevel: ActivityLevel;
};
}
/**
* Dagsmål: energi via TDEE + måljustering; protein per kg kroppsvikt;
* fett som andel av energi; kolhydrater = resten; fiber 3 g/MJ (nordisk rekommendation);
* salt max 6 g/dag.
*/
export function computeDailyTargets(p: EnergyProfile): DailyTargetsResult {
const bmr = bmrMifflinStJeor(p);
const maintenance = tdee(p);
const adjustment = GOAL_ADJUSTMENTS[p.primaryGoal ?? "maintain_weight"] ?? 0;
const kcal = Math.max(MIN_KCAL, Math.round(maintenance + adjustment));
const proteinPerKg =
p.primaryGoal === "build_muscle" || p.primaryGoal === "more_protein"
? 1.8
: p.primaryGoal === "lose_weight"
? 1.6
: 1.2;
const proteinG = Math.round(p.weightKg * proteinPerKg);
const fatShare = p.primaryGoal === "less_fat" ? 0.25 : 0.3;
const fatG = Math.round((kcal * fatShare) / 9);
const carbsKcal = Math.max(0, kcal - proteinG * 4 - fatG * 9);
const carbsG = Math.round(carbsKcal / 4);
// 3 g fiber per MJ (1 MJ ≈ 239 kcal)
const fiberG = Math.round((kcal / 239) * 3);
return {
targets: { kcal, proteinG, carbsG, fatG, fiberG, saltMaxG: 6 },
basis: {
bmrKcal: Math.round(bmr),
tdeeKcal: Math.round(maintenance),
goalAdjustmentKcal: adjustment,
activityLevel: p.activityLevel,
},
};
}
/** Standardmål när profil saknas (visas tydligt som schablon i appen). */
export const DEFAULT_TARGETS: DailyTargets = {
kcal: 2000,
proteinG: 80,
carbsG: 220,
fatG: 67,
fiberG: 25,
saltMaxG: 6,
};
+5
View File
@@ -0,0 +1,5 @@
export * from "./units.js";
export * from "./calc.js";
export * from "./energy.js";
export * from "./day.js";
export * from "./recipe.js";
+53
View File
@@ -0,0 +1,53 @@
import type { NutritionValues, Unit } from "@app/shared-types";
import { computeItemNutrition, roundNutrition, scaleNutrition, sumNutrition } from "./calc.js";
import type { IngredientUnitInfo } from "./units.js";
import type { NutritionDeclaration } from "@app/shared-types";
export interface RecipeIngredientForCalc {
canonicalIngredientId: string;
quantity: number;
unit: Unit;
optional?: boolean;
}
export interface IngredientNutritionSource extends IngredientUnitInfo {
nutritionPer100: NutritionDeclaration;
}
export interface RecipeNutritionResult {
perPortion: NutritionValues;
total: NutritionValues;
/** Ingredienser som inte kunde beräknas (saknad densitet etc.) redovisas öppet. */
uncomputableIngredientIds: string[];
}
/**
* Beräkna ett recepts näringsvärden ur dess ingredienser deterministiskt.
* Valfria ingredienser exkluderas ur grundberäkningen.
*/
export function computeRecipeNutrition(
ingredients: RecipeIngredientForCalc[],
portions: number,
sources: Map<string, IngredientNutritionSource>,
): RecipeNutritionResult {
const parts: NutritionValues[] = [];
const uncomputable: string[] = [];
for (const ing of ingredients) {
if (ing.optional) continue;
const source = sources.get(ing.canonicalIngredientId);
if (!source) {
uncomputable.push(ing.canonicalIngredientId);
continue;
}
const values = computeItemNutrition(ing.quantity, ing.unit, source.nutritionPer100, source);
if (values == null) {
uncomputable.push(ing.canonicalIngredientId);
continue;
}
parts.push(values);
}
const total = sumNutrition(parts);
const perPortion =
portions > 0 ? roundNutrition(scaleNutrition(total, 1 / portions)) : roundNutrition(total);
return { perPortion, total: roundNutrition(total), uncomputableIngredientIds: uncomputable };
}
+70
View File
@@ -0,0 +1,70 @@
import { UNIT_INFO, type Unit, type UnitKind } from "@app/shared-types";
export interface IngredientUnitInfo {
/** g per ml krävs för volym → massa. */
densityGPerMl?: number | null;
/** g per styck krävs för antal → massa. */
gramsPerPiece?: number | null;
}
export function unitKind(unit: Unit): UnitKind {
return UNIT_INFO[unit].kind;
}
/** Konvertera till basenhet inom samma slag (g, ml eller st). */
export function toBase(quantity: number, unit: Unit): { kind: UnitKind; amount: number } {
const info = UNIT_INFO[unit];
return { kind: info.kind, amount: quantity * info.toBase };
}
/**
* Konvertera valfri mängd till gram. Returnerar null när konvertering inte är
* möjlig utan mer information hellre ärlig osäkerhet än gissning (spec §61.4).
*/
export function toGrams(
quantity: number,
unit: Unit,
info: IngredientUnitInfo = {},
): number | null {
const base = toBase(quantity, unit);
switch (base.kind) {
case "mass":
return base.amount;
case "volume": {
const density = info.densityGPerMl;
if (density == null || density <= 0) return null;
return base.amount * density;
}
case "count": {
const perPiece = info.gramsPerPiece;
if (perPiece == null || perPiece <= 0) return null;
return base.amount * perPiece;
}
}
}
/** Konvertera en mängd mellan enheter (samma slag, eller via densitet/styckvikt). */
export function convert(
quantity: number,
from: Unit,
to: Unit,
info: IngredientUnitInfo = {},
): number | null {
const fromInfo = UNIT_INFO[from];
const toInfo = UNIT_INFO[to];
if (fromInfo.kind === toInfo.kind) {
return (quantity * fromInfo.toBase) / toInfo.toBase;
}
// Olika slag: gå via gram.
const grams = toGrams(quantity, from, info);
if (grams == null) return null;
if (toInfo.kind === "mass") return grams / toInfo.toBase;
if (toInfo.kind === "volume") {
const density = info.densityGPerMl;
if (density == null || density <= 0) return null;
return grams / density / toInfo.toBase;
}
const perPiece = info.gramsPerPiece;
if (perPiece == null || perPiece <= 0) return null;
return grams / perPiece / toInfo.toBase;
}