From bc85a37fc0222f9d037a66b4b53b00e056f7584a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ond=C5=99ej=20And=C4=9Bl?=
Date: Thu, 8 Oct 2026 15:03:23 +0200
Subject: [PATCH] =?UTF-8?q?feat:=20hromadn=C3=A9=20editace?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
CLAUDE.md | 12 +-
client/src/AppRoutes.tsx | 4 +-
client/src/components/Header.tsx | 6 +-
client/src/components/MealGroupForm.tsx | 158 +++++++++++++++++++
client/src/components/MealGroupRow.tsx | 56 +++++++
client/src/components/NutrientsFieldset.tsx | 6 +-
client/src/components/modals/MealModal.tsx | 12 +-
client/src/pages/MealGroupsPage.scss | 56 +++++++
client/src/pages/MealGroupsPage.tsx | 109 +++++++++++++
client/src/pages/StatsPage.tsx | 15 +-
client/src/routes.ts | 1 +
server/src/meals.ts | 163 +++++++++++++++++++-
server/src/routes/mealRoutes.ts | 21 ++-
server/src/tests/mealGroups.test.ts | 101 ++++++++++++
types/api.yml | 4 +
types/paths/meals/fillGroup.yml | 35 +++++
types/paths/meals/groups.yml | 18 +++
types/schemas/_index.yml | 66 ++++++++
18 files changed, 825 insertions(+), 18 deletions(-)
create mode 100644 client/src/components/MealGroupForm.tsx
create mode 100644 client/src/components/MealGroupRow.tsx
create mode 100644 client/src/pages/MealGroupsPage.scss
create mode 100644 client/src/pages/MealGroupsPage.tsx
create mode 100644 server/src/tests/mealGroups.test.ts
create mode 100644 types/paths/meals/fillGroup.yml
create mode 100644 types/paths/meals/groups.yml
diff --git a/CLAUDE.md b/CLAUDE.md
index d6e3378..94bc40a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -75,7 +75,8 @@ cd client && yarn build # tsc --noEmit + vite build → client/dist
- **Pages:** `pages/` — `DayPage` (přehled dne se záložkami Příjem/Výdej),
`TemplatesPage` (šablony tréninků a recepty), `ProfilePage` (Profil — ukazatele,
vývoj hmotnosti, tělesné údaje, cíle a klidový výdej),
- `StatsPage` (statistiky příjmu a výdeje + import; výdej v `ActivityStatsPanel`)
+ `StatsPage` (statistiky příjmu a výdeje + import; výdej v `ActivityStatsPanel`),
+ `MealGroupsPage` (`/statistiky/doplneni` — hromadné doplnění údajů jídel)
- **Components:** `components/` (Header, CalorieLookup, MealSuggestions, NutrientsFieldset)
a `components/modals/` (MealModal, ActivityModal, ApplyWorkoutModal, ApplyMealModal —
výběr z historie a receptů, SettingsModal, ImportModal)
@@ -275,6 +276,15 @@ s kaloriemi `DayPage` nabídne (`FillSameNameOffer`) doplnit stejné hodnoty do
ostatních jídel stejného názvu, která je nemají (`fillSameNameMeals`,
`POST /api/meals/fillSameName`); jídla s kaloriemi se nepřepisují.
+**Hromadné doplnění** (`MealGroupsPage`, odkaz ze Statistik → Příjem) seskupí
+jídla podle znormalizovaného názvu **a zdroje** (`getMealGroups`,
+`GET /api/meals/groups`) a ukáže, kolika chybí kalorie, gramáž a živiny.
+`fillMealGroup` (`POST /api/meals/fillGroup`) doplní do skupiny **jen chybějící
+hodnoty**: gramáž ne tam, kde jde dopočítat z vlastní ceny a sazby, energii jen
+jídlům bez kalorií (jinak by dopočet přepsal ručně zadané kalorie), živiny po
+jednotlivých položkách. Předvyplněné hodnoty jsou z nejnovějšího jídla skupiny;
+energie a živiny bez nich i z jídla stejného názvu odjinud, sazba a gramáž ne.
+
Pole Zdroj (`SourceInput`) nabízí zdroje z historie (`getMealSources`,
`GET /api/meals/sources`, nejčastější první) a podniky se sazbou z nastavení;
psát jde i nový.
diff --git a/client/src/AppRoutes.tsx b/client/src/AppRoutes.tsx
index 8fd4c5c..28766e0 100644
--- a/client/src/AppRoutes.tsx
+++ b/client/src/AppRoutes.tsx
@@ -7,7 +7,8 @@ import DayPage from "./pages/DayPage";
import StatsPage from "./pages/StatsPage";
import TemplatesPage from "./pages/TemplatesPage";
import ProfilePage from "./pages/ProfilePage";
-import { DEN_URL, PROFIL_URL, SABLONY_URL, STATISTIKY_URL } from "./routes";
+import MealGroupsPage from "./pages/MealGroupsPage";
+import { DEN_URL, DOPLNENI_JIDEL_URL, PROFIL_URL, SABLONY_URL, STATISTIKY_URL } from "./routes";
export default function AppRoutes() {
const auth = useAuth();
@@ -25,6 +26,7 @@ export default function AppRoutes() {
} />
} />
} />
+ } />
} />
diff --git a/client/src/components/Header.tsx b/client/src/components/Header.tsx
index 4b98820..b079ce9 100644
--- a/client/src/components/Header.tsx
+++ b/client/src/components/Header.tsx
@@ -25,6 +25,10 @@ export default function Header() {
const effectiveDark = settings?.effectiveDark ?? false;
+ /** Podstránka (doplnění údajů ve statistikách) nechá zvýrazněnou svou nadřazenou položku. */
+ const isActive = (url: string) =>
+ location.pathname === url || (url !== DEN_URL && location.pathname.startsWith(`${url}/`));
+
const toggleTheme = () => {
const next: ThemePreference = effectiveDark ? 'light' : 'dark';
settings?.setThemePreference(next);
@@ -39,7 +43,7 @@ export default function Header() {
{NAV_ITEMS.map(item => (
+
+ {loading && (
+
+
+
+ )}
+
+ {!loading && !visibleList.length && (
+
+
+
+ {onlyIncomplete && !query.trim().length ? 'Všechna jídla mají údaje vyplněné' : 'Nic nenalezeno'}
+
+
+ )}
+
+ {!loading && visibleList.map(group => {
+ const key = getGroupKey(group);
+ return (
+ setExpandedKey(current => current === key ? undefined : key)}
+ onFilled={() => { setExpandedKey(undefined); load(); }}
+ />
+ );
+ })}
+ >
+ );
+};
+
+export default MealGroupsPage;
diff --git a/client/src/pages/StatsPage.tsx b/client/src/pages/StatsPage.tsx
index b01e172..abdfc9a 100644
--- a/client/src/pages/StatsPage.tsx
+++ b/client/src/pages/StatsPage.tsx
@@ -1,8 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Spinner } from "react-bootstrap";
+import { useNavigate } from "react-router";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCoins, faFire, faListCheck, faCalendarDay, faFileImport, faChartColumn, faUtensils, faPersonRunning,
+ faPenToSquare,
} from "@fortawesome/free-solid-svg-icons";
import {
Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis, Legend,
@@ -12,6 +14,7 @@ import { DAY_TAB_NAMES, DayTab, MEAL_TYPE_NAMES, StatsRange, STATS_RANGE_NAMES }
import {
formatCalories, formatMonth, formatPrice, formatPriceShort, getMonthEnd, getMonthStart,
} from "../Utils";
+import { DOPLNENI_JIDEL_URL } from "../routes";
import ImportModal from "../components/modals/ImportModal";
import PeriodPicker, { EARLIEST_YEAR } from "../components/PeriodPicker";
import ActivityStatsPanel from "./ActivityStatsPanel";
@@ -37,6 +40,7 @@ export default function StatsPage() {
const [stats, setStats] = useState();
const [loading, setLoading] = useState(true);
const [importOpen, setImportOpen] = useState(false);
+ const navigate = useNavigate();
/** Přepočte hranice období z aktuálně zvoleného rozsahu. */
const range = useMemo(() => {
@@ -87,9 +91,14 @@ export default function StatsPage() {
{tab === DayTab.PRIJEM && (
- setImportOpen(true)}>
- Import z Luncheru
-
+
+ navigate(DOPLNENI_JIDEL_URL)}>
+ Doplnit údaje jídel
+
+ setImportOpen(true)}>
+ Import z Luncheru
+
+
)}
diff --git a/client/src/routes.ts b/client/src/routes.ts
index c917940..984a38a 100644
--- a/client/src/routes.ts
+++ b/client/src/routes.ts
@@ -8,3 +8,4 @@ export const DEN_URL = '/';
export const STATISTIKY_URL = '/statistiky';
export const SABLONY_URL = '/sablony';
export const PROFIL_URL = '/profil';
+export const DOPLNENI_JIDEL_URL = '/statistiky/doplneni';
diff --git a/server/src/meals.ts b/server/src/meals.ts
index f6f703e..eae902e 100644
--- a/server/src/meals.ts
+++ b/server/src/meals.ts
@@ -1,6 +1,6 @@
import { randomUUID } from 'crypto';
import {
- DayRecord, FillSameNameResult, MealEntry, MealSuggestion, MealType, Nutrients,
+ DayRecord, FillSameNameResult, MealEntry, MealGroup, MealGroupValues, MealSuggestion, MealType, Nutrients,
} from "../../types/gen/types.gen";
import getStorage from "./storage";
import {
@@ -461,6 +461,10 @@ export async function findAllMeals(login: string): Promise {
.sort((a, b) => b.date.localeCompare(a.date) || b.createdAt.localeCompare(a.createdAt));
}
+/** Klíč jídla podle názvu a zdroje — jídlo stejného jména jinde bývá jinak velké i jinak účtované. */
+const getNameSourceKey = (name: string, source?: string): string =>
+ `${normalizeName(name)}|${normalizeName(source ?? '')}`;
+
/** 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;
@@ -478,7 +482,7 @@ const hasNutrition = (entry: MealEntry): boolean =>
* @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 sourceKey = (entry: MealEntry) => getNameSourceKey(entry.name, entry.source);
const byNameMap = new Map();
const byNameSourceMap = new Map();
for (const entry of historyList.filter(hasNutrition)) {
@@ -583,3 +587,158 @@ export async function fillSameNameMeals(
}
return { count: targetList.length, dates };
}
+
+/** Vrátí první vyplněnou hodnotu z jídel seřazených od nejnovějších. */
+const findLatestValue = (entryList: MealEntry[], pick: (entry: MealEntry) => T | undefined): T | undefined =>
+ entryList.map(pick).find(value => value != null);
+
+/** Vynechá nevyplněné hodnoty, aby se do úložiště nezapisovaly. */
+const omitUndefined = (value: T): T =>
+ Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)) as T;
+
+/**
+ * Sestaví hodnoty k předvyplnění formuláře skupiny — každou z nejnovějšího
+ * jídla, které ji má. Energie a živiny se bez vlastních hodnot vezmou z jídla
+ * stejného názvu odjinud (složení je stejné), sazba za 100 g a gramáž ne —
+ * jinde se účtuje i nandává jinak.
+ */
+const createMealGroupValues = (groupList: MealEntry[], sameNameList: MealEntry[]): MealGroupValues => {
+ const caloriesPer100g = findLatestValue(groupList, entry => entry.caloriesPer100g)
+ ?? findLatestValue(sameNameList, entry => entry.caloriesPer100g);
+ return omitUndefined({
+ pricePer100g: findLatestValue(groupList, entry => entry.pricePer100g),
+ weight: findLatestValue(groupList, getManualWeight),
+ caloriesPer100g,
+ // Ruční kalorie porce dávají smysl jen u jídla, které energii na 100 g nemá
+ calories: caloriesPer100g == null
+ ? findLatestValue(groupList, entry => entry.calories)
+ : undefined,
+ nutrientsPer100g: findLatestValue(groupList, entry => entry.nutrientsPer100g)
+ ?? findLatestValue(sameNameList, entry => entry.nutrientsPer100g),
+ });
+};
+
+/**
+ * Vrátí jídla uživatele seskupená podle názvu a zdroje, i s počty jídel,
+ * kterým chybí kalorie, gramáž nebo živiny — podklad pro hromadné doplnění
+ * údajů ke starším záznamům.
+ *
+ * @param login přihlašovací jméno uživatele
+ */
+export const getMealGroups = async (login: string): Promise => {
+ const entryList = await findAllMeals(login);
+ const groupMap = new Map();
+ const nameMap = new Map();
+ for (const entry of entryList) {
+ const key = getNameSourceKey(entry.name, entry.source);
+ groupMap.set(key, [...(groupMap.get(key) ?? []), entry]);
+ const nameKey = normalizeName(entry.name);
+ nameMap.set(nameKey, [...(nameMap.get(nameKey) ?? []), entry]);
+ }
+
+ const resultList = [...groupMap.values()].map((groupList): MealGroup => {
+ const [latest] = groupList;
+ return {
+ name: latest.name,
+ ...(latest.source ? { source: latest.source } : {}),
+ count: groupList.length,
+ lastDate: latest.date,
+ missingCaloriesCount: groupList.filter(entry => entry.calories == null).length,
+ missingWeightCount: groupList.filter(entry => entry.weight == null).length,
+ missingNutrientsCount: groupList.filter(entry => !entry.nutrientsPer100g).length,
+ values: createMealGroupValues(groupList, nameMap.get(normalizeName(latest.name)) ?? []),
+ };
+ });
+ return resultList.sort((a, b) => b.count - a.count || b.lastDate.localeCompare(a.lastDate));
+};
+
+/** Ověří hodnoty k doplnění do skupiny jídel. Nulová sazba nebo gramáž nic neříká, bere se jako nezadaná. */
+const normalizeGroupValues = (input: unknown): MealGroupValues => {
+ const value = (input ?? {}) as Record;
+ return {
+ pricePer100g: normalizeNumber(value.pricePer100g, 'pricePer100g') || undefined,
+ weight: normalizeNumber(value.weight, 'weight') || undefined,
+ caloriesPer100g: normalizeNumber(value.caloriesPer100g, 'caloriesPer100g') || undefined,
+ calories: normalizeNumber(value.calories, 'calories'),
+ nutrientsPer100g: normalizeNutrients(value.nutrientsPer100g, 'nutrientsPer100g'),
+ };
+};
+
+/**
+ * Doplní do jídla hodnoty, které mu chybí; co už má, nechá být.
+ *
+ * Gramáž se nedoplní tam, kde jde dopočítat z vlastní ceny jídla — ta je
+ * přesnější než jedna gramáž pro všechny. Energie se doplní jen jídlu bez
+ * kalorií, jinak by dopočet z gramáže přepsal kalorie zadané ručně. Živiny
+ * se doplňují po jednotlivých položkách.
+ */
+const fillMissingValues = (entry: MealEntry, values: MealGroupValues): MealEntry => {
+ const pricePer100g = entry.pricePer100g ?? values.pricePer100g;
+ const weightDerivable = entry.price != null && !!pricePer100g;
+ const caloriesMissing = entry.calories == null;
+ const derived = deriveAmounts({
+ ...entry,
+ pricePer100g,
+ weight: entry.weight ?? (weightDerivable ? undefined : values.weight),
+ caloriesPer100g: entry.caloriesPer100g ?? (caloriesMissing ? values.caloriesPer100g : undefined),
+ calories: caloriesMissing ? values.calories : entry.calories,
+ });
+ return omitUndefined({
+ ...entry,
+ pricePer100g,
+ weight: derived.weight,
+ caloriesPer100g: derived.caloriesPer100g,
+ calories: caloriesMissing ? derived.calories : entry.calories,
+ nutrientsPer100g: fillMissingNutrients(entry.nutrientsPer100g, values.nutrientsPer100g),
+ });
+};
+
+/** Doplní chybějící živiny; vyplněné zůstanou a na svém místě, aby šlo poznat, že se nic nezměnilo. */
+const fillMissingNutrients = (current?: Nutrients, values?: Nutrients): Nutrients | undefined => {
+ if (!values) return current;
+ const missing = Object.entries(values).filter(([key]) => current?.[key as keyof Nutrients] == null);
+ return missing.length ? { ...current, ...Object.fromEntries(missing) } : current;
+};
+
+/**
+ * Doplní chybějící údaje do všech jídel se stejným názvem a zdrojem — typicky
+ * ke starším záznamům, u kterých se kalorie nebo živiny nezadávaly.
+ *
+ * @param login přihlašovací jméno uživatele
+ * @param name název jídla skupiny
+ * @param source zdroj skupiny; prázdný znamená jídla bez zdroje
+ * @param input hodnoty k doplnění
+ */
+export const fillMealGroup = async (
+ login: string,
+ name: unknown,
+ source: unknown,
+ input: unknown,
+): Promise => {
+ const groupName = normalizeText(name, 'name');
+ if (!groupName) {
+ throw new BadRequestError("Nebyl předán název jídla");
+ }
+ const key = getNameSourceKey(groupName, normalizeText(source, 'source'));
+ const values = normalizeGroupValues(input);
+ const isInGroup = (entry: MealEntry) => getNameSourceKey(entry.name, entry.source) === key;
+
+ const groupDates = [...new Set((await findAllMeals(login)).filter(isInGroup).map(entry => entry.date))].sort();
+ const updatedAt = new Date().toISOString();
+ let count = 0;
+ const dates: string[] = [];
+ for (const date of groupDates) {
+ await storage.updateData(getDayKey(login, date), current => (current ?? []).map(entry => {
+ if (!isInGroup(entry)) return entry;
+ const filled = fillMissingValues(entry, values);
+ if (JSON.stringify(filled) === JSON.stringify(entry)) return entry;
+ count++;
+ if (!dates.includes(date)) dates.push(date);
+ return { ...filled, updatedAt };
+ }));
+ }
+ if (values.caloriesPer100g) {
+ await rememberCalories(login, groupName, values.caloriesPer100g);
+ }
+ return { count, dates };
+};
diff --git a/server/src/routes/mealRoutes.ts b/server/src/routes/mealRoutes.ts
index 3293308..818fde1 100644
--- a/server/src/routes/mealRoutes.ts
+++ b/server/src/routes/mealRoutes.ts
@@ -1,7 +1,7 @@
import express, { Request, Response, NextFunction } from "express";
import {
- addMeal, deleteMeal, fillSameNameMeals, getDay, getMealHistory, getMealSources, getRange, setMealMultiplier,
- updateMeal,
+ addMeal, deleteMeal, fillMealGroup, fillSameNameMeals, getDay, getMealGroups, getMealHistory, getMealSources,
+ getRange, setMealMultiplier, updateMeal,
} from "../meals";
import { getDayOverview } from "../dayOverview";
import { getLogin } from "../auth";
@@ -59,6 +59,23 @@ router.post("/fillSameName", async (req: Request, res: Response, next: NextFunct
} catch (e) { next(e); }
});
+/** Vrátí jídla seskupená podle názvu a zdroje — podklad pro hromadné doplnění údajů. */
+router.get("/groups", async (req: Request, res: Response, next: NextFunction) => {
+ try {
+ const login = getLogin(parseToken(req));
+ res.status(200).json(await getMealGroups(login));
+ } catch (e) { next(e); }
+});
+
+/** Doplní chybějící údaje do všech jídel se stejným názvem a zdrojem. */
+router.post("/fillGroup", async (req: Request, res: Response, next: NextFunction) => {
+ try {
+ const login = getLogin(parseToken(req));
+ const { name, source, values } = req.body ?? {};
+ res.status(200).json(await fillMealGroup(login, name, source, values));
+ } catch (e) { next(e); }
+});
+
/** Přidá jídlo do zvoleného dne. */
router.post("/add", async (req: Request, res: Response, next: NextFunction) => {
try {
diff --git a/server/src/tests/mealGroups.test.ts b/server/src/tests/mealGroups.test.ts
new file mode 100644
index 0000000..eba2fff
--- /dev/null
+++ b/server/src/tests/mealGroups.test.ts
@@ -0,0 +1,101 @@
+process.env.CALORIE_PROVIDER = 'none';
+
+import { addMeal, fillMealGroup, getDay, getMealGroups } from "../meals";
+import { resetMemoryStorage } from "../storage/memory";
+import { MealType } from "../../../types/gen/types.gen";
+
+const LOGIN = 'tester';
+
+beforeEach(() => resetMemoryStorage());
+
+const cina = { mealType: MealType.OBED, name: 'Čína', source: 'Chefie' };
+
+describe('skupiny jídel podle názvu a zdroje', () => {
+
+ it('seskupí jídla podle názvu i zdroje a spočítá chybějící údaje', async () => {
+ await addMeal(LOGIN, { ...cina, date: '2025-03-01', price: 13900 });
+ await addMeal(LOGIN, { ...cina, date: '2025-03-02', name: 'cina', price: 14900 });
+ await addMeal(LOGIN, { ...cina, date: '2025-03-03', price: 14900, weight: 400, caloriesPer100g: 150 });
+ await addMeal(LOGIN, { ...cina, date: '2025-03-04', source: 'doma' });
+
+ const groups = await getMealGroups(LOGIN);
+
+ expect(groups).toHaveLength(2);
+ expect(groups[0]).toMatchObject({
+ name: 'Čína', source: 'Chefie', count: 3, lastDate: '2025-03-03',
+ missingCaloriesCount: 2, missingWeightCount: 2, missingNutrientsCount: 3,
+ values: { weight: 400, caloriesPer100g: 150 },
+ });
+ expect(groups[1]).toMatchObject({ source: 'doma', count: 1 });
+ });
+
+ 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,
+ nutrientsPer100g: { protein: 8 },
+ });
+ await addMeal(LOGIN, { ...cina, date: '2025-03-02', price: 13900 });
+
+ const group = (await getMealGroups(LOGIN)).find(item => item.source === 'Chefie');
+
+ expect(group?.values).toEqual({ caloriesPer100g: 140, nutrientsPer100g: { protein: 8 } });
+ });
+});
+
+describe('hromadné doplnění údajů do skupiny', () => {
+
+ it('doplní jen chybějící hodnoty a kalorie dopočte z gramáže', async () => {
+ await addMeal(LOGIN, { ...cina, date: '2025-03-01', price: 13900 });
+ await addMeal(LOGIN, { ...cina, date: '2025-03-02', price: 14900, weight: 300 });
+ await addMeal(LOGIN, { ...cina, date: '2025-03-03', price: 14900, calories: 700 });
+ await addMeal(LOGIN, { ...cina, date: '2025-03-04', source: 'doma' });
+
+ const result = await fillMealGroup(LOGIN, 'cina', 'chefie', {
+ weight: 400, caloriesPer100g: 150, nutrientsPer100g: { protein: 8 },
+ });
+
+ expect(result).toEqual({ count: 3, dates: ['2025-03-01', '2025-03-02', '2025-03-03'] });
+ expect((await getDay(LOGIN, '2025-03-01')).entries[0]).toMatchObject({ weight: 400, calories: 600, price: 13900 });
+ expect((await getDay(LOGIN, '2025-03-02')).entries[0]).toMatchObject({ weight: 300, calories: 450 });
+ // Ručně zadané kalorie zůstanou, energie na 100 g by je přepočítala
+ const third = (await getDay(LOGIN, '2025-03-03')).entries[0];
+ expect(third).toMatchObject({ weight: 400, calories: 700, nutrientsPer100g: { protein: 8 } });
+ expect(third.caloriesPer100g).toBeUndefined();
+ expect((await getDay(LOGIN, '2025-03-04')).entries[0].calories).toBeUndefined();
+ });
+
+ it('gramáž dopočte z vlastní ceny jídla, když přibude sazba', async () => {
+ await addMeal(LOGIN, { ...cina, date: '2025-03-01', price: 13200 });
+
+ await fillMealGroup(LOGIN, 'Čína', 'Chefie', { pricePer100g: 4400, weight: 400, caloriesPer100g: 150 });
+
+ expect((await getDay(LOGIN, '2025-03-01')).entries[0]).toMatchObject({ weight: 300, calories: 450 });
+ });
+
+ it('vyplněné živiny nepřepíše, chybějící doplní', async () => {
+ await addMeal(LOGIN, { ...cina, date: '2025-03-01', nutrientsPer100g: { protein: 5 } });
+
+ await fillMealGroup(LOGIN, 'Čína', 'Chefie', { nutrientsPer100g: { protein: 8, fat: 4 } });
+
+ expect((await getDay(LOGIN, '2025-03-01')).entries[0].nutrientsPer100g).toEqual({ protein: 5, fat: 4 });
+ });
+
+ it('jídla, kterým nic nechybí, nepočítá', async () => {
+ await addMeal(LOGIN, { ...cina, date: '2025-03-01', weight: 300, caloriesPer100g: 150 });
+
+ const result = await fillMealGroup(LOGIN, 'Čína', 'Chefie', { weight: 400, caloriesPer100g: 200 });
+
+ expect(result).toEqual({ count: 0, dates: [] });
+ expect((await getDay(LOGIN, '2025-03-01')).entries[0].calories).toBe(450);
+ });
+
+ it('bez zdroje doplní jen jídla bez zdroje', async () => {
+ await addMeal(LOGIN, { ...cina, date: '2025-03-01', source: undefined });
+ await addMeal(LOGIN, { ...cina, date: '2025-03-02' });
+
+ const result = await fillMealGroup(LOGIN, 'Čína', undefined, { calories: 600 });
+
+ expect(result.dates).toEqual(['2025-03-01']);
+ expect((await getDay(LOGIN, '2025-03-02')).entries[0].calories).toBeUndefined();
+ });
+});
diff --git a/types/api.yml b/types/api.yml
index cbc38c9..4de0aba 100644
--- a/types/api.yml
+++ b/types/api.yml
@@ -30,6 +30,10 @@ paths:
$ref: "./paths/meals/sources.yml"
/meals/fillSameName:
$ref: "./paths/meals/fillSameName.yml"
+ /meals/groups:
+ $ref: "./paths/meals/groups.yml"
+ /meals/fillGroup:
+ $ref: "./paths/meals/fillGroup.yml"
# Statistiky (/api/stats)
/stats/summary:
diff --git a/types/paths/meals/fillGroup.yml b/types/paths/meals/fillGroup.yml
new file mode 100644
index 0000000..77bf35f
--- /dev/null
+++ b/types/paths/meals/fillGroup.yml
@@ -0,0 +1,35 @@
+post:
+ operationId: fillMealGroup
+ summary: Doplní chybějící údaje do všech jídel se stejným názvem a zdrojem.
+ description: |
+ Doplňují se jen prázdné hodnoty, co už jídla mají, zůstane. Gramáž se
+ nedoplní tam, kde jde dopočítat z ceny a sazby za 100 g, a energie jen
+ jídlům, která kalorie nemají. Kalorie se pak dopočtou z gramáže jako
+ při běžném uložení.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [name, values]
+ properties:
+ name:
+ description: Název jídla skupiny
+ type: string
+ source:
+ description: Zdroj skupiny; bez něj jde o jídla bez zdroje
+ type: string
+ values:
+ $ref: "../../schemas/_index.yml#/MealGroupValues"
+ responses:
+ "200":
+ description: Jídla, do kterých se něco doplnilo
+ content:
+ application/json:
+ schema:
+ $ref: "../../schemas/_index.yml#/FillSameNameResult"
+ "400":
+ description: Neplatná data
+ "401":
+ description: Neautentizovaný uživatel
diff --git a/types/paths/meals/groups.yml b/types/paths/meals/groups.yml
new file mode 100644
index 0000000..eb7f483
--- /dev/null
+++ b/types/paths/meals/groups.yml
@@ -0,0 +1,18 @@
+get:
+ operationId: getMealGroups
+ summary: Vrátí jídla přihlášeného uživatele seskupená podle názvu a zdroje.
+ description: |
+ Slouží k hromadnému doplnění chybějících údajů ke starším jídlům. Názvy
+ i zdroje se porovnávají bez diakritiky a velikosti písmen. Seřazeno od
+ nejčastěji jedených, při shodě od naposledy jedených.
+ responses:
+ "200":
+ description: Skupiny jídel
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: "../../schemas/_index.yml#/MealGroup"
+ "401":
+ description: Neautentizovaný uživatel
diff --git a/types/schemas/_index.yml b/types/schemas/_index.yml
index f78de3c..ee03cef 100644
--- a/types/schemas/_index.yml
+++ b/types/schemas/_index.yml
@@ -193,6 +193,72 @@ FillSameNameResult:
type: string
format: date
+MealGroupValues:
+ description: |
+ Hodnoty popisující jídlo samo — to, co mají všechna jídla jedné skupiny
+ (stejný název a zdroj) společné. Chod, datum, cena ani násobek mezi ně
+ nepatří, liší se den ode dne.
+ type: object
+ additionalProperties: false
+ properties:
+ pricePer100g:
+ description: Cena za 100 g v haléřích
+ type: integer
+ weight:
+ description: Gramáž porce zadaná ručně
+ type: integer
+ caloriesPer100g:
+ type: integer
+ calories:
+ description: Kalorie porce, jen pokud nejdou dopočítat z gramáže a energie na 100 g
+ type: integer
+ nutrientsPer100g:
+ $ref: "#/Nutrients"
+
+MealGroup:
+ description: |
+ Všechna jídla uživatele se stejným názvem a zdrojem. Slouží k hromadnému
+ doplnění údajů, které u starších záznamů chybí.
+ type: object
+ additionalProperties: false
+ required:
+ - name
+ - count
+ - lastDate
+ - missingCaloriesCount
+ - missingWeightCount
+ - missingNutrientsCount
+ - values
+ properties:
+ name:
+ description: Název jídla tak, jak byl zapsán naposledy
+ type: string
+ source:
+ description: Zdroj tak, jak byl zapsán naposledy
+ type: string
+ count:
+ description: Počet jídel ve skupině
+ type: integer
+ lastDate:
+ description: Den, kdy uživatel jídlo jedl naposledy
+ type: string
+ format: date
+ missingCaloriesCount:
+ description: Počet jídel, u kterých nejsou kalorie porce (do součtu dne nevstupují)
+ type: integer
+ missingWeightCount:
+ description: Počet jídel bez gramáže
+ type: integer
+ missingNutrientsCount:
+ description: Počet jídel bez nutričních hodnot
+ type: integer
+ values:
+ description: |
+ Hodnoty k předvyplnění — z nejnovějšího jídla skupiny, které je má.
+ Energie a živiny se, když je skupina nemá, vezmou z jídla stejného
+ názvu z jiného zdroje (složení je stejné, sazba za 100 g ne).
+ $ref: "#/MealGroupValues"
+
MealSuggestion:
description: |
Jídlo z historie uživatele nabízené při zakládání nového. Každý název je