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 => ( + + + + ); +}; + +export default MealGroupForm; diff --git a/client/src/components/MealGroupRow.tsx b/client/src/components/MealGroupRow.tsx new file mode 100644 index 0000000..a14068c --- /dev/null +++ b/client/src/components/MealGroupRow.tsx @@ -0,0 +1,56 @@ +import { Button } from "react-bootstrap"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faPenToSquare } from "@fortawesome/free-solid-svg-icons"; +import { MealGroup } from "../../../types"; +import { formatDateString } from "../Utils"; +import MealGroupForm from "./MealGroupForm"; + +type Props = { + group: MealGroup, + expanded: boolean, + onToggle: () => void, + onFilled: () => void, +}; + +/** Kolik jídel skupiny daný údaj nemá — "u všech", nebo "3 z 5". */ +const describeMissing = (missing: number, count: number): string => + missing === count ? 'u všech' : `${missing} z ${count}`; + +/** + * Řádek skupiny jídel (stejný název a zdroj) s přehledem chybějících údajů. + * Po rozbalení nabídne formulář, kterým se údaje doplní všem jídlům skupiny. + */ +const MealGroupRow = ({ group, expanded, onToggle, onFilled }: Readonly) => ( +
+
+
+
{group.name}
+
+ {group.source && {group.source}} + {group.count}× · naposledy {formatDateString(group.lastDate)} + {group.missingCaloriesCount > 0 && ( + + bez kalorií {describeMissing(group.missingCaloriesCount, group.count)} + + )} + {group.missingWeightCount > 0 && ( + + bez gramáže {describeMissing(group.missingWeightCount, group.count)} + + )} + {group.missingNutrientsCount > 0 && ( + + bez živin {describeMissing(group.missingNutrientsCount, group.count)} + + )} +
+
+ +
+ {expanded && onToggle()} />} +
+); + +export default MealGroupRow; diff --git a/client/src/components/NutrientsFieldset.tsx b/client/src/components/NutrientsFieldset.tsx index 29d4990..f46e2df 100644 --- a/client/src/components/NutrientsFieldset.tsx +++ b/client/src/components/NutrientsFieldset.tsx @@ -4,7 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons"; import { Nutrients } from "../../../types"; import { NUTRIENTS_IN_ORDER, NUTRIENT_NAMES } from "../enums"; -import { formatNutrient, getPortionNutrients, parseNutrientInput } from "../Utils"; +import { formatNutrient, formatNutrientInput, getPortionNutrients, parseNutrientInput } from "../Utils"; /** Rozepsané hodnoty živin tak, jak je uživatel píše do polí. */ export type NutrientInputs = Record; @@ -25,6 +25,10 @@ export const emptyNutrientInputs = (): NutrientInputs => ({ protein: '', carbohydrates: '', sugars: '', fat: '', saturatedFat: '', fiber: '', salt: '', }); +/** Převede uložené živiny na text pro editační pole. */ +export const toNutrientInputs = (nutrients?: Nutrients): NutrientInputs => + Object.fromEntries(NUTRIENTS_IN_ORDER.map(key => [key, formatNutrientInput(nutrients?.[key])])) as NutrientInputs; + /** Převede rozepsané hodnoty na živiny k odeslání; bez vyplněné hodnoty vrátí undefined. */ export const toNutrients = (values: NutrientInputs): Nutrients | undefined => { const result: Nutrients = {}; diff --git a/client/src/components/modals/MealModal.tsx b/client/src/components/modals/MealModal.tsx index 3d638c4..0245750 100644 --- a/client/src/components/modals/MealModal.tsx +++ b/client/src/components/modals/MealModal.tsx @@ -1,18 +1,20 @@ import { useEffect, useMemo, useState } from "react"; import { Button, Form, Modal } from "react-bootstrap"; import { - DayRecord, MealEntry, MealSuggestion, MealTemplate, MealType, Nutrients, UserSettings, + DayRecord, MealEntry, MealSuggestion, MealTemplate, MealType, UserSettings, addMeal, getMealHistory, getMealTemplates, getSettings, updateMeal, } from "../../../../types"; import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES, NUTRIENTS_IN_ORDER } from "../../enums"; import { applyMultiplier, deriveAmounts, formatCalories, formatMultiplier, formatMultiplierInput, formatPriceInput, - formatNutrientInput, formatWeight, normalizeSearch, parseIntInput, parseMultiplierInput, parseNutrientInput, + formatWeight, normalizeSearch, parseIntInput, parseMultiplierInput, parseNutrientInput, parsePriceInput, } from "../../Utils"; import CalorieLookup from "../CalorieLookup"; import MealSuggestions from "../MealSuggestions"; -import NutrientsFieldset, { NutrientInputs, emptyNutrientInputs, toNutrients } from "../NutrientsFieldset"; +import NutrientsFieldset, { + NutrientInputs, emptyNutrientInputs, toNutrientInputs, toNutrients, +} from "../NutrientsFieldset"; import SourceInput from "../SourceInput"; type Props = { @@ -65,10 +67,6 @@ const mergeSuggestion = (current: MealForm, values: Partial, onlyEmpty return result; }; -/** Převede uložené živiny na text pro editační pole. */ -const toNutrientInputs = (nutrients?: Nutrients): NutrientInputs => - Object.fromEntries(NUTRIENTS_IN_ORDER.map(key => [key, formatNutrientInput(nutrients?.[key])])) as NutrientInputs; - /** Znormalizuje název zdroje pro porovnání se sazbami z nastavení. */ function normalizeSource(source: string): string { return source.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim(); diff --git a/client/src/pages/MealGroupsPage.scss b/client/src/pages/MealGroupsPage.scss new file mode 100644 index 0000000..985f8c7 --- /dev/null +++ b/client/src/pages/MealGroupsPage.scss @@ -0,0 +1,56 @@ +.meal-groups-filter { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px 20px; + margin-bottom: 16px; + padding: 14px 18px; + + .form-control { + flex: 1 1 240px; + } + + .form-check { + margin: 0; + white-space: nowrap; + } +} + +.meal-group { + margin-bottom: 10px; + padding: 14px 18px; + + .meal-group-head { + display: flex; + align-items: center; + gap: 12px; + } + + .meal-group-info { + flex: 1; + min-width: 0; + } + + .meal-group-name { + font-weight: 700; + color: var(--ft-text); + margin-bottom: 4px; + } + + .meal-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + + .tag-missing { + background: color-mix(in srgb, var(--ft-warning) 15%, transparent); + color: var(--ft-warning); + } + + .meal-group-form { + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid var(--ft-border-light); + } +} diff --git a/client/src/pages/MealGroupsPage.tsx b/client/src/pages/MealGroupsPage.tsx new file mode 100644 index 0000000..0b1c336 --- /dev/null +++ b/client/src/pages/MealGroupsPage.tsx @@ -0,0 +1,109 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Button, Form, Spinner } from "react-bootstrap"; +import { useNavigate } from "react-router"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faArrowLeft, faCircleCheck } from "@fortawesome/free-solid-svg-icons"; +import { MealGroup, getMealGroups } from "../../../types"; +import { normalizeSearch } from "../Utils"; +import { STATISTIKY_URL } from "../routes"; +import MealGroupRow from "../components/MealGroupRow"; +import "./MealGroupsPage.scss"; + +/** Skupina se pozná podle názvu a zdroje, porovnávaných stejně jako na serveru. */ +const getGroupKey = (group: MealGroup): string => + `${normalizeSearch(group.name)}|${normalizeSearch(group.source ?? '')}`; + +const isIncomplete = (group: MealGroup): boolean => + group.missingCaloriesCount + group.missingWeightCount + group.missingNutrientsCount > 0; + +/** + * Hromadné doplnění údajů ke starším jídlům. Jídla se stejným názvem a zdrojem + * tvoří skupinu a chybějící kalorie, gramáž nebo živiny se jim doplní najednou, + * bez procházení jednotlivých dnů. + */ +const MealGroupsPage = () => { + const navigate = useNavigate(); + const [groupList, setGroupList] = useState([]); + const [loading, setLoading] = useState(true); + const [query, setQuery] = useState(''); + const [onlyIncomplete, setOnlyIncomplete] = useState(true); + const [expandedKey, setExpandedKey] = useState(); + + const load = useCallback(async () => { + const response = await getMealGroups(); + setGroupList(response.data ?? []); + setLoading(false); + }, []); + + useEffect(() => { load(); }, [load]); + + const visibleList = useMemo(() => { + const search = normalizeSearch(query); + return groupList.filter(group => + (!onlyIncomplete || isIncomplete(group)) + && (!search.length || normalizeSearch(`${group.name} ${group.source ?? ''}`).includes(search))); + }, [groupList, query, onlyIncomplete]); + + const incompleteCount = groupList.filter(group => isIncomplete(group)).length; + + return ( + <> +
+
+

Doplnění údajů jídel

+

+ Jídla se stejným názvem a zdrojem doplníte najednou — kalorie, gramáž i nutriční hodnoty +

+
+ +
+ +
+ setQuery(event.target.value)} + /> + setOnlyIncomplete(event.target.checked)} + /> +
+ + {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 && ( - +
+ + +
)} 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