From e13f7ce8e515af1732a98a4a22a6f34a576f599d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20And=C4=9Bl?= Date: Mon, 12 Oct 2026 10:20:12 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20recepty=20p=C5=99esnuty=20k=20j=C3=ADdl?= =?UTF-8?q?=C5=AFm=20feat:=20p=C5=99id=C3=A1na=20podpora=20fotek?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 27 +- client/src/AppRoutes.tsx | 6 +- client/src/components/Header.tsx | 4 +- client/src/components/MealGroupPhotos.tsx | 115 ++++++++ client/src/components/MealGroupRecipe.tsx | 50 ++++ client/src/components/MealGroupRow.tsx | 72 ++++- client/src/components/MealTemplateCard.tsx | 13 +- client/src/components/MealTemplateForm.tsx | 74 ++--- .../src/components/modals/ActivityModal.tsx | 2 +- .../src/components/modals/ApplyMealModal.tsx | 10 +- .../components/modals/ApplyWorkoutModal.tsx | 6 +- client/src/components/modals/MealModal.tsx | 1 - client/src/enums.ts | 28 +- client/src/imageResize.ts | 31 ++ client/src/pages/MealGroupsPage.scss | 95 +++++- client/src/pages/MealGroupsPage.tsx | 50 +++- client/src/pages/MealTemplatesPanel.tsx | 88 ------ client/src/pages/TemplatesPage.tsx | 50 ---- client/src/pages/WorkoutsPage.tsx | 17 ++ .../{TemplatesPage.scss => templates.scss} | 9 + client/src/routes.ts | 2 +- server/src/mealGroups.ts | 274 ++++++++++++++++++ server/src/mealPhotos.ts | 159 ++++++++++ server/src/mealTemplates.ts | 50 +++- server/src/meals.ts | 238 +-------------- server/src/routes/mealRoutes.ts | 32 +- server/src/storage/StorageInterface.ts | 3 + server/src/storage/json.ts | 5 + server/src/storage/memory.ts | 5 + server/src/storage/redis.ts | 4 + server/src/tests/activityStats.test.ts | 3 +- server/src/tests/mealGroupExtras.test.ts | 100 +++++++ server/src/tests/mealGroups.test.ts | 3 +- server/src/tests/setsAndMealTemplates.test.ts | 7 +- types/api.yml | 6 + types/paths/meals/photoAdd.yml | 34 +++ types/paths/meals/photoDelete.yml | 23 ++ types/paths/meals/photos.yml | 29 ++ types/schemas/_index.yml | 52 +++- 39 files changed, 1266 insertions(+), 511 deletions(-) create mode 100644 client/src/components/MealGroupPhotos.tsx create mode 100644 client/src/components/MealGroupRecipe.tsx create mode 100644 client/src/imageResize.ts delete mode 100644 client/src/pages/MealTemplatesPanel.tsx delete mode 100644 client/src/pages/TemplatesPage.tsx create mode 100644 client/src/pages/WorkoutsPage.tsx rename client/src/pages/{TemplatesPage.scss => templates.scss} (93%) create mode 100644 server/src/mealGroups.ts create mode 100644 server/src/mealPhotos.ts create mode 100644 server/src/tests/mealGroupExtras.test.ts create mode 100644 types/paths/meals/photoAdd.yml create mode 100644 types/paths/meals/photoDelete.yml create mode 100644 types/paths/meals/photos.yml diff --git a/CLAUDE.md b/CLAUDE.md index e9b7f25..493e926 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,8 +59,9 @@ cd client && yarn build # tsc --noEmit + vite build → client/dist - **Entry:** `index.ts` — Express app, auth middleware, error middleware - **Routes:** `routes/` — `dayRoutes`, `mealRoutes`, `activityRoutes`, `workoutRoutes`, `statsRoutes`, `calorieRoutes`, `settingsRoutes`, `importRoutes` -- **Domain:** `meals.ts` (jídlo a historie jídel), `nutrients.ts` (živiny), - `mealTemplates.ts` (recepty), `activities.ts` (pohyb), `workouts.ts` (šablony +- **Domain:** `meals.ts` (jídlo a historie jídel), `mealGroups.ts` (seznam jídel — + skupiny podle názvu a zdroje, doplnění a přejmenování), `mealPhotos.ts` (fotky + jídel), `nutrients.ts` (živiny), `mealTemplates.ts` (recepty), `activities.ts` (pohyb), `workouts.ts` (šablony tréninků), `sleep.ts` (spánek), `weights.ts` (vážení po dnech), `profile.ts` (tělesné údaje a výpočet BMR), `bodyMetrics.ts` (BMI, podíl tuku, cíle), `dayOverview.ts` (spojení dne a bilance), `statsService.ts` (agregace), @@ -75,7 +76,7 @@ cd client && yarn build # tsc --noEmit + vite build → client/dist - **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, + `WorkoutsPage` (`/treninky` — šablony tréninků), `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`) @@ -305,7 +306,21 @@ 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í). +ní sloučí (formulář na to předem upozorní). Recept a fotky jdou s jídlem; dvě +jídla s recepty sloučit nejde. + +**Recept a fotky jsou rozšířením jídla v seznamu**, ne samostatnou evidencí — +patří dvojici název a zdroj, ne dni. V přehledu dne se nezobrazují. + +- Recept se k jídlu váže **shodou názvu a zdroje** (`getRecipeKey`), jedno jídlo + má nejvýš jeden (`saveMealTemplate` druhý odmítne). Recept jídla, které se + ještě nejedlo, je v seznamu jako skupina s `count: 0` a bez `lastDate`. + Má-li jídlo recept, doplnění údajů předvyplní gramáž porce a energii z něj. +- Fotky (`mealPhotos.ts`): klient je před nahráním zmenší (`imageResize.ts`, + ~1600 px JPEG) a pošle i náhled (240 px). Rejstřík s náhledy je jeden pro + uživatele (`mealPhotos:`, klíč jídla → fotky), aby ho seznam jídel + načetl jedním čtením; plné obrázky leží každý zvlášť (`mealPhoto::`) + a načtou se, až když se fotky jídla otevřou. Pole Zdroj (`SourceInput`) nabízí zdroje z historie (`getMealSources`, `GET /api/meals/sources`, nejčastější první) a podniky se sazbou z nastavení; @@ -313,7 +328,9 @@ psát jde i nový. `MealTemplate` (`mealTemplates.ts`, klíč `mealTemplates:`) je **recept** — vařené jídlo se surovinami (`ingredients`), počtem porcí (`servings`), hmotností -hotového jídla (`cookedWeight`) a postupem v `note`. `deriveRecipe` z nich při +hotového jídla (`cookedWeight`) a postupem v `note`. **Chod nemá** — patří ke dni, +ne k jídlu; použije se zvolený při použití, jinak oběd (starší recepty ho v úložišti +mít můžou, `toMealTemplate` ho zahodí). `deriveRecipe` z nich při uložení spočítá hodnoty **jedné porce** a uloží je do plochých polí (`weight`, `caloriesPer100g`, `calories`, `price`); použitím vznikne běžný `MealEntry` přes `addMeal`. Energie na 100 g se bere z hmotnosti hotového jídla, ne ze součtu diff --git a/client/src/AppRoutes.tsx b/client/src/AppRoutes.tsx index d5dc294..15c832a 100644 --- a/client/src/AppRoutes.tsx +++ b/client/src/AppRoutes.tsx @@ -5,10 +5,10 @@ import Header from "./components/Header"; import Login from "./Login"; import DayPage from "./pages/DayPage"; import StatsPage from "./pages/StatsPage"; -import TemplatesPage from "./pages/TemplatesPage"; +import WorkoutsPage from "./pages/WorkoutsPage"; import ProfilePage from "./pages/ProfilePage"; import MealGroupsPage from "./pages/MealGroupsPage"; -import { DEN_URL, JIDLA_URL, PROFIL_URL, SABLONY_URL, STATISTIKY_URL } from "./routes"; +import { DEN_URL, JIDLA_URL, PROFIL_URL, STATISTIKY_URL, TRENINKY_URL } from "./routes"; export default function AppRoutes() { const auth = useAuth(); @@ -25,7 +25,7 @@ export default function AppRoutes() { } /> } /> } /> - } /> + } /> } /> } /> diff --git a/client/src/components/Header.tsx b/client/src/components/Header.tsx index 010ab05..faf977d 100644 --- a/client/src/components/Header.tsx +++ b/client/src/components/Header.tsx @@ -5,14 +5,14 @@ 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, JIDLA_URL, PROFIL_URL, SABLONY_URL, STATISTIKY_URL } from "../routes"; +import { DEN_URL, JIDLA_URL, PROFIL_URL, STATISTIKY_URL, TRENINKY_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: TRENINKY_URL, label: 'Tréninky' }, { url: STATISTIKY_URL, label: 'Statistiky' }, { url: PROFIL_URL, label: 'Profil' }, ]; diff --git a/client/src/components/MealGroupPhotos.tsx b/client/src/components/MealGroupPhotos.tsx new file mode 100644 index 0000000..345f9c2 --- /dev/null +++ b/client/src/components/MealGroupPhotos.tsx @@ -0,0 +1,115 @@ +import { useEffect, useRef, useState } from "react"; +import { Button, Modal, Spinner } from "react-bootstrap"; +import { toast } from "react-toastify"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faCamera, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { MealGroup, MealPhoto, addMealPhoto, deleteMealPhoto, getMealPhotos } from "../../../types"; +import { prepareMealPhoto } from "../imageResize"; + +type Props = { + group: MealGroup, + /** Zavolá se po přidání nebo smazání fotky, aby se v seznamu obnovil náhled a počet. */ + onChanged: () => void, +}; + +/** + * Fotky jídla — patří jídlu jako celku (názvu a zdroji), ne dni, kdy se jedlo. + * Fotky se před nahráním zmenší v prohlížeči. + */ +const MealGroupPhotos = ({ group, onChanged }: Readonly) => { + const [photoList, setPhotoList] = useState([]); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [shown, setShown] = useState(); + const inputRef = useRef(null); + + useEffect(() => { + getMealPhotos({ query: { name: group.name, source: group.source } }).then(response => { + setPhotoList(response.data ?? []); + setLoading(false); + }); + }, [group.name, group.source]); + + const upload = async (fileList: FileList | null) => { + if (!fileList?.length) return; + setBusy(true); + const addedList: MealPhoto[] = []; + for (const file of Array.from(fileList)) { + try { + const prepared = await prepareMealPhoto(file); + const response = await addMealPhoto({ body: { name: group.name, source: group.source, ...prepared } }); + if (response.data) addedList.push(response.data); + } catch { + toast.error(`Soubor ${file.name} se nepodařilo načíst jako obrázek.`); + } + } + setBusy(false); + if (inputRef.current) inputRef.current.value = ''; + if (addedList.length) { + setPhotoList(current => [...current, ...addedList]); + onChanged(); + } + }; + + const remove = async (photo: MealPhoto) => { + setBusy(true); + const response = await deleteMealPhoto({ body: { id: photo.id } }); + setBusy(false); + if (!response.error) { + setPhotoList(current => current.filter(item => item.id !== photo.id)); + setShown(undefined); + onChanged(); + } + }; + + return ( +
+ {loading && } + {!loading && ( +
+ {photoList.map(photo => ( + + ))} + +
+ )} + + setShown(undefined)} centered size="lg"> + + {group.name} + + + {shown && {group.name}} + + + + + + +
+ ); +}; + +export default MealGroupPhotos; diff --git a/client/src/components/MealGroupRecipe.tsx b/client/src/components/MealGroupRecipe.tsx new file mode 100644 index 0000000..c2b794d --- /dev/null +++ b/client/src/components/MealGroupRecipe.tsx @@ -0,0 +1,50 @@ +import { useState } from "react"; +import { MealGroup, deleteMealTemplate } from "../../../types"; +import MealTemplateCard from "./MealTemplateCard"; +import MealTemplateForm from "./MealTemplateForm"; + +type Props = { + group: MealGroup, + /** Zavolá se po uložení nebo smazání receptu, aby se seznam načetl znovu. */ + onChanged: () => void, + onClose: () => void, +}; + +/** + * Recept jídla v seznamu jídel. Recept rozšiřuje jídlo (název a zdroj) o suroviny + * a postup; jídlo bez receptu nabídne rovnou formulář na jeho založení. + */ +const MealGroupRecipe = ({ group, onChanged, onClose }: Readonly) => { + const [editing, setEditing] = useState(!group.recipe); + const [busy, setBusy] = useState(false); + + const remove = async (id: string) => { + setBusy(true); + const response = await deleteMealTemplate({ body: { id } }); + setBusy(false); + if (response.data) onChanged(); + }; + + return ( +
+ {group.recipe && !editing && ( + setEditing(true)} + onDelete={() => remove(group.recipe!.id)} + /> + )} + {editing && ( + onChanged()} + onCancel={() => group.recipe ? setEditing(false) : onClose()} + /> + )} +
+ ); +}; + +export default MealGroupRecipe; diff --git a/client/src/components/MealGroupRow.tsx b/client/src/components/MealGroupRow.tsx index 5c9945e..4fab5f3 100644 --- a/client/src/components/MealGroupRow.tsx +++ b/client/src/components/MealGroupRow.tsx @@ -1,13 +1,15 @@ import { useState } from "react"; import { Button } from "react-bootstrap"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faCalendarDays, faPen, faPenToSquare } from "@fortawesome/free-solid-svg-icons"; +import { faBookOpen, faCalendarDays, faCamera, 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"; +import MealGroupRecipe from "./MealGroupRecipe"; +import MealGroupPhotos from "./MealGroupPhotos"; type Props = { group: MealGroup, @@ -16,8 +18,10 @@ type Props = { /** 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. */ + /** Zavolá se po doplnění, přejmenování nebo změně receptu — seznam se načte znovu a akce zavře. */ onChanged: () => void, + /** Obnoví seznam a otevřenou akci nechá — po změně fotek, aby šlo přidávat dál. */ + onRefresh: () => void, }; /** Kolik jídel skupiny daný údaj nemá — "u všech", nebo "3 z 5". */ @@ -26,20 +30,35 @@ const describeMissing = (missing: number, count: number): string => /** * Řádek skupiny jídel (stejný název a zdroj) s přehledem chybějících údajů. - * 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. + * Nabízí recept a fotky jídla, 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, groupList, action, onAction, onChanged }: Readonly) => { +const MealGroupRow = ({ group, groupList, action, onAction, onChanged, onRefresh }: Readonly) => { const [daysOpen, setDaysOpen] = useState(false); const toggle = (value: MealGroupAction) => onAction(action === value ? undefined : value); return (
+ {group.thumbnail && ( + + )}
{group.name}
{group.source && {group.source}} - {group.count}× · naposledy {formatDateString(group.lastDate)} + {group.lastDate + ? {group.count}× · naposledy {formatDateString(group.lastDate)} + : zatím nejedeno} + {group.recipe && ( + recept + )} {group.missingCaloriesCount > 0 && ( bez kalorií {describeMissing(group.missingCaloriesCount, group.count)} @@ -58,7 +77,7 @@ const MealGroupRow = ({ group, groupList, action, onAction, onChanged }: Readonl
- {!action && ( + {!action && group.count > 0 && ( )} + + - + {group.count > 0 && ( + + )}
{(action === MealGroupAction.DOPLNIT || (!action && daysOpen)) && } {action === MealGroupAction.DOPLNIT && ( onAction(undefined)} /> )} + {action === MealGroupAction.RECEPT && ( + onAction(undefined)} /> + )} + {action === MealGroupAction.FOTKY && } {action === MealGroupAction.PREJMENOVAT && ( ) => { const recipe = deriveRecipe(template.ingredients, template.servings, template.cookedWeight); @@ -22,13 +21,7 @@ const MealTemplateCard = ({ template, busy, onEdit, onDelete }: Readonly)
- {template.name} - {template.mealType && ( - - {MEAL_TYPE_ICONS[template.mealType]} {MEAL_TYPE_NAMES[template.mealType]} - - )} - {template.source && {template.source}} + Recept {template.weight != null && porce {formatWeight(template.weight)}} {(template.servings ?? 1) > 1 && ( {template.servings} porcí diff --git a/client/src/components/MealTemplateForm.tsx b/client/src/components/MealTemplateForm.tsx index 3ec2c0b..6f8150e 100644 --- a/client/src/components/MealTemplateForm.tsx +++ b/client/src/components/MealTemplateForm.tsx @@ -1,7 +1,6 @@ import { useState } from "react"; import { Alert, Button, Form } from "react-bootstrap"; -import { MealTemplate, MealType, saveMealTemplate } from "../../../types"; -import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../enums"; +import { MealTemplate, saveMealTemplate } from "../../../types"; import { deriveRecipe, formatCalories, formatPrice, formatWeight, parseIntInput } from "../Utils"; import SourceInput from "./SourceInput"; import RecipeIngredientsEditor, { @@ -11,15 +10,19 @@ import RecipeIngredientsEditor, { type Props = { /** Upravovaný recept. Pokud chybí, zakládá se nový. */ template?: MealTemplate, + /** + * Jídlo ze seznamu jídel, ke kterému recept patří. Název a zdroj pak nejdou + * měnit — mění se přejmenováním jídla, aby recept zůstal u svých záznamů. + */ + meal?: { name: string, source?: string }, onSaved: (templates: MealTemplate[]) => void, onCancel: () => void, }; /** Naplní formulář z receptu, nebo ho nechá prázdný pro nový. */ -const createForm = (template?: MealTemplate) => ({ - name: template?.name ?? '', - source: template?.source ?? '', - mealType: (template?.mealType ?? '') as MealType | '', +const createForm = (template?: MealTemplate, meal?: Props['meal']) => ({ + name: meal?.name ?? template?.name ?? '', + source: meal?.source ?? template?.source ?? '', note: template?.note ?? '', servings: template?.servings != null ? String(template.servings) : '', cookedWeight: template?.cookedWeight != null ? String(template.cookedWeight) : '', @@ -29,8 +32,8 @@ const createForm = (template?: MealTemplate) => ({ * Formulář receptu — vařeného jídla. Zapisují se suroviny a postup a hodnoty * porce se z nich dopočtou — stejně jako na serveru, tady jen jako živý náhled. */ -const MealTemplateForm = ({ template, onSaved, onCancel }: Readonly) => { - const [form, setForm] = useState(() => createForm(template)); +const MealTemplateForm = ({ template, meal, onSaved, onCancel }: Readonly) => { + const [form, setForm] = useState(() => createForm(template, meal)); const [ingredients, setIngredients] = useState(() => template?.ingredients?.length ? template.ingredients.map(toDraftIngredient) : [emptyIngredient()]); const [error, setError] = useState(); @@ -54,7 +57,6 @@ const MealTemplateForm = ({ template, onSaved, onCancel }: Readonly) => { ...(template ? { id: template.id } : {}), name: form.name.trim(), ...(form.source.trim() ? { source: form.source.trim() } : {}), - ...(form.mealType ? { mealType: form.mealType } : {}), ...(form.note.trim() ? { note: form.note.trim() } : {}), ingredients: filledIngredients, servings: parseIntInput(form.servings), @@ -75,38 +77,26 @@ const MealTemplateForm = ({ template, onSaved, onCancel }: Readonly) => { {error && {error}} -
-
- Název - update({ name: event.target.value })} - /> + {!meal && ( +
+
+ Název + update({ name: event.target.value })} + /> +
+
+ Zdroj + update({ source })} + /> +
-
- Zdroj - update({ source })} - /> -
-
- Chod - update({ mealType: event.target.value as MealType | '' })} - > - - {MEAL_TYPES_IN_ORDER.map(type => ( - - ))} - -
-
+ )}
Suroviny @@ -168,9 +158,7 @@ const MealTemplateForm = ({ template, onSaved, onCancel }: Readonly) => { {recipe.calories != null && ( porce {formatCalories(recipe.calories)} )} - {template && ( - - )} + diff --git a/client/src/components/modals/ActivityModal.tsx b/client/src/components/modals/ActivityModal.tsx index d9cc2e8..a38ca3f 100644 --- a/client/src/components/modals/ActivityModal.tsx +++ b/client/src/components/modals/ActivityModal.tsx @@ -123,7 +123,7 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }: ? response.data.activities.entries.find(item => item.id === entry.id) : [...response.data.activities.entries].sort((a, b) => a.createdAt.localeCompare(b.createdAt)).pop(); if (saved) { - // Šablona z jednoho cviku; víc jich jde spojit na stránce Šablony + // Šablona z jednoho cviku; víc jich jde spojit na stránce Tréninky await createWorkoutTemplateFromActivities({ body: { date: saved.date, name: saved.name, ids: [saved.id] }, }); diff --git a/client/src/components/modals/ApplyMealModal.tsx b/client/src/components/modals/ApplyMealModal.tsx index 00c00a6..5e77ee8 100644 --- a/client/src/components/modals/ApplyMealModal.tsx +++ b/client/src/components/modals/ApplyMealModal.tsx @@ -6,9 +6,9 @@ import { faPlay, faClockRotateLeft } from "@fortawesome/free-solid-svg-icons"; import { MealSuggestion, MealTemplate, MealType, addMeal, applyMealTemplate, getMealHistory, getMealTemplates, } from "../../../../types"; -import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES, TemplateTab } from "../../enums"; +import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES, RecipeFilter } from "../../enums"; import { describeMealSuggestion, describeMealTemplate, getMealKey, normalizeSearch, parseMultiplierInput } from "../../Utils"; -import { SABLONY_URL } from "../../routes"; +import { JIDLA_URL } from "../../routes"; type Props = { isOpen: boolean, @@ -114,7 +114,7 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly) =
Zatím nemáte co vybírat

Každé zapsané jídlo se tu příště nabídne. Vařená jídla si můžete zapsat jako - recept na stránce Šablony. + recept u jídla na stránce Jídla.

)} @@ -135,7 +135,7 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly) = Založit jako setMealType(event.target.value as MealType | '')} > @@ -218,7 +218,7 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly) = diff --git a/client/src/components/modals/ApplyWorkoutModal.tsx b/client/src/components/modals/ApplyWorkoutModal.tsx index e8b6d5a..ae3224f 100644 --- a/client/src/components/modals/ApplyWorkoutModal.tsx +++ b/client/src/components/modals/ApplyWorkoutModal.tsx @@ -6,7 +6,7 @@ import { faPlay, faLayerGroup, faDumbbell } from "@fortawesome/free-solid-svg-ic import { DayOverview, WorkoutItemKind, WorkoutTemplate, applyWorkoutTemplate, getWorkoutTemplates } from "../../../../types"; import { ACTIVITY_UNIT_SHORT } from "../../enums"; import { formatCalories, formatQuantityWithSets } from "../../Utils"; -import { SABLONY_URL } from "../../routes"; +import { TRENINKY_URL } from "../../routes"; type Props = { isOpen: boolean, @@ -70,7 +70,7 @@ const ApplyWorkoutModal = ({ isOpen, date, onClose, onApplied }: Readonly
Zatím nemáte žádnou šablonu
-

Vytvořit si ji můžete na stránce Šablony.

+

Vytvořit si ji můžete na stránce Tréninky.

)} @@ -95,7 +95,7 @@ const ApplyWorkoutModal = ({ isOpen, date, onClose, onApplied }: Readonly diff --git a/client/src/components/modals/MealModal.tsx b/client/src/components/modals/MealModal.tsx index 67f1c3b..5bc41c6 100644 --- a/client/src/components/modals/MealModal.tsx +++ b/client/src/components/modals/MealModal.tsx @@ -139,7 +139,6 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea setPickedSuggestion(recipe.name); setForm(current => mergeSuggestion(current, { name: recipe.name, - ...(recipe.mealType ? { mealType: recipe.mealType } : {}), source: recipe.source ?? '', price: formatPriceInput(recipe.price), pricePer100g: '', diff --git a/client/src/enums.ts b/client/src/enums.ts index 70c3125..078e66a 100644 --- a/client/src/enums.ts +++ b/client/src/enums.ts @@ -134,17 +134,6 @@ export const BMI_KATEGORIE_NAMES: Record = { /** Nabízená týdenní tempa změny hmotnosti v gramech; záporné je hubnutí. */ export const WEEKLY_CHANGE_OPTIONS = [-1000, -750, -500, -250, 0, 250, 500]; -/** Záložky stránky se šablonami. */ -export enum TemplateTab { - TRENINKY = 'TRENINKY', - RECEPTY = 'RECEPTY', -} - -export const TEMPLATE_TAB_NAMES: Record = { - TRENINKY: 'Tréninky', - RECEPTY: 'Recepty', -}; - /** Živiny v pořadí, v jakém je uvádí etiketa potravin. */ export const NUTRIENTS_IN_ORDER: (keyof Nutrients)[] = [ 'protein', 'carbohydrates', 'sugars', 'fat', 'saturatedFat', 'fiber', 'salt', @@ -177,12 +166,27 @@ export const IMPORT_MODE_NAMES: Record = { /** Předvolby násobku porce nabízené v přehledu dne. */ export const MULTIPLIER_PRESETS = [0.5, 1, 1.5, 2, 3]; -/** Akce nad skupinou jídel v seznamu jídel — doplnění jen připisuje, přejmenování přepisuje. */ +/** Rozbalitelné části jídla v seznamu jídel. Doplnění jen připisuje, přejmenování přepisuje. */ export enum MealGroupAction { + RECEPT = 'RECEPT', + FOTKY = 'FOTKY', DOPLNIT = 'DOPLNIT', PREJMENOVAT = 'PREJMENOVAT', } +/** Filtr seznamu jídel podle receptu. */ +export enum RecipeFilter { + VSE = 'VSE', + S_RECEPTEM = 'S_RECEPTEM', + BEZ_RECEPTU = 'BEZ_RECEPTU', +} + +export const RECIPE_FILTER_NAMES: Record = { + VSE: 'S receptem i bez', + S_RECEPTEM: 'S receptem', + BEZ_RECEPTU: 'Bez receptu', +}; + /** Zvláštní volby filtru zdroje v seznamu jídel; jinak se filtruje konkrétním zdrojem. */ export enum SourceFilter { VSE = 'VSE', diff --git a/client/src/imageResize.ts b/client/src/imageResize.ts new file mode 100644 index 0000000..9f434f8 --- /dev/null +++ b/client/src/imageResize.ts @@ -0,0 +1,31 @@ +/** Delší strana fotky jídla — na telefonu i monitoru stačí, a velikost zůstane ve stovkách kB. */ +const PHOTO_MAX_SIZE = 1600; +/** Delší strana náhledu v seznamu jídel. */ +const THUMBNAIL_MAX_SIZE = 240; + +/** Překreslí obrázek do JPEGu s delší stranou nejvýš `maxSize`; menší obrázek nezvětšuje. */ +const toJpegDataUrl = (image: ImageBitmap, maxSize: number, quality: number): string => { + const scale = Math.min(1, maxSize / Math.max(image.width, image.height)); + const canvas = document.createElement('canvas'); + canvas.width = Math.round(image.width * scale); + canvas.height = Math.round(image.height * scale); + canvas.getContext('2d')?.drawImage(image, 0, 0, canvas.width, canvas.height); + return canvas.toDataURL('image/jpeg', quality); +}; + +/** + * Připraví fotku z fotoaparátu k uložení — zmenšenou fotku a malý náhled, obojí + * jako data URL. Originál z telefonu má několik MB, ukládat ho celý nemá smysl. + * `createImageBitmap` zároveň srovná otočení fotky podle EXIF. + */ +export const prepareMealPhoto = async (file: File): Promise<{ image: string, thumbnail: string }> => { + const image = await createImageBitmap(file, { imageOrientation: 'from-image' }); + try { + return { + image: toJpegDataUrl(image, PHOTO_MAX_SIZE, 0.85), + thumbnail: toJpegDataUrl(image, THUMBNAIL_MAX_SIZE, 0.7), + }; + } finally { + image.close(); + } +}; diff --git a/client/src/pages/MealGroupsPage.scss b/client/src/pages/MealGroupsPage.scss index 7eb282e..0696fad 100644 --- a/client/src/pages/MealGroupsPage.scss +++ b/client/src/pages/MealGroupsPage.scss @@ -10,8 +10,9 @@ flex: 1 1 240px; } - .meal-groups-source { - flex: 0 1 220px; + .meal-groups-source, + .meal-groups-recipe { + flex: 0 1 200px; } .form-check { @@ -93,3 +94,93 @@ } } } + +.meal-group { + .meal-group-thumb { + flex: 0 0 auto; + padding: 0; + border: 0; + background: none; + + img { + width: 56px; + height: 56px; + object-fit: cover; + border-radius: var(--ft-radius-sm); + } + } + + .meal-group-actions { + flex-wrap: wrap; + justify-content: flex-end; + } + + .tag-recipe { + background: var(--ft-primary-light); + color: var(--ft-primary); + } + + // Recept je uvnitř karty jídla, druhý rámeček kolem by jen zabíral místo + .meal-group-recipe .card-panel { + margin: 0; + padding: 0; + border: 0; + box-shadow: none; + background: none; + } +} + +.meal-photos { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.meal-photo { + width: 104px; + height: 104px; + padding: 0; + border: 1px solid var(--ft-border); + border-radius: var(--ft-radius-sm); + background: var(--ft-bg-card); + overflow: hidden; + cursor: pointer; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +} + +.meal-photo-add { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + margin: 0; + border-style: dashed; + color: var(--ft-text-muted); + font-size: 0.8rem; + + svg { + font-size: 1.3rem; + } + + &:hover { + color: var(--ft-primary); + border-color: var(--ft-primary); + } + + &.disabled { + pointer-events: none; + opacity: 0.6; + } +} + +.meal-photo-full { + max-width: 100%; + max-height: 75vh; + border-radius: var(--ft-radius-sm); +} diff --git a/client/src/pages/MealGroupsPage.tsx b/client/src/pages/MealGroupsPage.tsx index 0e4d907..6cc0aae 100644 --- a/client/src/pages/MealGroupsPage.tsx +++ b/client/src/pages/MealGroupsPage.tsx @@ -1,12 +1,14 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { Form, Spinner } from "react-bootstrap"; +import { Button, Form, Spinner } from "react-bootstrap"; import { useLocation } from "react-router"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faCircleCheck } from "@fortawesome/free-solid-svg-icons"; +import { faBookOpen, faCircleCheck } from "@fortawesome/free-solid-svg-icons"; import { MealGroup, getMealGroups } from "../../../types"; -import { MealGroupAction, SourceFilter } from "../enums"; +import { MealGroupAction, RECIPE_FILTER_NAMES, RecipeFilter, SourceFilter } from "../enums"; import { getMealKey, normalizeSearch } from "../Utils"; import MealGroupRow from "../components/MealGroupRow"; +import MealTemplateForm from "../components/MealTemplateForm"; +import "./templates.scss"; import "./MealGroupsPage.scss"; const isIncomplete = (group: MealGroup): boolean => @@ -32,6 +34,15 @@ const listSources = (groupList: MealGroup[]): { value: string, label: string }[] .map(([value, item]) => ({ value, label: item.label })); }; +const matchesRecipe = (group: MealGroup, recipeFilter: RecipeFilter): boolean => { + if (recipeFilter === RecipeFilter.S_RECEPTEM) return !!group.recipe; + if (recipeFilter === RecipeFilter.BEZ_RECEPTU) return !group.recipe; + return true; +}; + +/** Stav, se kterým stránku může otevřít odkaz odjinud (statistiky, výběr z historie). */ +type LocationState = { onlyIncomplete?: boolean, recipeFilter?: RecipeFilter } | null; + const matchesSource = (group: MealGroup, sourceFilter: string): boolean => { if (sourceFilter === SourceFilter.VSE) return true; if (sourceFilter === SourceFilter.BEZ_ZDROJE) return !group.source; @@ -43,6 +54,8 @@ const matchesSource = (group: MealGroup, sourceFilter: string): boolean => { * * 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ů. + * Jídlo jde rozšířit o recept a fotky; recept jídla, které se ještě nejedlo, + * se zakládá tlačítkem v hlavičce a v seznamu je pak samo. */ const MealGroupsPage = () => { const location = useLocation(); @@ -50,9 +63,11 @@ const MealGroupsPage = () => { const [loading, setLoading] = useState(true); const [query, setQuery] = useState(''); const [sourceFilter, setSourceFilter] = useState(SourceFilter.VSE); - const [onlyIncomplete, setOnlyIncomplete] = useState(() => - !!(location.state as { onlyIncomplete?: boolean } | null)?.onlyIncomplete); + const [onlyIncomplete, setOnlyIncomplete] = useState(() => !!(location.state as LocationState)?.onlyIncomplete); + const [recipeFilter, setRecipeFilter] = useState(() => + (location.state as LocationState)?.recipeFilter ?? RecipeFilter.VSE); const [opened, setOpened] = useState<{ key: string, action: MealGroupAction } | undefined>(); + const [newRecipeOpen, setNewRecipeOpen] = useState(false); const load = useCallback(async () => { const response = await getMealGroups(); @@ -69,11 +84,12 @@ const MealGroupsPage = () => { return groupList.filter(group => (!onlyIncomplete || isIncomplete(group)) && matchesSource(group, sourceFilter) + && matchesRecipe(group, recipeFilter) && (!search.length || normalizeSearch(group.name).includes(search))); - }, [groupList, query, sourceFilter, onlyIncomplete]); + }, [groupList, query, sourceFilter, recipeFilter, onlyIncomplete]); const incompleteCount = groupList.filter(group => isIncomplete(group)).length; - const filtered = !!query.trim().length || sourceFilter !== SourceFilter.VSE; + const filtered = !!query.trim().length || sourceFilter !== SourceFilter.VSE || recipeFilter !== RecipeFilter.VSE; return ( <> @@ -84,8 +100,18 @@ const MealGroupsPage = () => { Všechna jídla podle názvu a zdroje — údaje doplníte nebo název změníte ve všech dnech najednou

+
+ {newRecipeOpen && ( + { setNewRecipeOpen(false); load(); }} + onCancel={() => setNewRecipeOpen(false)} + /> + )} +
{ {sourceList.map(source => )} + setRecipeFilter(event.target.value as RecipeFilter)} + > + {Object.values(RecipeFilter).map(value => ( + + ))} + { action={opened?.key === key ? opened.action : undefined} onAction={action => setOpened(action ? { key, action } : undefined)} onChanged={() => { setOpened(undefined); load(); }} + onRefresh={() => load()} /> ); })} diff --git a/client/src/pages/MealTemplatesPanel.tsx b/client/src/pages/MealTemplatesPanel.tsx deleted file mode 100644 index c75059f..0000000 --- a/client/src/pages/MealTemplatesPanel.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -import { Spinner } from "react-bootstrap"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faBookOpen } from "@fortawesome/free-solid-svg-icons"; -import { MealTemplate, deleteMealTemplate, getMealTemplates } from "../../../types"; -import MealTemplateCard from "../components/MealTemplateCard"; -import MealTemplateForm from "../components/MealTemplateForm"; - -/** - * Panel se správou receptů — vařených jídel se surovinami a postupem, ze kterých - * se hodnoty porce dopočtou. Objednaná a koupená jídla šablony nemají, při - * zakládání jídla se nabízejí z historie. - */ -const MealTemplatesPanel = () => { - const [templates, setTemplates] = useState([]); - const [loading, setLoading] = useState(true); - const [edited, setEdited] = useState(); - const [busy, setBusy] = useState(false); - /** Po uložení se zvýší, aby se formulář vyprázdnil i u nové šablony. */ - const [formVersion, setFormVersion] = useState(0); - - const load = useCallback(async () => { - setLoading(true); - const response = await getMealTemplates(); - setTemplates(response.data ?? []); - setLoading(false); - }, []); - - useEffect(() => { load(); }, [load]); - - const remove = async (id: string) => { - setBusy(true); - const response = await deleteMealTemplate({ body: { id } }); - setBusy(false); - if (response.data) { - setTemplates(response.data); - if (edited?.id === id) setEdited(undefined); - } - }; - - const edit = (template: MealTemplate) => { - setEdited(template); - globalThis.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); - }; - - return ( - <> -

- Recept je vařené jídlo — zapíšete suroviny a postup a kalorie porce se spočítají samy. - V přehledu dne se porce založí jedním kliknutím, nebo se recept nabídne, když začnete psát - název jídla. Objednaná a koupená jídla si zapisovat nemusíte, nabízejí se z historie. -

- - {loading && ( -
- -
- )} - - {!loading && !templates.length && ( -
-
-
Zatím nemáte žádný recept
-

Zapište si ho ve formuláři níže.

-
- )} - - {!loading && templates.map(template => ( - edit(template)} - onDelete={() => remove(template.id)} - /> - ))} - - { setTemplates(saved); setEdited(undefined); setFormVersion(current => current + 1); }} - onCancel={() => setEdited(undefined)} - /> - - ); -}; - -export default MealTemplatesPanel; diff --git a/client/src/pages/TemplatesPage.tsx b/client/src/pages/TemplatesPage.tsx deleted file mode 100644 index 98a08f0..0000000 --- a/client/src/pages/TemplatesPage.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { useState } from "react"; -import { useLocation } from "react-router"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faDumbbell, faBookOpen } from "@fortawesome/free-solid-svg-icons"; -import { TemplateTab, TEMPLATE_TAB_NAMES } from "../enums"; -import WorkoutTemplatesPanel from "./WorkoutTemplatesPanel"; -import MealTemplatesPanel from "./MealTemplatesPanel"; -import "./TemplatesPage.scss"; - -/** - * Šablony — tréninky a recepty vedle sebe. - * - * Obojí jsou předpřipravené položky zakládané do dne jedním kliknutím, ale mají - * jinou strukturu (tréninky se skládají z jiných šablon, recepty ze surovin), - * proto mají vlastní panely a společné jen záložky. Hotová jídla šablony - * nemají, nabízejí se z historie. - */ -const TemplatesPage = () => { - // Odkaz může rovnou otevřít konkrétní záložku, třeba recepty z výběru jídla - const location = useLocation(); - const [tab, setTab] = useState(() => - (location.state as { tab?: TemplateTab } | null)?.tab ?? TemplateTab.TRENINKY); - - return ( - <> -
-

Šablony

-
- -
- - -
- - {tab === TemplateTab.TRENINKY ? : } - - ); -}; - -export default TemplatesPage; diff --git a/client/src/pages/WorkoutsPage.tsx b/client/src/pages/WorkoutsPage.tsx new file mode 100644 index 0000000..dc92b3d --- /dev/null +++ b/client/src/pages/WorkoutsPage.tsx @@ -0,0 +1,17 @@ +import WorkoutTemplatesPanel from "./WorkoutTemplatesPanel"; +import "./templates.scss"; + +/** + * Tréninky — šablony tréninků zakládané do dne jedním kliknutím. Recepty sem + * dřív patřily také, teď jsou rozšířením jídla v seznamu jídel. + */ +const WorkoutsPage = () => ( + <> +
+

Tréninky

+
+ + +); + +export default WorkoutsPage; diff --git a/client/src/pages/TemplatesPage.scss b/client/src/pages/templates.scss similarity index 93% rename from client/src/pages/TemplatesPage.scss rename to client/src/pages/templates.scss index dc6420c..b4980e7 100644 --- a/client/src/pages/TemplatesPage.scss +++ b/client/src/pages/templates.scss @@ -66,6 +66,15 @@ } .template-form { + .chart-title { + font-size: 0.85rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ft-text-secondary); + margin: 0 0 14px; + } + margin-top: 20px; } diff --git a/client/src/routes.ts b/client/src/routes.ts index 44304d8..e5165d4 100644 --- a/client/src/routes.ts +++ b/client/src/routes.ts @@ -6,7 +6,7 @@ */ export const DEN_URL = '/'; export const STATISTIKY_URL = '/statistiky'; -export const SABLONY_URL = '/sablony'; +export const TRENINKY_URL = '/treninky'; export const PROFIL_URL = '/profil'; export const JIDLA_URL = '/jidla'; diff --git a/server/src/mealGroups.ts b/server/src/mealGroups.ts new file mode 100644 index 0000000..3c30581 --- /dev/null +++ b/server/src/mealGroups.ts @@ -0,0 +1,274 @@ +import { + FillSameNameResult, MealEntry, MealGroup, MealGroupDay, MealGroupValues, MealTemplate, Nutrients, +} from "../../types/gen/types.gen"; +import getStorage from "./storage"; +import { BadRequestError } from "./utils"; +import { normalizeName, rememberCalories } from "./calories"; +import { normalizeNutrients } from "./nutrients"; +import { + deriveAmounts, findAllMeals, getDayKey, getManualWeight, getNameSourceKey, normalizeNumber, normalizeText, +} from "./meals"; +import { getMealTemplates, getRecipeKey, renameMealTemplate } from "./mealTemplates"; +import { getPhotoIndex, moveMealPhotos } from "./mealPhotos"; + +const storage = getStorage(); + +/** 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. + * + * Má-li jídlo recept, gramáž porce a energie jsou z něj — spočítané ze surovin + * jsou přesnější než cokoli, co se kdy zapsalo ke dni. + */ +const createMealGroupValues = ( + groupList: MealEntry[], sameNameList: MealEntry[], recipe?: MealTemplate, +): MealGroupValues => { + const caloriesPer100g = recipe?.caloriesPer100g + ?? findLatestValue(groupList, entry => entry.caloriesPer100g) + ?? findLatestValue(sameNameList, entry => entry.caloriesPer100g); + return omitUndefined({ + pricePer100g: findLatestValue(groupList, entry => entry.pricePer100g), + weight: recipe?.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 + ? recipe?.calories ?? findLatestValue(groupList, entry => entry.calories) + : undefined, + nutrientsPer100g: findLatestValue(groupList, entry => entry.nutrientsPer100g) + ?? findLatestValue(sameNameList, entry => entry.nutrientsPer100g), + }); +}; + +/** Vrátí true, pokud jídlu chybí kalorie, gramáž nebo živiny — tedy to, co doplnění řeší. */ +const isIncomplete = (entry: MealEntry): boolean => + entry.calories == null || entry.weight == null || !entry.nutrientsPer100g; + +/** Dny, kdy se jídla skupiny jedla, ve stejném pořadí jako skupina (od nejnovějšího). */ +const createMealGroupDays = (groupList: MealEntry[]): MealGroupDay[] => { + const dayMap = new Map(); + for (const entry of groupList) { + const day = dayMap.get(entry.date) ?? { date: entry.date, incomplete: false }; + day.incomplete ||= isIncomplete(entry); + dayMap.set(entry.date, day); + } + return [...dayMap.values()]; +}; + +/** + * 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. + * + * Ke skupině patří i její recept a fotky. Recept jídla, které se zatím nejedlo, + * tvoří skupinu sám (s nulovým počtem), aby šel v seznamu najít. + * + * @param login přihlašovací jméno uživatele + */ +export const getMealGroups = async (login: string): Promise => { + const entryList = await findAllMeals(login); + const recipeMap = new Map(); + for (const recipe of await getMealTemplates(login)) { + if (!recipeMap.has(getRecipeKey(recipe))) recipeMap.set(getRecipeKey(recipe), recipe); + } + const photoIndex = await getPhotoIndex(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 createGroup = (key: string, groupList: MealEntry[], name: string, source?: string): MealGroup => { + const recipe = recipeMap.get(key); + const photoList = photoIndex[key] ?? []; + return omitUndefined({ + name, + source, + count: groupList.length, + lastDate: groupList[0]?.date, + missingCaloriesCount: groupList.filter(entry => entry.calories == null).length, + missingWeightCount: groupList.filter(entry => entry.weight == null).length, + missingNutrientsCount: groupList.filter(entry => !entry.nutrientsPer100g).length, + days: createMealGroupDays(groupList), + values: createMealGroupValues(groupList, nameMap.get(normalizeName(name)) ?? [], recipe), + recipe, + photoCount: photoList.length, + thumbnail: photoList[0]?.thumbnail, + }); + }; + + const resultList = [...groupMap.entries()] + .map(([key, groupList]) => createGroup(key, groupList, groupList[0].name, groupList[0].source)); + for (const [key, recipe] of recipeMap) { + if (!groupMap.has(key)) resultList.push(createGroup(key, [], recipe.name, recipe.source)); + } + return resultList.sort((a, b) => + b.count - a.count || (b.lastDate ?? '').localeCompare(a.lastDate ?? '') || a.name.localeCompare(b.name, 'cs')); +}; + +/** 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 }; +}; + +/** + * 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čí. + * Recept a fotky jdou s jídlem; dva recepty ale sloučit nejde. + * + * @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 targetKey = getNameSourceKey(targetName, targetSource); + const isInGroup = (entry: MealEntry) => getNameSourceKey(entry.name, entry.source) === key; + + const recipeKeyList = (await getMealTemplates(login)).map(getRecipeKey); + if (key !== targetKey && recipeKeyList.includes(key) && recipeKeyList.includes(targetKey)) { + throw new BadRequestError("Obě jídla mají recept, sloučit je nejde — jeden z receptů nejdřív smažte"); + } + await renameMealTemplate(login, key, targetName, targetSource); + await moveMealPhotos(login, key, targetKey); + + 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/mealPhotos.ts b/server/src/mealPhotos.ts new file mode 100644 index 0000000..c6d764c --- /dev/null +++ b/server/src/mealPhotos.ts @@ -0,0 +1,159 @@ +import { randomUUID } from 'crypto'; +import { MealPhoto } from "../../types/gen/types.gen"; +import getStorage from "./storage"; +import { BadRequestError, NotFoundError } from "./utils"; +import { getNameSourceKey, normalizeText } from "./meals"; + +const storage = getStorage(); + +/** Kolik fotek může mít jedno jídlo — pojistka, aby úložiště nerostlo bez omezení. */ +const MAX_PHOTOS_PER_MEAL = 20; + +/** + * Největší přípustná velikost fotky a náhledu jako data URL. Klient fotku před + * odesláním zmenší (zhruba 1600 px), takže běžně má pár set kB; limit je rezerva. + */ +const MAX_IMAGE_LENGTH = 3 * 1024 * 1024; +const MAX_THUMBNAIL_LENGTH = 100 * 1024; + +const DATA_URL_PATTERN = /^data:image\/(jpeg|png|webp);base64,[A-Za-z0-9+/]+=*$/; + +/** Fotka v rejstříku — bez plného obrázku, ten leží pod vlastním klíčem. */ +type PhotoInfo = { + id: string; + thumbnail: string; + createdAt: string; +}; + +/** Rejstřík fotek uživatele: klíč jídla (název a zdroj) → fotky od nejstarší. */ +type PhotoIndex = Record; + +/** + * Rejstřík s náhledy je jeden pro uživatele, aby ho seznam jídel načetl jedním + * čtením. Plné obrázky jsou každý pod svým klíčem — do seznamu se nenačítají. + */ +const getIndexKey = (login: string): string => `mealPhotos:${login}`; +const getPhotoKey = (login: string, id: string): string => `mealPhoto:${login}:${id}`; + +/** Ověří obrázek v podobě data URL. */ +const normalizeImage = (value: unknown, name: string, maxLength: number): string => { + if (typeof value !== 'string' || !DATA_URL_PATTERN.test(value)) { + throw new BadRequestError(`Pole '${name}' musí být obrázek JPEG, PNG nebo WebP jako data URL`); + } + if (value.length > maxLength) { + throw new BadRequestError(`Obrázek v poli '${name}' je příliš velký`); + } + return value; +}; + +/** Ověří název jídla a vrátí klíč, pod kterým má fotky. */ +const getMealKey = (name: unknown, source: unknown): string => { + const mealName = normalizeText(name, 'name'); + if (!mealName) { + throw new BadRequestError("Nebyl předán název jídla"); + } + return getNameSourceKey(mealName, normalizeText(source, 'source')); +}; + +/** + * Vrátí rejstřík fotek uživatele — náhledy podle klíče jídla. + * + * @param login přihlašovací jméno uživatele + */ +export const getPhotoIndex = async (login: string): Promise => + await storage.getData(getIndexKey(login)) ?? {}; + +/** + * Vrátí fotky jednoho jídla i s plnými obrázky, od nejstarší. + * + * @param login přihlašovací jméno uživatele + * @param name název jídla + * @param source zdroj jídla; prázdný znamená jídlo bez zdroje + */ +export const getMealPhotos = async (login: string, name: unknown, source: unknown): Promise => { + const infoList = (await getPhotoIndex(login))[getMealKey(name, source)] ?? []; + const resultList: MealPhoto[] = []; + for (const info of infoList) { + const image = await storage.getData(getPhotoKey(login, info.id)); + if (image) resultList.push({ ...info, image }); + } + return resultList; +}; + +/** + * Přidá fotku k jídlu. Fotka patří jídlu jako celku (názvu a zdroji), ne + * jednomu dni, ve kterém se jedlo. + * + * @param login přihlašovací jméno uživatele + * @param name název jídla + * @param source zdroj jídla + * @param image zmenšená fotka jako data URL + * @param thumbnail náhled do seznamu jídel jako data URL + */ +export const addMealPhoto = async ( + login: string, + name: unknown, + source: unknown, + image: unknown, + thumbnail: unknown, +): Promise => { + const key = getMealKey(name, source); + const result: MealPhoto = { + id: randomUUID(), + image: normalizeImage(image, 'image', MAX_IMAGE_LENGTH), + thumbnail: normalizeImage(thumbnail, 'thumbnail', MAX_THUMBNAIL_LENGTH), + createdAt: new Date().toISOString(), + }; + if (((await getPhotoIndex(login))[key]?.length ?? 0) >= MAX_PHOTOS_PER_MEAL) { + throw new BadRequestError(`Jídlo může mít nejvýše ${MAX_PHOTOS_PER_MEAL} fotek`); + } + // Obrázek se ukládá první, aby rejstřík nikdy neukazoval na fotku, která neexistuje + await storage.setData(getPhotoKey(login, result.id), result.image); + const info: PhotoInfo = { id: result.id, thumbnail: result.thumbnail, createdAt: result.createdAt }; + await storage.updateData(getIndexKey(login), current => + ({ ...current, [key]: [...(current?.[key] ?? []), info] })); + return result; +}; + +/** + * Smaže fotku jídla. + * + * @param login přihlašovací jméno uživatele + * @param id identifikátor fotky + */ +export const deleteMealPhoto = async (login: string, id: unknown): Promise => { + if (typeof id !== 'string' || !id.length) { + throw new BadRequestError("Nebyl předán identifikátor fotky"); + } + let found = false; + await storage.updateData(getIndexKey(login), current => Object.fromEntries( + Object.entries(current ?? {}) + .map(([key, infoList]): [string, PhotoInfo[]] => { + const restList = infoList.filter(info => info.id !== id); + found ||= restList.length !== infoList.length; + return [key, restList]; + }) + .filter(([, infoList]) => infoList.length > 0))); + if (!found) { + throw new NotFoundError("Fotka nebyla nalezena"); + } + await storage.deleteData(getPhotoKey(login, id)); +}; + +/** + * Přesune fotky jídla pod jiný klíč — při přejmenování. Fotky jídla, se kterým + * se tím sloučí, zůstanou a přesouvané se přidají za ně. + * + * @param login přihlašovací jméno uživatele + * @param fromKey dosavadní klíč jídla (`getNameSourceKey`) + * @param toKey nový klíč jídla + */ +export const moveMealPhotos = async (login: string, fromKey: string, toKey: string): Promise => { + if (fromKey === toKey) return; + const index = await getPhotoIndex(login); + if (!index[fromKey]?.length) return; + await storage.updateData(getIndexKey(login), current => { + const { [fromKey]: movedList = [], ...rest } = current ?? {}; + return { ...rest, [toKey]: [...(rest[toKey] ?? []), ...movedList] }; + }); +}; diff --git a/server/src/mealTemplates.ts b/server/src/mealTemplates.ts index 724274b..3b1c656 100644 --- a/server/src/mealTemplates.ts +++ b/server/src/mealTemplates.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'crypto'; import { MealTemplate, MealType, RecipeIngredient } from "../../types/gen/types.gen"; import getStorage from "./storage"; -import { addMeal, isMealType } from "./meals"; +import { addMeal, getNameSourceKey, isMealType } from "./meals"; import { rememberCalories } from "./calories"; import { BadRequestError, NotFoundError, isValidIsoDate, normalizeMultiplier } from "./utils"; @@ -19,13 +19,14 @@ const getKey = (login: string): string => `mealTemplates:${login}`; /** * Záznam tak, jak může ležet v úložišti. Dřív se pod stejným klíčem ukládaly - * i šablony hotových jídel (druh `HOTOVE`, nebo bez druhu) a recepty nesly - * `kind: 'RECEPT'`. + * i šablony hotových jídel (druh `HOTOVE`, nebo bez druhu), recepty nesly + * `kind: 'RECEPT'` a měly i chod. */ type StoredMealTemplate = Omit & { ingredients?: RecipeIngredient[]; kind?: string; pricePer100g?: number; + mealType?: MealType; }; type StoredRecipe = StoredMealTemplate & { ingredients: RecipeIngredient[] }; @@ -36,9 +37,9 @@ type StoredRecipe = StoredMealTemplate & { ingredients: RecipeIngredient[] }; */ const isRecipe = (stored: StoredMealTemplate): stored is StoredRecipe => !!stored.ingredients?.length; -/** Odstraní z receptu pole, která patřila jen šablonám hotových jídel. */ +/** Odstraní z receptu pole, která patřila jen šablonám hotových jídel, a zaniklý chod. */ const toMealTemplate = (stored: StoredRecipe): MealTemplate => { - const { kind, pricePer100g, ...result } = stored; + const { kind, pricePer100g, mealType, ...result } = stored; return result; }; @@ -176,9 +177,15 @@ const normalizeIngredient = (raw: unknown, index: number): RecipeIngredient => { }); }; +/** Klíč jídla, ke kterému recept patří — stejně jako u skupin v seznamu jídel. */ +export const getRecipeKey = (recipe: MealTemplate): string => getNameSourceKey(recipe.name, recipe.source); + /** * Založí nový recept, nebo přepíše existující podle předaného id. * + * Recept rozšiřuje jídlo v seznamu jídel (název a zdroj), proto ho jedno jídlo + * může mít nejvýš jeden. + * * Hodnoty jedné porce (gramáž, energie, cena) se dopočtou ze surovin a uloží * do plochých polí, takže použití receptu založí běžné jídlo jako každé jiné. * Zápis zároveň z úložiště odklidí zaniklé šablony hotových jídel. @@ -192,9 +199,6 @@ export const saveMealTemplate = async (login: string, input: unknown): Promise item.id !== template.id && getRecipeKey(item) === getRecipeKey(template)); + if (otherRecipe) { + throw new BadRequestError("Toto jídlo už recept má"); + } for (const ingredient of ingredients) { if (ingredient.caloriesPer100g) { await rememberCalories(login, ingredient.name, ingredient.caloriesPer100g); @@ -266,7 +274,7 @@ export const deleteMealTemplate = async (login: string, id: unknown): Promise => { + const recipeList = await getMealTemplates(login); + const recipe = recipeList.find(item => getRecipeKey(item) === fromKey); + if (!recipe) return; + const renamed: MealTemplate = definedOnly({ ...recipe, name, source }); + await storage.updateData(getKey(login), current => + loadRecipes(current).map(item => item.id === recipe.id ? renamed : item)); +}; diff --git a/server/src/meals.ts b/server/src/meals.ts index 30cbcb9..82b6a84 100644 --- a/server/src/meals.ts +++ b/server/src/meals.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'crypto'; import { - DayRecord, FillSameNameResult, MealEntry, MealGroup, MealGroupDay, MealGroupValues, MealSuggestion, MealType, Nutrients, + DayRecord, FillSameNameResult, MealEntry, MealSuggestion, MealType, Nutrients, } from "../../types/gen/types.gen"; import getStorage from "./storage"; import { @@ -34,7 +34,7 @@ export function isMealType(value: unknown): value is MealType { } /** Sestaví klíč úložiště pro jeden den jednoho uživatele. */ -function getDayKey(login: string, date: string): string { +export function getDayKey(login: string, date: string): string { return `meals:${login}:${date}`; } @@ -89,7 +89,7 @@ export async function getRange(login: string, from: string, to: string): Promise } /** Ořízne a znormalizuje volitelný textový vstup. Prázdný text vrací jako undefined. */ -function normalizeText(value: unknown, name: string): string | undefined { +export function normalizeText(value: unknown, name: string): string | undefined { if (value == null) return undefined; if (typeof value !== 'string') { throw new BadRequestError(`Pole '${name}' musí být text`); @@ -103,7 +103,7 @@ function normalizeText(value: unknown, name: string): string | undefined { } /** Ověří volitelnou nezápornou celočíselnou hodnotu (cena v haléřích, kalorie). */ -function normalizeNumber(value: unknown, name: string): number | undefined { +export function normalizeNumber(value: unknown, name: string): number | undefined { if (value == null) return undefined; if (typeof value !== 'number' || !Number.isFinite(value)) { throw new BadRequestError(`Pole '${name}' musí být číslo`); @@ -444,7 +444,7 @@ export async function findMealsForDays(login: string, dates: string[]): Promise< * Vrátí gramáž záznamu, pokud byla zadaná ručně. Gramáž dopočtená z ceny se * neukládá zvlášť, pozná se tak, že odpovídá ceně a sazbě za 100 g. */ -function getManualWeight(source: MealEntry): number | undefined { +export function getManualWeight(source: MealEntry): number | undefined { const weightFromPrice = source.price != null && source.pricePer100g ? Math.round(source.price / source.pricePer100g * 100) : undefined; @@ -476,7 +476,7 @@ 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 => +export const getNameSourceKey = (name: string, source?: string): string => `${normalizeName(name)}|${normalizeName(source ?? '')}`; /** @@ -645,229 +645,3 @@ 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í true, pokud jídlu chybí kalorie, gramáž nebo živiny — tedy to, co doplnění řeší. */ -const isIncomplete = (entry: MealEntry): boolean => - entry.calories == null || entry.weight == null || !entry.nutrientsPer100g; - -/** Dny, kdy se jídla skupiny jedla, ve stejném pořadí jako skupina (od nejnovějšího). */ -const createMealGroupDays = (groupList: MealEntry[]): MealGroupDay[] => { - const dayMap = new Map(); - for (const entry of groupList) { - const day = dayMap.get(entry.date) ?? { date: entry.date, incomplete: false }; - day.incomplete ||= isIncomplete(entry); - dayMap.set(entry.date, day); - } - return [...dayMap.values()]; -}; - -/** - * 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(); - 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, - days: createMealGroupDays(groupList), - 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 }; -}; - -/** - * 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 fb5da0e..0b04d8f 100644 --- a/server/src/routes/mealRoutes.ts +++ b/server/src/routes/mealRoutes.ts @@ -1,8 +1,10 @@ import express, { Request, Response, NextFunction } from "express"; import { - addMeal, deleteMeal, fillMealGroup, fillSameNameMeals, getDay, getMealGroups, getMealHistory, getMealSources, - getRange, renameMealGroup, setMealMultiplier, splitMeal, updateMeal, + addMeal, deleteMeal, fillSameNameMeals, getDay, getMealHistory, getMealSources, getRange, setMealMultiplier, + splitMeal, updateMeal, } from "../meals"; +import { fillMealGroup, getMealGroups, renameMealGroup } from "../mealGroups"; +import { addMealPhoto, deleteMealPhoto, getMealPhotos } from "../mealPhotos"; import { getDayOverview } from "../dayOverview"; import { getLogin } from "../auth"; import { formatDate, getToday, parseToken, requireIsoDate } from "../utils"; @@ -85,6 +87,32 @@ router.post("/renameGroup", async (req: Request, res: Response, next: NextFuncti } catch (e) { next(e); } }); +/** Vrátí fotky jídla (názvu a zdroje) i s plnými obrázky. */ +router.get("/photos", async (req: Request, res: Response, next: NextFunction) => { + try { + const login = getLogin(parseToken(req)); + res.status(200).json(await getMealPhotos(login, req.query.name, req.query.source)); + } catch (e) { next(e); } +}); + +/** Přidá fotku k jídlu. */ +router.post("/photos/add", async (req: Request, res: Response, next: NextFunction) => { + try { + const login = getLogin(parseToken(req)); + const { name, source, image, thumbnail } = req.body ?? {}; + res.status(200).json(await addMealPhoto(login, name, source, image, thumbnail)); + } catch (e) { next(e); } +}); + +/** Smaže fotku jídla. */ +router.post("/photos/delete", async (req: Request, res: Response, next: NextFunction) => { + try { + const login = getLogin(parseToken(req)); + await deleteMealPhoto(login, req.body?.id); + res.status(204).end(); + } 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/storage/StorageInterface.ts b/server/src/storage/StorageInterface.ts index 722572a..5c23b8e 100644 --- a/server/src/storage/StorageInterface.ts +++ b/server/src/storage/StorageInterface.ts @@ -11,6 +11,9 @@ export interface StorageInterface { setData(key: string, data: Type): Promise; + /** Odstraní klíč i s hodnotou; neexistující klíč nevadí. */ + deleteData(key: string): Promise; + /** * Vrátí seznam všech klíčů, případně jen těch obsahujících předaný podřetězec. * @param contains volitelný podřetězec, který musí klíč obsahovat diff --git a/server/src/storage/json.ts b/server/src/storage/json.ts index 9f57b32..cdd9980 100644 --- a/server/src/storage/json.ts +++ b/server/src/storage/json.ts @@ -30,6 +30,11 @@ export default class JsonStorage implements StorageInterface { return Promise.resolve(); } + deleteData(key: string): Promise { + db.delete(key); + return Promise.resolve(); + } + listKeys(contains?: string): Promise { const keys = Object.keys(db.JSON()); return Promise.resolve(contains ? keys.filter(k => k.includes(contains)) : keys); diff --git a/server/src/storage/memory.ts b/server/src/storage/memory.ts index 8862f05..a99bb9d 100644 --- a/server/src/storage/memory.ts +++ b/server/src/storage/memory.ts @@ -25,6 +25,11 @@ export default class MemoryStorage implements StorageInterface { return Promise.resolve(); } + deleteData(key: string): Promise { + store.delete(key); + return Promise.resolve(); + } + listKeys(contains?: string): Promise { const keys = Array.from(store.keys()); return Promise.resolve(contains ? keys.filter(k => k.includes(contains)) : keys); diff --git a/server/src/storage/redis.ts b/server/src/storage/redis.ts index 5da62bc..cfd9af8 100644 --- a/server/src/storage/redis.ts +++ b/server/src/storage/redis.ts @@ -54,6 +54,10 @@ export default class RedisStorage implements StorageInterface { await this.getClient().set(key, JSON.stringify(data)); } + async deleteData(key: string): Promise { + await this.getClient().del(key); + } + async listKeys(contains?: string): Promise { const keys: string[] = []; for await (const key of this.getClient().scanIterator({ MATCH: contains ? `*${contains}*` : '*', COUNT: 1000 })) { diff --git a/server/src/tests/activityStats.test.ts b/server/src/tests/activityStats.test.ts index a2f112d..4c99d36 100644 --- a/server/src/tests/activityStats.test.ts +++ b/server/src/tests/activityStats.test.ts @@ -195,10 +195,9 @@ describe('vazba jídla na recept', () => { it('jídlo z receptu si pamatuje, ze kterého vzniklo', async () => { const [template] = await saveMealTemplate(LOGIN, { name: 'Štrůdl', - mealType: MealType.PULNOCNI_SVACINA, ingredients: [{ name: 'Těsto', weight: 300, calories: 900 }], }); - await applyMealTemplate(LOGIN, template.id, DATE); + await applyMealTemplate(LOGIN, template.id, DATE, MealType.PULNOCNI_SVACINA); const [meal] = (await getDay(LOGIN, DATE)).entries; expect(meal.templateId).toBe(template.id); diff --git a/server/src/tests/mealGroupExtras.test.ts b/server/src/tests/mealGroupExtras.test.ts new file mode 100644 index 0000000..1f4b6b2 --- /dev/null +++ b/server/src/tests/mealGroupExtras.test.ts @@ -0,0 +1,100 @@ +process.env.CALORIE_PROVIDER = 'none'; + +import { addMeal } from "../meals"; +import { getMealGroups, renameMealGroup } from "../mealGroups"; +import { getMealTemplates, saveMealTemplate } from "../mealTemplates"; +import { addMealPhoto, deleteMealPhoto, getMealPhotos } from "../mealPhotos"; +import { resetMemoryStorage } from "../storage/memory"; +import { MealType } from "../../../types/gen/types.gen"; + +const LOGIN = 'tester'; +const IMAGE = 'data:image/jpeg;base64,AAAA'; +const THUMBNAIL = 'data:image/jpeg;base64,BBBB'; + +beforeEach(() => resetMemoryStorage()); + +const rizoto = { + name: 'Rizoto', source: 'doma', servings: 2, cookedWeight: 800, + ingredients: [{ name: 'Rýže', weight: 200, caloriesPer100g: 350 }, { name: 'Kuře', weight: 300, calories: 400 }], +}; + +describe('recept jídla v seznamu jídel', () => { + + it('patří ke skupině se stejným názvem a zdrojem a předvyplní z něj porci', async () => { + await addMeal(LOGIN, { date: '2025-03-01', mealType: MealType.VECERE, name: 'rizoto', source: 'Doma' }); + await saveMealTemplate(LOGIN, rizoto); + + const [group] = await getMealGroups(LOGIN); + + // (700 + 400) kcal na 800 g, porce je polovina + expect(group).toMatchObject({ + count: 1, recipe: { name: 'Rizoto' }, values: { weight: 400, caloriesPer100g: 138 }, + }); + }); + + it('recept jídla, které se ještě nejedlo, je v seznamu sám', async () => { + await addMeal(LOGIN, { date: '2025-03-01', mealType: MealType.OBED, name: 'Guláš' }); + await saveMealTemplate(LOGIN, rizoto); + + const groups = await getMealGroups(LOGIN); + + expect(groups.map(group => `${group.name} ${group.count}`)).toEqual(['Guláš 1', 'Rizoto 0']); + expect(groups[1].lastDate).toBeUndefined(); + }); + + it('druhý recept téhož jídla nejde založit', async () => { + await saveMealTemplate(LOGIN, rizoto); + + await expect(saveMealTemplate(LOGIN, { ...rizoto, name: 'RIZOTO' })).rejects.toThrow(/už recept má/); + }); + + it('přejmenování vezme recept i fotky s sebou', async () => { + await addMeal(LOGIN, { date: '2025-03-01', mealType: MealType.VECERE, ...rizoto }); + await saveMealTemplate(LOGIN, rizoto); + await addMealPhoto(LOGIN, 'Rizoto', 'doma', IMAGE, THUMBNAIL); + + await renameMealGroup(LOGIN, 'Rizoto', 'doma', 'Houbové rizoto', undefined); + + const [recipe] = await getMealTemplates(LOGIN); + expect(recipe.name).toBe('Houbové rizoto'); + expect(recipe).not.toHaveProperty('source'); + expect(await getMealPhotos(LOGIN, 'Houbové rizoto', undefined)).toHaveLength(1); + expect(await getMealPhotos(LOGIN, 'Rizoto', 'doma')).toHaveLength(0); + }); + + it('dvě jídla s recepty přejmenováním nesloučí', async () => { + await saveMealTemplate(LOGIN, rizoto); + await saveMealTemplate(LOGIN, { ...rizoto, name: 'Rizoto 2' }); + + await expect(renameMealGroup(LOGIN, 'Rizoto 2', 'doma', 'Rizoto', 'doma')).rejects.toThrow(/Obě jídla/); + }); +}); + +describe('fotky jídla', () => { + + it('patří jídlu podle názvu a zdroje a skupina nese počet a náhled', async () => { + await addMeal(LOGIN, { date: '2025-03-01', mealType: MealType.OBED, name: 'Gyros', source: 'Everest' }); + await addMeal(LOGIN, { date: '2025-03-02', mealType: MealType.OBED, name: 'Gyros', source: 'Namaste' }); + await addMealPhoto(LOGIN, 'gyros', 'everest', IMAGE, THUMBNAIL); + + const groups = await getMealGroups(LOGIN); + + expect(groups.find(group => group.source === 'Everest')).toMatchObject({ photoCount: 1, thumbnail: THUMBNAIL }); + expect(groups.find(group => group.source === 'Namaste')).toMatchObject({ photoCount: 0 }); + expect(await getMealPhotos(LOGIN, 'Gyros', 'Everest')).toMatchObject([{ image: IMAGE }]); + }); + + it('smazaná fotka zmizí z jídla', async () => { + const photo = await addMealPhoto(LOGIN, 'Gyros', undefined, IMAGE, THUMBNAIL); + + await deleteMealPhoto(LOGIN, photo.id); + + expect(await getMealPhotos(LOGIN, 'Gyros', undefined)).toEqual([]); + await expect(deleteMealPhoto(LOGIN, photo.id)).rejects.toThrow(/nebyla nalezena/); + }); + + it('odmítne něco jiného než obrázek', async () => { + await expect(addMealPhoto(LOGIN, 'Gyros', undefined, 'data:text/html;base64,AAAA', THUMBNAIL)) + .rejects.toThrow(/obrázek/); + }); +}); diff --git a/server/src/tests/mealGroups.test.ts b/server/src/tests/mealGroups.test.ts index e54e9eb..96712ea 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, renameMealGroup } from "../meals"; +import { addMeal, getDay } from "../meals"; +import { fillMealGroup, getMealGroups, renameMealGroup } from "../mealGroups"; import { searchCalories } from "../calories"; import { resetMemoryStorage } from "../storage/memory"; import { MealType } from "../../../types/gen/types.gen"; diff --git a/server/src/tests/setsAndMealTemplates.test.ts b/server/src/tests/setsAndMealTemplates.test.ts index 76a0035..b0f0441 100644 --- a/server/src/tests/setsAndMealTemplates.test.ts +++ b/server/src/tests/setsAndMealTemplates.test.ts @@ -160,16 +160,15 @@ describe('recepty', () => { await applyMealTemplate(LOGIN, template.id, DATE); const [entry] = (await getDay(LOGIN, DATE)).entries; - expect(entry).toMatchObject({ name: 'Kuřecí rizoto', mealType: MealType.VECERE, weight: 500, calories: 515 }); + expect(entry).toMatchObject({ name: 'Kuřecí rizoto', mealType: MealType.OBED, weight: 500, calories: 515 }); expect(entry.note).toBeUndefined(); }); - it('zvolený chod přebije ten z receptu a bez obou se založí oběd', async () => { + it('recept nemá chod — založí se pod zvoleným, bez volby jako oběd', async () => { const [template] = await saveMealTemplate(LOGIN, rizoto); - const [bezChodu] = await saveMealTemplate(LOGIN, { ...rizoto, name: 'Bez chodu', mealType: undefined }); await applyMealTemplate(LOGIN, template.id, DATE, MealType.SNIDANE); - await applyMealTemplate(LOGIN, bezChodu.id, '2025-03-04'); + await applyMealTemplate(LOGIN, template.id, '2025-03-04'); expect((await getDay(LOGIN, DATE)).entries[0].mealType).toBe(MealType.SNIDANE); expect((await getDay(LOGIN, '2025-03-04')).entries[0].mealType).toBe(MealType.OBED); diff --git a/types/api.yml b/types/api.yml index cd18254..5fa82ee 100644 --- a/types/api.yml +++ b/types/api.yml @@ -38,6 +38,12 @@ paths: $ref: "./paths/meals/fillGroup.yml" /meals/renameGroup: $ref: "./paths/meals/renameGroup.yml" + /meals/photos: + $ref: "./paths/meals/photos.yml" + /meals/photos/add: + $ref: "./paths/meals/photoAdd.yml" + /meals/photos/delete: + $ref: "./paths/meals/photoDelete.yml" # Statistiky (/api/stats) /stats/summary: diff --git a/types/paths/meals/photoAdd.yml b/types/paths/meals/photoAdd.yml new file mode 100644 index 0000000..37f2f77 --- /dev/null +++ b/types/paths/meals/photoAdd.yml @@ -0,0 +1,34 @@ +post: + operationId: addMealPhoto + summary: Přidá fotku k jídlu (názvu a zdroji, ne ke dni). + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name, image, thumbnail] + properties: + name: + description: Název jídla + type: string + source: + description: Zdroj jídla; bez něj jde o jídlo bez zdroje + type: string + image: + description: Zmenšená fotka jako data URL + type: string + thumbnail: + description: Náhled do seznamu jídel jako data URL + type: string + responses: + "200": + description: Uložená fotka + content: + application/json: + schema: + $ref: "../../schemas/_index.yml#/MealPhoto" + "400": + description: Neplatná data nebo příliš mnoho fotek + "401": + description: Neautentizovaný uživatel diff --git a/types/paths/meals/photoDelete.yml b/types/paths/meals/photoDelete.yml new file mode 100644 index 0000000..8e235a1 --- /dev/null +++ b/types/paths/meals/photoDelete.yml @@ -0,0 +1,23 @@ +post: + operationId: deleteMealPhoto + summary: Smaže fotku jídla. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: + description: Identifikátor fotky + type: string + responses: + "204": + description: Fotka smazána + "400": + description: Neplatná data + "401": + description: Neautentizovaný uživatel + "404": + description: Fotka neexistuje diff --git a/types/paths/meals/photos.yml b/types/paths/meals/photos.yml new file mode 100644 index 0000000..73ea74f --- /dev/null +++ b/types/paths/meals/photos.yml @@ -0,0 +1,29 @@ +get: + operationId: getMealPhotos + summary: Vrátí fotky jídla i s plnými obrázky, od nejstarší. + parameters: + - in: query + name: name + required: true + schema: + type: string + description: Název jídla + - in: query + name: source + required: false + schema: + type: string + description: Zdroj jídla; bez něj jde o jídlo bez zdroje + responses: + "200": + description: Fotky jídla + content: + application/json: + schema: + type: array + items: + $ref: "../../schemas/_index.yml#/MealPhoto" + "400": + description: Neplatná data + "401": + description: Neautentizovaný uživatel diff --git a/types/schemas/_index.yml b/types/schemas/_index.yml index 790fe1f..8fc04ad 100644 --- a/types/schemas/_index.yml +++ b/types/schemas/_index.yml @@ -218,18 +218,19 @@ MealGroupValues: MealGroup: description: | 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á. + ze kterého se doplňují chybějící údaje a hromadně přejmenovává. Nese i recept + a fotky jídla; recept jídla, které se zatím nejedlo, tvoří položku sám. type: object additionalProperties: false required: - name - count - - lastDate - missingCaloriesCount - missingWeightCount - missingNutrientsCount - days - values + - photoCount properties: name: description: Název jídla tak, jak byl zapsán naposledy @@ -238,10 +239,10 @@ MealGroup: description: Zdroj tak, jak byl zapsán naposledy type: string count: - description: Počet jídel ve skupině + description: Počet jídel ve skupině; nula u receptu, podle kterého se ještě nejedlo type: integer lastDate: - description: Den, kdy uživatel jídlo jedl naposledy + description: Den, kdy uživatel jídlo jedl naposledy; chybí, pokud se ještě nejedlo type: string format: date missingCaloriesCount: @@ -262,8 +263,42 @@ MealGroup: 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). + názvu z jiného zdroje (složení je stejné, sazba za 100 g ne). Gramáž + porce a energie mají přednost z receptu. $ref: "#/MealGroupValues" + recipe: + description: Recept jídla, pokud ho má + $ref: "#/MealTemplate" + photoCount: + description: Počet fotek jídla + type: integer + thumbnail: + description: Náhled první fotky jako data URL + type: string + +MealPhoto: + description: | + Fotka jídla. Patří jídlu jako celku (názvu a zdroji), ne jednomu dni. + Obrázky jsou data URL, klient je před nahráním zmenší. + type: object + additionalProperties: false + required: + - id + - image + - thumbnail + - createdAt + properties: + id: + type: string + image: + description: Fotka jako data URL (JPEG, PNG nebo WebP) + type: string + thumbnail: + description: Malý náhled do seznamu jídel jako data URL + type: string + createdAt: + type: string + format: date-time MealSuggestion: description: | @@ -1135,10 +1170,6 @@ MealTemplate: source: description: Zdroj jídla, typicky "doma" type: string - mealType: - description: Chod, pod kterým se jídlo zakládá. Bez něj se použije oběd. - allOf: - - $ref: "#/MealType" calories: description: Energetická hodnota jedné porce v kcal type: integer @@ -1186,9 +1217,6 @@ MealTemplateInput: type: string source: type: string - mealType: - allOf: - - $ref: "#/MealType" note: type: string ingredients: