From 35cbc5a9cab2dd2267255f7150b60c213fb97d02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20And=C4=9Bl?= Date: Fri, 16 Oct 2026 08:30:18 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Jen=20s=20chyb=C4=9Bj=C3=ADc=C3=ADm?= =?UTF-8?q?=C3=AD=20=C3=BAdaji=20->=20k=20dopln=C4=9Bn=C3=AD=20feat:=20zob?= =?UTF-8?q?razen=C3=AD=20aktu=C3=A1ln=C3=ADho=20po=C4=8Dtu=20j=C3=ADdel=20?= =?UTF-8?q?feat:=20chu=C5=A5=20a=20typ=20receptu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 5 ++ client/src/components/MealGroupRow.tsx | 11 +++- client/src/components/MealTemplateCard.tsx | 3 + client/src/components/MealTemplateForm.tsx | 40 +++++++++++++- client/src/components/RecipeCategoryInput.tsx | 55 +++++++++++++++++++ client/src/enums.ts | 8 ++- client/src/pages/MealGroupsPage.scss | 8 +++ client/src/pages/MealGroupsPage.tsx | 19 +++++-- server/src/mealTemplates.ts | 37 ++++++++++--- server/src/tests/mealGroupExtras.test.ts | 21 ++++++- types/schemas/_index.yml | 20 +++++++ 11 files changed, 206 insertions(+), 21 deletions(-) create mode 100644 client/src/components/RecipeCategoryInput.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 493e926..de667d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -335,6 +335,11 @@ uložení spočítá hodnoty **jedné porce** a uloží je do plochých polí (` `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 surovin — vařením se voda odpaří nebo nasákne. Postup se do záznamu dne nekopíruje. +Recept má volitelný **typ** (`category` — polévka, omáčka, pečení…) jako volný +text; formulář nabízí typy z ostatních receptů (`RecipeCategoryInput`), pevný +číselník to záměrně není. **Chuť** (`flavor`, `RecipeFlavor`: SLANE/SLADKE) je +v API vždy vyplněná; slaná je výchozí a do úložiště se nezapisuje, `toMealTemplate` +ji při čtení doplní. Recepty se nabízejí v našeptávání i ve výběru z historie (`ApplyMealModal`) a jídlo stejného jména z historie se vedle receptu nenabízí podruhé. diff --git a/client/src/components/MealGroupRow.tsx b/client/src/components/MealGroupRow.tsx index 4fab5f3..e16b5ae 100644 --- a/client/src/components/MealGroupRow.tsx +++ b/client/src/components/MealGroupRow.tsx @@ -2,8 +2,8 @@ import { useState } from "react"; import { Button } from "react-bootstrap"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faBookOpen, faCalendarDays, faCamera, faPen, faPenToSquare } from "@fortawesome/free-solid-svg-icons"; -import { MealGroup } from "../../../types"; -import { MealGroupAction } from "../enums"; +import { MealGroup, RecipeFlavor } from "../../../types"; +import { MealGroupAction, RECIPE_FLAVOR_NAMES } from "../enums"; import { formatDateString } from "../Utils"; import MealGroupForm from "./MealGroupForm"; import MealGroupRenameForm from "./MealGroupRenameForm"; @@ -57,7 +57,12 @@ const MealGroupRow = ({ group, groupList, action, onAction, onChanged, onRefresh ? {group.count}× · naposledy {formatDateString(group.lastDate)} : zatím nejedeno} {group.recipe && ( - recept + + {group.recipe.category ?? 'recept'} + + )} + {group.recipe?.flavor === RecipeFlavor.SLADKE && ( + {RECIPE_FLAVOR_NAMES[RecipeFlavor.SLADKE].toLowerCase()} )} {group.missingCaloriesCount > 0 && ( diff --git a/client/src/components/MealTemplateCard.tsx b/client/src/components/MealTemplateCard.tsx index 585e1e1..a3e867f 100644 --- a/client/src/components/MealTemplateCard.tsx +++ b/client/src/components/MealTemplateCard.tsx @@ -1,6 +1,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faPenToSquare, faTrash } from "@fortawesome/free-solid-svg-icons"; import { MealTemplate } from "../../../types"; +import { RECIPE_FLAVOR_NAMES } from "../enums"; import { deriveRecipe, formatCalories, formatPrice, formatWeight, getIngredientCalories } from "../Utils"; type Props = { @@ -22,6 +23,8 @@ const MealTemplateCard = ({ template, busy, onEdit, onDelete }: Readonly)
Recept + {template.category && {template.category}} + {RECIPE_FLAVOR_NAMES[template.flavor].toLowerCase()} {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 6f8150e..b5f1089 100644 --- a/client/src/components/MealTemplateForm.tsx +++ b/client/src/components/MealTemplateForm.tsx @@ -1,8 +1,10 @@ -import { useState } from "react"; -import { Alert, Button, Form } from "react-bootstrap"; -import { MealTemplate, saveMealTemplate } from "../../../types"; +import { useId, useState } from "react"; +import { Alert, Button, Form, ToggleButton, ToggleButtonGroup } from "react-bootstrap"; +import { MealTemplate, RecipeFlavor, saveMealTemplate } from "../../../types"; +import { RECIPE_FLAVOR_NAMES } from "../enums"; import { deriveRecipe, formatCalories, formatPrice, formatWeight, parseIntInput } from "../Utils"; import SourceInput from "./SourceInput"; +import RecipeCategoryInput from "./RecipeCategoryInput"; import RecipeIngredientsEditor, { DraftIngredient, emptyIngredient, toDraftIngredient, toIngredient, } from "./RecipeIngredientsEditor"; @@ -23,6 +25,8 @@ type Props = { const createForm = (template?: MealTemplate, meal?: Props['meal']) => ({ name: meal?.name ?? template?.name ?? '', source: meal?.source ?? template?.source ?? '', + category: template?.category ?? '', + flavor: template?.flavor ?? RecipeFlavor.SLANE, note: template?.note ?? '', servings: template?.servings != null ? String(template.servings) : '', cookedWeight: template?.cookedWeight != null ? String(template.cookedWeight) : '', @@ -38,6 +42,7 @@ const MealTemplateForm = ({ template, meal, onSaved, onCancel }: Readonly template?.ingredients?.length ? template.ingredients.map(toDraftIngredient) : [emptyIngredient()]); const [error, setError] = useState(); const [busy, setBusy] = useState(false); + const flavorId = useId(); const update = (changes: Partial>) => setForm(current => ({ ...current, ...changes })); @@ -57,6 +62,8 @@ const MealTemplateForm = ({ template, meal, onSaved, onCancel }: Readonly ...(template ? { id: template.id } : {}), name: form.name.trim(), ...(form.source.trim() ? { source: form.source.trim() } : {}), + ...(form.category.trim() ? { category: form.category.trim() } : {}), + flavor: form.flavor, ...(form.note.trim() ? { note: form.note.trim() } : {}), ingredients: filledIngredients, servings: parseIntInput(form.servings), @@ -98,6 +105,33 @@ const MealTemplateForm = ({ template, meal, onSaved, onCancel }: Readonly
)} +
+
+ Typ + update({ category })} /> +
+
+ Chuť + update({ flavor })} + > + {Object.values(RecipeFlavor).map(flavor => ( + + {RECIPE_FLAVOR_NAMES[flavor]} + + ))} + +
+
+
Suroviny diff --git a/client/src/components/RecipeCategoryInput.tsx b/client/src/components/RecipeCategoryInput.tsx new file mode 100644 index 0000000..965197e --- /dev/null +++ b/client/src/components/RecipeCategoryInput.tsx @@ -0,0 +1,55 @@ +import { useEffect, useId, useState } from "react"; +import { Form } from "react-bootstrap"; +import { MealTemplate, getMealTemplates } from "../../../types"; +import { normalizeSearch } from "../Utils"; + +type Props = { + value: string, + onChange: (value: string) => void, +}; + +/** + * Typy z ostatních receptů, každý jednou, od nejpoužívanějšího. Zápis je + * z prvního receptu, ve kterém se typ našel. + */ +const listCategories = (templateList: MealTemplate[]): string[] => { + const countMap = new Map(); + for (const template of templateList) { + if (!template.category) continue; + const key = normalizeSearch(template.category); + const existing = countMap.get(key); + countMap.set(key, { label: existing?.label ?? template.category, count: (existing?.count ?? 0) + 1 }); + } + return [...countMap.values()] + .sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, 'cs')) + .map(item => item.label); +}; + +/** + * Pole pro typ receptu (polévka, omáčka, pečení…). Typ není pevný číselník — + * nabízejí se typy už zapsané u ostatních receptů a psát jde i nový. + */ +const RecipeCategoryInput = ({ value, onChange }: Readonly) => { + const listId = useId(); + const [categoryList, setCategoryList] = useState([]); + + useEffect(() => { + getMealTemplates().then(response => setCategoryList(listCategories(response.data ?? []))); + }, []); + + return ( + <> + onChange(event.target.value)} + /> + + {categoryList.map(category => + + ); +}; + +export default RecipeCategoryInput; diff --git a/client/src/enums.ts b/client/src/enums.ts index 8c885f3..3c35535 100644 --- a/client/src/enums.ts +++ b/client/src/enums.ts @@ -1,4 +1,4 @@ -import { ActivityUnit, BmiKategorie, ImportMode, MealType, Nutrients, Pohlavi } from "../../types"; +import { ActivityUnit, BmiKategorie, ImportMode, MealType, Nutrients, Pohlavi, RecipeFlavor } from "../../types"; /** Lidsky čitelné názvy typů jídel. */ export const MEAL_TYPE_NAMES: Record = { @@ -187,6 +187,12 @@ export const RECIPE_FILTER_NAMES: Record = { BEZ_RECEPTU: 'Bez receptu', }; +/** Popisky chuti receptu. */ +export const RECIPE_FLAVOR_NAMES: Record = { + SLANE: 'Slané', + SLADKE: 'Sladké', +}; + /** 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/pages/MealGroupsPage.scss b/client/src/pages/MealGroupsPage.scss index 0696fad..0bd4a12 100644 --- a/client/src/pages/MealGroupsPage.scss +++ b/client/src/pages/MealGroupsPage.scss @@ -19,6 +19,14 @@ margin: 0; white-space: nowrap; } + + .meal-groups-count { + margin-left: auto; + font-size: 0.85rem; + color: var(--ft-text-muted); + white-space: nowrap; + font-variant-numeric: tabular-nums; + } } .meal-group { diff --git a/client/src/pages/MealGroupsPage.tsx b/client/src/pages/MealGroupsPage.tsx index 6cc0aae..cf4b490 100644 --- a/client/src/pages/MealGroupsPage.tsx +++ b/client/src/pages/MealGroupsPage.tsx @@ -40,6 +40,11 @@ const matchesRecipe = (group: MealGroup, recipeFilter: RecipeFilter): boolean => return true; }; +/** Hledá se v názvu jídla a v typu jeho receptu (polévka, omáčka…). */ +const matchesSearch = (group: MealGroup, search: string): boolean => + normalizeSearch(group.name).includes(search) + || (!!group.recipe?.category && normalizeSearch(group.recipe.category).includes(search)); + /** 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; @@ -85,10 +90,9 @@ const MealGroupsPage = () => { (!onlyIncomplete || isIncomplete(group)) && matchesSource(group, sourceFilter) && matchesRecipe(group, recipeFilter) - && (!search.length || normalizeSearch(group.name).includes(search))); + && (!search.length || matchesSearch(group, search))); }, [groupList, query, sourceFilter, recipeFilter, onlyIncomplete]); - const incompleteCount = groupList.filter(group => isIncomplete(group)).length; const filtered = !!query.trim().length || sourceFilter !== SourceFilter.VSE || recipeFilter !== RecipeFilter.VSE; return ( @@ -115,7 +119,7 @@ const MealGroupsPage = () => {
setQuery(event.target.value)} /> { setOnlyIncomplete(event.target.checked)} /> + {!loading && ( + + Zobrazeno {visibleList.length === groupList.length + ? groupList.length + : `${visibleList.length} z ${groupList.length}`} + + )}
{loading && ( diff --git a/server/src/mealTemplates.ts b/server/src/mealTemplates.ts index 3b1c656..e0ae9fc 100644 --- a/server/src/mealTemplates.ts +++ b/server/src/mealTemplates.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'crypto'; -import { MealTemplate, MealType, RecipeIngredient } from "../../types/gen/types.gen"; +import { MealTemplate, MealType, RecipeFlavor, RecipeIngredient } from "../../types/gen/types.gen"; import getStorage from "./storage"; import { addMeal, getNameSourceKey, isMealType } from "./meals"; import { rememberCalories } from "./calories"; @@ -22,8 +22,9 @@ const getKey = (login: string): string => `mealTemplates:${login}`; * i šablony hotových jídel (druh `HOTOVE`, nebo bez druhu), recepty nesly * `kind: 'RECEPT'` a měly i chod. */ -type StoredMealTemplate = Omit & { +type StoredMealTemplate = Omit & { ingredients?: RecipeIngredient[]; + flavor?: RecipeFlavor; kind?: string; pricePer100g?: number; mealType?: MealType; @@ -37,12 +38,28 @@ 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, a zaniklý chod. */ +/** + * Odstraní z receptu pole, která patřila jen šablonám hotových jídel, a zaniklý chod. + * Slaný recept je výchozí stav a v úložišti chuť nemá, doplní se tady. + */ const toMealTemplate = (stored: StoredRecipe): MealTemplate => { - const { kind, pricePer100g, mealType, ...result } = stored; - return result; + const { kind, pricePer100g, mealType, ...rest } = stored; + return { ...rest, flavor: rest.flavor ?? RecipeFlavor.SLANE }; }; +/** Chuť receptu ze vstupu; bez ní slaný. */ +const normalizeFlavor = (value: unknown): RecipeFlavor => { + if (value == null) return RecipeFlavor.SLANE; + if (value !== RecipeFlavor.SLANE && value !== RecipeFlavor.SLADKE) { + throw new BadRequestError("Nebyla předána platná chuť receptu"); + } + return value; +}; + +/** Recept tak, jak se ukládá — výchozí slanou chuť neukládá. */ +const toStored = ({ flavor, ...rest }: MealTemplate): StoredMealTemplate => + flavor === RecipeFlavor.SLANE ? rest : { ...rest, flavor }; + /** Načte recepty z úložiště; šablony hotových jídel vynechá. */ const loadRecipes = (storedList: StoredMealTemplate[] | undefined): MealTemplate[] => (storedList ?? []).filter(isRecipe).map(toMealTemplate); @@ -214,6 +231,8 @@ export const saveMealTemplate = async (login: string, input: unknown): Promise(getKey(login), current => [ - ...loadRecipes(current).filter(item => item.id !== template.id), - template, + ...loadRecipes(current).filter(item => item.id !== template.id).map(toStored), + toStored(template), ]); return getMealTemplates(login); }; @@ -256,7 +275,7 @@ export const deleteMealTemplate = async (login: string, id: unknown): Promise(getKey(login), current => { const recipeList = loadRecipes(current); found = recipeList.some(item => item.id === id); - return recipeList.filter(item => item.id !== id); + return recipeList.filter(item => item.id !== id).map(toStored); }); if (!found) { throw new NotFoundError("Recept nebyl nalezen"); @@ -329,5 +348,5 @@ export const renameMealTemplate = async ( 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)); + loadRecipes(current).map(item => toStored(item.id === recipe.id ? renamed : item))); }; diff --git a/server/src/tests/mealGroupExtras.test.ts b/server/src/tests/mealGroupExtras.test.ts index 1f4b6b2..fed3ba4 100644 --- a/server/src/tests/mealGroupExtras.test.ts +++ b/server/src/tests/mealGroupExtras.test.ts @@ -5,7 +5,7 @@ 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"; +import { MealType, RecipeFlavor } from "../../../types/gen/types.gen"; const LOGIN = 'tester'; const IMAGE = 'data:image/jpeg;base64,AAAA'; @@ -62,6 +62,25 @@ describe('recept jídla v seznamu jídel', () => { expect(await getMealPhotos(LOGIN, 'Rizoto', 'doma')).toHaveLength(0); }); + it('recept je bez zadané chuti slaný a typ i chuť přežijí přejmenování', async () => { + const [created] = await saveMealTemplate(LOGIN, rizoto); + expect(created).toMatchObject({ flavor: RecipeFlavor.SLANE }); + expect(created).not.toHaveProperty('category'); + + const [saved] = await saveMealTemplate(LOGIN, { + ...rizoto, id: created.id, category: ' Pečení ', flavor: RecipeFlavor.SLADKE, + }); + await renameMealGroup(LOGIN, 'Rizoto', 'doma', 'Sladké rizoto', 'doma'); + + expect(await getMealTemplates(LOGIN)).toMatchObject([ + { id: saved.id, name: 'Sladké rizoto', category: 'Pečení', flavor: RecipeFlavor.SLADKE }, + ]); + }); + + it('odmítne neznámou chuť receptu', async () => { + await expect(saveMealTemplate(LOGIN, { ...rizoto, flavor: 'KYSELE' })).rejects.toThrow(/chuť/); + }); + it('dvě jídla s recepty přejmenováním nesloučí', async () => { await saveMealTemplate(LOGIN, rizoto); await saveMealTemplate(LOGIN, { ...rizoto, name: 'Rizoto 2' }); diff --git a/types/schemas/_index.yml b/types/schemas/_index.yml index 8fc04ad..37f9954 100644 --- a/types/schemas/_index.yml +++ b/types/schemas/_index.yml @@ -1161,6 +1161,7 @@ MealTemplate: - id - name - ingredients + - flavor properties: id: type: string @@ -1170,6 +1171,13 @@ MealTemplate: source: description: Zdroj jídla, typicky "doma" type: string + category: + description: | + Typ receptu (polévka, omáčka, pečení, ...). Volný text — nabízí se + typy z ostatních receptů, ale zapsat jde i nový. + type: string + flavor: + $ref: "#/RecipeFlavor" calories: description: Energetická hodnota jedné porce v kcal type: integer @@ -1217,6 +1225,11 @@ MealTemplateInput: type: string source: type: string + category: + type: string + flavor: + description: Bez něj slané + $ref: "#/RecipeFlavor" note: type: string ingredients: @@ -1228,6 +1241,13 @@ MealTemplateInput: cookedWeight: type: integer +RecipeFlavor: + description: Zda je recept slaný, nebo sladký + type: string + enum: + - SLANE + - SLADKE + ExtraWeightGrams: description: | Dodatečná zátěž v gramech (činky, zátěžová vesta). Eviduje se jako