feat: předělání historie a profilu
This commit is contained in:
1 parent
9e72f431a1
commit
8fa7f601f2
97 files changed
+7502
-1034
No files matched your search
@@ -0,0 +1,154 @@
|
||||
import { BmiKategorie, BodyMetrics, Goals, PersonalProfile, Pohlavi, WeightEntry } from "../../types/gen/types.gen";
|
||||
import { BadRequestError } from "./utils";
|
||||
import { WEIGHT_LIMITS } from "./profile";
|
||||
|
||||
/**
|
||||
* Energie odpovídající jednomu kilogramu tělesné hmotnosti. Běžně používaná
|
||||
* aproximace (tuková tkáň ~ 7 700 kcal/kg); skutečná změna hmotnosti se od ní
|
||||
* liší vodou a svaly, pro plánování tempa ale stačí.
|
||||
*/
|
||||
const KCAL_PER_KG = 7700;
|
||||
|
||||
/** Meze cílů — chrání před překlepem, ne před nerozumným plánem. */
|
||||
const WEEKLY_CHANGE_LIMITS = { min: -1500, max: 1500 };
|
||||
const PROTEIN_PER_KG_LIMITS = { min: 0.5, max: 4 };
|
||||
|
||||
/** Hranice pásem BMI podle WHO. */
|
||||
const BMI_NORMAL_MIN = 18.5;
|
||||
const BMI_OVERWEIGHT_MIN = 25;
|
||||
const BMI_OBESITY_MIN = 30;
|
||||
|
||||
const roundTenth = (value: number): number => Math.round(value * 10) / 10;
|
||||
|
||||
/**
|
||||
* Spočítá BMI — hmotnost v kg dělená druhou mocninou výšky v metrech.
|
||||
*
|
||||
* @param weightGrams hmotnost v gramech
|
||||
* @param heightCm výška v centimetrech
|
||||
*/
|
||||
export const computeBmi = (weightGrams: number, heightCm: number): number =>
|
||||
roundTenth(weightGrams / 1000 / (heightCm / 100) ** 2);
|
||||
|
||||
/** Zařadí BMI do pásma podle WHO. */
|
||||
export const getBmiCategory = (bmi: number): BmiKategorie => {
|
||||
if (bmi < BMI_NORMAL_MIN) return BmiKategorie.PODVAHA;
|
||||
if (bmi < BMI_OVERWEIGHT_MIN) return BmiKategorie.NORMA;
|
||||
if (bmi < BMI_OBESITY_MIN) return BmiKategorie.NADVAHA;
|
||||
return BmiKategorie.OBEZITA;
|
||||
};
|
||||
|
||||
/** Hmotnost v gramech pro dané BMI a výšku, zaokrouhlená na desítky dekagramů. */
|
||||
const weightForBmi = (bmi: number, heightCm: number): number =>
|
||||
Math.round(bmi * (heightCm / 100) ** 2 * 10) * 100;
|
||||
|
||||
/**
|
||||
* Odhadne podíl tělesného tuku metodou US Navy (obvody v cm):
|
||||
*
|
||||
* ```
|
||||
* muž: 495 / (1,0324 − 0,19077 × log10(pas − krk) + 0,15456 × log10(výška)) − 450
|
||||
* žena: 495 / (1,29579 − 0,35004 × log10(pas + boky − krk) + 0,221 × log10(výška)) − 450
|
||||
* ```
|
||||
*
|
||||
* Odchylka proti měření DEXA bývá kolem ±3–4 procentních bodů — hodí se hlavně
|
||||
* ke sledování vývoje, ne jako přesná hodnota.
|
||||
*
|
||||
* @param profile tělesné údaje s obvody
|
||||
* @returns procento tuku, nebo nic, když chybí údaje nebo jsou obvody nesmyslné
|
||||
*/
|
||||
export const computeBodyFat = (profile?: PersonalProfile): number | undefined => {
|
||||
if (!profile?.sex || !profile.heightCm || !profile.waistCm || !profile.neckCm) return undefined;
|
||||
const isMale = profile.sex === Pohlavi.MUZ;
|
||||
if (!isMale && !profile.hipCm) return undefined;
|
||||
const circumference = isMale
|
||||
? profile.waistCm - profile.neckCm
|
||||
: profile.waistCm + profile.hipCm! - profile.neckCm;
|
||||
if (circumference <= 0) return undefined;
|
||||
const density = isMale
|
||||
? 1.0324 - 0.19077 * Math.log10(circumference) + 0.15456 * Math.log10(profile.heightCm)
|
||||
: 1.29579 - 0.35004 * Math.log10(circumference) + 0.221 * Math.log10(profile.heightCm);
|
||||
const result = roundTenth(495 / density - 450);
|
||||
// Mimo tenhle rozsah jde o překlep v obvodech, ne o skutečný odhad
|
||||
return result > 0 && result < 75 ? result : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Cílová denní bilance podle týdenního tempa změny hmotnosti — půl kila týdně
|
||||
* dolů je zhruba −550 kcal denně.
|
||||
*/
|
||||
export const getTargetDailyBalance = (goals?: Goals): number | undefined =>
|
||||
goals?.weeklyChangeGrams != null
|
||||
? Math.round(goals.weeklyChangeGrams / 1000 * KCAL_PER_KG / 7)
|
||||
: undefined;
|
||||
|
||||
/** Cílový denní příjem bílkovin v gramech podle hmotnosti. */
|
||||
export const getProteinTarget = (goals?: Goals, weightGrams?: number): number | undefined =>
|
||||
goals?.proteinPerKg != null && weightGrams != null
|
||||
? Math.round(weightGrams / 1000 * goals.proteinPerKg)
|
||||
: undefined;
|
||||
|
||||
/**
|
||||
* Za kolik týdnů se při zvoleném tempu dosáhne cílové hmotnosti. Jen když
|
||||
* tempo vede k cíli — při nulovém tempu nebo opačném směru nic.
|
||||
*/
|
||||
const getWeeksToTarget = (goals?: Goals, weightGrams?: number): number | undefined => {
|
||||
if (!goals?.weeklyChangeGrams || goals.targetWeightGrams == null || weightGrams == null) return undefined;
|
||||
const weeks = (goals.targetWeightGrams - weightGrams) / goals.weeklyChangeGrams;
|
||||
return weeks > 0 ? Math.ceil(weeks) : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Spočítá ukazatele z profilu, posledního vážení a cílů. Každý ukazatel se
|
||||
* vynechá, když pro něj nejsou údaje.
|
||||
*
|
||||
* @param profile tělesné údaje
|
||||
* @param goals cíle uživatele
|
||||
* @param latestWeight poslední vážení; bez něj se použije hmotnost z profilu
|
||||
*/
|
||||
export const computeBodyMetrics = (
|
||||
profile?: PersonalProfile,
|
||||
goals?: Goals,
|
||||
latestWeight?: WeightEntry,
|
||||
): BodyMetrics => {
|
||||
const weightGrams = latestWeight?.weightGrams ?? profile?.weightGrams;
|
||||
const heightCm = profile?.heightCm;
|
||||
const bmi = weightGrams != null && heightCm ? computeBmi(weightGrams, heightCm) : undefined;
|
||||
const result: BodyMetrics = {
|
||||
weightGrams,
|
||||
weightDate: latestWeight?.date,
|
||||
bmi,
|
||||
bmiCategory: bmi != null ? getBmiCategory(bmi) : undefined,
|
||||
healthyWeightMinGrams: heightCm ? weightForBmi(BMI_NORMAL_MIN, heightCm) : undefined,
|
||||
healthyWeightMaxGrams: heightCm ? weightForBmi(BMI_OVERWEIGHT_MIN, heightCm) : undefined,
|
||||
bodyFatPercent: computeBodyFat(profile),
|
||||
targetDailyBalance: getTargetDailyBalance(goals),
|
||||
proteinTargetGrams: getProteinTarget(goals, weightGrams),
|
||||
weeksToTarget: getWeeksToTarget(goals, weightGrams),
|
||||
};
|
||||
return Object.fromEntries(Object.entries(result).filter(([, value]) => value != null)) as BodyMetrics;
|
||||
};
|
||||
|
||||
/** Ověří volitelné číslo v rozsahu. */
|
||||
const normalizeRanged = (
|
||||
value: unknown, name: string, limits: { min: number, max: number }, round: (value: number) => number,
|
||||
): number | undefined => {
|
||||
if (value == null) return undefined;
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
throw new BadRequestError(`Pole '${name}' musí být číslo`);
|
||||
}
|
||||
const rounded = round(value);
|
||||
if (rounded < limits.min || rounded > limits.max) {
|
||||
throw new BadRequestError(`Pole '${name}' je mimo rozsah (${limits.min}–${limits.max})`);
|
||||
}
|
||||
return rounded;
|
||||
};
|
||||
|
||||
/** Ověří a znormalizuje cíle. Nevyplněné hodnoty se vynechají. */
|
||||
export const normalizeGoals = (input: unknown): Goals => {
|
||||
const value = (input ?? {}) as Record<string, unknown>;
|
||||
const result: Goals = {
|
||||
weeklyChangeGrams: normalizeRanged(value.weeklyChangeGrams, 'weeklyChangeGrams', WEEKLY_CHANGE_LIMITS, Math.round),
|
||||
targetWeightGrams: normalizeRanged(value.targetWeightGrams, 'targetWeightGrams', WEIGHT_LIMITS, Math.round),
|
||||
proteinPerKg: normalizeRanged(value.proteinPerKg, 'proteinPerKg', PROTEIN_PER_KG_LIMITS, roundTenth),
|
||||
};
|
||||
return Object.fromEntries(Object.entries(result).filter(([, item]) => item != null)) as Goals;
|
||||
};
|
||||
@@ -3,6 +3,9 @@ import { getDay } from "./meals";
|
||||
import { getActivityDay } from "./activities";
|
||||
import { getSettings } from "./settings";
|
||||
import { getSleep } from "./sleep";
|
||||
import { findWeightOn, getWeights } from "./weights";
|
||||
import { computeBasalCalories } from "./profile";
|
||||
import { getProteinTarget, getTargetDailyBalance } from "./bodyMetrics";
|
||||
|
||||
/**
|
||||
* Spočítá energetickou bilanci dne.
|
||||
@@ -18,8 +21,14 @@ import { getSleep } from "./sleep";
|
||||
* @param intake přijaté kcal z jídla
|
||||
* @param activityBurn spálené kcal pohybem
|
||||
* @param basal klidový výdej v kcal za den, nebo nic
|
||||
* @param targetBalance cílová bilance dne podle tempa změny hmotnosti, nebo nic
|
||||
*/
|
||||
export const buildEnergyBalance = (intake: number, activityBurn: number, basal?: number): EnergyBalance => {
|
||||
export const buildEnergyBalance = (
|
||||
intake: number,
|
||||
activityBurn: number,
|
||||
basal?: number,
|
||||
targetBalance?: number,
|
||||
): EnergyBalance => {
|
||||
const resolvedBasal = basal ?? 0;
|
||||
const totalBurn = resolvedBasal + activityBurn;
|
||||
return {
|
||||
@@ -29,28 +38,43 @@ export const buildEnergyBalance = (intake: number, activityBurn: number, basal?:
|
||||
totalBurn,
|
||||
balance: intake - totalBurn,
|
||||
hasBasal: !!basal,
|
||||
...(targetBalance != null ? { targetBalance } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Sestaví kompletní přehled dne — jídlo, pohyb, spánek a energetickou bilanci.
|
||||
* Sestaví kompletní přehled dne — jídlo, pohyb, spánek, vážení a energetickou bilanci.
|
||||
*
|
||||
* Spočítaný klidový výdej i cíl bílkovin vycházejí z hmotnosti platné k tomuto
|
||||
* dni (poslední vážení v ten den nebo dřív), takže starší dny se se zhubnutím
|
||||
* zpětně nepřepočítají. Bez vážení se použije hmotnost z profilu.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param date datum ve formátu YYYY-MM-DD
|
||||
*/
|
||||
export const getDayOverview = async (login: string, date: string): Promise<DayOverview> => {
|
||||
const [meals, activities, settings, sleepMinutes] = await Promise.all([
|
||||
const [meals, activities, settings, sleepMinutes, weightList] = await Promise.all([
|
||||
getDay(login, date),
|
||||
getActivityDay(login, date),
|
||||
getSettings(login),
|
||||
getSleep(login, date),
|
||||
getWeights(login),
|
||||
]);
|
||||
const weightGrams = findWeightOn(weightList, date)?.weightGrams
|
||||
?? settings.profile?.weightGrams
|
||||
?? weightList[0]?.weightGrams;
|
||||
// Ručně zadaný klidový výdej vyhrává nad spočítaným z tělesných údajů
|
||||
const basal = settings.basalCalories ?? computeBasalCalories(settings.profile, weightGrams);
|
||||
const dayWeight = weightList.find(entry => entry.date === date)?.weightGrams;
|
||||
const proteinTargetGrams = getProteinTarget(settings.goals, weightGrams);
|
||||
return {
|
||||
date,
|
||||
meals,
|
||||
activities,
|
||||
// Ručně zadaný klidový výdej vyhrává nad spočítaným z tělesných údajů
|
||||
energy: buildEnergyBalance(meals.totalCalories, activities.totalCalories, settings.effectiveBasalCalories),
|
||||
energy: buildEnergyBalance(
|
||||
meals.totalCalories, activities.totalCalories, basal, getTargetDailyBalance(settings.goals)),
|
||||
...(sleepMinutes ? { sleepMinutes } : {}),
|
||||
...(dayWeight != null ? { weightGrams: dayWeight } : {}),
|
||||
...(proteinTargetGrams != null ? { proteinTargetGrams } : {}),
|
||||
};
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import ExcelJS from 'exceljs';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { ImportMode, ImportResult, MealEntry, MealType } from "../../types/gen/types.gen";
|
||||
import { addEntries, findMealsForDays, isMealType } from "./meals";
|
||||
import { addEntries, createNutritionFiller, findAllMeals, findMealsForDays, isMealType } from "./meals";
|
||||
import { BadRequestError, isValidIsoDate } from "./utils";
|
||||
|
||||
/** Označení zdroje, kterým se importované záznamy značí. */
|
||||
@@ -376,6 +376,10 @@ const isImportMode = (value: unknown): value is ImportMode =>
|
||||
* dřívější import dotčených dnů odebere a založí ho znovu — hodí se, když se
|
||||
* v Luncheru něco opravilo. Ručně zadaná jídla zůstávají v obou režimech.
|
||||
*
|
||||
* Luncher kalorie nezná, takže se importovaným jídlům doplní energie a živiny
|
||||
* z historie — z posledního jídla stejného názvu, které je má (viz
|
||||
* `createNutritionFiller`).
|
||||
*
|
||||
* Náhled vrací všechny řádky, které jde založit, a k nim jídla, která už
|
||||
* v dotčených dnech jsou. Uživatel si v něm odškrtne, co nechce, a ostrý
|
||||
* import pak dostane jen vybrané klíče.
|
||||
@@ -423,6 +427,7 @@ export async function importLuncherExport(
|
||||
|
||||
const dates = Array.from(new Set(parsed.rows.map(row => row.date)));
|
||||
const existing = await findMealsForDays(login, dates);
|
||||
const fillNutrition = createNutritionFiller(await findAllMeals(login));
|
||||
// Při nahrazení se dřívější import založí znovu, duplicitou tedy není
|
||||
const existingKeys = new Set(replace
|
||||
? []
|
||||
@@ -439,7 +444,7 @@ export async function importLuncherExport(
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
candidates.push(toMealEntry(row, mealType, importKey));
|
||||
candidates.push(fillNutrition(toMealEntry(row, mealType, importKey)));
|
||||
}
|
||||
|
||||
const selectedKeyList = selectedKeys == null ? undefined : new Set(selectedKeys.map(String));
|
||||
|
||||
+80
-137
@@ -1,8 +1,8 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { MealTemplate, MealTemplateKind, MealType, RecipeIngredient } from "../../types/gen/types.gen";
|
||||
import { MealTemplate, MealType, RecipeIngredient } from "../../types/gen/types.gen";
|
||||
import getStorage from "./storage";
|
||||
import { addMeal, getDay, isMealType } from "./meals";
|
||||
import { normalizeName, rememberCalories } from "./calories";
|
||||
import { addMeal, isMealType } from "./meals";
|
||||
import { rememberCalories } from "./calories";
|
||||
import { BadRequestError, NotFoundError, isValidIsoDate, normalizeMultiplier } from "./utils";
|
||||
|
||||
const storage = getStorage();
|
||||
@@ -14,29 +14,45 @@ const MAX_NOTE_LENGTH = 4000;
|
||||
/** Maximální počet surovin receptu — pojistka proti nesmyslnému vstupu. */
|
||||
const MAX_INGREDIENTS = 50;
|
||||
|
||||
/** Klíč úložiště se šablonami jídel jednoho uživatele. */
|
||||
/** Klíč úložiště s recepty jednoho uživatele. */
|
||||
const getKey = (login: string): string => `mealTemplates:${login}`;
|
||||
|
||||
/**
|
||||
* Doplní šabloně druh, pokud chybí.
|
||||
*
|
||||
* Šablony uložené dřív, než přibyly recepty, pole `kind` nemají — všechny jsou
|
||||
* ploché záznamy s výslednými hodnotami, tedy hotová jídla. V úložišti se nic
|
||||
* nepřepisuje, druh se doplní jen při čtení.
|
||||
* Záznam tak, jak může ležet v úložišti. Dřív se pod stejným klíčem ukládaly
|
||||
* i šablony hotových jídel (druh `HOTOVE`, nebo bez druhu) a recepty nesly
|
||||
* `kind: 'RECEPT'`.
|
||||
*/
|
||||
const withKind = (template: MealTemplate): MealTemplate => ({
|
||||
...template,
|
||||
kind: template.kind ?? MealTemplateKind.HOTOVE,
|
||||
});
|
||||
type StoredMealTemplate = Omit<MealTemplate, 'ingredients'> & {
|
||||
ingredients?: RecipeIngredient[];
|
||||
kind?: string;
|
||||
pricePer100g?: number;
|
||||
};
|
||||
|
||||
type StoredRecipe = StoredMealTemplate & { ingredients: RecipeIngredient[] };
|
||||
|
||||
/**
|
||||
* Vrátí šablony jídel uživatele seřazené podle názvu.
|
||||
* Šablony hotových jídel zanikly — opakovaně jedené jídlo se nabízí z historie.
|
||||
* Poznají se podle toho, že nemají suroviny; recept bez surovin uložit nejde.
|
||||
*/
|
||||
const isRecipe = (stored: StoredMealTemplate): stored is StoredRecipe => !!stored.ingredients?.length;
|
||||
|
||||
/** Odstraní z receptu pole, která patřila jen šablonám hotových jídel. */
|
||||
const toMealTemplate = (stored: StoredRecipe): MealTemplate => {
|
||||
const { kind, pricePer100g, ...result } = stored;
|
||||
return result;
|
||||
};
|
||||
|
||||
/** Načte recepty z úložiště; šablony hotových jídel vynechá. */
|
||||
const loadRecipes = (storedList: StoredMealTemplate[] | undefined): MealTemplate[] =>
|
||||
(storedList ?? []).filter(isRecipe).map(toMealTemplate);
|
||||
|
||||
/**
|
||||
* Vrátí recepty uživatele seřazené podle názvu.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
*/
|
||||
export const getMealTemplates = async (login: string): Promise<MealTemplate[]> =>
|
||||
(await storage.getData<MealTemplate[]>(getKey(login)) ?? [])
|
||||
.map(withKind)
|
||||
loadRecipes(await storage.getData<StoredMealTemplate[]>(getKey(login)))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
|
||||
|
||||
/** Hodnoty hotového jídla a jedné porce receptu dopočtené ze surovin. */
|
||||
@@ -161,117 +177,97 @@ const normalizeIngredient = (raw: unknown, index: number): RecipeIngredient => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Založí novou šablonu jídla, nebo přepíše existující podle předaného id.
|
||||
* Založí nový recept, nebo přepíše existující podle předaného id.
|
||||
*
|
||||
* U hotového jídla se ukládají zadané výsledné hodnoty. U receptu se hodnoty
|
||||
* porce (gramáž, energie, cena) dopočtou ze surovin a uloží do stejných polí —
|
||||
* použití šablony pak funguje pro oba druhy stejně.
|
||||
* Hodnoty jedné porce (gramáž, energie, cena) se dopočtou ze surovin a uloží
|
||||
* do plochých polí, takže použití receptu založí běžné jídlo jako každé jiné.
|
||||
* Zápis zároveň z úložiště odklidí zaniklé šablony hotových jídel.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param input data šablony
|
||||
* @param input data receptu
|
||||
*/
|
||||
export const saveMealTemplate = async (login: string, input: unknown): Promise<MealTemplate[]> => {
|
||||
const value = (input ?? {}) as Record<string, unknown>;
|
||||
const name = normalizeText(value.name, 'name');
|
||||
if (!name) {
|
||||
throw new BadRequestError("Nebyl předán název šablony");
|
||||
throw new BadRequestError("Nebyl předán název receptu");
|
||||
}
|
||||
if (value.mealType != null && !isMealType(value.mealType)) {
|
||||
throw new BadRequestError("Nebyl předán platný typ jídla");
|
||||
}
|
||||
if (value.kind != null && value.kind !== MealTemplateKind.HOTOVE && value.kind !== MealTemplateKind.RECEPT) {
|
||||
throw new BadRequestError("Nebyl předán platný druh šablony");
|
||||
if (!Array.isArray(value.ingredients) || !value.ingredients.length) {
|
||||
throw new BadRequestError("Recept musí mít alespoň jednu surovinu");
|
||||
}
|
||||
const kind = value.kind === MealTemplateKind.RECEPT ? MealTemplateKind.RECEPT : MealTemplateKind.HOTOVE;
|
||||
if (value.ingredients.length > MAX_INGREDIENTS) {
|
||||
throw new BadRequestError(`Recept může mít nejvýše ${MAX_INGREDIENTS} surovin`);
|
||||
}
|
||||
const ingredients = value.ingredients.map(normalizeIngredient);
|
||||
const servings = normalizeNumber(value.servings, 'servings');
|
||||
const cookedWeight = normalizeNumber(value.cookedWeight, 'cookedWeight') || undefined;
|
||||
const summary = deriveRecipe(ingredients, servings, cookedWeight);
|
||||
|
||||
const common = {
|
||||
const template: MealTemplate = definedOnly({
|
||||
id: typeof value.id === 'string' && value.id.length ? value.id : randomUUID(),
|
||||
kind,
|
||||
name,
|
||||
mealType: value.mealType != null ? value.mealType as MealType : undefined,
|
||||
source: normalizeText(value.source, 'source'),
|
||||
note: normalizeText(value.note, 'note', MAX_NOTE_LENGTH),
|
||||
};
|
||||
|
||||
let template: MealTemplate;
|
||||
if (kind === MealTemplateKind.RECEPT) {
|
||||
if (!Array.isArray(value.ingredients) || !value.ingredients.length) {
|
||||
throw new BadRequestError("Recept musí mít alespoň jednu surovinu");
|
||||
ingredients,
|
||||
// Jedna porce je výchozí stav, ukládat ji zvlášť nemá smysl
|
||||
servings: servings && servings > 1 ? servings : undefined,
|
||||
cookedWeight,
|
||||
weight: summary.weight,
|
||||
caloriesPer100g: summary.caloriesPer100g,
|
||||
calories: summary.calories,
|
||||
price: summary.price,
|
||||
});
|
||||
for (const ingredient of ingredients) {
|
||||
if (ingredient.caloriesPer100g) {
|
||||
await rememberCalories(login, ingredient.name, ingredient.caloriesPer100g);
|
||||
}
|
||||
if (value.ingredients.length > MAX_INGREDIENTS) {
|
||||
throw new BadRequestError(`Recept může mít nejvýše ${MAX_INGREDIENTS} surovin`);
|
||||
}
|
||||
const ingredients = value.ingredients.map(normalizeIngredient);
|
||||
const servings = normalizeNumber(value.servings, 'servings');
|
||||
const cookedWeight = normalizeNumber(value.cookedWeight, 'cookedWeight') || undefined;
|
||||
const summary = deriveRecipe(ingredients, servings, cookedWeight);
|
||||
template = definedOnly({
|
||||
...common,
|
||||
ingredients,
|
||||
// Jedna porce je výchozí stav, ukládat ji zvlášť nemá smysl
|
||||
servings: servings && servings > 1 ? servings : undefined,
|
||||
cookedWeight,
|
||||
weight: summary.weight,
|
||||
caloriesPer100g: summary.caloriesPer100g,
|
||||
calories: summary.calories,
|
||||
price: summary.price,
|
||||
});
|
||||
for (const ingredient of ingredients) {
|
||||
if (ingredient.caloriesPer100g) {
|
||||
await rememberCalories(login, ingredient.name, ingredient.caloriesPer100g);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
template = definedOnly({
|
||||
...common,
|
||||
calories: normalizeNumber(value.calories, 'calories'),
|
||||
caloriesPer100g: normalizeNumber(value.caloriesPer100g, 'caloriesPer100g'),
|
||||
weight: normalizeNumber(value.weight, 'weight'),
|
||||
price: normalizeNumber(value.price, 'price'),
|
||||
pricePer100g: normalizeNumber(value.pricePer100g, 'pricePer100g'),
|
||||
});
|
||||
}
|
||||
|
||||
await storage.updateData<MealTemplate[]>(getKey(login), current => {
|
||||
const others = (current ?? []).filter(item => item.id !== template.id);
|
||||
return [...others, template];
|
||||
});
|
||||
await storage.updateData<StoredMealTemplate[]>(getKey(login), current => [
|
||||
...loadRecipes(current).filter(item => item.id !== template.id),
|
||||
template,
|
||||
]);
|
||||
return getMealTemplates(login);
|
||||
};
|
||||
|
||||
/**
|
||||
* Smaže šablonu jídla.
|
||||
* Smaže recept. Jídla z něj založená zůstávají, jsou samostatná.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param id identifikátor šablony
|
||||
* @param id identifikátor receptu
|
||||
*/
|
||||
export const deleteMealTemplate = async (login: string, id: unknown): Promise<MealTemplate[]> => {
|
||||
if (typeof id !== 'string' || !id.length) {
|
||||
throw new BadRequestError("Nebyl předán identifikátor šablony");
|
||||
throw new BadRequestError("Nebyl předán identifikátor receptu");
|
||||
}
|
||||
let found = false;
|
||||
await storage.updateData<MealTemplate[]>(getKey(login), current => {
|
||||
found = (current ?? []).some(item => item.id === id);
|
||||
return (current ?? []).filter(item => item.id !== id);
|
||||
await storage.updateData<StoredMealTemplate[]>(getKey(login), current => {
|
||||
const recipeList = loadRecipes(current);
|
||||
found = recipeList.some(item => item.id === id);
|
||||
return recipeList.filter(item => item.id !== id);
|
||||
});
|
||||
if (!found) {
|
||||
throw new NotFoundError("Šablona nebyla nalezena");
|
||||
throw new NotFoundError("Recept nebyl nalezen");
|
||||
}
|
||||
return getMealTemplates(login);
|
||||
};
|
||||
|
||||
/**
|
||||
* Založí do zvoleného dne jídlo ze šablony.
|
||||
* Založí do zvoleného dne jednu porci receptu.
|
||||
*
|
||||
* Vznikne z ní běžný záznam jídla, takže jde pak upravit nebo smazat, aniž by
|
||||
* to ovlivnilo šablonu. Gramáž, cenu a kalorie dopočítá `addMeal` stejně jako
|
||||
* u ručně zadaného jídla.
|
||||
* to ovlivnilo recept. Gramáž, cenu a kalorie dopočítá `addMeal` stejně jako
|
||||
* u ručně zadaného jídla. Postup do záznamu dne nepatří, zůstává u receptu.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param id identifikátor šablony
|
||||
* @param id identifikátor receptu
|
||||
* @param date den, do kterého se jídlo založí
|
||||
* @param mealType chod; bez něj se použije chod ze šablony, jinak oběd
|
||||
* @param multiplier násobek porce, třeba 2 za dvě stejné sušenky
|
||||
* @param mealType chod; bez něj se použije chod receptu, jinak oběd
|
||||
* @param multiplier násobek porce, třeba 2 za dvě porce
|
||||
*/
|
||||
export const applyMealTemplate = async (
|
||||
login: string,
|
||||
@@ -281,7 +277,7 @@ export const applyMealTemplate = async (
|
||||
multiplier?: unknown,
|
||||
): Promise<string> => {
|
||||
if (typeof id !== 'string' || !id.length) {
|
||||
throw new BadRequestError("Nebyl předán identifikátor šablony");
|
||||
throw new BadRequestError("Nebyl předán identifikátor receptu");
|
||||
}
|
||||
if (!isValidIsoDate(date)) {
|
||||
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
|
||||
@@ -291,7 +287,7 @@ export const applyMealTemplate = async (
|
||||
}
|
||||
const template = (await getMealTemplates(login)).find(item => item.id === id);
|
||||
if (!template) {
|
||||
throw new NotFoundError("Šablona nebyla nalezena");
|
||||
throw new NotFoundError("Recept nebyl nalezen");
|
||||
}
|
||||
|
||||
await addMeal(login, {
|
||||
@@ -301,62 +297,9 @@ export const applyMealTemplate = async (
|
||||
source: template.source,
|
||||
weight: template.weight,
|
||||
price: template.price,
|
||||
pricePer100g: template.pricePer100g,
|
||||
calories: template.calories,
|
||||
caloriesPer100g: template.caloriesPer100g,
|
||||
// Postup receptu do záznamu dne nepatří, zůstává u šablony
|
||||
note: template.kind === MealTemplateKind.RECEPT ? undefined : template.note,
|
||||
multiplier: normalizeMultiplier(multiplier),
|
||||
}, template.id);
|
||||
return date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Vytvoří šablonu jídla z existujícího záznamu v přehledu dne.
|
||||
*
|
||||
* Když už šablona se stejným názvem existuje, přepíše se — opakované "uložit
|
||||
* jako šablonu" u téhož jídla má dát jednu šablonu, ne několik stejných.
|
||||
* Výjimkou je recept: nese suroviny a postup, které záznam dne nemá, takže by
|
||||
* přepsáním zmizely. Ze záznamu dne vzniká vždy hotové jídlo.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param date den, ve kterém jídlo je
|
||||
* @param id identifikátor jídla
|
||||
*/
|
||||
export const createMealTemplateFromMeal = async (
|
||||
login: string,
|
||||
date: unknown,
|
||||
id: unknown,
|
||||
): Promise<MealTemplate[]> => {
|
||||
if (typeof id !== 'string' || !id.length) {
|
||||
throw new BadRequestError("Nebyl předán identifikátor jídla");
|
||||
}
|
||||
if (!isValidIsoDate(date)) {
|
||||
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
|
||||
}
|
||||
const meal = (await getDay(login, date)).entries.find(entry => entry.id === id);
|
||||
if (!meal) {
|
||||
throw new NotFoundError("Jídlo nebylo nalezeno");
|
||||
}
|
||||
|
||||
const key = normalizeName(meal.name);
|
||||
const existing = (await getMealTemplates(login)).find(item => normalizeName(item.name) === key);
|
||||
if (existing?.kind === MealTemplateKind.RECEPT) {
|
||||
throw new BadRequestError(`Jídlo „${meal.name}" už máte jako recept, šablona se nepřepíše`);
|
||||
}
|
||||
|
||||
return saveMealTemplate(login, {
|
||||
...(existing ? { id: existing.id } : {}),
|
||||
kind: MealTemplateKind.HOTOVE,
|
||||
name: meal.name,
|
||||
source: meal.source,
|
||||
mealType: meal.mealType,
|
||||
weight: meal.weight,
|
||||
price: meal.price,
|
||||
pricePer100g: meal.pricePer100g,
|
||||
// Absolutní kalorie ukládáme jen tam, kde je nejde dopočítat z gramáže
|
||||
calories: meal.caloriesPer100g ? undefined : meal.calories,
|
||||
caloriesPer100g: meal.caloriesPer100g,
|
||||
note: meal.note,
|
||||
});
|
||||
};
|
||||
+211
-2
@@ -1,10 +1,13 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { DayRecord, MealEntry, MealType } from "../../types/gen/types.gen";
|
||||
import {
|
||||
DayRecord, FillSameNameResult, MealEntry, MealSuggestion, MealType, Nutrients,
|
||||
} from "../../types/gen/types.gen";
|
||||
import getStorage from "./storage";
|
||||
import {
|
||||
BadRequestError, NotFoundError, applyMultiplier, isValidIsoDate, listDatesInRange, normalizeMultiplier,
|
||||
} from "./utils";
|
||||
import { rememberCalories } from "./calories";
|
||||
import { normalizeName, rememberCalories } from "./calories";
|
||||
import { getPortionNutrients, normalizeNutrients, sumNutrients } from "./nutrients";
|
||||
|
||||
const storage = getStorage();
|
||||
|
||||
@@ -46,11 +49,14 @@ function sortEntries(entries: MealEntry[]): MealEntry[] {
|
||||
/** Sestaví přehled dne včetně součtů z předaných záznamů. */
|
||||
export function buildDayRecord(date: string, entries: MealEntry[]): DayRecord {
|
||||
const sorted = sortEntries(entries);
|
||||
const totalNutrients = sumNutrients(sorted.map(entry =>
|
||||
getPortionNutrients(entry.nutrientsPer100g, entry.weight, entry.multiplier)));
|
||||
return {
|
||||
date,
|
||||
entries: sorted,
|
||||
totalPrice: sorted.reduce((sum, entry) => sum + (entry.price ?? 0), 0),
|
||||
totalCalories: sorted.reduce((sum, entry) => sum + (applyMultiplier(entry.calories, entry.multiplier) ?? 0), 0),
|
||||
...(totalNutrients ? { totalNutrients } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -125,6 +131,7 @@ export type NormalizedMealInput = {
|
||||
weight?: number;
|
||||
pricePer100g?: number;
|
||||
caloriesPer100g?: number;
|
||||
nutrientsPer100g?: Nutrients;
|
||||
multiplier?: number;
|
||||
};
|
||||
|
||||
@@ -172,6 +179,7 @@ export function normalizeInput(input: unknown): NormalizedMealInput {
|
||||
weight: normalizeNumber(value.weight, 'weight'),
|
||||
pricePer100g: normalizeNumber(value.pricePer100g, 'pricePer100g'),
|
||||
caloriesPer100g: normalizeNumber(value.caloriesPer100g, 'caloriesPer100g'),
|
||||
nutrientsPer100g: normalizeNutrients(value.nutrientsPer100g, 'nutrientsPer100g'),
|
||||
multiplier: normalizeMultiplier(value.multiplier),
|
||||
});
|
||||
}
|
||||
@@ -270,6 +278,7 @@ export async function updateMeal(login: string, id: unknown, input: unknown): Pr
|
||||
weight: normalized.weight,
|
||||
pricePer100g: normalized.pricePer100g,
|
||||
caloriesPer100g: normalized.caloriesPer100g,
|
||||
nutrientsPer100g: normalized.nutrientsPer100g,
|
||||
multiplier: normalized.multiplier,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
@@ -374,3 +383,203 @@ export async function findMealsForDays(login: string, dates: string[]): Promise<
|
||||
const days = await Promise.all([...dates].sort().map(date => getDay(login, date)));
|
||||
return days.flatMap(day => day.entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrátí gramáž záznamu, pokud byla zadaná ručně. Gramáž dopočtená z ceny se
|
||||
* neukládá zvlášť, pozná se tak, že odpovídá ceně a sazbě za 100 g.
|
||||
*/
|
||||
function getManualWeight(source: MealEntry): number | undefined {
|
||||
const weightFromPrice = source.price != null && source.pricePer100g
|
||||
? Math.round(source.price / source.pricePer100g * 100)
|
||||
: undefined;
|
||||
return source.weight !== weightFromPrice ? source.weight : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Převede záznam jídla na návrh pro zakládání nového.
|
||||
*
|
||||
* Přenáší se jen to, co popisuje jídlo samotné. Chod, datum, násobek a poznámka
|
||||
* patří ke konkrétnímu dni. Gramáž dopočtená z ceny se vynechá, aby se v novém
|
||||
* záznamu dopočetla znovu z jeho ceny, a ruční kalorie jen tam, kde nejdou
|
||||
* dopočítat z gramáže — jinak by se z dopočtené hodnoty stala ručně zadaná.
|
||||
*/
|
||||
function createMealSuggestion(source: MealEntry): MealSuggestion {
|
||||
const caloriesDerived = source.weight != null && !!source.caloriesPer100g;
|
||||
return {
|
||||
name: source.name,
|
||||
source: source.source,
|
||||
price: source.price,
|
||||
pricePer100g: source.pricePer100g,
|
||||
weight: getManualWeight(source),
|
||||
caloriesPer100g: source.caloriesPer100g,
|
||||
calories: caloriesDerived ? undefined : source.calories,
|
||||
nutrientsPer100g: source.nutrientsPer100g,
|
||||
lastDate: source.date,
|
||||
count: 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrátí jídla z celé historie uživatele, každý název jednou — slouží
|
||||
* k našeptávání při zakládání jídla místo ručně ukládaných šablon.
|
||||
*
|
||||
* Názvy se porovnávají znormalizované (bez diakritiky a velikosti písmen), takže
|
||||
* "Kuřecí gyros" a "kureci gyros" jsou jedno jídlo. Hodnoty návrhu jsou
|
||||
* z posledního záznamu, protože cena i gramáž se časem mění. Když poslední
|
||||
* záznam nemá kalorie (typicky import z Luncheru), doplní se z dřívějšího stejně
|
||||
* jako při importu. Řadí se od nejčastěji jedených, při shodě od naposledy jedených.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
*/
|
||||
export async function getMealHistory(login: string): Promise<MealSuggestion[]> {
|
||||
const entryList = await findAllMeals(login);
|
||||
const fillNutrition = createNutritionFiller(entryList);
|
||||
const resultMap = new Map<string, MealSuggestion>();
|
||||
for (const entry of entryList) {
|
||||
const key = normalizeName(entry.name);
|
||||
const existing = resultMap.get(key);
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
} else {
|
||||
resultMap.set(key, createMealSuggestion(fillNutrition(entry)));
|
||||
}
|
||||
}
|
||||
return [...resultMap.values()]
|
||||
.sort((a, b) => b.count - a.count || b.lastDate.localeCompare(a.lastDate));
|
||||
}
|
||||
|
||||
/**
|
||||
* Načte všechna jídla uživatele napříč dny, od nejnovějších.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
*/
|
||||
export async function findAllMeals(login: string): Promise<MealEntry[]> {
|
||||
const prefix = `meals:${login}:`;
|
||||
const keyList = (await storage.listKeys(prefix)).filter(key => key.startsWith(prefix));
|
||||
return (await Promise.all(keyList.map(key => storage.getData<MealEntry[]>(key))))
|
||||
.flatMap(entries => entries ?? [])
|
||||
.sort((a, b) => b.date.localeCompare(a.date) || b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
/** Nese záznam něco o energii nebo živinách — jen takový má smysl přebírat. */
|
||||
const hasNutrition = (entry: MealEntry): boolean =>
|
||||
entry.calories != null || !!entry.caloriesPer100g || !!entry.nutrientsPer100g;
|
||||
|
||||
/**
|
||||
* Připraví doplňování energie a živin podle historie — jídlo stejného názvu
|
||||
* má stejné složení, i když se cena mění.
|
||||
*
|
||||
* Vzorem je nejnovější jídlo stejného názvu, které energii nebo živiny má;
|
||||
* přednost má to ze stejného zdroje. Sazba za 100 g se přebírá jen ze stejného
|
||||
* zdroje (jinde se účtuje jinak), gramáž se pak dopočte z ceny nového záznamu.
|
||||
* Bez sazby se vezme gramáž vzoru — porce bývá stejná. Jídlo, které už energii
|
||||
* nebo živiny má, zůstane, jak je.
|
||||
*
|
||||
* @param historyList jídla uživatele od nejnovějších (`findAllMeals`)
|
||||
*/
|
||||
export function createNutritionFiller(historyList: MealEntry[]): (entry: MealEntry) => MealEntry {
|
||||
const sourceKey = (entry: MealEntry) => `${normalizeName(entry.name)}|${normalizeName(entry.source ?? '')}`;
|
||||
const byNameMap = new Map<string, MealEntry>();
|
||||
const byNameSourceMap = new Map<string, MealEntry>();
|
||||
for (const entry of historyList.filter(hasNutrition)) {
|
||||
if (!byNameMap.has(normalizeName(entry.name))) byNameMap.set(normalizeName(entry.name), entry);
|
||||
if (!byNameSourceMap.has(sourceKey(entry))) byNameSourceMap.set(sourceKey(entry), entry);
|
||||
}
|
||||
|
||||
return (entry: MealEntry): MealEntry => {
|
||||
if (hasNutrition(entry)) return entry;
|
||||
const sameSourceRef = byNameSourceMap.get(sourceKey(entry));
|
||||
const ref = sameSourceRef ?? byNameMap.get(normalizeName(entry.name));
|
||||
if (!ref) return entry;
|
||||
|
||||
const pricePer100g = sameSourceRef ? ref.pricePer100g : undefined;
|
||||
const fromPrice = deriveAmounts({
|
||||
...entry, pricePer100g, weight: getManualWeight(ref), caloriesPer100g: ref.caloriesPer100g,
|
||||
});
|
||||
const weight = fromPrice.weight ?? ref.weight;
|
||||
const calories = weight != null && ref.caloriesPer100g
|
||||
? Math.round(weight / 100 * ref.caloriesPer100g)
|
||||
: ref.calories;
|
||||
const result: MealEntry = {
|
||||
...entry, pricePer100g, weight, caloriesPer100g: ref.caloriesPer100g, calories,
|
||||
nutrientsPer100g: ref.nutrientsPer100g,
|
||||
};
|
||||
// Nevyplněné hodnoty se do úložiště nezapisují
|
||||
return Object.fromEntries(Object.entries(result).filter(([, value]) => value !== undefined)) as MealEntry;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrátí zdroje jídel, které uživatel už použil — nabídka pro pole Zdroj.
|
||||
*
|
||||
* Každý zdroj je jednou (porovnává se bez diakritiky a velikosti písmen),
|
||||
* v zápisu z posledního použití a seřazené od nejčastěji použitých.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
*/
|
||||
export async function getMealSources(login: string): Promise<string[]> {
|
||||
const countMap = new Map<string, { source: string, count: number }>();
|
||||
for (const entry of await findAllMeals(login)) {
|
||||
if (!entry.source) continue;
|
||||
const key = normalizeName(entry.source);
|
||||
const existing = countMap.get(key);
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
} else {
|
||||
countMap.set(key, { source: entry.source, count: 1 });
|
||||
}
|
||||
}
|
||||
return [...countMap.values()]
|
||||
.sort((a, b) => b.count - a.count || a.source.localeCompare(b.source, 'cs'))
|
||||
.map(item => item.source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Doplní energii a živiny jídla do ostatních jídel stejného názvu, která je
|
||||
* nemají — typicky po ručním doplnění kalorií jednomu naimportovanému jídlu.
|
||||
*
|
||||
* Přebírá se stejně jako při importu (\`createNutritionFiller\`), takže u jídel
|
||||
* ze stejného zdroje se gramáž dopočte z jejich vlastní ceny. Jídla, která
|
||||
* energii nebo živiny už mají, se nemění.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param id identifikátor jídla, ze kterého se hodnoty převezmou
|
||||
* @param date datum toho jídla (YYYY-MM-DD)
|
||||
* @param dryRun pokud true, jen spočítá dotčená jídla a nic nezmění
|
||||
*/
|
||||
export async function fillSameNameMeals(
|
||||
login: string,
|
||||
id: unknown,
|
||||
date: unknown,
|
||||
dryRun = false,
|
||||
): Promise<FillSameNameResult> {
|
||||
if (typeof id !== 'string' || !id.length) {
|
||||
throw new BadRequestError("Nebyl předán identifikátor záznamu");
|
||||
}
|
||||
if (!isValidIsoDate(date)) {
|
||||
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
|
||||
}
|
||||
const source = (await getDay(login, date)).entries.find(entry => entry.id === id);
|
||||
if (!source) {
|
||||
throw new NotFoundError("Záznam o jídle nebyl nalezen");
|
||||
}
|
||||
if (!hasNutrition(source)) {
|
||||
throw new BadRequestError("Jídlo nemá kalorie ani živiny, není co doplnit");
|
||||
}
|
||||
|
||||
const key = normalizeName(source.name);
|
||||
const targetList = (await findAllMeals(login))
|
||||
.filter(entry => entry.id !== source.id && normalizeName(entry.name) === key && !hasNutrition(entry));
|
||||
const dates = [...new Set(targetList.map(entry => entry.date))].sort();
|
||||
|
||||
if (!dryRun) {
|
||||
const fill = createNutritionFiller([source]);
|
||||
const targetIds = new Set(targetList.map(entry => entry.id));
|
||||
const updatedAt = new Date().toISOString();
|
||||
for (const targetDate of dates) {
|
||||
await storage.updateData<MealEntry[]>(getDayKey(login, targetDate), current => (current ?? [])
|
||||
.map(entry => targetIds.has(entry.id) ? { ...fill(entry), updatedAt } : entry));
|
||||
}
|
||||
}
|
||||
return { count: targetList.length, dates };
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Nutrients } from "../../types/gen/types.gen";
|
||||
import { BadRequestError } from "./utils";
|
||||
|
||||
/** Živiny v pořadí, v jakém je uvádí etiketa potravin. */
|
||||
export const NUTRIENT_KEYS: (keyof Nutrients)[] = [
|
||||
'protein', 'carbohydrates', 'sugars', 'fat', 'saturatedFat', 'fiber', 'salt',
|
||||
];
|
||||
|
||||
/** Zaokrouhlí na desetiny gramu — přesnost, kterou uvádějí etikety. */
|
||||
const roundTenth = (value: number): number => Math.round(value * 10) / 10;
|
||||
|
||||
/**
|
||||
* Ověří a znormalizuje nutriční hodnoty na 100 g.
|
||||
* Nevyplněné živiny vynechá; když není vyplněná žádná, vrátí undefined.
|
||||
*
|
||||
* @param value hodnoty ze vstupu
|
||||
* @param name název pole pro chybovou hlášku
|
||||
*/
|
||||
export const normalizeNutrients = (value: unknown, name: string): Nutrients | undefined => {
|
||||
if (value == null) return undefined;
|
||||
if (typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new BadRequestError(`Pole '${name}' musí být objekt s nutričními hodnotami`);
|
||||
}
|
||||
const source = value as Record<string, unknown>;
|
||||
const result: Nutrients = {};
|
||||
for (const key of NUTRIENT_KEYS) {
|
||||
const item = source[key];
|
||||
if (item == null) continue;
|
||||
if (typeof item !== 'number' || !Number.isFinite(item)) {
|
||||
throw new BadRequestError(`Pole '${name}.${key}' musí být číslo`);
|
||||
}
|
||||
if (item < 0) {
|
||||
throw new BadRequestError(`Pole '${name}.${key}' nesmí být záporné`);
|
||||
}
|
||||
result[key] = roundTenth(item);
|
||||
}
|
||||
return Object.keys(result).length ? result : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Spočítá živiny snědené porce z hodnot na 100 g, gramáže a násobku.
|
||||
* Bez gramáže je spočítat nejde — hodnoty na 100 g samy o porci nic neříkají.
|
||||
*
|
||||
* Klient má stejnou funkci v `Utils.tsx` pro náhled ve formuláři — musí dávat
|
||||
* stejné výsledky.
|
||||
*
|
||||
* @param nutrientsPer100g nutriční hodnoty na 100 g
|
||||
* @param weight gramáž jedné porce
|
||||
* @param multiplier násobek porce, nevyplněný znamená 1
|
||||
*/
|
||||
export const getPortionNutrients = (
|
||||
nutrientsPer100g: Nutrients | undefined,
|
||||
weight: number | undefined,
|
||||
multiplier?: number,
|
||||
): Nutrients | undefined => {
|
||||
if (!nutrientsPer100g || weight == null) return undefined;
|
||||
const result: Nutrients = {};
|
||||
for (const key of NUTRIENT_KEYS) {
|
||||
const per100g = nutrientsPer100g[key];
|
||||
if (per100g != null) {
|
||||
result[key] = roundTenth(per100g * weight / 100 * (multiplier ?? 1));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sečte živiny více porcí. Živina je ve výsledku, pokud ji má aspoň jedna porce;
|
||||
* když nemá žádná porce nic, vrátí undefined.
|
||||
*/
|
||||
export const sumNutrients = (nutrientsList: (Nutrients | undefined)[]): Nutrients | undefined => {
|
||||
const result: Nutrients = {};
|
||||
for (const nutrients of nutrientsList) {
|
||||
if (!nutrients) continue;
|
||||
for (const key of NUTRIENT_KEYS) {
|
||||
const value = nutrients[key];
|
||||
if (value != null) {
|
||||
result[key] = roundTenth((result[key] ?? 0) + value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.keys(result).length ? result : undefined;
|
||||
};
|
||||
+21
-14
@@ -1,11 +1,17 @@
|
||||
import { PersonalProfile, Pohlavi } from "../../types/gen/types.gen";
|
||||
import { BadRequestError } from "./utils";
|
||||
|
||||
/** Meze hmotnosti v gramech — platí pro profil i vážení po dnech. */
|
||||
export const WEIGHT_LIMITS = { min: 20_000, max: 400_000 };
|
||||
|
||||
/** Meze tělesných údajů — chrání před překlepem o řád, ne před neobvyklými hodnotami. */
|
||||
const LIMITS = {
|
||||
birthYear: { min: 1900, max: new Date().getFullYear() },
|
||||
heightCm: { min: 50, max: 250 },
|
||||
weightGrams: { min: 20_000, max: 400_000 },
|
||||
weightGrams: WEIGHT_LIMITS,
|
||||
waistCm: { min: 40, max: 250 },
|
||||
neckCm: { min: 20, max: 80 },
|
||||
hipCm: { min: 50, max: 250 },
|
||||
};
|
||||
|
||||
/** Vrátí true, pokud je hodnota podporovaným pohlavím. */
|
||||
@@ -32,15 +38,12 @@ export const normalizeProfile = (input: unknown): PersonalProfile => {
|
||||
if (value.sex != null && !isPohlavi(value.sex)) {
|
||||
throw new BadRequestError("Nebylo předáno platné pohlaví");
|
||||
}
|
||||
return {
|
||||
...(value.sex != null ? { sex: value.sex as Pohlavi } : {}),
|
||||
...(normalizeRanged(value.birthYear, 'birthYear') != null
|
||||
? { birthYear: normalizeRanged(value.birthYear, 'birthYear') } : {}),
|
||||
...(normalizeRanged(value.heightCm, 'heightCm') != null
|
||||
? { heightCm: normalizeRanged(value.heightCm, 'heightCm') } : {}),
|
||||
...(normalizeRanged(value.weightGrams, 'weightGrams') != null
|
||||
? { weightGrams: normalizeRanged(value.weightGrams, 'weightGrams') } : {}),
|
||||
};
|
||||
const result: PersonalProfile = value.sex != null ? { sex: value.sex as Pohlavi } : {};
|
||||
for (const name of Object.keys(LIMITS) as (keyof typeof LIMITS)[]) {
|
||||
const normalized = normalizeRanged(value[name], name);
|
||||
if (normalized != null) result[name] = normalized;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -59,14 +62,18 @@ export const normalizeProfile = (input: unknown): PersonalProfile => {
|
||||
* Výsledek je odhad s běžnou chybou kolem ±10 %, ne měřená hodnota.
|
||||
*
|
||||
* @param profile tělesné údaje uživatele
|
||||
* @returns klidový výdej v kcal za den, nebo nic, když profil není úplný
|
||||
* @param weightGrams hmotnost — z vážení platného k danému dni, bez něj z profilu
|
||||
* @returns klidový výdej v kcal za den, nebo nic, když údaje nejsou úplné
|
||||
*/
|
||||
export const computeBasalCalories = (profile?: PersonalProfile): number | undefined => {
|
||||
if (!profile?.sex || !profile.birthYear || !profile.heightCm || !profile.weightGrams) {
|
||||
export const computeBasalCalories = (
|
||||
profile?: PersonalProfile,
|
||||
weightGrams = profile?.weightGrams,
|
||||
): number | undefined => {
|
||||
if (!profile?.sex || !profile.birthYear || !profile.heightCm || !weightGrams) {
|
||||
return undefined;
|
||||
}
|
||||
const age = new Date().getFullYear() - profile.birthYear;
|
||||
const base = 10 * (profile.weightGrams / 1000)
|
||||
const base = 10 * (weightGrams / 1000)
|
||||
+ 6.25 * profile.heightCm
|
||||
- 5 * age;
|
||||
return Math.round(base + (profile.sex === Pohlavi.MUZ ? 5 : -161));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import express, { Request, Response, NextFunction } from "express";
|
||||
import { getDayOverview } from "../dayOverview";
|
||||
import { saveSleep } from "../sleep";
|
||||
import { getWeights, saveWeight } from "../weights";
|
||||
import { getLogin } from "../auth";
|
||||
import { formatDate, getToday, parseToken, requireIsoDate } from "../utils";
|
||||
|
||||
@@ -27,4 +28,22 @@ router.post("/sleep", async (req: Request, res: Response, next: NextFunction) =>
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Zapíše vážení ke zvolenému dni. */
|
||||
router.post("/weight", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const { date, weightGrams } = req.body ?? {};
|
||||
const day = await saveWeight(login, date, weightGrams);
|
||||
res.status(200).json(await getDayOverview(login, day));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Vrátí všechna vážení — pro graf vývoje hmotnosti. */
|
||||
router.get("/weights", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
res.status(200).json(await getWeights(login));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,5 +1,8 @@
|
||||
import express, { Request, Response, NextFunction } from "express";
|
||||
import { addMeal, deleteMeal, getDay, getRange, setMealMultiplier, updateMeal } from "../meals";
|
||||
import {
|
||||
addMeal, deleteMeal, fillSameNameMeals, getDay, getMealHistory, getMealSources, getRange, setMealMultiplier,
|
||||
updateMeal,
|
||||
} from "../meals";
|
||||
import { getDayOverview } from "../dayOverview";
|
||||
import { getLogin } from "../auth";
|
||||
import { formatDate, getToday, parseToken, requireIsoDate } from "../utils";
|
||||
@@ -31,6 +34,31 @@ router.get("/range", async (req: Request, res: Response, next: NextFunction) =>
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Vrátí jídla z historie, každý název jednou — pro našeptávání při zakládání jídla. */
|
||||
router.get("/history", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
res.status(200).json(await getMealHistory(login));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Vrátí zdroje jídel, které uživatel už použil — nabídka pro pole Zdroj. */
|
||||
router.get("/sources", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
res.status(200).json(await getMealSources(login));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Doplní energii a živiny jídla do ostatních jídel stejného názvu, která je nemají. */
|
||||
router.post("/fillSameName", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const { id, date, dryRun } = req.body ?? {};
|
||||
res.status(200).json(await fillSameNameMeals(login, id, date, dryRun === true));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Přidá jídlo do zvoleného dne. */
|
||||
router.post("/add", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import express, { Request, Response, NextFunction } from "express";
|
||||
import {
|
||||
applyMealTemplate, createMealTemplateFromMeal, deleteMealTemplate, getMealTemplates, saveMealTemplate,
|
||||
} from "../mealTemplates";
|
||||
import { applyMealTemplate, deleteMealTemplate, getMealTemplates, saveMealTemplate } from "../mealTemplates";
|
||||
import { getDayOverview } from "../dayOverview";
|
||||
import { getLogin } from "../auth";
|
||||
import { parseToken } from "../utils";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/** Vrátí šablony jídel přihlášeného uživatele. */
|
||||
/** Vrátí recepty přihlášeného uživatele. */
|
||||
router.get("/", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
@@ -16,7 +14,7 @@ router.get("/", async (req: Request, res: Response, next: NextFunction) => {
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Založí novou šablonu jídla, nebo upraví existující. */
|
||||
/** Založí nový recept, nebo upraví existující. */
|
||||
router.post("/save", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
@@ -24,7 +22,7 @@ router.post("/save", async (req: Request, res: Response, next: NextFunction) =>
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Smaže šablonu jídla. */
|
||||
/** Smaže recept. */
|
||||
router.post("/delete", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
@@ -32,7 +30,7 @@ router.post("/delete", async (req: Request, res: Response, next: NextFunction) =
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Založí jídlo ze šablony do zvoleného dne. */
|
||||
/** Založí porci receptu jako jídlo do zvoleného dne. */
|
||||
router.post("/apply", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
@@ -42,13 +40,4 @@ router.post("/apply", async (req: Request, res: Response, next: NextFunction) =>
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Vytvoří šablonu z existujícího jídla v přehledu dne. */
|
||||
router.post("/fromMeal", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const { id, date } = req.body ?? {};
|
||||
res.status(200).json(await createMealTemplateFromMeal(login, date, id));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,5 +1,5 @@
|
||||
import express, { Request, Response, NextFunction } from "express";
|
||||
import { getSettings, saveBasalCalories, saveProfile, saveSourceRate } from "../settings";
|
||||
import { getSettings, saveBasalCalories, saveGoals, saveProfile, saveSourceRate } from "../settings";
|
||||
import { getLogin } from "../auth";
|
||||
import { parseToken } from "../utils";
|
||||
|
||||
@@ -38,4 +38,12 @@ router.post("/profile", async (req: Request, res: Response, next: NextFunction)
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Uloží cíle uživatele. */
|
||||
router.post("/goals", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
res.status(200).json(await saveGoals(login, req.body));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
+31
-9
@@ -3,6 +3,8 @@ import getStorage from "./storage";
|
||||
import { BadRequestError } from "./utils";
|
||||
import { normalizeName } from "./calories";
|
||||
import { computeBasalCalories, normalizeProfile } from "./profile";
|
||||
import { computeBodyMetrics, normalizeGoals } from "./bodyMetrics";
|
||||
import { getWeights } from "./weights";
|
||||
|
||||
const storage = getStorage();
|
||||
|
||||
@@ -29,22 +31,27 @@ function emptySettings(): UserSettings {
|
||||
*/
|
||||
export async function getSettings(login: string): Promise<UserSettings> {
|
||||
const stored = await storage.getData<UserSettings>(getSettingsKey(login)) ?? emptySettings();
|
||||
return withDerived(stored);
|
||||
return withDerived(login, stored);
|
||||
}
|
||||
|
||||
/**
|
||||
* Doplní nastavení o hodnoty, které se dopočítávají a neukládají.
|
||||
* Doplní nastavení o hodnoty, které se dopočítávají a neukládají — klidový
|
||||
* výdej a tělesné ukazatele. Počítají se z posledního vážení, bez něj
|
||||
* z hmotnosti v profilu.
|
||||
*
|
||||
* Ručně zadaný klidový výdej má přednost před spočítaným z tělesných údajů —
|
||||
* kdo si změřil vlastní, nechce ho mít přebitý odhadem z rovnice.
|
||||
*/
|
||||
function withDerived(settings: UserSettings): UserSettings {
|
||||
const computed = computeBasalCalories(settings.profile);
|
||||
const effective = settings.basalCalories ?? computed;
|
||||
async function withDerived(login: string, settings: UserSettings): Promise<UserSettings> {
|
||||
const { computedBasalCalories: _basal, effectiveBasalCalories: _effective, bodyMetrics: _metrics, ...stored } = settings;
|
||||
const latestWeight = (await getWeights(login)).pop();
|
||||
const computed = computeBasalCalories(stored.profile, latestWeight?.weightGrams ?? stored.profile?.weightGrams);
|
||||
const effective = stored.basalCalories ?? computed;
|
||||
return {
|
||||
...settings,
|
||||
...stored,
|
||||
...(computed != null ? { computedBasalCalories: computed } : {}),
|
||||
...(effective != null ? { effectiveBasalCalories: effective } : {}),
|
||||
bodyMetrics: computeBodyMetrics(stored.profile, stored.goals, latestWeight),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,7 +67,7 @@ export async function saveProfile(login: string, profile: unknown): Promise<User
|
||||
...(current ?? emptySettings()),
|
||||
profile: normalized,
|
||||
}));
|
||||
return withDerived(saved);
|
||||
return withDerived(login, saved);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,7 +100,7 @@ export async function saveSourceRate(login: string, source: unknown, pricePer100
|
||||
// Nulová sazba znamená smazání — ukládat ji nemá smysl
|
||||
return { ...settings, sourceRates: rate > 0 ? [...rates, { source: name, pricePer100g: rate }] : rates };
|
||||
});
|
||||
return withDerived(saved);
|
||||
return withDerived(login, saved);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,7 +127,7 @@ export async function saveBasalCalories(login: string, basalCalories: unknown):
|
||||
const { basalCalories: _removed, ...rest } = settings;
|
||||
return rest;
|
||||
});
|
||||
return withDerived(saved);
|
||||
return withDerived(login, saved);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,3 +142,18 @@ export async function getSourceRate(login: string, source?: string): Promise<num
|
||||
const settings = await getSettings(login);
|
||||
return settings.sourceRates.find(rate => normalizeName(rate.source) === key)?.pricePer100g;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uloží cíle uživatele. Nevyplněné hodnoty se odstraní, prázdné cíle celé.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param goals cíle
|
||||
*/
|
||||
export async function saveGoals(login: string, goals: unknown): Promise<UserSettings> {
|
||||
const normalized = normalizeGoals(goals);
|
||||
const saved = await storage.updateData<UserSettings>(getSettingsKey(login), current => {
|
||||
const { goals: _removed, ...rest } = current ?? emptySettings();
|
||||
return Object.keys(normalized).length ? { ...rest, goals: normalized } : rest;
|
||||
});
|
||||
return withDerived(login, saved);
|
||||
}
|
||||
@@ -190,10 +190,14 @@ describe('denní detail výdeje', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('vazba jídla na šablonu', () => {
|
||||
describe('vazba jídla na recept', () => {
|
||||
|
||||
it('jídlo ze šablony si pamatuje, ze které vzniklo', async () => {
|
||||
const [template] = await saveMealTemplate(LOGIN, { name: 'Štrůdl', mealType: MealType.PULNOCNI_SVACINA });
|
||||
it('jídlo z receptu si pamatuje, ze kterého vzniklo', async () => {
|
||||
const [template] = await saveMealTemplate(LOGIN, {
|
||||
name: 'Štrůdl',
|
||||
mealType: MealType.PULNOCNI_SVACINA,
|
||||
ingredients: [{ name: 'Těsto', weight: 300, calories: 900 }],
|
||||
});
|
||||
await applyMealTemplate(LOGIN, template.id, DATE);
|
||||
|
||||
const [meal] = (await getDay(LOGIN, DATE)).entries;
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
process.env.CALORIE_PROVIDER = 'none';
|
||||
|
||||
import { computeBmi, computeBodyFat, computeBodyMetrics, getBmiCategory, normalizeGoals } from "../bodyMetrics";
|
||||
import { getSettings, saveBasalCalories, saveGoals, saveProfile } from "../settings";
|
||||
import { getWeights, saveWeight } from "../weights";
|
||||
import { getDayOverview } from "../dayOverview";
|
||||
import { addMeal } from "../meals";
|
||||
import { resetMemoryStorage } from "../storage/memory";
|
||||
import { BmiKategorie, MealType, Pohlavi } from "../../../types/gen/types.gen";
|
||||
|
||||
const LOGIN = 'tester';
|
||||
|
||||
const muz = { sex: Pohlavi.MUZ, birthYear: 1990, heightCm: 185 };
|
||||
|
||||
beforeEach(() => resetMemoryStorage());
|
||||
|
||||
describe('BMI a zdravá hmotnost', () => {
|
||||
|
||||
it('spočítá BMI na desetiny', () => {
|
||||
// 90 / 1,85² = 26,296…
|
||||
expect(computeBmi(90_000, 185)).toBe(26.3);
|
||||
});
|
||||
|
||||
it('zařadí BMI do pásem WHO včetně hranic', () => {
|
||||
expect(getBmiCategory(18.4)).toBe(BmiKategorie.PODVAHA);
|
||||
expect(getBmiCategory(18.5)).toBe(BmiKategorie.NORMA);
|
||||
expect(getBmiCategory(24.9)).toBe(BmiKategorie.NORMA);
|
||||
expect(getBmiCategory(25)).toBe(BmiKategorie.NADVAHA);
|
||||
expect(getBmiCategory(30)).toBe(BmiKategorie.OBEZITA);
|
||||
});
|
||||
|
||||
it('rozmezí zdravé hmotnosti odpovídá BMI 18,5–25 pro výšku', () => {
|
||||
const metrics = computeBodyMetrics(muz);
|
||||
|
||||
// 18,5 × 1,85² = 63,3 kg, 25 × 1,85² = 85,6 kg
|
||||
expect(metrics.healthyWeightMinGrams).toBe(63_300);
|
||||
expect(metrics.healthyWeightMaxGrams).toBe(85_600);
|
||||
expect(metrics.bmi).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('podíl tuku (US Navy)', () => {
|
||||
|
||||
it('u muže počítá z pasu a krku', () => {
|
||||
expect(computeBodyFat({ ...muz, waistCm: 90, neckCm: 40 })).toBe(17.6);
|
||||
});
|
||||
|
||||
it('u ženy potřebuje i boky', () => {
|
||||
const zena = { sex: Pohlavi.ZENA, heightCm: 168, waistCm: 72, neckCm: 33 };
|
||||
|
||||
expect(computeBodyFat(zena)).toBeUndefined();
|
||||
// 495 / (1,29579 − 0,35004 × log10(137) + 0,221 × log10(168)) − 450
|
||||
expect(computeBodyFat({ ...zena, hipCm: 98 })).toBe(26.1);
|
||||
});
|
||||
|
||||
it('nesmyslné obvody nedají žádný odhad', () => {
|
||||
expect(computeBodyFat({ ...muz, waistCm: 40, neckCm: 45 })).toBeUndefined();
|
||||
expect(computeBodyFat({ ...muz, waistCm: 90 })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cíle', () => {
|
||||
|
||||
it('půl kila týdně dolů je denní bilance −550 kcal', () => {
|
||||
expect(computeBodyMetrics(muz, { weeklyChangeGrams: -500 }).targetDailyBalance).toBe(-550);
|
||||
});
|
||||
|
||||
it('bílkoviny počítá z hmotnosti a cílový termín jen ve směru tempa', () => {
|
||||
const metrics = computeBodyMetrics(
|
||||
muz, { weeklyChangeGrams: -500, targetWeightGrams: 85_000, proteinPerKg: 1.6 },
|
||||
{ date: '2026-10-01', weightGrams: 90_000 });
|
||||
|
||||
expect(metrics.proteinTargetGrams).toBe(144);
|
||||
expect(metrics.weeksToTarget).toBe(10);
|
||||
expect(computeBodyMetrics(muz, { weeklyChangeGrams: 250, targetWeightGrams: 85_000 },
|
||||
{ date: '2026-10-01', weightGrams: 90_000 }).weeksToTarget).toBeUndefined();
|
||||
});
|
||||
|
||||
it('cíle se ukládají znormalizované a prázdné se odstraní', async () => {
|
||||
const settings = await saveGoals(LOGIN, { weeklyChangeGrams: -500, proteinPerKg: 1.64 });
|
||||
expect(settings.goals).toEqual({ weeklyChangeGrams: -500, proteinPerKg: 1.6 });
|
||||
|
||||
expect((await saveGoals(LOGIN, {})).goals).toBeUndefined();
|
||||
});
|
||||
|
||||
it('odmítne cíl mimo rozsah', () => {
|
||||
expect(() => normalizeGoals({ weeklyChangeGrams: -5000 })).toThrow(/mimo rozsah/);
|
||||
expect(() => normalizeGoals({ proteinPerKg: 10 })).toThrow(/mimo rozsah/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vážení po dnech', () => {
|
||||
|
||||
it('ukládá, přepisuje a maže vážení dne', async () => {
|
||||
await saveWeight(LOGIN, '2026-10-02', 89_500);
|
||||
await saveWeight(LOGIN, '2026-10-01', 90_000);
|
||||
await saveWeight(LOGIN, '2026-10-02', 89_400);
|
||||
|
||||
expect(await getWeights(LOGIN)).toEqual([
|
||||
{ date: '2026-10-01', weightGrams: 90_000 },
|
||||
{ date: '2026-10-02', weightGrams: 89_400 },
|
||||
]);
|
||||
|
||||
await saveWeight(LOGIN, '2026-10-02', null);
|
||||
expect(await getWeights(LOGIN)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('odmítne hmotnost mimo rozsah', async () => {
|
||||
await expect(saveWeight(LOGIN, '2026-10-01', 900)).rejects.toThrow(/mimo rozsah/);
|
||||
});
|
||||
|
||||
it('ukazatele v nastavení počítají s posledním vážením místo hmotnosti z profilu', async () => {
|
||||
await saveProfile(LOGIN, { ...muz, weightGrams: 95_000 });
|
||||
await saveWeight(LOGIN, '2026-10-01', 90_000);
|
||||
|
||||
const fromWeighing = await getSettings(LOGIN);
|
||||
await saveWeight(LOGIN, '2026-10-01', null);
|
||||
const fromProfile = await getSettings(LOGIN);
|
||||
|
||||
expect(fromWeighing.bodyMetrics).toMatchObject({ weightGrams: 90_000, weightDate: '2026-10-01', bmi: 26.3 });
|
||||
expect(fromProfile.bodyMetrics?.weightDate).toBeUndefined();
|
||||
// 10 kcal na kilogram rozdílu
|
||||
expect(fromProfile.computedBasalCalories! - fromWeighing.computedBasalCalories!).toBe(50);
|
||||
});
|
||||
|
||||
it('bilance dne bere hmotnost platnou k tomu dni, ne poslední', async () => {
|
||||
await saveProfile(LOGIN, muz);
|
||||
await saveWeight(LOGIN, '2026-09-01', 95_000);
|
||||
await saveWeight(LOGIN, '2026-10-01', 90_000);
|
||||
|
||||
const september = await getDayOverview(LOGIN, '2026-09-15');
|
||||
const october = await getDayOverview(LOGIN, '2026-10-05');
|
||||
|
||||
expect(september.energy.basal - october.energy.basal).toBe(50);
|
||||
expect(september.weightGrams).toBeUndefined();
|
||||
expect((await getDayOverview(LOGIN, '2026-10-01')).weightGrams).toBe(90_000);
|
||||
});
|
||||
|
||||
it('ruční klidový výdej má přednost i před vážením', async () => {
|
||||
await saveProfile(LOGIN, muz);
|
||||
await saveWeight(LOGIN, '2026-10-01', 90_000);
|
||||
await saveBasalCalories(LOGIN, 1500);
|
||||
|
||||
expect((await getDayOverview(LOGIN, '2026-10-01')).energy.basal).toBe(1500);
|
||||
});
|
||||
|
||||
it('přehled dne nese cílovou bilanci a cíl bílkovin', async () => {
|
||||
await saveWeight(LOGIN, '2026-10-01', 90_000);
|
||||
await saveGoals(LOGIN, { weeklyChangeGrams: -500, proteinPerKg: 2 });
|
||||
await addMeal(LOGIN, { date: '2026-10-01', mealType: MealType.OBED, name: 'Guláš', calories: 700 });
|
||||
|
||||
const overview = await getDayOverview(LOGIN, '2026-10-01');
|
||||
|
||||
expect(overview.energy.targetBalance).toBe(-550);
|
||||
expect(overview.proteinTargetGrams).toBe(180);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import ExcelJS from 'exceljs';
|
||||
import { importLuncherExport } from "../luncherImport";
|
||||
import { getDay } from "../meals";
|
||||
import { addMeal, getDay } from "../meals";
|
||||
import { resetMemoryStorage } from "../storage/memory";
|
||||
import { MealType } from "../../../types/gen/types.gen";
|
||||
|
||||
@@ -278,3 +278,82 @@ describe('import přehledu z Luncheru', () => {
|
||||
.rejects.toThrow(/nepodařilo najít očekávané sloupce/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('doplnění energie a živin z historie při importu', () => {
|
||||
|
||||
const svickova = {
|
||||
date: '2025-02-10', mealType: MealType.OBED, name: 'Svíčková na smetaně', source: 'Sladovnická',
|
||||
weight: 400, caloriesPer100g: 150, nutrientsPer100g: { protein: 8, fat: 9.5 },
|
||||
};
|
||||
|
||||
/** Naimportovaný záznam daného dne. */
|
||||
const importedOn = async (date: string) => {
|
||||
await importLuncherExport(LOGIN, 'export.json', buildLuncherJson());
|
||||
return (await getDay(LOGIN, date)).entries.find(entry => entry.importSource === 'luncher')!;
|
||||
};
|
||||
|
||||
it('převezme energii, živiny i gramáž jídla stejného názvu', async () => {
|
||||
await addMeal(LOGIN, svickova);
|
||||
|
||||
const entry = await importedOn('2025-03-03');
|
||||
|
||||
expect(entry).toMatchObject({
|
||||
weight: 400, caloriesPer100g: 150, calories: 600, nutrientsPer100g: { protein: 8, fat: 9.5 },
|
||||
});
|
||||
// Cena zůstane z importu
|
||||
expect(entry.price).toBe(15900);
|
||||
});
|
||||
|
||||
it('u stejného zdroje převezme sazbu za 100 g a gramáž dopočte z nové ceny', async () => {
|
||||
await addMeal(LOGIN, {
|
||||
date: '2025-02-10', mealType: MealType.OBED, name: 'Kuřecí burger', source: 'Bolt Food',
|
||||
price: 20000, pricePer100g: 5000, caloriesPer100g: 250,
|
||||
});
|
||||
|
||||
const entry = await importedOn('2025-03-05');
|
||||
|
||||
// 249 Kč za 50 Kč/100 g = 498 g, × 250 kcal/100 g
|
||||
expect(entry).toMatchObject({ pricePer100g: 5000, weight: 498, calories: 1245 });
|
||||
});
|
||||
|
||||
it('z jiného zdroje sazbu nepřebírá a použije gramáž porce', async () => {
|
||||
await addMeal(LOGIN, {
|
||||
date: '2025-02-10', mealType: MealType.OBED, name: 'Kuřecí burger', source: 'Jinde',
|
||||
price: 20000, pricePer100g: 5000, caloriesPer100g: 250,
|
||||
});
|
||||
|
||||
const entry = await importedOn('2025-03-05');
|
||||
|
||||
expect(entry.pricePer100g).toBeUndefined();
|
||||
expect(entry).toMatchObject({ weight: 400, calories: 1000 });
|
||||
});
|
||||
|
||||
it('má přednost jídlo ze stejného zdroje, i když je starší', async () => {
|
||||
await addMeal(LOGIN, svickova);
|
||||
await addMeal(LOGIN, { ...svickova, date: '2025-02-20', source: 'Jinde', caloriesPer100g: 200 });
|
||||
|
||||
expect((await importedOn('2025-03-03')).caloriesPer100g).toBe(150);
|
||||
});
|
||||
|
||||
it('jídlo stejného názvu bez kalorií vzorem není, vezme se starší s kaloriemi', async () => {
|
||||
await addMeal(LOGIN, svickova);
|
||||
await addMeal(LOGIN, { date: '2025-02-20', mealType: MealType.OBED, name: 'Svíčková na smetaně', price: 15900 });
|
||||
|
||||
expect((await importedOn('2025-03-03')).calories).toBe(600);
|
||||
});
|
||||
|
||||
it('bez historie zůstane jídlo bez kalorií', async () => {
|
||||
const entry = await importedOn('2025-03-03');
|
||||
|
||||
expect(entry.calories).toBeUndefined();
|
||||
expect(entry).not.toHaveProperty('weight');
|
||||
});
|
||||
|
||||
it('doplněné hodnoty ukáže už náhled', async () => {
|
||||
await addMeal(LOGIN, svickova);
|
||||
|
||||
const result = await importLuncherExport(LOGIN, 'export.json', buildLuncherJson(), undefined, true);
|
||||
|
||||
expect(result.entries.find(entry => entry.date === '2025-03-03')?.calories).toBe(600);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
process.env.CALORIE_PROVIDER = 'none';
|
||||
|
||||
import { addMeal, fillSameNameMeals, getDay, getMealHistory, getMealSources, updateMeal } from "../meals";
|
||||
import { getPortionNutrients, sumNutrients } from "../nutrients";
|
||||
import { resetMemoryStorage } from "../storage/memory";
|
||||
import { MealType } from "../../../types/gen/types.gen";
|
||||
|
||||
const LOGIN = 'tester';
|
||||
const DATE = '2025-03-03';
|
||||
|
||||
beforeEach(() => resetMemoryStorage());
|
||||
|
||||
describe('návrhy jídel z historie', () => {
|
||||
|
||||
const gyros = {
|
||||
date: DATE, mealType: MealType.OBED, name: 'Kuřecí gyros',
|
||||
source: 'TechTower', price: 17600, pricePer100g: 4400, caloriesPer100g: 172,
|
||||
};
|
||||
|
||||
it('každý název nabídne jednou, i když se jedl vícekrát', async () => {
|
||||
await addMeal(LOGIN, gyros);
|
||||
await addMeal(LOGIN, { ...gyros, date: '2025-03-04' });
|
||||
await addMeal(LOGIN, { ...gyros, date: '2025-03-05', name: 'kureci GYROS' });
|
||||
await addMeal(LOGIN, { ...gyros, name: 'Polévka' });
|
||||
|
||||
const history = await getMealHistory(LOGIN);
|
||||
|
||||
expect(history.map(item => item.name)).toEqual(['kureci GYROS', 'Polévka']);
|
||||
expect(history[0].count).toBe(3);
|
||||
});
|
||||
|
||||
it('hodnoty bere z posledního záznamu', async () => {
|
||||
await addMeal(LOGIN, { ...gyros, date: '2025-03-10', price: 22000 });
|
||||
await addMeal(LOGIN, gyros);
|
||||
|
||||
const [item] = await getMealHistory(LOGIN);
|
||||
|
||||
expect(item.price).toBe(22000);
|
||||
expect(item.lastDate).toBe('2025-03-10');
|
||||
});
|
||||
|
||||
it('nepřenáší chod, násobek ani poznámku', async () => {
|
||||
await addMeal(LOGIN, { ...gyros, mealType: MealType.VECERE, multiplier: 2, note: 's kolegy' });
|
||||
|
||||
const [item] = await getMealHistory(LOGIN);
|
||||
|
||||
expect(item).not.toHaveProperty('mealType');
|
||||
expect(item).not.toHaveProperty('multiplier');
|
||||
expect(item).not.toHaveProperty('note');
|
||||
});
|
||||
|
||||
it('gramáž dopočtenou z ceny vynechá, ručně zadanou ponechá', async () => {
|
||||
await addMeal(LOGIN, gyros);
|
||||
await addMeal(LOGIN, { ...gyros, name: 'Salát', weight: 250 });
|
||||
|
||||
const history = await getMealHistory(LOGIN);
|
||||
|
||||
expect(history.find(item => item.name === 'Kuřecí gyros')?.weight).toBeUndefined();
|
||||
expect(history.find(item => item.name === 'Salát')?.weight).toBe(250);
|
||||
});
|
||||
|
||||
it('kalorie dopočtené z gramáže vynechá, ručně zadané ponechá', async () => {
|
||||
await addMeal(LOGIN, gyros);
|
||||
await addMeal(LOGIN, { date: DATE, mealType: MealType.SNIDANE, name: 'Jablko', calories: 80 });
|
||||
|
||||
const history = await getMealHistory(LOGIN);
|
||||
|
||||
expect(history.find(item => item.name === 'Kuřecí gyros')).toMatchObject({ caloriesPer100g: 172 });
|
||||
expect(history.find(item => item.name === 'Kuřecí gyros')?.calories).toBeUndefined();
|
||||
expect(history.find(item => item.name === 'Jablko')?.calories).toBe(80);
|
||||
});
|
||||
|
||||
it('řadí od nejčastějších, při shodě od naposledy jedených', async () => {
|
||||
await addMeal(LOGIN, { ...gyros, name: 'Starší' });
|
||||
await addMeal(LOGIN, { ...gyros, name: 'Novější', date: '2025-03-04' });
|
||||
await addMeal(LOGIN, { ...gyros, name: 'Častější' });
|
||||
await addMeal(LOGIN, { ...gyros, name: 'Častější', date: '2025-03-02' });
|
||||
|
||||
const history = await getMealHistory(LOGIN);
|
||||
|
||||
expect(history.map(item => item.name)).toEqual(['Častější', 'Novější', 'Starší']);
|
||||
});
|
||||
|
||||
it('historie je oddělená mezi uživateli', async () => {
|
||||
await addMeal(LOGIN, gyros);
|
||||
|
||||
expect(await getMealHistory('nekdo-jiny')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nutriční hodnoty', () => {
|
||||
|
||||
const tycinka = {
|
||||
date: DATE, mealType: MealType.DOPOLEDNI_SVACINA, name: 'Proteinová tyčinka', weight: 50,
|
||||
nutrientsPer100g: { protein: 30, carbohydrates: 40.04, sugars: 5, fat: 12.5, salt: 0.44 },
|
||||
};
|
||||
|
||||
it('uloží hodnoty na 100 g zaokrouhlené na desetiny', async () => {
|
||||
const day = await addMeal(LOGIN, tycinka);
|
||||
|
||||
expect(day.entries[0].nutrientsPer100g).toEqual({
|
||||
protein: 30, carbohydrates: 40, sugars: 5, fat: 12.5, salt: 0.4,
|
||||
});
|
||||
});
|
||||
|
||||
it('den sečte živiny porcí včetně násobku', async () => {
|
||||
await addMeal(LOGIN, tycinka);
|
||||
await addMeal(LOGIN, { ...tycinka, multiplier: 2 });
|
||||
|
||||
const day = await getDay(LOGIN, DATE);
|
||||
|
||||
// 50 g = polovina hodnot na 100 g; jednou ×1 a jednou ×2
|
||||
expect(day.totalNutrients).toEqual({
|
||||
protein: 45, carbohydrates: 60, sugars: 7.5, fat: 18.8, salt: 0.6,
|
||||
});
|
||||
});
|
||||
|
||||
it('jídlo bez gramáže nebo bez živin do součtu nevstupuje', async () => {
|
||||
await addMeal(LOGIN, { ...tycinka, weight: undefined });
|
||||
await addMeal(LOGIN, { date: DATE, mealType: MealType.OBED, name: 'Guláš', weight: 400, calories: 600 });
|
||||
|
||||
expect((await getDay(LOGIN, DATE)).totalNutrients).toBeUndefined();
|
||||
});
|
||||
|
||||
it('úprava umí živiny smazat', async () => {
|
||||
const day = await addMeal(LOGIN, tycinka);
|
||||
const { nutrientsPer100g, ...bezZivin } = tycinka;
|
||||
|
||||
const updated = await updateMeal(LOGIN, day.entries[0].id, bezZivin);
|
||||
|
||||
expect(nutrientsPer100g).toBeDefined();
|
||||
expect(updated.entries[0].nutrientsPer100g).toBeUndefined();
|
||||
expect(updated.totalNutrients).toBeUndefined();
|
||||
});
|
||||
|
||||
it('odmítne zápornou nebo nečíselnou hodnotu', async () => {
|
||||
await expect(addMeal(LOGIN, { ...tycinka, nutrientsPer100g: { fat: -1 } })).rejects.toThrow(/záporné/);
|
||||
await expect(addMeal(LOGIN, { ...tycinka, nutrientsPer100g: { fat: 'hodně' } })).rejects.toThrow(/číslo/);
|
||||
});
|
||||
|
||||
it('prázdné hodnoty se neukládají', async () => {
|
||||
const day = await addMeal(LOGIN, { ...tycinka, nutrientsPer100g: {} });
|
||||
|
||||
expect(day.entries[0].nutrientsPer100g).toBeUndefined();
|
||||
});
|
||||
|
||||
it('přenesou se do návrhu z historie', async () => {
|
||||
await addMeal(LOGIN, tycinka);
|
||||
|
||||
const [item] = await getMealHistory(LOGIN);
|
||||
|
||||
expect(item.nutrientsPer100g?.protein).toBe(30);
|
||||
});
|
||||
|
||||
it('porce a součet počítají jen vyplněné živiny', () => {
|
||||
expect(getPortionNutrients({ protein: 10 }, 250, 0.5)).toEqual({ protein: 12.5 });
|
||||
expect(getPortionNutrients({ protein: 10 }, undefined)).toBeUndefined();
|
||||
expect(sumNutrients([{ protein: 1.1 }, undefined, { protein: 2.2, fat: 1 }])).toEqual({ protein: 3.3, fat: 1 });
|
||||
expect(sumNutrients([undefined])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('zdroje jídel', () => {
|
||||
|
||||
it('vrátí každý zdroj jednou, od nejčastějšího, v posledním zápisu', async () => {
|
||||
await addMeal(LOGIN, { date: DATE, mealType: MealType.OBED, name: 'A', source: 'techtower' });
|
||||
await addMeal(LOGIN, { date: '2025-03-04', mealType: MealType.OBED, name: 'B', source: 'TechTower' });
|
||||
await addMeal(LOGIN, { date: DATE, mealType: MealType.OBED, name: 'C', source: 'Chefie' });
|
||||
await addMeal(LOGIN, { date: DATE, mealType: MealType.OBED, name: 'D' });
|
||||
|
||||
expect(await getMealSources(LOGIN)).toEqual(['TechTower', 'Chefie']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('doplnění do jídel stejného názvu', () => {
|
||||
|
||||
const salat = { mealType: MealType.OBED, name: 'Těstovinový salát', source: 'Chefie', price: 12900 };
|
||||
|
||||
/** Založí jídla bez kalorií a jedno s nimi; vrátí to s kaloriemi. */
|
||||
const seed = async () => {
|
||||
await addMeal(LOGIN, { ...salat, date: '2025-03-01' });
|
||||
await addMeal(LOGIN, { ...salat, date: '2025-03-02', name: 'těstovinový SALÁT' });
|
||||
await addMeal(LOGIN, { ...salat, date: '2025-03-02', name: 'Jiné jídlo' });
|
||||
await addMeal(LOGIN, { ...salat, date: '2025-03-04', calories: 300, caloriesPer100g: 120 });
|
||||
const day = await addMeal(LOGIN, {
|
||||
...salat, date: DATE, weight: 350, caloriesPer100g: 160, nutrientsPer100g: { protein: 6 },
|
||||
});
|
||||
return day.entries[0];
|
||||
};
|
||||
|
||||
it('náhled spočítá jídla bez kalorií a nic nezmění', async () => {
|
||||
const source = await seed();
|
||||
|
||||
const result = await fillSameNameMeals(LOGIN, source.id, DATE, true);
|
||||
|
||||
expect(result).toEqual({ count: 2, dates: ['2025-03-01', '2025-03-02'] });
|
||||
expect((await getDay(LOGIN, '2025-03-01')).totalCalories).toBe(0);
|
||||
});
|
||||
|
||||
it('doplní energii, živiny a gramáž, jídla s kaloriemi nechá', async () => {
|
||||
const source = await seed();
|
||||
|
||||
await fillSameNameMeals(LOGIN, source.id, DATE);
|
||||
|
||||
const [first] = (await getDay(LOGIN, '2025-03-01')).entries;
|
||||
expect(first).toMatchObject({ weight: 350, calories: 560, nutrientsPer100g: { protein: 6 }, price: 12900 });
|
||||
const second = (await getDay(LOGIN, '2025-03-02')).entries;
|
||||
expect(second.find(entry => entry.name === 'těstovinový SALÁT')?.calories).toBe(560);
|
||||
expect(second.find(entry => entry.name === 'Jiné jídlo')?.calories).toBeUndefined();
|
||||
expect((await getDay(LOGIN, '2025-03-04')).entries[0].calories).toBe(300);
|
||||
});
|
||||
|
||||
it('odmítne jídlo bez kalorií i živin', async () => {
|
||||
const day = await addMeal(LOGIN, { ...salat, date: DATE });
|
||||
|
||||
await expect(fillSameNameMeals(LOGIN, day.entries[0].id, DATE)).rejects.toThrow(/není co doplnit/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('návrh z historie s posledním záznamem bez kalorií', () => {
|
||||
|
||||
it('převezme kalorie z dřívějšího záznamu, cenu z posledního', async () => {
|
||||
await addMeal(LOGIN, {
|
||||
date: '2025-03-01', mealType: MealType.OBED, name: 'Salát', source: 'Chefie',
|
||||
price: 12900, weight: 350, caloriesPer100g: 160,
|
||||
});
|
||||
await addMeal(LOGIN, { date: DATE, mealType: MealType.OBED, name: 'Salát', source: 'Chefie', price: 13900 });
|
||||
|
||||
const [item] = await getMealHistory(LOGIN);
|
||||
|
||||
expect(item).toMatchObject({ price: 13900, weight: 350, caloriesPer100g: 160, lastDate: DATE });
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { applyWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate } from "
|
||||
import { applyMealTemplate, deleteMealTemplate, getMealTemplates, saveMealTemplate } from "../mealTemplates";
|
||||
import { getDay } from "../meals";
|
||||
import { resetMemoryStorage } from "../storage/memory";
|
||||
import getStorage from "../storage";
|
||||
import { ActivityUnit, MealType, WorkoutItemKind } from "../../../types/gen/types.gen";
|
||||
|
||||
const LOGIN = 'tester';
|
||||
@@ -115,74 +116,67 @@ describe('série u cviků', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('šablony jídel', () => {
|
||||
|
||||
const salat = {
|
||||
name: 'Těstovinový salát',
|
||||
source: 'Chefie',
|
||||
mealType: MealType.OBED,
|
||||
weight: 350,
|
||||
caloriesPer100g: 160,
|
||||
price: 12900,
|
||||
describe('recepty', () => {
|
||||
|
||||
const rizoto = {
|
||||
name: 'Kuřecí rizoto',
|
||||
source: 'doma',
|
||||
mealType: MealType.VECERE,
|
||||
servings: 2,
|
||||
cookedWeight: 1000,
|
||||
ingredients: [
|
||||
{ name: 'Rýže', weight: 200, caloriesPer100g: 350, price: 3000 },
|
||||
{ name: 'Kuřecí prsa', weight: 300, caloriesPer100g: 110, price: 9000 },
|
||||
],
|
||||
note: 'Rýži orestovat, podlít vývarem.',
|
||||
};
|
||||
|
||||
it('uloží šablonu a vrátí ji v seznamu', async () => {
|
||||
const templates = await saveMealTemplate(LOGIN, salat);
|
||||
it('uloží recept a dopočte hodnoty jedné porce', async () => {
|
||||
const [template] = await saveMealTemplate(LOGIN, rizoto);
|
||||
|
||||
// 700 + 330 kcal na 1000 g hotového jídla, rozděleno na dvě porce
|
||||
expect(template).toMatchObject({
|
||||
name: 'Kuřecí rizoto',
|
||||
weight: 500,
|
||||
calories: 515,
|
||||
caloriesPer100g: 103,
|
||||
price: 6000,
|
||||
});
|
||||
});
|
||||
|
||||
it('úprava podle id recept přepíše, nezaloží druhý', async () => {
|
||||
const [created] = await saveMealTemplate(LOGIN, rizoto);
|
||||
|
||||
const templates = await saveMealTemplate(LOGIN, { ...rizoto, id: created.id, name: 'Rizoto velké' });
|
||||
|
||||
expect(templates).toHaveLength(1);
|
||||
expect(templates[0].name).toBe('Těstovinový salát');
|
||||
expect(templates[0].source).toBe('Chefie');
|
||||
expect(templates[0].name).toBe('Rizoto velké');
|
||||
});
|
||||
|
||||
it('úprava podle id šablonu přepíše, nezaloží druhou', async () => {
|
||||
const [created] = await saveMealTemplate(LOGIN, salat);
|
||||
|
||||
const templates = await saveMealTemplate(LOGIN, { ...salat, id: created.id, name: 'Salát velký' });
|
||||
|
||||
expect(templates).toHaveLength(1);
|
||||
expect(templates[0].name).toBe('Salát velký');
|
||||
});
|
||||
|
||||
it('použití založí jídlo do dne a dopočte kalorie', async () => {
|
||||
const [template] = await saveMealTemplate(LOGIN, salat);
|
||||
it('použití založí porci do dne, bez postupu', async () => {
|
||||
const [template] = await saveMealTemplate(LOGIN, rizoto);
|
||||
|
||||
await applyMealTemplate(LOGIN, template.id, DATE);
|
||||
|
||||
const day = await getDay(LOGIN, DATE);
|
||||
expect(day.entries).toHaveLength(1);
|
||||
expect(day.entries[0].name).toBe('Těstovinový salát');
|
||||
expect(day.entries[0].source).toBe('Chefie');
|
||||
// 350 g × 160 kcal/100 g
|
||||
expect(day.entries[0].calories).toBe(560);
|
||||
expect(day.totalPrice).toBe(12900);
|
||||
const [entry] = (await getDay(LOGIN, DATE)).entries;
|
||||
expect(entry).toMatchObject({ name: 'Kuřecí rizoto', mealType: MealType.VECERE, weight: 500, calories: 515 });
|
||||
expect(entry.note).toBeUndefined();
|
||||
});
|
||||
|
||||
it('zvolený chod přebije ten ze šablony', async () => {
|
||||
const [template] = await saveMealTemplate(LOGIN, salat);
|
||||
it('zvolený chod přebije ten z receptu a bez obou se založí oběd', async () => {
|
||||
const [template] = await saveMealTemplate(LOGIN, rizoto);
|
||||
const [bezChodu] = await saveMealTemplate(LOGIN, { ...rizoto, name: 'Bez chodu', mealType: undefined });
|
||||
|
||||
await applyMealTemplate(LOGIN, template.id, DATE, MealType.VECERE);
|
||||
await applyMealTemplate(LOGIN, template.id, DATE, MealType.SNIDANE);
|
||||
await applyMealTemplate(LOGIN, bezChodu.id, '2025-03-04');
|
||||
|
||||
expect((await getDay(LOGIN, DATE)).entries[0].mealType).toBe(MealType.VECERE);
|
||||
expect((await getDay(LOGIN, DATE)).entries[0].mealType).toBe(MealType.SNIDANE);
|
||||
expect((await getDay(LOGIN, '2025-03-04')).entries[0].mealType).toBe(MealType.OBED);
|
||||
});
|
||||
|
||||
it('bez chodu ve šabloně i v požadavku se založí jako oběd', async () => {
|
||||
const [template] = await saveMealTemplate(LOGIN, { name: 'Jablko', calories: 80 });
|
||||
|
||||
await applyMealTemplate(LOGIN, template.id, DATE);
|
||||
|
||||
expect((await getDay(LOGIN, DATE)).entries[0].mealType).toBe(MealType.OBED);
|
||||
});
|
||||
|
||||
it('šablona jen s absolutními kaloriemi funguje taky', async () => {
|
||||
const [template] = await saveMealTemplate(LOGIN, { name: 'Jablko', calories: 80 });
|
||||
|
||||
await applyMealTemplate(LOGIN, template.id, DATE);
|
||||
|
||||
expect((await getDay(LOGIN, DATE)).totalCalories).toBe(80);
|
||||
});
|
||||
|
||||
it('založené jídlo je samostatné — smazání šablony ho nesmaže', async () => {
|
||||
const [template] = await saveMealTemplate(LOGIN, salat);
|
||||
it('založené jídlo je samostatné — smazání receptu ho nesmaže', async () => {
|
||||
const [template] = await saveMealTemplate(LOGIN, rizoto);
|
||||
await applyMealTemplate(LOGIN, template.id, DATE);
|
||||
|
||||
await deleteMealTemplate(LOGIN, template.id);
|
||||
@@ -191,25 +185,66 @@ describe('šablony jídel', () => {
|
||||
expect((await getDay(LOGIN, DATE)).entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('unese poznámku s postupem přípravy', async () => {
|
||||
const recept = 'Těstoviny uvařit al dente.\nZeleninu nakrájet.\nSmíchat s dresinkem.';
|
||||
const [template] = await saveMealTemplate(LOGIN, { name: 'Domácí salát', note: recept });
|
||||
|
||||
expect(template.note).toBe(recept);
|
||||
|
||||
await applyMealTemplate(LOGIN, template.id, DATE);
|
||||
expect((await getDay(LOGIN, DATE)).entries[0].note).toBe(recept);
|
||||
it('odmítne recept bez názvu, bez surovin a neexistující id', async () => {
|
||||
await expect(saveMealTemplate(LOGIN, { ...rizoto, name: ' ' })).rejects.toThrow(/název receptu/);
|
||||
await expect(saveMealTemplate(LOGIN, { ...rizoto, ingredients: [] })).rejects.toThrow(/surovinu/);
|
||||
await expect(applyMealTemplate(LOGIN, 'neexistuje', DATE)).rejects.toThrow(/nebyl nalezen/);
|
||||
await expect(deleteMealTemplate(LOGIN, 'neexistuje')).rejects.toThrow(/nebyl nalezen/);
|
||||
});
|
||||
|
||||
it('odmítne šablonu bez názvu a neexistující id', async () => {
|
||||
await expect(saveMealTemplate(LOGIN, { name: ' ' })).rejects.toThrow(/název šablony/);
|
||||
await expect(applyMealTemplate(LOGIN, 'neexistuje', DATE)).rejects.toThrow(/nebyla nalezena/);
|
||||
await expect(deleteMealTemplate(LOGIN, 'neexistuje')).rejects.toThrow(/nebyla nalezena/);
|
||||
});
|
||||
|
||||
it('šablony jídel jsou oddělené mezi uživateli', async () => {
|
||||
await saveMealTemplate(LOGIN, salat);
|
||||
it('recepty jsou oddělené mezi uživateli', async () => {
|
||||
await saveMealTemplate(LOGIN, rizoto);
|
||||
|
||||
expect(await getMealTemplates('nekdo-jiny')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('zaniklé šablony hotových jídel', () => {
|
||||
|
||||
const KEY = `mealTemplates:${LOGIN}`;
|
||||
|
||||
/** Úložiště tak, jak vypadalo před zrušením hotových jídel. */
|
||||
const seedLegacy = async () => {
|
||||
await getStorage().setData(KEY, [
|
||||
{ id: 'stara', name: 'Sušenka', calories: 80 },
|
||||
{ id: 'hotove', kind: 'HOTOVE', name: 'Salát', weight: 350, caloriesPer100g: 160, pricePer100g: 3700 },
|
||||
{
|
||||
id: 'recept', kind: 'RECEPT', name: 'Guláš', weight: 400, calories: 600,
|
||||
ingredients: [{ name: 'Hovězí', weight: 400, calories: 600 }],
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
it('se nevracejí, recepty zůstanou bez starého druhu', async () => {
|
||||
await seedLegacy();
|
||||
|
||||
const templates = await getMealTemplates(LOGIN);
|
||||
|
||||
expect(templates.map(item => item.id)).toEqual(['recept']);
|
||||
expect(templates[0]).not.toHaveProperty('kind');
|
||||
});
|
||||
|
||||
it('zápis je z úložiště odklidí', async () => {
|
||||
await seedLegacy();
|
||||
|
||||
await saveMealTemplate(LOGIN, { name: 'Polévka', ingredients: [{ name: 'Mrkev', weight: 100, calories: 40 }] });
|
||||
|
||||
const stored = await getStorage().getData<{ id: string, name: string, kind?: string }[]>(KEY) ?? [];
|
||||
expect(stored.map(item => item.name).sort()).toEqual(['Guláš', 'Polévka']);
|
||||
expect(stored.some(item => item.kind)).toBe(false);
|
||||
});
|
||||
|
||||
it('jídla z nich založená ve dnech zůstanou', async () => {
|
||||
await seedLegacy();
|
||||
await getStorage().setData(`meals:${LOGIN}:${DATE}`, [{
|
||||
id: 'zeSablony', date: DATE, mealType: MealType.OBED, name: 'Salát',
|
||||
calories: 560, templateId: 'hotove', createdAt: '2025-03-03T10:00:00.000Z',
|
||||
}]);
|
||||
|
||||
await deleteMealTemplate(LOGIN, 'recept');
|
||||
|
||||
const day = await getDay(LOGIN, DATE);
|
||||
expect(day.entries).toHaveLength(1);
|
||||
expect(day.totalCalories).toBe(560);
|
||||
});
|
||||
});
|
||||
@@ -1,78 +1,15 @@
|
||||
process.env.CALORIE_PROVIDER = 'none';
|
||||
|
||||
import { addActivity, getActivityDay } from "../activities";
|
||||
import { addMeal, getDay } from "../meals";
|
||||
import { createMealTemplateFromMeal, getMealTemplates } from "../mealTemplates";
|
||||
import { createWorkoutTemplateFromActivities, getWorkoutTemplates } from "../workouts";
|
||||
import { resetMemoryStorage } from "../storage/memory";
|
||||
import { ActivityUnit, MealType } from "../../../types/gen/types.gen";
|
||||
import { ActivityUnit } from "../../../types/gen/types.gen";
|
||||
|
||||
const LOGIN = 'tester';
|
||||
const DATE = '2025-03-03';
|
||||
|
||||
beforeEach(() => resetMemoryStorage());
|
||||
|
||||
describe('překlopení jídla do šablony', () => {
|
||||
|
||||
const gyros = {
|
||||
date: DATE, mealType: MealType.OBED, name: 'Kuřecí gyros',
|
||||
source: 'TechTower', price: 17600, pricePer100g: 4400, caloriesPer100g: 172,
|
||||
};
|
||||
|
||||
it('převezme název, zdroj, gramáž i jednotkové hodnoty', async () => {
|
||||
const day = await addMeal(LOGIN, gyros);
|
||||
|
||||
const templates = await createMealTemplateFromMeal(LOGIN, DATE, day.entries[0].id);
|
||||
|
||||
expect(templates).toHaveLength(1);
|
||||
expect(templates[0]).toMatchObject({
|
||||
name: 'Kuřecí gyros',
|
||||
source: 'TechTower',
|
||||
mealType: MealType.OBED,
|
||||
weight: 400,
|
||||
caloriesPer100g: 172,
|
||||
price: 17600,
|
||||
pricePer100g: 4400,
|
||||
});
|
||||
});
|
||||
|
||||
it('jídlo v dni zůstane beze změny', async () => {
|
||||
const day = await addMeal(LOGIN, gyros);
|
||||
|
||||
await createMealTemplateFromMeal(LOGIN, DATE, day.entries[0].id);
|
||||
|
||||
expect((await getDay(LOGIN, DATE)).entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('opakované uložení téhož jídla šablonu přepíše, nezaloží druhou', async () => {
|
||||
const day = await addMeal(LOGIN, gyros);
|
||||
await createMealTemplateFromMeal(LOGIN, DATE, day.entries[0].id);
|
||||
|
||||
// Totéž jídlo jindy a s jinou gramáží
|
||||
const later = await addMeal(LOGIN, { ...gyros, date: '2025-03-10', price: 22000 });
|
||||
const templates = await createMealTemplateFromMeal(LOGIN, '2025-03-10', later.entries[0].id);
|
||||
|
||||
expect(templates).toHaveLength(1);
|
||||
expect(templates[0].weight).toBe(500);
|
||||
});
|
||||
|
||||
it('u jídla s ručními kaloriemi uloží absolutní hodnotu', async () => {
|
||||
const day = await addMeal(LOGIN, {
|
||||
date: DATE, mealType: MealType.SNIDANE, name: 'Jablko', calories: 80,
|
||||
});
|
||||
|
||||
const templates = await createMealTemplateFromMeal(LOGIN, DATE, day.entries[0].id);
|
||||
|
||||
expect(templates[0].calories).toBe(80);
|
||||
expect(templates[0].caloriesPer100g).toBeUndefined();
|
||||
});
|
||||
|
||||
it('hlásí chybu u neexistujícího jídla', async () => {
|
||||
await expect(createMealTemplateFromMeal(LOGIN, DATE, 'neexistuje'))
|
||||
.rejects.toThrow(/nebylo nalezeno/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('překlopení aktivit do šablony tréninku', () => {
|
||||
|
||||
const kliky = {
|
||||
@@ -163,6 +100,5 @@ describe('překlopení aktivit do šablony tréninku', () => {
|
||||
await createWorkoutTemplateFromActivities(LOGIN, DATE, 'Silový blok');
|
||||
|
||||
expect(await getWorkoutTemplates('nekdo-jiny')).toEqual([]);
|
||||
expect(await getMealTemplates('nekdo-jiny')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { WeightEntry } from "../../types/gen/types.gen";
|
||||
import getStorage from "./storage";
|
||||
import { BadRequestError, isValidIsoDate } from "./utils";
|
||||
import { WEIGHT_LIMITS } from "./profile";
|
||||
|
||||
const storage = getStorage();
|
||||
|
||||
/**
|
||||
* Klíč úložiště s váženími jednoho uživatele. Hodnotou je mapa den → gramy;
|
||||
* vážení je málo a pro graf i dohledání hmotnosti k datu je potřeba celá řada.
|
||||
*/
|
||||
const getKey = (login: string): string => `weights:${login}`;
|
||||
|
||||
type WeightLog = Record<string, number>;
|
||||
|
||||
/**
|
||||
* Vrátí všechna vážení uživatele od nejstaršího.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
*/
|
||||
export const getWeights = async (login: string): Promise<WeightEntry[]> =>
|
||||
Object.entries(await storage.getData<WeightLog>(getKey(login)) ?? {})
|
||||
.map(([date, weightGrams]) => ({ date, weightGrams }))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
/**
|
||||
* Najde vážení platné k danému dni — poslední zapsané v ten den nebo dřív.
|
||||
*
|
||||
* @param weightList vážení od nejstaršího
|
||||
* @param date den ve formátu YYYY-MM-DD
|
||||
*/
|
||||
export const findWeightOn = (weightList: WeightEntry[], date: string): WeightEntry | undefined =>
|
||||
weightList.filter(entry => entry.date <= date).pop();
|
||||
|
||||
/**
|
||||
* Zapíše vážení ke zvolenému dni. Nevyplněná nebo nulová hodnota ho smaže.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param date den ve formátu YYYY-MM-DD
|
||||
* @param weightGrams hmotnost v gramech
|
||||
* @returns datum, kterého se zápis týkal
|
||||
*/
|
||||
export const saveWeight = async (login: string, date: unknown, weightGrams: unknown): Promise<string> => {
|
||||
if (!isValidIsoDate(date)) {
|
||||
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
|
||||
}
|
||||
if (weightGrams != null && (typeof weightGrams !== 'number' || !Number.isFinite(weightGrams))) {
|
||||
throw new BadRequestError("Hmotnost musí být číslo");
|
||||
}
|
||||
const value = weightGrams == null ? 0 : Math.round(weightGrams);
|
||||
if (value !== 0 && (value < WEIGHT_LIMITS.min || value > WEIGHT_LIMITS.max)) {
|
||||
throw new BadRequestError(`Hmotnost je mimo rozsah (${WEIGHT_LIMITS.min / 1000}–${WEIGHT_LIMITS.max / 1000} kg)`);
|
||||
}
|
||||
await storage.updateData<WeightLog>(getKey(login), current => {
|
||||
const { [date]: _removed, ...rest } = current ?? {};
|
||||
// Nula znamená smazání — ukládat ji nemá smysl
|
||||
return value > 0 ? { ...rest, [date]: value } : rest;
|
||||
});
|
||||
return date;
|
||||
};
|
||||
Reference in new issue
Block a user