feat: split jídel
feat: graf příjmů
This commit is contained in:
1 parent
bc85a37fc0
commit
b880b36edd
32 files changed
+2048
-262
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { DayOverview, EnergyBalance } from "../../types/gen/types.gen";
|
||||
import { DayOverview, EnergyBalance, UserSettings, WeightEntry } from "../../types/gen/types.gen";
|
||||
import { getDay } from "./meals";
|
||||
import { getActivityDay } from "./activities";
|
||||
import { getSettings } from "./settings";
|
||||
@@ -42,6 +42,19 @@ export const buildEnergyBalance = (
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Vrátí hmotnost platnou k danému dni — poslední vážení v ten den nebo dřív,
|
||||
* bez vážení hmotnost z profilu, nakonec nejstarší vážení.
|
||||
*/
|
||||
export const getWeightOn = (settings: UserSettings, weightList: WeightEntry[], date: string): number | undefined =>
|
||||
findWeightOn(weightList, date)?.weightGrams
|
||||
?? settings.profile?.weightGrams
|
||||
?? weightList[0]?.weightGrams;
|
||||
|
||||
/** Klidový výdej dne. Ručně zadaný vyhrává nad spočítaným z tělesných údajů a hmotnosti k tomu dni. */
|
||||
export const getBasalOn = (settings: UserSettings, weightList: WeightEntry[], date: string): number | undefined =>
|
||||
settings.basalCalories ?? computeBasalCalories(settings.profile, getWeightOn(settings, weightList, date));
|
||||
|
||||
/**
|
||||
* Sestaví kompletní přehled dne — jídlo, pohyb, spánek, vážení a energetickou bilanci.
|
||||
*
|
||||
@@ -60,11 +73,8 @@ export const getDayOverview = async (login: string, date: string): Promise<DayOv
|
||||
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 weightGrams = getWeightOn(settings, weightList, date);
|
||||
const basal = getBasalOn(settings, weightList, date);
|
||||
const dayWeight = weightList.find(entry => entry.date === date)?.weightGrams;
|
||||
const proteinTargetGrams = getProteinTarget(settings.goals, weightGrams);
|
||||
return {
|
||||
|
||||
+89
-17
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import {
|
||||
DayRecord, FillSameNameResult, MealEntry, MealGroup, MealGroupValues, MealSuggestion, MealType, Nutrients,
|
||||
DayRecord, FillSameNameResult, MealEntry, MealGroup, MealGroupDay, MealGroupValues, MealSuggestion, MealType, Nutrients,
|
||||
} from "../../types/gen/types.gen";
|
||||
import getStorage from "./storage";
|
||||
import {
|
||||
@@ -237,6 +237,28 @@ export async function addEntries(login: string, entries: MealEntry[], replaceImp
|
||||
return Array.from(byDate.keys()).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Přepíše záznam o jídle novými hodnotami. Identita záznamu (id, vznik, vazba
|
||||
* na import a recept) zůstává.
|
||||
*
|
||||
* Cena, kalorie, zdroj i poznámka se přepisují včetně vymazání na prázdno,
|
||||
* proto se berou z normalizovaného vstupu a ne z původního záznamu.
|
||||
*/
|
||||
const applyMealInput = (original: MealEntry, normalized: NormalizedMealInput): MealEntry => ({
|
||||
...original,
|
||||
...normalized,
|
||||
price: normalized.price,
|
||||
calories: normalized.calories,
|
||||
source: normalized.source,
|
||||
note: normalized.note,
|
||||
weight: normalized.weight,
|
||||
pricePer100g: normalized.pricePer100g,
|
||||
caloriesPer100g: normalized.caloriesPer100g,
|
||||
nutrientsPer100g: normalized.nutrientsPer100g,
|
||||
multiplier: normalized.multiplier,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Upraví existující záznam o jídle a vrátí aktualizovaný přehled dne.
|
||||
*
|
||||
@@ -266,22 +288,7 @@ export async function updateMeal(login: string, id: unknown, input: unknown): Pr
|
||||
throw new NotFoundError("Záznam o jídle nebyl nalezen");
|
||||
}
|
||||
|
||||
// Cena, kalorie, zdroj i poznámka se přepisují včetně vymazání na prázdno,
|
||||
// proto se berou z normalizovaného vstupu a ne z původního záznamu.
|
||||
const updated: MealEntry = {
|
||||
...original,
|
||||
...normalized,
|
||||
price: normalized.price,
|
||||
calories: normalized.calories,
|
||||
source: normalized.source,
|
||||
note: normalized.note,
|
||||
weight: normalized.weight,
|
||||
pricePer100g: normalized.pricePer100g,
|
||||
caloriesPer100g: normalized.caloriesPer100g,
|
||||
nutrientsPer100g: normalized.nutrientsPer100g,
|
||||
multiplier: normalized.multiplier,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const updated = applyMealInput(original, normalized);
|
||||
const entries = await storage.updateData<MealEntry[]>(
|
||||
getDayKey(login, normalized.date),
|
||||
current => [...(current ?? []), updated]
|
||||
@@ -292,6 +299,55 @@ export async function updateMeal(login: string, id: unknown, input: unknown): Pr
|
||||
return buildDayRecord(normalized.date, entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rozdělí jídlo na dvě: původní záznam se přepíše a hned za něj se vloží nový.
|
||||
* Obojí proběhne jedním zápisem dne, takže nemůže zůstat jen půlka.
|
||||
*
|
||||
* Odštěpená část převezme příznak importu, ale ne jeho klíč. Opakovaný import
|
||||
* v režimu přidání tak řádek pozná podle původního záznamu a znovu ho nezaloží;
|
||||
* režim nahrazení odebere obě části a založí jídlo znovu celé, místo aby vedle
|
||||
* rozděleného vzniklo i nerozdělené.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param id identifikátor rozdělovaného jídla
|
||||
* @param date den rozdělovaného jídla; obě části v něm zůstanou
|
||||
* @param originalInput nové hodnoty původního jídla
|
||||
* @param splitInput hodnoty odštěpené části
|
||||
*/
|
||||
export const splitMeal = async (
|
||||
login: string, id: unknown, date: unknown, originalInput: unknown, splitInput: unknown,
|
||||
): Promise<DayRecord> => {
|
||||
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 original = normalizeInput({ ...(originalInput as object ?? {}), date });
|
||||
const split = normalizeInput({ ...(splitInput as object ?? {}), date });
|
||||
|
||||
let found = false;
|
||||
const entries = await storage.updateData<MealEntry[]>(getDayKey(login, date), current =>
|
||||
(current ?? []).flatMap(entry => {
|
||||
if (entry.id !== id) return [entry];
|
||||
found = true;
|
||||
const created: MealEntry = {
|
||||
id: randomUUID(),
|
||||
createdAt: new Date().toISOString(),
|
||||
...split,
|
||||
...(entry.importSource ? { importSource: entry.importSource } : {}),
|
||||
};
|
||||
return [applyMealInput(entry, original), created];
|
||||
}));
|
||||
if (!found) {
|
||||
throw new NotFoundError("Záznam o jídle nebyl nalezen");
|
||||
}
|
||||
for (const part of [original, split]) {
|
||||
if (part.caloriesPer100g) await rememberCalories(login, part.name, part.caloriesPer100g);
|
||||
}
|
||||
return buildDayRecord(date, entries);
|
||||
};
|
||||
|
||||
/**
|
||||
* Smaže záznam o jídle a vrátí aktualizovaný přehled dne.
|
||||
*
|
||||
@@ -625,6 +681,21 @@ const createMealGroupValues = (groupList: MealEntry[], sameNameList: MealEntry[]
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
*/
|
||||
/** Vrátí true, pokud jídlu chybí kalorie, gramáž nebo živiny — tedy to, co doplnění řeší. */
|
||||
const isIncomplete = (entry: MealEntry): boolean =>
|
||||
entry.calories == null || entry.weight == null || !entry.nutrientsPer100g;
|
||||
|
||||
/** Dny, kdy se jídla skupiny jedla, ve stejném pořadí jako skupina (od nejnovějšího). */
|
||||
const createMealGroupDays = (groupList: MealEntry[]): MealGroupDay[] => {
|
||||
const dayMap = new Map<string, MealGroupDay>();
|
||||
for (const entry of groupList) {
|
||||
const day = dayMap.get(entry.date) ?? { date: entry.date, incomplete: false };
|
||||
day.incomplete ||= isIncomplete(entry);
|
||||
dayMap.set(entry.date, day);
|
||||
}
|
||||
return [...dayMap.values()];
|
||||
};
|
||||
|
||||
export const getMealGroups = async (login: string): Promise<MealGroup[]> => {
|
||||
const entryList = await findAllMeals(login);
|
||||
const groupMap = new Map<string, MealEntry[]>();
|
||||
@@ -646,6 +717,7 @@ export const getMealGroups = async (login: string): Promise<MealGroup[]> => {
|
||||
missingCaloriesCount: groupList.filter(entry => entry.calories == null).length,
|
||||
missingWeightCount: groupList.filter(entry => entry.weight == null).length,
|
||||
missingNutrientsCount: groupList.filter(entry => !entry.nutrientsPer100g).length,
|
||||
days: createMealGroupDays(groupList),
|
||||
values: createMealGroupValues(groupList, nameMap.get(normalizeName(latest.name)) ?? []),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import express, { Request, Response, NextFunction } from "express";
|
||||
import {
|
||||
addMeal, deleteMeal, fillMealGroup, fillSameNameMeals, getDay, getMealGroups, getMealHistory, getMealSources,
|
||||
getRange, setMealMultiplier, updateMeal,
|
||||
getRange, setMealMultiplier, splitMeal, updateMeal,
|
||||
} from "../meals";
|
||||
import { getDayOverview } from "../dayOverview";
|
||||
import { getLogin } from "../auth";
|
||||
@@ -93,6 +93,15 @@ router.post("/update", async (req: Request, res: Response, next: NextFunction) =
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Rozdělí jídlo na dvě ve stejném dni. */
|
||||
router.post("/split", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const { id, date, original, split } = req.body ?? {};
|
||||
res.status(200).json(await splitMeal(login, id, date, original, split));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Smaže záznam o jídle. */
|
||||
router.post("/delete", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import express, { Request, Response, NextFunction } from "express";
|
||||
import { getActivityStats, getFoodStats } from "../statsService";
|
||||
import { getActivityStats, getFoodStats, getIntakeStats } from "../statsService";
|
||||
import { getLogin } from "../auth";
|
||||
import { parseToken, requireIsoDate } from "../utils";
|
||||
|
||||
@@ -15,6 +15,16 @@ router.get("/summary", async (req: Request, res: Response, next: NextFunction) =
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Vrátí statistiky energetického příjmu přihlášeného uživatele za zvolené období. */
|
||||
router.get("/intake", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const from = requireIsoDate(req.query.from, 'from');
|
||||
const to = requireIsoDate(req.query.to, 'to');
|
||||
res.status(200).json(await getIntakeStats(login, from, to));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Vrátí statistiky výdeje pohybem přihlášeného uživatele za zvolené období. */
|
||||
router.get("/activity", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
|
||||
+139
-2
@@ -1,10 +1,15 @@
|
||||
import {
|
||||
ActivityDayStats, ActivityEntry, ActivityGroupTotal, ActivityPeriodTotal, ActivityStats, ActivitySummaryItem,
|
||||
ActivityUnit, FoodStats, GroupTotal, MealEntry, PeriodTotal, PlanDeviationKind, TemplateDayUsage,
|
||||
TemplateUsageTotal,
|
||||
ActivityUnit, FoodStats, GroupTotal, IntakeGroupTotal, IntakePeriodTotal, IntakeStats, MealEntry, Nutrients,
|
||||
PeriodTotal, PlanDeviationKind, TemplateDayUsage, TemplateUsageTotal,
|
||||
} from "../../types/gen/types.gen";
|
||||
import { getRange } from "./meals";
|
||||
import { getActivityDay } from "./activities";
|
||||
import { getSettings } from "./settings";
|
||||
import { getWeights } from "./weights";
|
||||
import { getBasalOn, getWeightOn } from "./dayOverview";
|
||||
import { getProteinTarget, getTargetDailyBalance } from "./bodyMetrics";
|
||||
import { sumNutrients } from "./nutrients";
|
||||
import { getWorkoutTemplates } from "./workouts";
|
||||
import { WorkoutPlan, WorkoutPlanItem, getWorkoutPlans } from "./workoutPlans";
|
||||
import { BadRequestError, applyMultiplier, listDatesInRange } from "./utils";
|
||||
@@ -89,6 +94,138 @@ export async function getFoodStats(login: string, from: string, to: string): Pro
|
||||
};
|
||||
}
|
||||
|
||||
/** Přičte kcal do skupiny příjmu pod daným klíčem. */
|
||||
const accumulateIntakeGroup = (groups: Map<string, IntakeGroupTotal>, key: string, calories: number): void => {
|
||||
const group = groups.get(key) ?? { key, calories: 0, count: 0 };
|
||||
group.calories += calories;
|
||||
group.count++;
|
||||
groups.set(key, group);
|
||||
};
|
||||
|
||||
/** Seřadí skupiny příjmu sestupně dle kcal. */
|
||||
const sortIntakeGroups = (groups: Iterable<IntakeGroupTotal>): IntakeGroupTotal[] =>
|
||||
Array.from(groups).sort((a, b) => b.calories - a.calories || b.count - a.count || a.key.localeCompare(b.key, 'cs'));
|
||||
|
||||
/** Průběžný součet příjmu jednoho období; chody se sčítají v mapě a do kontraktu jdou až na konci. */
|
||||
type IntakeBucket = {
|
||||
period: string, calories: number, burnCalories: number, dayCount: number, caloriesDayCount: number,
|
||||
mealTypes: Map<string, IntakeGroupTotal>,
|
||||
};
|
||||
|
||||
/**
|
||||
* Přičte den do období pod daným klíčem. Jídla bez kalorií se přeskočí;
|
||||
* výdej má jen den, kde nějaké kalorie jsou.
|
||||
*/
|
||||
const accumulateIntakeDay = (
|
||||
buckets: Map<string, IntakeBucket>, period: string, burn: number | undefined, entryList: MealEntry[],
|
||||
): void => {
|
||||
const bucket = buckets.get(period)
|
||||
?? { period, calories: 0, burnCalories: 0, dayCount: 0, caloriesDayCount: 0, mealTypes: new Map() };
|
||||
bucket.dayCount++;
|
||||
if (burn != null) {
|
||||
bucket.burnCalories += burn;
|
||||
bucket.caloriesDayCount++;
|
||||
}
|
||||
for (const entry of entryList) {
|
||||
const calories = applyMultiplier(entry.calories, entry.multiplier);
|
||||
if (calories == null) continue;
|
||||
bucket.calories += calories;
|
||||
accumulateIntakeGroup(bucket.mealTypes, entry.mealType, calories);
|
||||
}
|
||||
buckets.set(period, bucket);
|
||||
};
|
||||
|
||||
/** Převede období příjmu na kontrakt, vzestupně dle označení období. */
|
||||
const toIntakePeriods = (buckets: Map<string, IntakeBucket>): IntakePeriodTotal[] =>
|
||||
Array.from(buckets.values())
|
||||
.sort((a, b) => a.period.localeCompare(b.period))
|
||||
.map(({ mealTypes, ...bucket }) => ({ ...bucket, byMealType: sortIntakeGroups(mealTypes.values()) }));
|
||||
|
||||
/**
|
||||
* Sestaví statistiky energetického příjmu za zvolené období.
|
||||
*
|
||||
* Výdej (klidový + pohyb) se počítá jen za dny, kdy má aspoň jedno jídlo
|
||||
* kalorie — den, kdy uživatel nic nezapsal nebo kalorie nezná (typicky import
|
||||
* z Luncheru), by jinak vypadal jako obrovský deficit. Klidový výdej je
|
||||
* s hmotností platnou k danému dni, stejně jako v přehledu dne.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param from první den období (včetně), YYYY-MM-DD
|
||||
* @param to poslední den období (včetně), YYYY-MM-DD
|
||||
*/
|
||||
export async function getIntakeStats(login: string, from: string, to: string): Promise<IntakeStats> {
|
||||
const days = await getRange(login, from, to);
|
||||
const [activityDays, settings, weightList] = await Promise.all([
|
||||
Promise.all(days.map(day => getActivityDay(login, day.date))),
|
||||
getSettings(login),
|
||||
getWeights(login),
|
||||
]);
|
||||
|
||||
const byDay = new Map<string, IntakeBucket>();
|
||||
const byMonth = new Map<string, IntakeBucket>();
|
||||
const byYear = new Map<string, IntakeBucket>();
|
||||
const byMealType = new Map<string, IntakeGroupTotal>();
|
||||
const bySource = new Map<string, IntakeGroupTotal>();
|
||||
const nutrientsList: Nutrients[] = [];
|
||||
|
||||
let burnCalories = 0;
|
||||
let hasBasal = false;
|
||||
let entryCount = 0;
|
||||
let missingCaloriesCount = 0;
|
||||
let caloriesDayCount = 0;
|
||||
|
||||
days.forEach((day, index) => {
|
||||
let burn: number | undefined;
|
||||
if (day.entries.some(entry => entry.calories != null)) {
|
||||
const basal = getBasalOn(settings, weightList, day.date);
|
||||
hasBasal ||= !!basal;
|
||||
burn = (basal ?? 0) + activityDays[index].totalCalories;
|
||||
burnCalories += burn;
|
||||
caloriesDayCount++;
|
||||
}
|
||||
entryCount += day.entries.length;
|
||||
if (day.totalNutrients) nutrientsList.push(day.totalNutrients);
|
||||
|
||||
accumulateIntakeDay(byDay, day.date, burn, day.entries);
|
||||
accumulateIntakeDay(byMonth, day.date.substring(0, 7), burn, day.entries);
|
||||
accumulateIntakeDay(byYear, day.date.substring(0, 4), burn, day.entries);
|
||||
|
||||
for (const entry of day.entries) {
|
||||
const calories = applyMultiplier(entry.calories, entry.multiplier);
|
||||
if (calories == null) {
|
||||
missingCaloriesCount++;
|
||||
continue;
|
||||
}
|
||||
accumulateIntakeGroup(byMealType, entry.mealType, calories);
|
||||
accumulateIntakeGroup(bySource, entry.source ?? UNKNOWN_SOURCE, calories);
|
||||
}
|
||||
});
|
||||
|
||||
const targetDailyBalance = getTargetDailyBalance(settings.goals);
|
||||
const proteinTargetGrams = getProteinTarget(settings.goals, getWeightOn(settings, weightList, to));
|
||||
const totalNutrients = sumNutrients(nutrientsList);
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
totalCalories: days.reduce((sum, day) => sum + day.totalCalories, 0),
|
||||
burnCalories,
|
||||
hasBasal,
|
||||
...(targetDailyBalance != null ? { targetDailyBalance } : {}),
|
||||
...(proteinTargetGrams != null ? { proteinTargetGrams } : {}),
|
||||
entryCount,
|
||||
missingCaloriesCount,
|
||||
dayCount: days.length,
|
||||
caloriesDayCount,
|
||||
...(totalNutrients ? { totalNutrients } : {}),
|
||||
nutrientsDayCount: nutrientsList.length,
|
||||
byDay: toIntakePeriods(byDay),
|
||||
byMonth: toIntakePeriods(byMonth),
|
||||
byYear: toIntakePeriods(byYear),
|
||||
byMealType: sortIntakeGroups(byMealType.values()),
|
||||
bySource: sortIntakeGroups(bySource.values()),
|
||||
};
|
||||
}
|
||||
|
||||
/** Název šablony, která už neexistuje a nemá ani uložený plán s názvem. */
|
||||
const DELETED_TEMPLATE = 'Smazaná šablona';
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
process.env.CALORIE_PROVIDER = 'none';
|
||||
|
||||
import { addMeal } from "../meals";
|
||||
import { addActivity } from "../activities";
|
||||
import { saveBasalCalories, saveGoals } from "../settings";
|
||||
import { getIntakeStats } from "../statsService";
|
||||
import { resetMemoryStorage } from "../storage/memory";
|
||||
import { ActivityUnit, MealType } from "../../../types/gen/types.gen";
|
||||
|
||||
const LOGIN = 'tester';
|
||||
|
||||
const stats = () => getIntakeStats(LOGIN, '2025-03-01', '2025-03-31');
|
||||
|
||||
beforeEach(() => resetMemoryStorage());
|
||||
|
||||
describe('statistiky příjmu', () => {
|
||||
|
||||
it('sečte příjem po dnech a chodech včetně násobku, jídla bez kalorií jen spočítá', async () => {
|
||||
await addMeal(LOGIN, { date: '2025-03-03', mealType: MealType.SNIDANE, name: 'Kaše', calories: 300 });
|
||||
await addMeal(LOGIN, {
|
||||
date: '2025-03-03', mealType: MealType.OBED, name: 'Čína', source: 'Chefie', calories: 600, multiplier: 0.5,
|
||||
});
|
||||
await addMeal(LOGIN, { date: '2025-03-04', mealType: MealType.OBED, name: 'Čína', source: 'Chefie', calories: 600 });
|
||||
await addMeal(LOGIN, { date: '2025-03-04', mealType: MealType.VECERE, name: 'Neznámo', price: 9900 });
|
||||
|
||||
const result = await stats();
|
||||
|
||||
expect(result).toMatchObject({
|
||||
totalCalories: 1200, entryCount: 4, missingCaloriesCount: 1, dayCount: 2, caloriesDayCount: 2,
|
||||
});
|
||||
expect(result.byDay.map(day => [day.period, day.calories])).toEqual([['2025-03-03', 600], ['2025-03-04', 600]]);
|
||||
expect(result.byMonth).toEqual([expect.objectContaining({ period: '2025-03', calories: 1200, dayCount: 2 })]);
|
||||
expect(result.byMealType).toEqual([
|
||||
{ key: MealType.OBED, calories: 900, count: 2 },
|
||||
{ key: MealType.SNIDANE, calories: 300, count: 1 },
|
||||
]);
|
||||
expect(result.bySource).toEqual([
|
||||
{ key: 'Chefie', calories: 900, count: 2 },
|
||||
{ key: 'Neuvedeno', calories: 300, count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('výdej počítá jen za dny, kdy má nějaké jídlo kalorie', async () => {
|
||||
await saveBasalCalories(LOGIN, 1800);
|
||||
await addMeal(LOGIN, { date: '2025-03-03', mealType: MealType.OBED, name: 'Čína', calories: 700 });
|
||||
await addActivity(LOGIN, {
|
||||
date: '2025-03-03', name: 'Chůze', unit: ActivityUnit.KROKY, quantity: 5000, caloriesPer100Units: 4,
|
||||
});
|
||||
// Pohyb v den bez jídla do výdeje nevstoupí
|
||||
await addActivity(LOGIN, {
|
||||
date: '2025-03-05', name: 'Chůze', unit: ActivityUnit.KROKY, quantity: 5000, caloriesPer100Units: 4,
|
||||
});
|
||||
// Den, kde žádné jídlo kalorie nemá (import), by jinak vyšel jako deficit celého výdeje
|
||||
await addMeal(LOGIN, { date: '2025-03-06', mealType: MealType.OBED, name: 'Neznámo', price: 9900 });
|
||||
|
||||
const result = await stats();
|
||||
|
||||
expect(result.hasBasal).toBe(true);
|
||||
expect(result.burnCalories).toBe(2000);
|
||||
expect(result).toMatchObject({ dayCount: 2, caloriesDayCount: 1 });
|
||||
expect(result.byDay).toEqual([
|
||||
expect.objectContaining({ period: '2025-03-03', burnCalories: 2000, caloriesDayCount: 1 }),
|
||||
expect.objectContaining({ period: '2025-03-06', burnCalories: 0, caloriesDayCount: 0 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('bez klidového výdeje to pozná a cíl vrátí, jen když je nastavený', async () => {
|
||||
await addMeal(LOGIN, { date: '2025-03-03', mealType: MealType.OBED, name: 'Čína', calories: 700 });
|
||||
|
||||
const before = await stats();
|
||||
expect(before.hasBasal).toBe(false);
|
||||
expect(before.targetDailyBalance).toBeUndefined();
|
||||
|
||||
await saveGoals(LOGIN, { weeklyChangeGrams: -500 });
|
||||
expect((await stats()).targetDailyBalance).toBe(-550);
|
||||
});
|
||||
|
||||
it('živiny sečte jen ze dnů, kde je jídlo má', async () => {
|
||||
await addMeal(LOGIN, {
|
||||
date: '2025-03-03', mealType: MealType.OBED, name: 'Kuře', weight: 200, caloriesPer100g: 150,
|
||||
nutrientsPer100g: { protein: 25 },
|
||||
});
|
||||
await addMeal(LOGIN, { date: '2025-03-04', mealType: MealType.OBED, name: 'Čína', calories: 700 });
|
||||
|
||||
const result = await stats();
|
||||
|
||||
expect(result.totalNutrients).toEqual({ protein: 50 });
|
||||
expect(result.nutrientsDayCount).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -25,10 +25,29 @@ describe('skupiny jídel podle názvu a zdroje', () => {
|
||||
name: 'Čína', source: 'Chefie', count: 3, lastDate: '2025-03-03',
|
||||
missingCaloriesCount: 2, missingWeightCount: 2, missingNutrientsCount: 3,
|
||||
values: { weight: 400, caloriesPer100g: 150 },
|
||||
days: [
|
||||
{ date: '2025-03-03', incomplete: true },
|
||||
{ date: '2025-03-02', incomplete: true },
|
||||
{ date: '2025-03-01', incomplete: true },
|
||||
],
|
||||
});
|
||||
expect(groups[1]).toMatchObject({ source: 'doma', count: 1 });
|
||||
});
|
||||
|
||||
it('u dnů skupiny označí ty, kde jídlu něco chybí', async () => {
|
||||
await addMeal(LOGIN, {
|
||||
...cina, date: '2025-03-01', weight: 400, caloriesPer100g: 150, nutrientsPer100g: { protein: 8 },
|
||||
});
|
||||
await addMeal(LOGIN, { ...cina, date: '2025-03-02', price: 13900 });
|
||||
|
||||
const [group] = await getMealGroups(LOGIN);
|
||||
|
||||
expect(group.days).toEqual([
|
||||
{ date: '2025-03-02', incomplete: true },
|
||||
{ date: '2025-03-01', incomplete: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('energii a živiny předvyplní i z jídla stejného názvu odjinud, sazbu ne', async () => {
|
||||
await addMeal(LOGIN, {
|
||||
...cina, date: '2025-03-01', source: 'doma', pricePer100g: 2000, caloriesPer100g: 140,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
process.env.CALORIE_PROVIDER = 'none';
|
||||
|
||||
import { addEntries, addMeal, getDay, splitMeal } from "../meals";
|
||||
import { IMPORT_SOURCE } from "../luncherImport";
|
||||
import { resetMemoryStorage } from "../storage/memory";
|
||||
import { MealType } from "../../../types/gen/types.gen";
|
||||
|
||||
const LOGIN = 'tester';
|
||||
const DATE = '2025-03-03';
|
||||
|
||||
beforeEach(() => resetMemoryStorage());
|
||||
|
||||
describe('rozdělení jídla', () => {
|
||||
|
||||
it('upraví původní jídlo a hned za něj založí nové ve stejném dni', async () => {
|
||||
await addMeal(LOGIN, { date: DATE, mealType: MealType.SNIDANE, name: 'Kaše', calories: 300 });
|
||||
const day = await addMeal(LOGIN, {
|
||||
date: DATE, mealType: MealType.OBED, name: 'Tikka masala+garlic naan', source: 'Everest', price: 19900,
|
||||
nutrientsPer100g: { protein: 9 }, multiplier: 0.5,
|
||||
});
|
||||
const original = day.entries.find(entry => entry.mealType === MealType.OBED)!;
|
||||
|
||||
const result = await splitMeal(LOGIN, original.id, DATE,
|
||||
{
|
||||
mealType: MealType.OBED, name: 'Tikka masala', source: 'Everest', price: 19900,
|
||||
weight: 400, caloriesPer100g: 150, nutrientsPer100g: { protein: 9 }, multiplier: 0.5,
|
||||
},
|
||||
// Datum části se ignoruje, obě zůstávají v rozdělovaném dni
|
||||
{ date: '2025-03-10', mealType: MealType.OBED, name: 'Garlic naan', source: 'Everest', calories: 280 });
|
||||
|
||||
expect(result.entries.map(entry => entry.name)).toEqual(['Kaše', 'Tikka masala', 'Garlic naan']);
|
||||
const [, updated, created] = result.entries;
|
||||
expect(updated).toMatchObject({
|
||||
id: original.id, createdAt: original.createdAt, price: 19900, calories: 600, multiplier: 0.5,
|
||||
nutrientsPer100g: { protein: 9 },
|
||||
});
|
||||
expect(created.id).not.toBe(original.id);
|
||||
expect(created).toMatchObject({ date: DATE, price: undefined, calories: 280 });
|
||||
expect(result.totalCalories).toBe(300 + 300 + 280);
|
||||
expect((await getDay(LOGIN, '2025-03-10')).entries).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('odštěpená část převezme příznak importu, ale ne jeho klíč', async () => {
|
||||
await addEntries(LOGIN, [{
|
||||
id: 'imp-1', createdAt: '2025-03-03T10:00:00.000Z', date: DATE, mealType: MealType.OBED,
|
||||
name: 'Tikka masala+garlic naan', price: 19900, importSource: IMPORT_SOURCE, importKey: 'klic-1',
|
||||
}]);
|
||||
|
||||
const result = await splitMeal(LOGIN, 'imp-1', DATE,
|
||||
{ mealType: MealType.OBED, name: 'Tikka masala', price: 19900 },
|
||||
{ mealType: MealType.OBED, name: 'Garlic naan' });
|
||||
|
||||
const [updated, created] = result.entries;
|
||||
expect(updated).toMatchObject({ importSource: IMPORT_SOURCE, importKey: 'klic-1' });
|
||||
expect(created.importSource).toBe(IMPORT_SOURCE);
|
||||
expect(created.importKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it('neexistující jídlo ani chybějící název nerozdělí', async () => {
|
||||
const day = await addMeal(LOGIN, { date: DATE, mealType: MealType.OBED, name: 'Čína' });
|
||||
const { id } = day.entries[0];
|
||||
|
||||
await expect(splitMeal(LOGIN, 'neni', DATE, { mealType: MealType.OBED, name: 'A' }, { mealType: MealType.OBED, name: 'B' }))
|
||||
.rejects.toThrow('nebyl nalezen');
|
||||
await expect(splitMeal(LOGIN, id, DATE, { mealType: MealType.OBED, name: 'A' }, { mealType: MealType.OBED, name: ' ' }))
|
||||
.rejects.toThrow('název');
|
||||
expect((await getDay(LOGIN, DATE)).entries).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user