From efed5e6e86acedc6e03af78b791789915e304148 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20And=C4=9Bl?= Date: Mon, 12 Oct 2026 09:49:41 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20graf=20cena=20per=20zdroj=20nahrazen=20k?= =?UTF-8?q?ol=C3=A1=C4=8Dem=20feat:=20str=C3=A1nka=20doplnit=20=C3=BAdaje?= =?UTF-8?q?=20p=C5=99ed=C4=9Bl=C3=A1na=20na=20samostatnou=20str=C3=A1nku?= =?UTF-8?q?=20j=C3=ADdel=20+=20p=C5=99id=C3=A1no=20p=C5=99ejmenov=C3=A1n?= =?UTF-8?q?=C3=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 24 +++-- client/src/AppRoutes.tsx | 4 +- client/src/Utils.tsx | 7 ++ client/src/components/FillSameNameOffer.tsx | 8 +- client/src/components/Header.tsx | 5 +- client/src/components/MealGroupRenameForm.tsx | 98 ++++++++++++++++++ client/src/components/MealGroupRow.tsx | 54 +++++++--- client/src/components/MealSuggestions.tsx | 4 +- .../src/components/modals/ApplyMealModal.tsx | 4 +- client/src/components/modals/MealModal.tsx | 4 +- .../src/components/modals/MealSplitModal.tsx | 5 +- client/src/enums.ts | 12 +++ client/src/pages/IntakeStatsPanel.tsx | 10 +- client/src/pages/MealGroupsPage.scss | 4 + client/src/pages/MealGroupsPage.tsx | 92 +++++++++++------ client/src/pages/SpendingStatsPanel.tsx | 35 +++---- client/src/pages/StatsPage.tsx | 8 +- client/src/pages/intakeChartSeries.ts | 24 +---- client/src/pages/statsShared.ts | 26 +++++ client/src/routes.ts | 2 +- server/src/meals.ts | 99 +++++++++++++++---- server/src/routes/mealRoutes.ts | 13 ++- server/src/tests/mealGroups.test.ts | 55 ++++++++++- server/src/tests/mealHistory.test.ts | 12 +++ types/api.yml | 2 + types/paths/meals/fillSameName.yml | 6 +- types/paths/meals/groups.yml | 2 +- types/paths/meals/history.yml | 2 +- types/paths/meals/renameGroup.yml | 38 +++++++ types/schemas/_index.yml | 8 +- 30 files changed, 514 insertions(+), 153 deletions(-) create mode 100644 client/src/components/MealGroupRenameForm.tsx create mode 100644 types/paths/meals/renameGroup.yml diff --git a/CLAUDE.md b/CLAUDE.md index a62f26a..e9b7f25 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,11 +73,12 @@ cd client && yarn build # tsc --noEmit + vite build → client/dist ### Client (client/src/) - **Entry:** `index.tsx` → `AppRoutes.tsx`; `Login.tsx` je přihlašovací obrazovka - **Pages:** `pages/` — `DayPage` (přehled dne se záložkami Příjem/Výdej), + `MealGroupsPage` (`/jidla` — seznam jídel podle názvu a zdroje s filtrem zdroje, + hromadným doplněním údajů a přejmenováním), `TemplatesPage` (šablony tréninků a recepty), `ProfilePage` (Profil — ukazatele, vývoj hmotnosti, tělesné údaje, cíle a klidový výdej), `StatsPage` (statistiky se záložkami Příjem / Výdej / Útrata a import; panely - `IntakeStatsPanel`, `ActivityStatsPanel`, `SpendingStatsPanel`), - `MealGroupsPage` (`/statistiky/doplneni` — hromadné doplnění údajů jídel) + `IntakeStatsPanel`, `ActivityStatsPanel`, `SpendingStatsPanel`) - **Components:** `components/` (Header, CalorieLookup, MealSuggestions, NutrientsFieldset) a `components/modals/` (MealModal, ActivityModal, ApplyWorkoutModal, ApplyMealModal — výběr z historie a receptů, SettingsModal, ImportModal) @@ -267,9 +268,12 @@ takže `DayPage` si vystačí s jedním voláním. Hotová jídla (objednaná, koupená) **nemají šablony**. Při zakládání jídla se k rozepsanému názvu nabízí historie: `getMealHistory` v `meals.ts` -(`GET /api/meals/history`) projde všechna jídla uživatele a každý název vrátí -jednou (`MealSuggestion`). Názvy se porovnávají znormalizované přes -`normalizeName`, hodnoty jsou z **posledního** záznamu toho jména (cena i gramáž +(`GET /api/meals/history`) projde všechna jídla uživatele a každou dvojici +**název a zdroj** vrátí jednou (`MealSuggestion`). Stejnojmenné jídlo z jiného +zdroje (tikka masala z Everestu a z Namaste) je jiné jídlo a nikde se s ním +neslučuje — ani v návrzích, ani v seznamu jídel, ani při `fillSameNameMeals`; +na klientovi to hlídá `getMealKey` v `Utils.tsx`. Porovnává se znormalizovaně +přes `normalizeName`, hodnoty jsou z **posledního** záznamu (cena i gramáž se časem mění) a řadí se od nejčastěji jedených. Návrh nese jen to, co popisuje jídlo samo — **chod, datum, násobek ani poznámku @@ -283,10 +287,10 @@ Návrhy se nabízejí i při **úpravě** jídla, ale až po změně názvu, a d prázdná pole (`mergeSuggestion` v `MealModal`) — slouží k přejmenování naimportovaného jídla, jehož cena a zdroj jsou skutečné. Po uložení jídla s kaloriemi `DayPage` nabídne (`FillSameNameOffer`) doplnit stejné hodnoty do -ostatních jídel stejného názvu, která je nemají (`fillSameNameMeals`, +ostatních jídel stejného názvu a zdroje, 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í +**Seznam jídel** (`MealGroupsPage`, položka Jídla v hlavičce) seskupí jídla podle znormalizovaného názvu **a zdroje** (`getMealGroups`, `GET /api/meals/groups`) a ukáže, kolika chybí kalorie, gramáž a živiny. Skupina nese i dny, kdy se jídlo jedlo (`MealGroup.days`, s příznakem neúplných @@ -297,6 +301,12 @@ jídlům bez kalorií (jinak by dopočet přepsal ručně zadané kalorie), živ 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. +Druhou akcí skupiny je **přejmenování** (`renameMealGroup`, +`POST /api/meals/renameGroup`) — změní název i zdroj všem jídlům skupiny ve +všech dnech. Je oddělené od doplnění schválně: doplnění jen připisuje, co chybí, +přejmenování přepisuje. Když nový název a zdroj patří jiné skupině, jídla se do +ní sloučí (formulář na to předem upozorní). + 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 28766e0..d5dc294 100644 --- a/client/src/AppRoutes.tsx +++ b/client/src/AppRoutes.tsx @@ -8,7 +8,7 @@ import StatsPage from "./pages/StatsPage"; import TemplatesPage from "./pages/TemplatesPage"; import ProfilePage from "./pages/ProfilePage"; import MealGroupsPage from "./pages/MealGroupsPage"; -import { DEN_URL, DOPLNENI_JIDEL_URL, PROFIL_URL, SABLONY_URL, STATISTIKY_URL } from "./routes"; +import { DEN_URL, JIDLA_URL, PROFIL_URL, SABLONY_URL, STATISTIKY_URL } from "./routes"; export default function AppRoutes() { const auth = useAuth(); @@ -23,10 +23,10 @@ export default function AppRoutes() {
} /> + } /> } /> } /> } /> - } /> } />
diff --git a/client/src/Utils.tsx b/client/src/Utils.tsx index 10a957a..b02517b 100644 --- a/client/src/Utils.tsx +++ b/client/src/Utils.tsx @@ -342,6 +342,13 @@ export const deriveRecipe = ( export const normalizeSearch = (value: string): string => value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim(); +/** + * Klíč jídla podle názvu a zdroje. Stejnojmenné jídlo z jiného zdroje je jiné + * jídlo — jinde se vaří, nandává i účtuje jinak — a nesmí se s ním slučovat. + */ +export const getMealKey = (meal: { name: string, source?: string }): string => + `${normalizeSearch(meal.name)}|${normalizeSearch(meal.source ?? '')}`; + /** Popíše recept jednou řádkou — zdroj a hodnoty jedné porce, co je vyplněné. */ export const describeMealTemplate = (template: MealTemplate): string => [ template.source, diff --git a/client/src/components/FillSameNameOffer.tsx b/client/src/components/FillSameNameOffer.tsx index ec2c786..5cd681a 100644 --- a/client/src/components/FillSameNameOffer.tsx +++ b/client/src/components/FillSameNameOffer.tsx @@ -12,7 +12,7 @@ type Props = { /** * Nabídka doplnit kalorie a živiny právě uloženého jídla do ostatních jídel - * stejného názvu, která je nemají — typicky naimportovaných z Luncheru. + * stejného názvu a zdroje, která je nemají — typicky naimportovaných z Luncheru. * Když takové jídlo není, nic se nezobrazí. */ const FillSameNameOffer = ({ entry, onFilled, onDismiss }: Readonly) => { @@ -27,6 +27,8 @@ const FillSameNameOffer = ({ entry, onFilled, onDismiss }: Readonly) => { if (!preview?.count) return null; + const label = entry.source ? `„${entry.name}“ z ${entry.source}` : `„${entry.name}“`; + const fill = async () => { setBusy(true); const response = await fillSameNameMeals({ body: { id: entry.id, date: entry.date } }); @@ -43,8 +45,8 @@ const FillSameNameOffer = ({ entry, onFilled, onDismiss }: Readonly) => { > {preview.count === 1 - ? `Další jídlo „${entry.name}“ nemá kalorie.` - : `Další jídla „${entry.name}“ (${preview.count}) nemají kalorie.`} + ? `Další jídlo ${label} nemá kalorie.` + : `Další jídla ${label} (${preview.count}) nemají kalorie.`} {' '}Doplnit stejné hodnoty? diff --git a/client/src/components/Header.tsx b/client/src/components/Header.tsx index b079ce9..010ab05 100644 --- a/client/src/components/Header.tsx +++ b/client/src/components/Header.tsx @@ -5,12 +5,13 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faSun, faMoon, faGear } from "@fortawesome/free-solid-svg-icons"; import { useAuth } from "../context/auth"; import { useSettings, ThemePreference } from "../context/settings"; -import { DEN_URL, PROFIL_URL, SABLONY_URL, STATISTIKY_URL } from "../routes"; +import { DEN_URL, JIDLA_URL, PROFIL_URL, SABLONY_URL, STATISTIKY_URL } from "../routes"; import SettingsModal from "./modals/SettingsModal"; /** Položky hlavní navigace. */ const NAV_ITEMS = [ { url: DEN_URL, label: 'Můj den' }, + { url: JIDLA_URL, label: 'Jídla' }, { url: SABLONY_URL, label: 'Šablony' }, { url: STATISTIKY_URL, label: 'Statistiky' }, { url: PROFIL_URL, label: 'Profil' }, @@ -25,7 +26,7 @@ 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. */ + /** Případná podstránka nechá zvýrazněnou svou nadřazenou položku. */ const isActive = (url: string) => location.pathname === url || (url !== DEN_URL && location.pathname.startsWith(`${url}/`)); diff --git a/client/src/components/MealGroupRenameForm.tsx b/client/src/components/MealGroupRenameForm.tsx new file mode 100644 index 0000000..12ea46a --- /dev/null +++ b/client/src/components/MealGroupRenameForm.tsx @@ -0,0 +1,98 @@ +import { useState } from "react"; +import { Alert, Button, Form } from "react-bootstrap"; +import { toast } from "react-toastify"; +import { MealGroup, renameMealGroup } from "../../../types"; +import { getMealKey } from "../Utils"; +import SourceInput from "./SourceInput"; + +type Props = { + group: MealGroup, + /** Ostatní skupiny — podle nich se pozná, že se jídla po přejmenování s některou sloučí. */ + groupList: MealGroup[], + onRenamed: () => void, + onCancel: () => void, +}; + +/** Popisek výsledku přejmenování se skloňovaným počtem jídel. */ +const describeRenamed = (count: number): string => { + if (!count) return 'Název i zdroj už jídla mají, nic se nezměnilo.'; + if (count === 1) return 'Změněno 1 jídlo.'; + if (count < 5) return `Změněna ${count} jídla.`; + return `Změněno ${count} jídel.`; +}; + +/** + * Hromadná změna názvu a zdroje všech jídel jedné skupiny ve všech dnech. + * + * Na rozdíl od doplnění údajů přepisuje, proto je to samostatná akce. Když nový + * název a zdroj patří jiné skupině, jídla se do ní sloučí — formulář na to upozorní. + */ +const MealGroupRenameForm = ({ group, groupList, onRenamed, onCancel }: Readonly) => { + const [name, setName] = useState(group.name); + const [source, setSource] = useState(group.source ?? ''); + const [saving, setSaving] = useState(false); + + const targetKey = getMealKey({ name, source }); + const mergeGroup = targetKey !== getMealKey(group) + ? groupList.find(item => getMealKey(item) === targetKey) + : undefined; + const unchanged = name.trim() === group.name && source.trim() === (group.source ?? ''); + const canSave = !!name.trim().length && !unchanged && !saving; + + const save = async () => { + if (!canSave) return; + setSaving(true); + const response = await renameMealGroup({ + body: { + name: group.name, + source: group.source, + newName: name.trim(), + ...(source.trim().length ? { newSource: source.trim() } : {}), + }, + }); + setSaving(false); + if (response.data) { + toast.success(describeRenamed(response.data.count)); + onRenamed(); + } + }; + + return ( +
{ event.preventDefault(); save(); }}> +

+ {group.count === 1 + ? 'Změní se jen u tohoto jídla.' + : `Změní se u všech ${group.count} jídel ve všech dnech, kdy se jedla.`} Ostatní údaje zůstanou. +

+
+
+ Název + setName(event.target.value)} + /> +
+
+ Zdroj + setSource(value)} /> +
+
+ {mergeGroup && ( + + Jídlo „{mergeGroup.name}“{mergeGroup.source ? ` (${mergeGroup.source})` : ' bez zdroje'} už + v seznamu je ({mergeGroup.count}×), jídla se s ním sloučí. + + )} +
+ + +
+
+ ); +}; + +export default MealGroupRenameForm; diff --git a/client/src/components/MealGroupRow.tsx b/client/src/components/MealGroupRow.tsx index fdbd1df..5c9945e 100644 --- a/client/src/components/MealGroupRow.tsx +++ b/client/src/components/MealGroupRow.tsx @@ -1,17 +1,23 @@ import { useState } from "react"; import { Button } from "react-bootstrap"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faCalendarDays, faPenToSquare } from "@fortawesome/free-solid-svg-icons"; +import { faCalendarDays, faPen, faPenToSquare } from "@fortawesome/free-solid-svg-icons"; import { MealGroup } from "../../../types"; +import { MealGroupAction } from "../enums"; import { formatDateString } from "../Utils"; import MealGroupForm from "./MealGroupForm"; +import MealGroupRenameForm from "./MealGroupRenameForm"; import MealGroupDays from "./MealGroupDays"; type Props = { group: MealGroup, - expanded: boolean, - onToggle: () => void, - onFilled: () => void, + /** Všechny skupiny — přejmenování podle nich pozná sloučení s jinou. */ + groupList: MealGroup[], + /** Rozbalená akce; nejvýš jeden řádek seznamu má nějakou otevřenou. */ + action?: MealGroupAction, + onAction: (action?: MealGroupAction) => void, + /** Zavolá se po doplnění nebo přejmenování, aby se seznam načetl znovu. */ + onChanged: () => void, }; /** Kolik jídel skupiny daný údaj nemá — "u všech", nebo "3 z 5". */ @@ -20,11 +26,12 @@ const describeMissing = (missing: number, count: number): string => /** * Řá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, - * a dny, kdy se jídlo jedlo, jako odkazy na přehled dne. + * Nabízí doplnění údajů a změnu názvu a zdroje všem jídlům skupiny a dny, + * kdy se jídlo jedlo, jako odkazy na přehled dne. */ -const MealGroupRow = ({ group, expanded, onToggle, onFilled }: Readonly) => { +const MealGroupRow = ({ group, groupList, action, onAction, onChanged }: Readonly) => { const [daysOpen, setDaysOpen] = useState(false); + const toggle = (value: MealGroupAction) => onAction(action === value ? undefined : value); return (
@@ -51,7 +58,7 @@ const MealGroupRow = ({ group, expanded, onToggle, onFilled }: Readonly)
- {!expanded && ( + {!action && ( )} - +
- {(expanded || daysOpen) && } - {expanded && onToggle()} />} + {(action === MealGroupAction.DOPLNIT || (!action && daysOpen)) && } + {action === MealGroupAction.DOPLNIT && ( + onAction(undefined)} /> + )} + {action === MealGroupAction.PREJMENOVAT && ( + onAction(undefined)} + /> + )} ); }; diff --git a/client/src/components/MealSuggestions.tsx b/client/src/components/MealSuggestions.tsx index 9c0d209..789d7bc 100644 --- a/client/src/components/MealSuggestions.tsx +++ b/client/src/components/MealSuggestions.tsx @@ -1,7 +1,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faBookOpen, faClockRotateLeft } from "@fortawesome/free-solid-svg-icons"; import { MealSuggestion, MealTemplate } from "../../../types"; -import { describeMealSuggestion, describeMealTemplate, normalizeSearch } from "../Utils"; +import { describeMealSuggestion, describeMealTemplate, getMealKey, normalizeSearch } from "../Utils"; type Props = { recipes: MealTemplate[], @@ -54,7 +54,7 @@ const MealSuggestions = ({ recipes, history, query, onPickRecipe, onPickHistory diff --git a/client/src/pages/MealGroupsPage.scss b/client/src/pages/MealGroupsPage.scss index 5998af1..7eb282e 100644 --- a/client/src/pages/MealGroupsPage.scss +++ b/client/src/pages/MealGroupsPage.scss @@ -10,6 +10,10 @@ flex: 1 1 240px; } + .meal-groups-source { + flex: 0 1 220px; + } + .form-check { margin: 0; white-space: nowrap; diff --git a/client/src/pages/MealGroupsPage.tsx b/client/src/pages/MealGroupsPage.tsx index 0b1c336..0e4d907 100644 --- a/client/src/pages/MealGroupsPage.tsx +++ b/client/src/pages/MealGroupsPage.tsx @@ -1,33 +1,58 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { Button, Form, Spinner } from "react-bootstrap"; -import { useNavigate } from "react-router"; +import { Form, Spinner } from "react-bootstrap"; +import { useLocation } from "react-router"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faArrowLeft, faCircleCheck } from "@fortawesome/free-solid-svg-icons"; +import { faCircleCheck } from "@fortawesome/free-solid-svg-icons"; import { MealGroup, getMealGroups } from "../../../types"; -import { normalizeSearch } from "../Utils"; -import { STATISTIKY_URL } from "../routes"; +import { MealGroupAction, SourceFilter } from "../enums"; +import { getMealKey, normalizeSearch } from "../Utils"; 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; +/** Hodnota výběru pro konkrétní zdroj; předpona ji odliší od zvláštních voleb filtru. */ +const toSourceOption = (source: string): string => `zdroj:${normalizeSearch(source)}`; + /** - * 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ů. + * Zdroje skupin pro filtr, každý jednou, od nejčastěji jedeného. Zápis je + * z nejčastější skupiny — seznam je seřazený od nejčastějších. + */ +const listSources = (groupList: MealGroup[]): { value: string, label: string }[] => { + const countMap = new Map(); + for (const group of groupList) { + if (!group.source) continue; + const value = toSourceOption(group.source); + const existing = countMap.get(value); + countMap.set(value, { label: existing?.label ?? group.source, count: (existing?.count ?? 0) + group.count }); + } + return [...countMap.entries()] + .sort(([, a], [, b]) => b.count - a.count || a.label.localeCompare(b.label, 'cs')) + .map(([value, item]) => ({ value, label: item.label })); +}; + +const matchesSource = (group: MealGroup, sourceFilter: string): boolean => { + if (sourceFilter === SourceFilter.VSE) return true; + if (sourceFilter === SourceFilter.BEZ_ZDROJE) return !group.source; + return !!group.source && toSourceOption(group.source) === sourceFilter; +}; + +/** + * Seznam jídel — všechna jídla uživatele seskupená podle názvu a zdroje. + * + * Skupině jde hromadně doplnit chybějící kalorie, gramáž nebo živiny a změnit + * název a zdroj, a to ve všech dnech naráz, bez procházení jednotlivých dnů. */ const MealGroupsPage = () => { - const navigate = useNavigate(); + const location = useLocation(); const [groupList, setGroupList] = useState([]); const [loading, setLoading] = useState(true); const [query, setQuery] = useState(''); - const [onlyIncomplete, setOnlyIncomplete] = useState(true); - const [expandedKey, setExpandedKey] = useState(); + const [sourceFilter, setSourceFilter] = useState(SourceFilter.VSE); + const [onlyIncomplete, setOnlyIncomplete] = useState(() => + !!(location.state as { onlyIncomplete?: boolean } | null)?.onlyIncomplete); + const [opened, setOpened] = useState<{ key: string, action: MealGroupAction } | undefined>(); const load = useCallback(async () => { const response = await getMealGroups(); @@ -37,35 +62,45 @@ const MealGroupsPage = () => { useEffect(() => { load(); }, [load]); + const sourceList = useMemo(() => listSources(groupList), [groupList]); + 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]); + && matchesSource(group, sourceFilter) + && (!search.length || normalizeSearch(group.name).includes(search))); + }, [groupList, query, sourceFilter, onlyIncomplete]); const incompleteCount = groupList.filter(group => isIncomplete(group)).length; + const filtered = !!query.trim().length || sourceFilter !== SourceFilter.VSE; return ( <>
-

Doplnění údajů jídel

+

Jídla

- Jídla se stejným názvem a zdrojem doplníte najednou — kalorie, gramáž i nutriční hodnoty + Všechna jídla podle názvu a zdroje — údaje doplníte nebo název změníte ve všech dnech najednou

-
setQuery(event.target.value)} /> + setSourceFilter(event.target.value)} + > + + + {sourceList.map(source => )} + {
- {onlyIncomplete && !query.trim().length ? 'Všechna jídla mají údaje vyplněné' : 'Nic nenalezeno'} + {onlyIncomplete && !filtered ? 'Všechna jídla mají údaje vyplněné' : 'Nic nenalezeno'}
)} {!loading && visibleList.map(group => { - const key = getGroupKey(group); + const key = getMealKey(group); return ( setExpandedKey(current => current === key ? undefined : key)} - onFilled={() => { setExpandedKey(undefined); load(); }} + groupList={groupList} + action={opened?.key === key ? opened.action : undefined} + onAction={action => setOpened(action ? { key, action } : undefined)} + onChanged={() => { setOpened(undefined); load(); }} /> ); })} diff --git a/client/src/pages/SpendingStatsPanel.tsx b/client/src/pages/SpendingStatsPanel.tsx index f374d85..99b13a2 100644 --- a/client/src/pages/SpendingStatsPanel.tsx +++ b/client/src/pages/SpendingStatsPanel.tsx @@ -5,8 +5,8 @@ import { faCalendarDay, faChartColumn, faCoins, faListCheck, faReceipt } from "@ import { Bar, BarChart, CartesianGrid, Cell, Legend, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; import { FoodStats, MealType, getFoodStats } from "../../../types"; import { MEAL_TYPE_NAMES, StatsRange } from "../enums"; -import { formatPrice, formatPriceShort } from "../Utils"; -import { StatsBounds, TOOLTIP_STYLE, formatPeriodLabel, pickPeriods } from "./statsShared"; +import { formatPrice } from "../Utils"; +import { StatsBounds, TOOLTIP_STYLE, buildSourceSlices, formatPeriodLabel, pickPeriods } from "./statsShared"; type Props = { range: StatsBounds; @@ -50,6 +50,8 @@ const SpendingStatsPanel = ({ range, mode, reloadKey }: Readonly) => { value: total.price / 100, })), [stats]); + const sourceSlices = useMemo(() => buildSourceSlices(stats?.bySource ?? [], total => total.price / 100), [stats]); + const averagePerDay = stats?.dayCount ? Math.round(stats.totalPrice / stats.dayCount) : 0; const pricedCount = (stats?.byMealType ?? []).reduce((sum, total) => sum + (total.price > 0 ? total.count : 0), 0); // Jídla bez ceny (vlastní, doma) by průměr na jídlo stahovala k nule @@ -130,25 +132,16 @@ const SpendingStatsPanel = ({ range, mode, reloadKey }: Readonly) => {
-

Kde nejvíc utrácíte

- - - - - - - - - - {stats.bySource.slice(0, 8).map(total => ( - - - - - - ))} - -
ZdrojJídelÚtrata
{total.key}{total.count}{formatPriceShort(total.price)}
+

Podle zdroje

+ + + + {sourceSlices.map(slice => )} + + + formatChartPrice(value)} contentStyle={TOOLTIP_STYLE} /> + +
diff --git a/client/src/pages/StatsPage.tsx b/client/src/pages/StatsPage.tsx index c131cff..2f9c492 100644 --- a/client/src/pages/StatsPage.tsx +++ b/client/src/pages/StatsPage.tsx @@ -1,13 +1,11 @@ import { useMemo, useState } from "react"; import { Button } from "react-bootstrap"; -import { useNavigate } from "react-router"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { - faCoins, faFileImport, faPenToSquare, faPersonRunning, faUtensils, + faCoins, faFileImport, faPersonRunning, faUtensils, } from "@fortawesome/free-solid-svg-icons"; import { StatsRange, STATS_RANGE_NAMES, STATS_TAB_NAMES, StatsTab } from "../enums"; import { formatMonth, 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"; @@ -35,7 +33,6 @@ export default function StatsPage() { const [importOpen, setImportOpen] = useState(false); /** Zvyšuje se po importu, aby si panely jídel načetly data znovu. */ const [reloadKey, setReloadKey] = useState(0); - const navigate = useNavigate(); /** Přepočte hranice období z aktuálně zvoleného rozsahu. */ const range = useMemo(() => { @@ -57,9 +54,6 @@ export default function StatsPage() { {tab !== StatsTab.VYDEJ && (
- diff --git a/client/src/pages/intakeChartSeries.ts b/client/src/pages/intakeChartSeries.ts index 4a6cdd5..c7e6713 100644 --- a/client/src/pages/intakeChartSeries.ts +++ b/client/src/pages/intakeChartSeries.ts @@ -1,4 +1,4 @@ -import { IntakeGroupTotal, IntakeStats, MealType } from "../../../types"; +import { IntakeStats, MealType } from "../../../types"; import { MEAL_TYPES_IN_ORDER, StatsRange } from "../enums"; import { StatsBounds, formatPeriodLabel, listPeriods, pickPeriods } from "./statsShared"; @@ -67,28 +67,6 @@ export const buildIntakeSeries = (stats: IntakeStats, range: StatsBounds, mode: }); }; -/** Kolik zdrojů má v koláči vlastní výseč; zbytek se slije do "Ostatní". */ -const SOURCE_SLICE_COUNT = 7; - -/** Výseč koláče zdrojů. */ -export type SourceSlice = { name: string, value: number, color: string }; - -/** - * Sestaví výseče koláče příjmu dle zdroje. Barva jde podle pořadí ve výběru, - * zdrojů bývá hodně a pevná barva pro každý by paletu nestačila. - * - * @param bySource příjem dle zdroje, sestupně dle kcal - */ -export const buildSourceSlices = (bySource: IntakeGroupTotal[]): SourceSlice[] => { - const slices = bySource - .filter(group => group.calories > 0) - .slice(0, SOURCE_SLICE_COUNT) - .map((group, index) => ({ name: group.key, value: group.calories, color: `var(--ft-series-${index + 1})` })); - const rest = bySource.slice(SOURCE_SLICE_COUNT).reduce((sum, group) => sum + group.calories, 0); - if (rest > 0) slices.push({ name: 'Ostatní', value: rest, color: 'var(--ft-series-other)' }); - return slices; -}; - /** Chody, které v období něco dodaly, v pořadí průběhu dne — pořadí dílů sloupce i legendy. */ export const getUsedMealTypes = (stats: IntakeStats): MealType[] => { const used = new Set(stats.byMealType.map(group => group.key)); diff --git a/client/src/pages/statsShared.ts b/client/src/pages/statsShared.ts index 66dfe47..a87dcd0 100644 --- a/client/src/pages/statsShared.ts +++ b/client/src/pages/statsShared.ts @@ -63,3 +63,29 @@ export const periodOf = (date: string, mode: StatsRange): string => { if (mode === StatsRange.ROK) return date.substring(0, 7); return date.substring(0, 4); }; + +/** Kolik zdrojů má v koláči vlastní výseč; zbytek se slije do "Ostatní". */ +const SOURCE_SLICE_COUNT = 7; + +/** Výseč koláče zdrojů. */ +export type SourceSlice = { name: string, value: number, color: string }; + +/** + * Sestaví výseče koláče dle zdroje — příjmu i útraty. Barva jde podle pořadí ve + * výběru, zdrojů bývá hodně a pevná barva pro každý by paletu nestačila. + * + * @param bySource součty dle zdroje, sestupně dle zobrazované hodnoty + * @param getValue zobrazovaná hodnota součtu (kcal, Kč) + */ +export const buildSourceSlices = ( + bySource: T[], + getValue: (total: T) => number, +): SourceSlice[] => { + const slices = bySource + .filter(total => getValue(total) > 0) + .slice(0, SOURCE_SLICE_COUNT) + .map((total, index) => ({ name: total.key, value: getValue(total), color: `var(--ft-series-${index + 1})` })); + const rest = bySource.slice(SOURCE_SLICE_COUNT).reduce((sum, total) => sum + getValue(total), 0); + if (rest > 0) slices.push({ name: 'Ostatní', value: rest, color: 'var(--ft-series-other)' }); + return slices; +}; diff --git a/client/src/routes.ts b/client/src/routes.ts index 2114d1f..44304d8 100644 --- a/client/src/routes.ts +++ b/client/src/routes.ts @@ -8,7 +8,7 @@ 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'; +export const JIDLA_URL = '/jidla'; /** Parametr adresy přehledu dne s datem YYYY-MM-DD. */ export const DAY_DATE_PARAM = 'datum'; diff --git a/server/src/meals.ts b/server/src/meals.ts index f518339..30cbcb9 100644 --- a/server/src/meals.ts +++ b/server/src/meals.ts @@ -475,12 +475,17 @@ function createMealSuggestion(source: MealEntry): MealSuggestion { }; } +/** 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 ?? '')}`; + /** - * Vrátí jídla z celé historie uživatele, každý název jednou — slouží - * k našeptávání při zakládání jídla místo ručně ukládaných šablon. + * Vrátí jídla z celé historie uživatele, každou dvojici název a zdroj jednou — + * slouží k našeptávání při zakládání jídla místo ručně ukládaných šablon. * - * Názvy se porovnávají znormalizované (bez diakritiky a velikosti písmen), takže - * "Kuřecí gyros" a "kureci gyros" jsou jedno jídlo. Hodnoty návrhu jsou + * Názvy i zdroje se porovnávají znormalizované (bez diakritiky a velikosti písmen), + * takže "Kuřecí gyros" a "kureci gyros" jsou jedno jídlo. Stejnojmenné jídlo + * z jiného zdroje je jiné jídlo — jinde se vaří, nandává i účtuje jinak. Hodnoty návrhu jsou * z posledního záznamu, protože cena i gramáž se časem mění. Když poslední * záznam nemá kalorie (typicky import z Luncheru), doplní se z dřívějšího stejně * jako při importu. Řadí se od nejčastěji jedených, při shodě od naposledy jedených. @@ -492,7 +497,7 @@ export async function getMealHistory(login: string): Promise { const fillNutrition = createNutritionFiller(entryList); const resultMap = new Map(); for (const entry of entryList) { - const key = normalizeName(entry.name); + const key = getNameSourceKey(entry.name, entry.source); const existing = resultMap.get(key); if (existing) { existing.count++; @@ -517,10 +522,6 @@ 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; @@ -595,8 +596,9 @@ export async function getMealSources(login: string): Promise { } /** - * Doplní energii a živiny jídla do ostatních jídel stejného názvu, která je - * nemají — typicky po ručním doplnění kalorií jednomu naimportovanému jídlu. + * Doplní energii a živiny jídla do ostatních jídel stejného názvu a zdroje, + * která je nemají — typicky po ručním doplnění kalorií jednomu naimportovanému + * jídlu. Stejnojmenné jídlo z jiného zdroje je jiné jídlo a nechá se být. * * Přebírá se stejně jako při importu (\`createNutritionFiller\`), takže u jídel * ze stejného zdroje se gramáž dopočte z jejich vlastní ceny. Jídla, která @@ -627,9 +629,9 @@ export async function fillSameNameMeals( throw new BadRequestError("Jídlo nemá kalorie ani živiny, není co doplnit"); } - const key = normalizeName(source.name); - const targetList = (await findAllMeals(login)) - .filter(entry => entry.id !== source.id && normalizeName(entry.name) === key && !hasNutrition(entry)); + const key = getNameSourceKey(source.name, source.source); + const targetList = (await findAllMeals(login)).filter(entry => + entry.id !== source.id && getNameSourceKey(entry.name, entry.source) === key && !hasNutrition(entry)); const dates = [...new Set(targetList.map(entry => entry.date))].sort(); if (!dryRun) { @@ -674,13 +676,6 @@ const createMealGroupValues = (groupList: MealEntry[], sameNameList: MealEntry[] }); }; -/** - * 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 - */ /** 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; @@ -696,6 +691,13 @@ const createMealGroupDays = (groupList: MealEntry[]): MealGroupDay[] => { return [...dayMap.values()]; }; +/** + * 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 — seznam jídel s hromadným + * doplněním údajů a přejmenování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(); @@ -814,3 +816,58 @@ export const fillMealGroup = async ( } return { count, dates }; }; + +/** + * Přejmenuje všechna jídla se stejným názvem a zdrojem a případně jim změní + * zdroj — oprava překlepu nebo sjednocení názvu napříč všemi dny, kdy se jídlo + * jedlo. Na rozdíl od doplnění údajů přepisuje, co jídla mají. + * + * Když nový název a zdroj patří jiné skupině, jídla se do ní tím sloučí. + * + * @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 newName nový název + * @param newSource nový zdroj; prázdný zdroj odebere + */ +export const renameMealGroup = async ( + login: string, + name: unknown, + source: unknown, + newName: unknown, + newSource: unknown, +): Promise => { + const groupName = normalizeText(name, 'name'); + if (!groupName) { + throw new BadRequestError("Nebyl předán název jídla"); + } + const targetName = normalizeText(newName, 'newName'); + if (!targetName) { + throw new BadRequestError("Nebyl předán nový název jídla"); + } + const targetSource = normalizeText(newSource, 'newSource'); + const key = getNameSourceKey(groupName, normalizeText(source, 'source')); + const isInGroup = (entry: MealEntry) => getNameSourceKey(entry.name, entry.source) === key; + + const groupList = (await findAllMeals(login)).filter(isInGroup); + const groupDates = [...new Set(groupList.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) || (entry.name === targetName && entry.source === targetSource)) return entry; + count++; + if (!dates.includes(date)) dates.push(date); + const renamed: MealEntry = { ...entry, name: targetName, source: targetSource, updatedAt }; + if (!targetSource) delete renamed.source; + return renamed; + })); + } + // Knihovna kalorií je podle názvu, pod novým se má jídlo najít také + const caloriesPer100g = findLatestValue(groupList, entry => entry.caloriesPer100g); + if (count && caloriesPer100g) { + await rememberCalories(login, targetName, caloriesPer100g); + } + return { count, dates }; +}; diff --git a/server/src/routes/mealRoutes.ts b/server/src/routes/mealRoutes.ts index 478b505..fb5da0e 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, fillMealGroup, fillSameNameMeals, getDay, getMealGroups, getMealHistory, getMealSources, - getRange, setMealMultiplier, splitMeal, updateMeal, + getRange, renameMealGroup, setMealMultiplier, splitMeal, updateMeal, } from "../meals"; import { getDayOverview } from "../dayOverview"; import { getLogin } from "../auth"; @@ -59,7 +59,7 @@ 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ů. */ +/** Vrátí jídla seskupená podle názvu a zdroje — seznam jídel. */ router.get("/groups", async (req: Request, res: Response, next: NextFunction) => { try { const login = getLogin(parseToken(req)); @@ -76,6 +76,15 @@ router.post("/fillGroup", async (req: Request, res: Response, next: NextFunction } catch (e) { next(e); } }); +/** Přejmenuje všechna jídla se stejným názvem a zdrojem, případně jim změní zdroj. */ +router.post("/renameGroup", async (req: Request, res: Response, next: NextFunction) => { + try { + const login = getLogin(parseToken(req)); + const { name, source, newName, newSource } = req.body ?? {}; + res.status(200).json(await renameMealGroup(login, name, source, newName, newSource)); + } 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 index 519f503..e54e9eb 100644 --- a/server/src/tests/mealGroups.test.ts +++ b/server/src/tests/mealGroups.test.ts @@ -1,6 +1,7 @@ process.env.CALORIE_PROVIDER = 'none'; -import { addMeal, fillMealGroup, getDay, getMealGroups } from "../meals"; +import { addMeal, fillMealGroup, getDay, getMealGroups, renameMealGroup } from "../meals"; +import { searchCalories } from "../calories"; import { resetMemoryStorage } from "../storage/memory"; import { MealType } from "../../../types/gen/types.gen"; @@ -118,3 +119,55 @@ describe('hromadné doplnění údajů do skupiny', () => { expect((await getDay(LOGIN, '2025-03-02')).entries[0].calories).toBeUndefined(); }); }); + +describe('přejmenování skupiny jídel', () => { + + it('přejmenuje a přesune do jiného zdroje všechna jídla skupiny', async () => { + await addMeal(LOGIN, { ...cina, date: '2025-03-01', price: 13900 }); + await addMeal(LOGIN, { ...cina, date: '2025-03-02', name: 'cina' }); + await addMeal(LOGIN, { ...cina, date: '2025-03-02', source: 'doma' }); + + const result = await renameMealGroup(LOGIN, 'Čína', 'Chefie', 'Kuře kung-pao', 'Everest'); + + expect(result).toEqual({ count: 2, dates: ['2025-03-01', '2025-03-02'] }); + expect((await getDay(LOGIN, '2025-03-01')).entries[0]) + .toMatchObject({ name: 'Kuře kung-pao', source: 'Everest', price: 13900 }); + const second = (await getDay(LOGIN, '2025-03-02')).entries; + expect(second.map(entry => `${entry.name}|${entry.source}`).sort()) + .toEqual(['Kuře kung-pao|Everest', 'Čína|doma']); + }); + + it('prázdný zdroj zdroj odebere a jídla se sloučí se skupinou bez zdroje', async () => { + await addMeal(LOGIN, { ...cina, date: '2025-03-01' }); + await addMeal(LOGIN, { ...cina, date: '2025-03-02', source: undefined }); + + await renameMealGroup(LOGIN, 'Čína', 'Chefie', 'Čína', undefined); + + const groups = await getMealGroups(LOGIN); + expect(groups).toHaveLength(1); + expect(groups[0].count).toBe(2); + expect((await getDay(LOGIN, '2025-03-01')).entries[0]).not.toHaveProperty('source'); + }); + + it('jídla se shodným zápisem nepočítá', async () => { + await addMeal(LOGIN, { ...cina, date: '2025-03-01' }); + await addMeal(LOGIN, { ...cina, date: '2025-03-02', name: 'čína' }); + + const result = await renameMealGroup(LOGIN, 'Čína', 'Chefie', 'Čína', 'Chefie'); + + expect(result).toEqual({ count: 1, dates: ['2025-03-02'] }); + }); + + it('energii na 100 g si knihovna zapamatuje i pod novým názvem', async () => { + await addMeal(LOGIN, { ...cina, date: '2025-03-01', weight: 400, caloriesPer100g: 150 }); + + await renameMealGroup(LOGIN, 'Čína', 'Chefie', 'Kuře kung-pao', 'Chefie'); + + const { suggestions } = await searchCalories(LOGIN, 'Kuře kung-pao'); + expect(suggestions[0]).toMatchObject({ name: 'Kuře kung-pao', caloriesPer100g: 150 }); + }); + + it('odmítne prázdný nový název', async () => { + await expect(renameMealGroup(LOGIN, 'Čína', 'Chefie', ' ', 'Chefie')).rejects.toThrow(/nový název/); + }); +}); diff --git a/server/src/tests/mealHistory.test.ts b/server/src/tests/mealHistory.test.ts index 1b329d9..b7a5874 100644 --- a/server/src/tests/mealHistory.test.ts +++ b/server/src/tests/mealHistory.test.ts @@ -81,6 +81,16 @@ describe('návrhy jídel z historie', () => { expect(history.map(item => item.name)).toEqual(['Častější', 'Novější', 'Starší']); }); + it('stejnojmenná jídla z různých zdrojů nabídne zvlášť', async () => { + await addMeal(LOGIN, { ...gyros, source: 'Everest', price: 15900 }); + await addMeal(LOGIN, { ...gyros, source: 'Namaste', price: 16900 }); + + const history = await getMealHistory(LOGIN); + + expect(history.map(item => `${item.source}: ${item.price}`).sort()) + .toEqual(['Everest: 15900', 'Namaste: 16900']); + }); + it('historie je oddělená mezi uživateli', async () => { await addMeal(LOGIN, gyros); @@ -181,6 +191,7 @@ describe('doplnění do jídel stejného názvu', () => { await addMeal(LOGIN, { ...salat, date: '2025-03-01' }); await addMeal(LOGIN, { ...salat, date: '2025-03-02', name: 'těstovinový SALÁT' }); await addMeal(LOGIN, { ...salat, date: '2025-03-02', name: 'Jiné jídlo' }); + await addMeal(LOGIN, { ...salat, date: '2025-03-05', source: 'Everest' }); await addMeal(LOGIN, { ...salat, date: '2025-03-04', calories: 300, caloriesPer100g: 120 }); const day = await addMeal(LOGIN, { ...salat, date: DATE, weight: 350, caloriesPer100g: 160, nutrientsPer100g: { protein: 6 }, @@ -207,6 +218,7 @@ describe('doplnění do jídel stejného názvu', () => { const second = (await getDay(LOGIN, '2025-03-02')).entries; expect(second.find(entry => entry.name === 'těstovinový SALÁT')?.calories).toBe(560); expect(second.find(entry => entry.name === 'Jiné jídlo')?.calories).toBeUndefined(); + expect((await getDay(LOGIN, '2025-03-05')).entries[0].calories).toBeUndefined(); expect((await getDay(LOGIN, '2025-03-04')).entries[0].calories).toBe(300); }); diff --git a/types/api.yml b/types/api.yml index 882ea0f..cd18254 100644 --- a/types/api.yml +++ b/types/api.yml @@ -36,6 +36,8 @@ paths: $ref: "./paths/meals/groups.yml" /meals/fillGroup: $ref: "./paths/meals/fillGroup.yml" + /meals/renameGroup: + $ref: "./paths/meals/renameGroup.yml" # Statistiky (/api/stats) /stats/summary: diff --git a/types/paths/meals/fillSameName.yml b/types/paths/meals/fillSameName.yml index 6a02158..2e83417 100644 --- a/types/paths/meals/fillSameName.yml +++ b/types/paths/meals/fillSameName.yml @@ -1,11 +1,11 @@ post: operationId: fillSameNameMeals summary: | - Doplní energii a živiny jídla do ostatních jídel stejného názvu, která je nemají. + Doplní energii a živiny jídla do ostatních jídel stejného názvu a zdroje, která je nemají. description: | Typicky po doplnění kalorií jednomu naimportovanému jídlu — ostatní jídla - téhož jména mají stejné složení. Jídla, která energii nebo živiny už mají, - se nemění. S `dryRun` jen spočítá, kterých jídel by se to týkalo. + téhož jména a zdroje mají stejné složení. Stejnojmenné jídlo z jiného zdroje + ani jídla, která energii nebo živiny už mají, se nemění. S `dryRun` jen spočítá, kterých jídel by se to týkalo. requestBody: required: true content: diff --git a/types/paths/meals/groups.yml b/types/paths/meals/groups.yml index eb7f483..1f6ee56 100644 --- a/types/paths/meals/groups.yml +++ b/types/paths/meals/groups.yml @@ -2,7 +2,7 @@ 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 + Seznam jídel s hromadným doplněním chybějících údajů a přejmenování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: diff --git a/types/paths/meals/history.yml b/types/paths/meals/history.yml index 3158bd4..2e67f86 100644 --- a/types/paths/meals/history.yml +++ b/types/paths/meals/history.yml @@ -1,6 +1,6 @@ get: operationId: getMealHistory - summary: Vrátí jídla z historie přihlášeného uživatele, každý název jednou. + summary: Vrátí jídla z historie přihlášeného uživatele, každou dvojici název a zdroj jednou. description: | Slouží k našeptávání při zakládání jídla. Seřazeno od nejčastěji jedených, při shodě od naposledy jedených. diff --git a/types/paths/meals/renameGroup.yml b/types/paths/meals/renameGroup.yml new file mode 100644 index 0000000..adb0d6d --- /dev/null +++ b/types/paths/meals/renameGroup.yml @@ -0,0 +1,38 @@ +post: + operationId: renameMealGroup + summary: Přejmenuje všechna jídla se stejným názvem a zdrojem a případně jim změní zdroj. + description: | + Na rozdíl od doplnění údajů přepisuje — nový název a zdroj dostanou + všechna jídla skupiny ve všech dnech. Když nový název a zdroj patří + jiné skupině, jídla se do ní tím sloučí. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name, newName] + 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 + newName: + description: Nový název jídla + type: string + newSource: + description: Nový zdroj; bez něj se zdroj jídlům odebere + type: string + responses: + "200": + description: Jídla, která se změnila + content: + application/json: + schema: + $ref: "../../schemas/_index.yml#/FillSameNameResult" + "400": + description: Neplatná data + "401": + description: Neautentizovaný uživatel diff --git a/types/schemas/_index.yml b/types/schemas/_index.yml index dc454e8..790fe1f 100644 --- a/types/schemas/_index.yml +++ b/types/schemas/_index.yml @@ -217,8 +217,8 @@ MealGroupValues: 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í. + Všechna jídla uživatele se stejným názvem a zdrojem. Položka seznamu jídel, + ze kterého se doplňují chybějící údaje a hromadně přejmenovává. type: object additionalProperties: false required: @@ -267,8 +267,8 @@ MealGroup: MealSuggestion: description: | - Jídlo z historie uživatele nabízené při zakládání nového. Každý název je - v návrzích jen jednou; hodnoty jsou z posledního záznamu toho jména. + Jídlo z historie uživatele nabízené při zakládání nového. Každá dvojice název + a zdroj je v návrzích jen jednou; hodnoty jsou z jejího posledního záznamu. Chod, datum, násobek ani poznámka se nepřenášejí — patří ke konkrétnímu dni. type: object additionalProperties: false