feat: split jídel

feat: graf příjmů
This commit is contained in:
Ondřej Anděl committed 2026-10-09 15:15:04 +02:00
1 parent bc85a37fc0
commit b880b36edd
32 files changed
+2048 -262

No files matched your search

+16 -6
View File
@@ -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
View File
@@ -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)) ?? []),
};
});
+10 -1
View File
@@ -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 {
+11 -1
View File
@@ -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
View File
@@ -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';
+90
View File
@@ -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);
});
});
+19
View File
@@ -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,
+69
View File
@@ -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);
});
});