diff --git a/CLAUDE.md b/CLAUDE.md index a7089f2..9777868 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -330,7 +330,7 @@ psát jde i nový. vařené jídlo se surovinami (`ingredients`), počtem porcí (`servings`), hmotností 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 +mít můžou, `toOwnRecipe` 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 @@ -338,8 +338,27 @@ surovin — vařením se voda odpaří nebo nasákne. Postup se do záznamu dne 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` +v API vždy vyplněná; slaná je výchozí a do úložiště se nezapisuje, `toOwnRecipe` ji při čtení doplní. +Volitelný je i **čas přípravy** (`prepMinutes`, v minutách). + +**Jednotky surovin:** surovina má množství v gramech (`weight`), nebo v jiné +jednotce (`amount` + `unit`, `IngredientUnit`: ks, ml, l, lžíce…) — vejce +na kusy, mléko v litrech. U jiné jednotky je `weight` jen nepovinný **přepočet +na gramy pro výživu**; energie i hmotnost jídla se počítají vždy z gramů +a surovina bez přepočtu do nich nevstupuje. Hustotu ani hmotnost kusu aplikace +neodhaduje, bylo by to vymyšlené číslo. Bez jednotky se `amount` neukládá. + +**Vazby mezi recepty:** přílohy (`sideDishIds`, směrové — ukládají se u hlavního +jídla, druhá strana je `sideDishOfIds`) a podobné (`similarIds`, oboustranné, +uložené jen u jednoho z dvojice). Druhou stranu dopočte při čtení `withLinks`, +do úložiště se nezapisuje. Uložení receptu je pro všechny vazby, které se ho +týkají, autoritativní (`relinkOther` je odebere ostatním a znovu zapíše jen +podle vstupu), takže vazbu jde přidat i odebrat z kteréhokoli z obou receptů. +Smazání receptu vazby na něj odklidí. Karta receptu vazby ukazuje jako odkazy +(v seznamu jídel i na kole štěstí), PDF jako odkazy na stránku receptu, pokud +je v tomtéž exportu. + 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/Utils.tsx b/client/src/Utils.tsx index 569f17f..35b1f6f 100644 --- a/client/src/Utils.tsx +++ b/client/src/Utils.tsx @@ -1,5 +1,5 @@ -import { MealSuggestion, MealTemplate, Nutrients } from "../../types"; -import { NUTRIENTS_IN_ORDER } from "./enums"; +import { MealSuggestion, MealTemplate, Nutrients, RecipeIngredient } from "../../types"; +import { INGREDIENT_UNIT_FORMS, NUTRIENTS_IN_ORDER } from "./enums"; const TOKEN_KEY = "token"; @@ -338,6 +338,45 @@ export const deriveRecipe = ( }; }; +/** Převede množství suroviny zadané uživatelem (i s desetinnou čárkou) na číslo; nekladný vstup odmítne. */ +export const parseAmountInput = (value: string): number | undefined => { + const trimmed = value.trim().replace(',', '.'); + if (!trimmed.length) return undefined; + const parsed = Number(trimmed); + return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed * 100) / 100 : undefined; +}; + +/** Převede množství suroviny na text pro editační pole. */ +export const formatAmountInput = (amount?: number): string => + amount == null ? '' : String(amount).replace('.', ','); + +/** + * Naformátuje množství suroviny receptu: "200 g", "3 ks (180 g)", "špetka". + * Gramáž u jiné jednotky je v závorce — je to přepočet pro výživu, ne údaj receptu. + */ +export const formatIngredientAmount = (ingredient: RecipeIngredient): string => { + const { amount, unit, weight } = ingredient; + if (!unit) return weight != null ? formatWeight(weight) : ''; + const [one, few, many, fraction] = INGREDIENT_UNIT_FORMS[unit]; + let text = one; + if (amount != null) { + let form = many; + if (!Number.isInteger(amount)) form = fraction; + else if (amount === 1) form = one; + else if (amount >= 2 && amount <= 4) form = few; + text = `${amount.toLocaleString('cs-CZ', { maximumFractionDigits: 2 })} ${form}`; + } + return weight != null ? `${text} (${formatWeight(weight)})` : text; +}; + +/** Naformátuje čas přípravy z minut, např. 90 → "1 h 30 min". */ +export const formatPrepTime = (minutes: number): string => { + const hours = Math.floor(minutes / 60); + const rest = minutes % 60; + if (!hours) return `${rest} min`; + return rest ? `${hours} h ${rest} min` : `${hours} h`; +}; + /** Znormalizuje text pro hledání — bez diakritiky a malými písmeny. */ export const normalizeSearch = (value: string): string => value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim(); diff --git a/client/src/components/MealGroupRecipe.tsx b/client/src/components/MealGroupRecipe.tsx index a13313e..424d4e1 100644 --- a/client/src/components/MealGroupRecipe.tsx +++ b/client/src/components/MealGroupRecipe.tsx @@ -1,11 +1,14 @@ import { useState } from "react"; -import { MealGroup, deleteMealTemplate } from "../../../types"; +import { MealGroup, MealTemplate, deleteMealTemplate } from "../../../types"; import MealTemplateCard from "./MealTemplateCard"; import MealTemplateForm from "./MealTemplateForm"; import { useRecipeExport } from "../useRecipeExport"; type Props = { group: MealGroup, + /** Všechny recepty — pro vazby na jiné recepty */ + recipeList: MealTemplate[], + onOpenRecipe: (recipe: MealTemplate) => void, /** Zavolá se po uložení nebo smazání receptu, aby se seznam načetl znovu. */ onChanged: () => void, onClose: () => void, @@ -15,7 +18,7 @@ type Props = { * 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 MealGroupRecipe = ({ group, recipeList, onOpenRecipe, onChanged, onClose }: Readonly) => { const [editing, setEditing] = useState(!group.recipe); const [busy, setBusy] = useState(false); const { exporting, exportRecipes } = useRecipeExport(); @@ -33,6 +36,8 @@ const MealGroupRecipe = ({ group, onChanged, onClose }: Readonly) => { exportRecipes([{ recipe: group.recipe!, photoCount: group.photoCount }])} onEdit={() => setEditing(true)} onDelete={() => remove(group.recipe!.id)} diff --git a/client/src/components/MealGroupRow.tsx b/client/src/components/MealGroupRow.tsx index e16b5ae..ae788eb 100644 --- a/client/src/components/MealGroupRow.tsx +++ b/client/src/components/MealGroupRow.tsx @@ -2,9 +2,9 @@ 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, RecipeFlavor } from "../../../types"; +import { MealGroup, MealTemplate, RecipeFlavor } from "../../../types"; import { MealGroupAction, RECIPE_FLAVOR_NAMES } from "../enums"; -import { formatDateString } from "../Utils"; +import { formatDateString, getMealKey } from "../Utils"; import MealGroupForm from "./MealGroupForm"; import MealGroupRenameForm from "./MealGroupRenameForm"; import MealGroupDays from "./MealGroupDays"; @@ -15,6 +15,10 @@ type Props = { group: MealGroup, /** Všechny skupiny — přejmenování podle nich pozná sloučení s jinou. */ groupList: MealGroup[], + /** Všechny recepty — pro vazby receptu na jiné */ + recipeList: MealTemplate[], + /** Otevře recept jiného jídla — z vazby receptu */ + onOpenRecipe: (recipe: MealTemplate) => void, /** Rozbalená akce; nejvýš jeden řádek seznamu má nějakou otevřenou. */ action?: MealGroupAction, onAction: (action?: MealGroupAction) => void, @@ -33,11 +37,13 @@ const describeMissing = (missing: number, count: number): string => * 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, onRefresh }: Readonly) => { +const MealGroupRow = ({ + group, groupList, recipeList, onOpenRecipe, action, onAction, onChanged, onRefresh, +}: Readonly) => { const [daysOpen, setDaysOpen] = useState(false); const toggle = (value: MealGroupAction) => onAction(action === value ? undefined : value); return ( -
+
{group.thumbnail && (
+ {recipeList && } + {template.note && }
); diff --git a/client/src/components/MealTemplateForm.tsx b/client/src/components/MealTemplateForm.tsx index f0b04c6..5db2781 100644 --- a/client/src/components/MealTemplateForm.tsx +++ b/client/src/components/MealTemplateForm.tsx @@ -1,11 +1,12 @@ -import { useId, useState } from "react"; +import { useEffect, 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 { MealTemplate, RecipeFlavor, getMealTemplates, saveMealTemplate } from "../../../types"; +import { RECIPE_FLAVOR_NAMES, RECIPE_LINK_FIELDS, RECIPE_LINK_NAMES, RecipeLinkKind, getRecipeLinkIds } from "../enums"; +import { deriveRecipe, formatCalories, formatPrepTime, formatPrice, formatWeight, parseIntInput } from "../Utils"; import SourceInput from "./SourceInput"; import RecipeCategoryInput from "./RecipeCategoryInput"; import RecipeNote from "./RecipeNote"; +import RecipeLinksInput from "./RecipeLinksInput"; import RecipeIngredientsEditor, { DraftIngredient, emptyIngredient, toDraftIngredient, toIngredient, } from "./RecipeIngredientsEditor"; @@ -34,11 +35,34 @@ const createForm = (template?: MealTemplate, meal?: Props['meal']) => ({ note: template?.note ?? '', servings: template?.servings != null ? String(template.servings) : '', cookedWeight: template?.cookedWeight != null ? String(template.cookedWeight) : '', + prepMinutes: template?.prepMinutes != null ? String(template.prepMinutes) : '', }); +/** Vazby receptu podle druhu, jak se editují ve formuláři. */ +const createLinks = (template?: MealTemplate): Record => ({ + PRILOHY: template ? getRecipeLinkIds(template, RecipeLinkKind.PRILOHY) : [], + PRILOHOU_K: template ? getRecipeLinkIds(template, RecipeLinkKind.PRILOHOU_K) : [], + PODOBNE: template ? getRecipeLinkIds(template, RecipeLinkKind.PODOBNE) : [], +}); + +/** Jídlo nemůže být zároveň přílohou receptu i jídlem, ke kterému je recept přílohou. */ +const getOppositeLinkIds = (links: Record, kind: RecipeLinkKind): string[] => { + if (kind === RecipeLinkKind.PRILOHY) return links.PRILOHOU_K; + if (kind === RecipeLinkKind.PRILOHOU_K) return links.PRILOHY; + return []; +}; + +/** Výzva v nabídce receptů k propojení podle druhu vazby. */ +const LINK_PLACEHOLDERS: Record = { + PRILOHY: 'Přidat přílohu…', + PRILOHOU_K: 'Přidat jídlo, ke kterému se hodí…', + PODOBNE: 'Přidat podobný recept…', +}; + /** * 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. + * Vazby na jiné recepty jdou zapsat z kterékoli strany, server upraví i druhou. */ const MealTemplateForm = ({ template, meal, onSaved, onCancel }: Readonly) => { const [form, setForm] = useState(() => createForm(template, meal)); @@ -47,14 +71,22 @@ const MealTemplateForm = ({ template, meal, onSaved, onCancel }: Readonly const [error, setError] = useState(); const [busy, setBusy] = useState(false); const [notePreview, setNotePreview] = useState(false); + const [links, setLinks] = useState(() => createLinks(template)); + const [otherRecipeList, setOtherRecipeList] = useState([]); const flavorId = useId(); + useEffect(() => { + getMealTemplates().then(response => + setOtherRecipeList((response.data ?? []).filter(recipe => recipe.id !== template?.id))); + }, [template?.id]); + const update = (changes: Partial>) => setForm(current => ({ ...current, ...changes })); const filledIngredients = ingredients.filter(item => item.name.trim().length > 0).map(toIngredient); const recipe = deriveRecipe(filledIngredients, parseIntInput(form.servings), parseIntInput(form.cookedWeight)); const ingredientWeight = filledIngredients.reduce((sum, item) => sum + (item.weight ?? 0), 0); + const prepMinutes = parseIntInput(form.prepMinutes); const canSave = form.name.trim().length > 0 && filledIngredients.length > 0 && !busy; @@ -73,6 +105,8 @@ const MealTemplateForm = ({ template, meal, onSaved, onCancel }: Readonly ingredients: filledIngredients, servings: parseIntInput(form.servings), cookedWeight: parseIntInput(form.cookedWeight), + prepMinutes, + ...Object.fromEntries(Object.values(RecipeLinkKind).map(kind => [RECIPE_LINK_FIELDS[kind], links[kind]])), }; const response = await saveMealTemplate({ body }); setBusy(false); @@ -111,11 +145,24 @@ const MealTemplateForm = ({ template, meal, onSaved, onCancel }: Readonly )}
-
+
Typ update({ category })} />
-
+
+ Čas přípravy +
+ update({ prepMinutes: event.target.value.replace(/\D/g, '') })} + /> + min +
+ {!!prepMinutes && prepMinutes >= 60 && {formatPrepTime(prepMinutes)}} +
+
Chuť
+ {!!otherRecipeList.length && ( +
+ Souvisí s + {Object.values(RecipeLinkKind).map(kind => ( +
+ {RECIPE_LINK_NAMES[kind]} +
+ + !getOppositeLinkIds(links, kind).includes(recipe.id))} + placeholder={LINK_PLACEHOLDERS[kind]} + onChange={value => setLinks(current => ({ ...current, [kind]: value }))} + /> +
+
+ ))} +
+ )} +
Postup diff --git a/client/src/components/RecipeIngredientsEditor.tsx b/client/src/components/RecipeIngredientsEditor.tsx index 7c0ce03..f5d4b01 100644 --- a/client/src/components/RecipeIngredientsEditor.tsx +++ b/client/src/components/RecipeIngredientsEditor.tsx @@ -1,12 +1,21 @@ import { Button, Form } from "react-bootstrap"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; -import { RecipeIngredient } from "../../../types"; -import { formatCalories, formatPriceInput, getIngredientCalories, parseIntInput, parsePriceInput } from "../Utils"; +import { IngredientUnit, RecipeIngredient } from "../../../types"; +import { INGREDIENT_UNIT_FORMS, INGREDIENT_UNITS_IN_ORDER } from "../enums"; +import { + formatAmountInput, formatCalories, formatPriceInput, getIngredientCalories, parseAmountInput, parseIntInput, + parsePriceInput, +} from "../Utils"; -/** Rozpracovaná surovina — hodnoty jsou texty, protože se editují v polích. */ +/** + * Rozpracovaná surovina — hodnoty jsou texty, protože se editují v polích. + * Prázdná jednotka znamená gramy; množstvím je pak `weight`. + */ export type DraftIngredient = { name: string, + amount: string, + unit: IngredientUnit | '', weight: string, caloriesPer100g: string, calories: string, @@ -15,6 +24,8 @@ export type DraftIngredient = { export const emptyIngredient = (): DraftIngredient => ({ name: '', + amount: '', + unit: '', weight: '', caloriesPer100g: '', calories: '', @@ -24,6 +35,8 @@ export const emptyIngredient = (): DraftIngredient => ({ /** Převede uloženou surovinu na rozpracovanou pro editaci. */ export const toDraftIngredient = (ingredient: RecipeIngredient): DraftIngredient => ({ name: ingredient.name, + amount: formatAmountInput(ingredient.amount), + unit: ingredient.unit ?? '', weight: ingredient.weight != null ? String(ingredient.weight) : '', caloriesPer100g: ingredient.caloriesPer100g != null ? String(ingredient.caloriesPer100g) : '', calories: ingredient.calories != null ? String(ingredient.calories) : '', @@ -33,12 +46,26 @@ export const toDraftIngredient = (ingredient: RecipeIngredient): DraftIngredient /** Převede rozpracovanou surovinu na hodnoty pro výpočet a uložení. */ export const toIngredient = (draft: DraftIngredient): RecipeIngredient => ({ name: draft.name.trim(), + ...(draft.unit ? { amount: parseAmountInput(draft.amount), unit: draft.unit } : {}), weight: parseIntInput(draft.weight), caloriesPer100g: parseIntInput(draft.caloriesPer100g), calories: parseIntInput(draft.calories), price: parsePriceInput(draft.price), }); +/** + * Změna jednotky přesune zapsané číslo do pole, které nově znamená množství — + * typicky se napíše "3" a teprve pak vybere "ks". Gramáž u jiné jednotky je jen + * přepočet pro výživu, takže při návratu ke gramům zůstane, pokud je vyplněná. + */ +const changeUnit = (ingredient: DraftIngredient, unit: IngredientUnit | ''): Partial => { + if (!ingredient.unit && unit) return { unit, amount: ingredient.weight, weight: '' }; + if (ingredient.unit && !unit) { + return { unit, amount: '', weight: ingredient.weight || String(parseIntInput(ingredient.amount) ?? '') }; + } + return { unit }; +}; + type Props = { ingredients: DraftIngredient[], onChange: (ingredients: DraftIngredient[]) => void, @@ -47,6 +74,9 @@ type Props = { /** * Editor surovin receptu. * + * Množství je v gramech, nebo v jiné jednotce (vejce na kusy, mléko v litrech). + * U jiné jednotky jde zvlášť zapsat přepočet na gramy — z něj se počítá energie + * a hmotnost jídla, recept ale ukazuje množství tak, jak se odměřuje. * Energie suroviny se dopočte z gramáže a hodnoty na 100 g; ručně jde zadat jen * tam, kde dopočítat nejde (třeba „lžíce oleje" bez vážení). */ @@ -70,16 +100,48 @@ const RecipeIngredientsEditor = ({ ingredients, onChange }: Readonly) => aria-label="Název suroviny" onChange={event => updateAt(index, { name: event.target.value })} /> -
- updateAt(index, { weight: event.target.value.replace(/\D/g, '') })} - /> - g +
+ {ingredient.unit ? ( + updateAt(index, { amount: event.target.value.replace(/[^\d,.]/g, '') })} + /> + ) : ( + updateAt(index, { weight: event.target.value.replace(/\D/g, '') })} + /> + )} + updateAt(index, changeUnit(ingredient, event.target.value as IngredientUnit | ''))} + > + + {INGREDIENT_UNITS_IN_ORDER.map(unit => ( + + ))} +
+ {ingredient.unit && ( +
+ updateAt(index, { weight: event.target.value.replace(/\D/g, '') })} + /> + g +
+ )} void, +}; + +/** Vazby receptu na jiné recepty — přílohy, k čemu je přílohou a podobné. */ +const RecipeLinks = ({ template, recipeList, onOpen }: Readonly) => { + const rowList = Object.values(RecipeLinkKind) + .map(kind => ({ + kind, + linkedList: getRecipeLinkIds(template, kind) + .map(id => recipeList.find(recipe => recipe.id === id)) + .filter(recipe => !!recipe), + })) + .filter(row => row.linkedList.length); + if (!rowList.length) return null; + + return ( +
+ {rowList.map(({ kind, linkedList }) => ( +
+ {RECIPE_LINK_NAMES[kind]}: + {linkedList.map(recipe => onOpen ? ( + + ) : ( + {recipe.name} + ))} +
+ ))} +
+ ); +}; + +export default RecipeLinks; diff --git a/client/src/components/RecipeLinksInput.tsx b/client/src/components/RecipeLinksInput.tsx new file mode 100644 index 0000000..7058f58 --- /dev/null +++ b/client/src/components/RecipeLinksInput.tsx @@ -0,0 +1,55 @@ +import { Form } from "react-bootstrap"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faXmark } from "@fortawesome/free-solid-svg-icons"; +import { MealTemplate } from "../../../types"; + +type Props = { + /** Vybrané recepty */ + value: string[], + /** Recepty, ze kterých jde vybírat — bez upravovaného receptu */ + recipeList: MealTemplate[], + placeholder: string, + onChange: (value: string[]) => void, +}; + +/** Výběr propojených receptů — vybrané jako štítky s odebráním, další přes rozbalovací seznam. */ +const RecipeLinksInput = ({ value, recipeList, placeholder, onChange }: Readonly) => { + const selectedList = recipeList.filter(recipe => value.includes(recipe.id)); + const offeredList = recipeList.filter(recipe => !value.includes(recipe.id)); + + return ( +
+ {selectedList.map(recipe => ( + + {recipe.name} + + + ))} + {!!offeredList.length && ( + event.target.value && onChange([...value, event.target.value])} + > + + {offeredList.map(recipe => ( + + ))} + + )} +
+ ); +}; + +export default RecipeLinksInput; diff --git a/client/src/components/WheelRecipe.tsx b/client/src/components/WheelRecipe.tsx index 5d89719..70fe374 100644 --- a/client/src/components/WheelRecipe.tsx +++ b/client/src/components/WheelRecipe.tsx @@ -6,18 +6,23 @@ import MealTemplateCard from "./MealTemplateCard"; type Props = { group: MealGroup & { recipe: MealTemplate }, + /** Všechny recepty — pro vazby na jiné recepty */ + recipeList: MealTemplate[], + onOpenRecipe: (recipe: MealTemplate) => void, }; /** * Recept jídla, které padlo na kole štěstí — jen k nahlédnutí, ať se podle * něj dá rovnou vařit. Upravuje se v seznamu jídel. */ -const WheelRecipe = ({ group }: Readonly) => { +const WheelRecipe = ({ group, recipeList, onOpenRecipe }: Readonly) => { const [photo, setPhoto] = useState(); const [loading, setLoading] = useState(group.photoCount > 0); const { exporting, exportRecipes } = useRecipeExport(); useEffect(() => { + setPhoto(undefined); + setLoading(group.photoCount > 0); if (!group.photoCount) return; getMealPhotos({ query: { name: group.name, source: group.source } }).then(response => { setPhoto(response.data?.[0]); @@ -32,6 +37,8 @@ const WheelRecipe = ({ group }: Readonly) => { exportRecipes([{ recipe: group.recipe, photoCount: group.photoCount }])} />
diff --git a/client/src/components/modals/SpinWheelModal.tsx b/client/src/components/modals/SpinWheelModal.tsx index 3020a33..fa8e986 100644 --- a/client/src/components/modals/SpinWheelModal.tsx +++ b/client/src/components/modals/SpinWheelModal.tsx @@ -93,6 +93,8 @@ const SpinWheelModal = ({ isOpen, onClose }: Readonly) => { const [excludedKeys, setExcludedKeys] = useState>(new Set()); const [picked, setPicked] = useState(); const [recipeShown, setRecipeShown] = useState(false); + // Recept otevřený z vazby padlého receptu (příloha, podobný) + const [linkedKey, setLinkedKey] = useState(); const [rotation, setRotation] = useState(0); const [spinning, setSpinning] = useState(false); const [respinRequested, setRespinRequested] = useState(false); @@ -134,9 +136,14 @@ const SpinWheelModal = ({ isOpen, onClose }: Readonly) => { const excludedCount = candidateList.length - wheelList.length; const onlyOne = wheelList.length === 1; + const showRecipe = (shown: boolean) => { + setRecipeShown(shown); + setLinkedKey(undefined); + }; + const clearResult = () => { setPicked(undefined); - setRecipeShown(false); + showRecipe(false); }; const changePool = (value: WheelPool) => { @@ -215,6 +222,10 @@ const SpinWheelModal = ({ isOpen, onClose }: Readonly) => { }; const pickedRecipe = picked?.group && hasRecipe(picked.group) ? picked.group : undefined; + const recipeGroupList = (groupList ?? []).filter(hasRecipe); + const shownRecipe = recipeShown + ? recipeGroupList.find(group => getMealKey(group) === linkedKey) ?? pickedRecipe + : undefined; const pickedSource = sourceStage ? picked : undefined; const renderWheel = () => { @@ -235,12 +246,18 @@ const SpinWheelModal = ({ isOpen, onClose }: Readonly) => { - {recipeShown && pickedRecipe ? pickedRecipe.name : 'Kolo štěstí: co dnes?'} + {shownRecipe ? shownRecipe.name : 'Kolo štěstí: co dnes?'} - {recipeShown && pickedRecipe ? : ( + {shownRecipe ? ( + group.recipe)} + onOpenRecipe={recipe => setLinkedKey(getMealKey(recipe))} + /> + ) : ( <>
@@ -372,10 +389,10 @@ const SpinWheelModal = ({ isOpen, onClose }: Readonly) => { {recipeShown && ( - + )} {!recipeShown && pickedRecipe && !spinning && ( - )} diff --git a/client/src/enums.ts b/client/src/enums.ts index 06367e8..54cf239 100644 --- a/client/src/enums.ts +++ b/client/src/enums.ts @@ -1,4 +1,6 @@ -import { ActivityUnit, BmiKategorie, ImportMode, MealType, Nutrients, Pohlavi, RecipeFlavor } from "../../types"; +import { + ActivityUnit, BmiKategorie, ImportMode, IngredientUnit, MealTemplate, MealType, Nutrients, Pohlavi, RecipeFlavor, +} from "../../types"; /** Lidsky čitelné názvy typů jídel. */ export const MEAL_TYPE_NAMES: Record = { @@ -193,6 +195,60 @@ export const RECIPE_FLAVOR_NAMES: Record = { SLADKE: 'Sladké', }; +/** + * Tvary jednotky suroviny za číslem: jedna, 2–4, 5 a víc, necelé množství + * ("1 lžíce", "3 lžíce", "5 lžic", "1,5 lžíce"). + */ +export const INGREDIENT_UNIT_FORMS: Record = { + KS: ['ks', 'ks', 'ks', 'ks'], + ML: ['ml', 'ml', 'ml', 'ml'], + L: ['l', 'l', 'l', 'l'], + LZICE: ['lžíce', 'lžíce', 'lžic', 'lžíce'], + LZICKA: ['lžička', 'lžičky', 'lžiček', 'lžičky'], + HRNEK: ['hrnek', 'hrnky', 'hrnků', 'hrnku'], + BALENI: ['balení', 'balení', 'balení', 'balení'], + SPETKA: ['špetka', 'špetky', 'špetek', 'špetky'], +}; + +/** Jednotky surovin v pořadí, v jakém se nabízejí. Gramy jsou výchozí a mezi nimi nejsou. */ +export const INGREDIENT_UNITS_IN_ORDER: IngredientUnit[] = [ + IngredientUnit.KS, + IngredientUnit.ML, + IngredientUnit.L, + IngredientUnit.LZICE, + IngredientUnit.LZICKA, + IngredientUnit.HRNEK, + IngredientUnit.BALENI, + IngredientUnit.SPETKA, +]; + +/** + * Druh vazby receptu na jiný recept. Přílohy a „přílohou k" jsou dvě strany + * téže vazby, podobnost je oboustranná. + */ +export enum RecipeLinkKind { + PRILOHY = 'PRILOHY', + PRILOHOU_K = 'PRILOHOU_K', + PODOBNE = 'PODOBNE', +} + +export const RECIPE_LINK_NAMES: Record = { + PRILOHY: 'Přílohy', + PRILOHOU_K: 'Přílohou k', + PODOBNE: 'Podobné', +}; + +/** Pole receptu, ve kterém API vazbu daného druhu nese. */ +export const RECIPE_LINK_FIELDS: Record = { + PRILOHY: 'sideDishIds', + PRILOHOU_K: 'sideDishOfIds', + PODOBNE: 'similarIds', +}; + +/** Recepty propojené s receptem vazbou daného druhu. */ +export const getRecipeLinkIds = (recipe: MealTemplate, kind: RecipeLinkKind): string[] => + recipe[RECIPE_LINK_FIELDS[kind]] ?? []; + /** 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.tsx b/client/src/pages/MealGroupsPage.tsx index 5fbbe67..75121bd 100644 --- a/client/src/pages/MealGroupsPage.tsx +++ b/client/src/pages/MealGroupsPage.tsx @@ -74,6 +74,7 @@ const MealGroupsPage = () => { (location.state as LocationState)?.recipeFilter ?? RecipeFilter.VSE); const [opened, setOpened] = useState<{ key: string, action: MealGroupAction } | undefined>(); const [newRecipeOpen, setNewRecipeOpen] = useState(false); + const [scrollKey, setScrollKey] = useState(); const { exporting, exportRecipes } = useRecipeExport(); const load = useCallback(async () => { @@ -85,6 +86,10 @@ const MealGroupsPage = () => { useEffect(() => { load(); }, [load]); const sourceList = useMemo(() => listSources(groupList), [groupList]); + const recipeList = useMemo( + () => groupList.flatMap(group => group.recipe ? [group.recipe] : []), + [groupList], + ); const visibleList = useMemo(() => { const search = normalizeSearch(query); @@ -100,6 +105,27 @@ const MealGroupsPage = () => { .filter((group): group is MealGroup & { recipe: MealTemplate } => !!group.recipe) .map(group => ({ recipe: group.recipe, photoCount: group.photoCount })), [visibleList]); + // Recept otevřený z vazby se odroluje do pohledu, až se jeho řádek vykreslí + useEffect(() => { + if (!scrollKey) return; + document.querySelector(`[data-meal-key="${CSS.escape(scrollKey)}"]`) + ?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + setScrollKey(undefined); + }, [scrollKey]); + + /** Otevře recept jiného jídla; když ho filtry skrývají, zruší je. */ + const openRecipe = (recipe: MealTemplate) => { + const key = getMealKey(recipe); + if (!visibleList.some(group => getMealKey(group) === key)) { + setQuery(''); + setSourceFilter(SourceFilter.VSE); + setRecipeFilter(RecipeFilter.VSE); + setOnlyIncomplete(false); + } + setOpened({ key, action: MealGroupAction.RECEPT }); + setScrollKey(key); + }; + const filtered = !!query.trim().length || sourceFilter !== SourceFilter.VSE || recipeFilter !== RecipeFilter.VSE; return ( @@ -196,6 +222,8 @@ const MealGroupsPage = () => { key={key} group={group} groupList={groupList} + recipeList={recipeList} + onOpenRecipe={recipe => openRecipe(recipe)} action={opened?.key === key ? opened.action : undefined} onAction={action => setOpened(action ? { key, action } : undefined)} onChanged={() => { setOpened(undefined); load(); }} diff --git a/client/src/pages/templates.scss b/client/src/pages/templates.scss index 015b193..9e18201 100644 --- a/client/src/pages/templates.scss +++ b/client/src/pages/templates.scss @@ -160,8 +160,24 @@ .recipe-ingredient-row { align-items: center; + // Množství s výběrem jednotky + .recipe-ingredient-amount { + width: 160px; + flex: 0 0 auto; + + .form-control { + min-width: 0; + } + + .form-select { + flex: 0 0 82px; + padding-left: 8px; + } + } + + // Přepočet na gramy u jiné jednotky .recipe-ingredient-weight { - width: 110px; + width: 90px; flex: 0 0 auto; } @@ -187,6 +203,7 @@ @media (max-width: 640px) { flex-wrap: wrap; + .recipe-ingredient-amount, .recipe-ingredient-weight, .recipe-ingredient-rate, .recipe-ingredient-calories, @@ -208,6 +225,64 @@ } } +// Vazby receptu na jiné recepty — ve formuláři výběr, na kartě odkazy +.recipe-links { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 10px; + font-size: 0.87rem; + + .recipe-links-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + } + + .recipe-links-label { + color: var(--ft-text-muted); + font-weight: 600; + } +} + +.recipe-link { + border: none; + background: var(--ft-primary-light); + color: var(--ft-primary); + border-radius: 999px; + padding: 2px 10px; + font-size: 0.8rem; + font-weight: 600; + + &:is(button):hover { + filter: brightness(0.95); + text-decoration: underline; + } + + .recipe-link-remove { + margin-left: 6px; + border: none; + background: none; + color: inherit; + padding: 0; + line-height: 1; + } +} + +.recipe-links-input { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + + .form-select { + width: auto; + min-width: 180px; + flex: 1 1 180px; + } +} + // Popisek postupu s přepínačem psaní a náhledu .recipe-note-label { display: flex; diff --git a/client/src/recipePdf.ts b/client/src/recipePdf.ts index 7c9cc88..f85af6d 100644 --- a/client/src/recipePdf.ts +++ b/client/src/recipePdf.ts @@ -1,9 +1,10 @@ import type { Content, TDocumentDefinitions } from "pdfmake/interfaces"; -import { MealGroup, MealPhoto, MealTemplate, getMealPhotos } from "../../types"; -import { RECIPE_FLAVOR_NAMES } from "./enums"; +import { MealGroup, MealPhoto, MealTemplate, getMealPhotos, getMealTemplates } from "../../types"; +import { RECIPE_FLAVOR_NAMES, RECIPE_LINK_NAMES, RecipeLinkKind, getRecipeLinkIds } from "./enums"; import { convertMarkdownToPdf } from "./markdownPdf"; import { - deriveRecipe, formatCalories, formatCount, formatDate, formatDateString, formatPrice, formatWeight, getIngredientCalories, + deriveRecipe, formatCalories, formatCount, formatDate, formatDateString, formatIngredientAmount, formatPrepTime, + formatPrice, formatWeight, getIngredientCalories, } from "./Utils"; /** Recept s počtem fotek jídla — fotky se stahují jen tam, kde nějaké jsou. */ @@ -27,6 +28,7 @@ const getRecipeMeta = (recipe: MealTemplate): string => { return [ recipe.category, RECIPE_FLAVOR_NAMES[recipe.flavor].toLowerCase(), + recipe.prepMinutes != null ? `příprava ${formatPrepTime(recipe.prepMinutes)}` : undefined, formatCount(recipe.servings ?? 1, 'porce', 'porce', 'porcí'), recipe.weight != null ? `porce ${formatWeight(recipe.weight)}` : undefined, recipe.calories != null ? `${formatCalories(recipe.calories)} na porci` : undefined, @@ -34,6 +36,33 @@ const getRecipeMeta = (recipe: MealTemplate): string => { ].filter(item => !!item).join(' · '); }; +/** Kotva receptu v dokumentu — cíl odkazů z obsahu a z vazeb mezi recepty. */ +const getRecipeAnchor = (index: number): string => `recept-${index}`; + +/** + * Vazby na jiné recepty po řádcích podle druhu. Recept, který je v tomtéž PDF, + * je odkaz na jeho stránku. + * + * @param recipe recept, jehož vazby se vypisují + * @param recipeList všechny recepty uživatele — vazba může vést i mimo export + * @param indexMap pořadí exportovaných receptů podle id + */ +const createLinks = (recipe: MealTemplate, recipeList: MealTemplate[], indexMap: Map): Content[] => + Object.values(RecipeLinkKind).flatMap(kind => { + const linkedList = getRecipeLinkIds(recipe, kind) + .map(id => recipeList.find(item => item.id === id)) + .filter(item => !!item); + if (!linkedList.length) return []; + const nameList = linkedList.flatMap((linked, position) => { + const index = indexMap.get(linked.id); + const name = index != null + ? { text: linked.name, linkToDestination: getRecipeAnchor(index), color: PRIMARY_COLOR } + : { text: linked.name }; + return position ? [{ text: ', ' }, name] : [name]; + }); + return [{ text: [{ text: `${RECIPE_LINK_NAMES[kind]}: `, bold: true }, ...nameList], style: 'recipeLinks' }]; + }); + /** První fotka přes celou šířku, ostatní menší v řadách po třech. */ const createPhotos = (photoList: MealPhoto[]): Content[] => { const [first, ...rest] = photoList; @@ -63,7 +92,7 @@ const createIngredients = (recipe: MealTemplate): Content[] => { .map((text, index) => ({ text, style: 'tableHeader', alignment: index ? 'right' as const : 'left' as const })); const rowList = recipe.ingredients.map(item => [ item.name, - { text: item.weight != null ? formatWeight(item.weight) : '', alignment: 'right' as const }, + { text: formatIngredientAmount(item), alignment: 'right' as const }, { text: formatCalories(getIngredientCalories(item)), alignment: 'right' as const }, ...(hasPrice ? [{ text: item.price != null ? formatPrice(item.price) : '', alignment: 'right' as const }] : []), ]); @@ -78,7 +107,7 @@ const createIngredients = (recipe: MealTemplate): Content[] => { { table: { headerRows: 1, - widths: ['*', 70, 70, ...(hasPrice ? [70] : [])], + widths: ['*', 100, 70, ...(hasPrice ? [70] : [])], body: [header, ...rowList], }, layout: { @@ -93,16 +122,26 @@ const createIngredients = (recipe: MealTemplate): Content[] => { ]; }; -const createRecipe = (recipe: MealTemplate, photoList: MealPhoto[], index: number, withToc: boolean): Content => ({ +type RecipeContext = { + index: number, + withToc: boolean, + recipeList: MealTemplate[], + indexMap: Map, +}; + +const createRecipe = ( + recipe: MealTemplate, photoList: MealPhoto[], { index, withToc, recipeList, indexMap }: RecipeContext, +): Content => ({ stack: [ { text: recipe.name, style: 'recipeTitle', - id: `recept-${index}`, + id: getRecipeAnchor(index), tocItem: withToc, }, ...(recipe.source ? [{ text: recipe.source, style: 'recipeSource' }] : []), { text: getRecipeMeta(recipe), style: 'recipeMeta' }, + ...createLinks(recipe, recipeList, indexMap), ...createPhotos(photoList), ...createIngredients(recipe), ...(recipe.note?.trim() @@ -139,7 +178,13 @@ const loadPdfMake = async () => { */ export const exportRecipesPdf = async (itemList: RecipeItem[]): Promise => { const sortedList = [...itemList].sort((a, b) => a.recipe.name.localeCompare(b.recipe.name, 'cs')); - const [pdfMake, photoLists] = await Promise.all([loadPdfMake(), Promise.all(sortedList.map(loadPhotos))]); + const [pdfMake, photoLists, recipeResponse] = await Promise.all([ + loadPdfMake(), + Promise.all(sortedList.map(loadPhotos)), + getMealTemplates(), + ]); + const recipeList = recipeResponse.data ?? []; + const indexMap = new Map(sortedList.map((item, index) => [item.recipe.id, index])); const withToc = sortedList.length > 1; const title = withToc ? 'Recepty' : sortedList[0]?.recipe.name ?? 'Recept'; @@ -159,7 +204,11 @@ export const exportRecipesPdf = async (itemList: RecipeItem[]): Promise => info: { title }, pageSize: 'A4', pageMargins: [40, 40, 40, 50], - content: [...cover, ...sortedList.map((item, index) => createRecipe(item.recipe, photoLists[index], index, withToc))], + content: [ + ...cover, + ...sortedList.map((item, index) => + createRecipe(item.recipe, photoLists[index], { index, withToc, recipeList, indexMap })), + ], footer: (currentPage, pageCount) => ({ text: `${currentPage} / ${pageCount}`, alignment: 'center', @@ -172,6 +221,7 @@ export const exportRecipesPdf = async (itemList: RecipeItem[]): Promise => recipeTitle: { fontSize: 20, bold: true, color: PRIMARY_COLOR }, recipeSource: { fontSize: 11, color: MUTED_COLOR }, recipeMeta: { fontSize: 9, color: MUTED_COLOR, margin: [0, 4, 0, 12] }, + recipeLinks: { fontSize: 9, margin: [0, -8, 0, 12] }, sectionHeading: { fontSize: 13, bold: true, margin: [0, 10, 0, 6] }, tableHeader: { bold: true, fontSize: 9, color: MUTED_COLOR }, summary: { bold: true, fontSize: 9, color: MUTED_COLOR, margin: [0, 6, 0, 0] }, diff --git a/server/src/mealTemplates.ts b/server/src/mealTemplates.ts index e0ae9fc..fc7315a 100644 --- a/server/src/mealTemplates.ts +++ b/server/src/mealTemplates.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'crypto'; -import { MealTemplate, MealType, RecipeFlavor, RecipeIngredient } from "../../types/gen/types.gen"; +import { IngredientUnit, MealTemplate, MealType, RecipeFlavor, RecipeIngredient } from "../../types/gen/types.gen"; import getStorage from "./storage"; import { addMeal, getNameSourceKey, isMealType } from "./meals"; import { rememberCalories } from "./calories"; @@ -14,15 +14,34 @@ const MAX_NOTE_LENGTH = 4000; /** Maximální počet surovin receptu — pojistka proti nesmyslnému vstupu. */ const MAX_INGREDIENTS = 50; +/** Maximální množství suroviny v jiné jednotce než gramech. */ +const MAX_AMOUNT = 100000; + +/** Nejdelší čas přípravy v minutách (týden — kvašení, nakládání). */ +const MAX_PREP_MINUTES = 7 * 24 * 60; + +/** Maximální počet vazeb jednoho druhu u receptu. */ +const MAX_LINKS = 50; + +/** Vynechá nevyplněné hodnoty, aby se do úložiště nepsaly prázdné klíče. */ +const definedOnly = (value: T): T => + Object.fromEntries(Object.entries(value).filter(([, item]) => item != null)) as T; + /** Klíč úložiště s recepty jednoho uživatele. */ const getKey = (login: string): string => `mealTemplates:${login}`; +/** + * Recept s vazbami jen ze své strany, jak se ukládá. Přílohy nese hlavní jídlo + * a podobnost jen jeden recept z dvojice; druhou stranu dopočte `withLinks`. + */ +type OwnRecipe = Omit; + /** * 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), recepty nesly * `kind: 'RECEPT'` a měly i chod. */ -type StoredMealTemplate = Omit & { +type StoredMealTemplate = Omit & { ingredients?: RecipeIngredient[]; flavor?: RecipeFlavor; kind?: string; @@ -42,7 +61,7 @@ const isRecipe = (stored: StoredMealTemplate): stored is StoredRecipe => !!store * 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 toOwnRecipe = (stored: StoredRecipe): OwnRecipe => { const { kind, pricePer100g, mealType, ...rest } = stored; return { ...rest, flavor: rest.flavor ?? RecipeFlavor.SLANE }; }; @@ -56,13 +75,41 @@ const normalizeFlavor = (value: unknown): RecipeFlavor => { 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 }; +/** Seznam vazeb do úložiště i API — prázdný se vynechá. */ +const toLinkIds = (idList?: string[]): string[] | undefined => (idList?.length ? idList : undefined); + +/** Recept tak, jak se ukládá — výchozí slanou chuť ani prázdné vazby neukládá. */ +const toStored = ({ flavor, sideDishIds, similarIds, ...rest }: OwnRecipe): StoredMealTemplate => definedOnly({ + ...rest, + flavor: flavor === RecipeFlavor.SLANE ? undefined : flavor, + sideDishIds: toLinkIds(sideDishIds), + similarIds: toLinkIds(similarIds), +}); /** Načte recepty z úložiště; šablony hotových jídel vynechá. */ -const loadRecipes = (storedList: StoredMealTemplate[] | undefined): MealTemplate[] => - (storedList ?? []).filter(isRecipe).map(toMealTemplate); +const loadRecipes = (storedList: StoredMealTemplate[] | undefined): OwnRecipe[] => + (storedList ?? []).filter(isRecipe).map(toOwnRecipe); + +/** + * Doplní receptům druhou stranu vazeb: přílohu všem jídlům, která ji uvádějí, + * a podobnost oběma receptům dvojice. Vazby na smazané recepty vynechá, + * ostatní seřadí podle názvu receptu. + */ +const withLinks = (recipeList: OwnRecipe[]): MealTemplate[] => { + const nameMap = new Map(recipeList.map(recipe => [recipe.id, recipe.name])); + const sortByName = (idList: string[]): string[] | undefined => toLinkIds([...new Set(idList)] + .filter(id => nameMap.has(id)) + .sort((a, b) => nameMap.get(a)!.localeCompare(nameMap.get(b)!, 'cs'))); + const findLinking = (recipeId: string, getIds: (recipe: OwnRecipe) => string[] | undefined): string[] => + recipeList.filter(other => getIds(other)?.includes(recipeId)).map(other => other.id); + + return recipeList.map(recipe => definedOnly({ + ...recipe, + sideDishIds: sortByName(recipe.sideDishIds ?? []), + sideDishOfIds: sortByName(findLinking(recipe.id, other => other.sideDishIds)), + similarIds: sortByName([...recipe.similarIds ?? [], ...findLinking(recipe.id, other => other.similarIds)]), + })); +}; /** * Vrátí recepty uživatele seřazené podle názvu. @@ -70,7 +117,7 @@ const loadRecipes = (storedList: StoredMealTemplate[] | undefined): MealTemplate * @param login přihlašovací jméno uživatele */ export const getMealTemplates = async (login: string): Promise => - loadRecipes(await storage.getData(getKey(login))) + withLinks(loadRecipes(await storage.getData(getKey(login)))) .sort((a, b) => a.name.localeCompare(b.name, 'cs')); /** Hodnoty hotového jídla a jedné porce receptu dopočtené ze surovin. */ @@ -113,6 +160,9 @@ const sumDefined = (valueList: (number | undefined)[]): number | undefined => { * vařením se voda odpaří nebo nasákne (rýže, těstoviny), takže by vyšla špatně. * Porci si pak jde převážit a kalorie se dopočtou z gramáže jako u každého jídla. * + * Počítá se vždy z gramáže; surovina v jiné jednotce (2 ks vajec) do výpočtu + * vstupuje jen svým přepočtem na gramy, pokud ho má. + * * Klient má stejnou funkci v `Utils.tsx` pro živý náhled — musí dávat stejné výsledky. * * @param ingredients suroviny receptu @@ -169,9 +219,17 @@ const normalizeNumber = (value: unknown, name: string): number | undefined => { return rounded; }; -/** Vynechá nevyplněné hodnoty, aby se do úložiště nepsaly prázdné klíče. */ -const definedOnly = (value: T): T => - Object.fromEntries(Object.entries(value).filter(([, item]) => item != null)) as T; +const isIngredientUnit = (value: unknown): value is IngredientUnit => + Object.values(IngredientUnit).includes(value as IngredientUnit); + +/** Ověří volitelné kladné množství suroviny; necelé na setiny (0,25 l). */ +const normalizeAmount = (value: unknown, index: number): number | undefined => { + if (value == null) return undefined; + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > MAX_AMOUNT) { + throw new BadRequestError(`Surovina ${index + 1} nemá platné množství`); + } + return Math.round(value * 100) / 100; +}; /** Ověří a znormalizuje jednu surovinu receptu. */ const normalizeIngredient = (raw: unknown, index: number): RecipeIngredient => { @@ -180,12 +238,19 @@ const normalizeIngredient = (raw: unknown, index: number): RecipeIngredient => { if (!name) { throw new BadRequestError(`Surovina ${index + 1} nemá název`); } + if (value.unit != null && !isIngredientUnit(value.unit)) { + throw new BadRequestError(`Surovina ${index + 1} nemá platnou jednotku`); + } + const unit = value.unit ?? undefined; const weight = normalizeNumber(value.weight, 'weight'); const caloriesPer100g = normalizeNumber(value.caloriesPer100g, 'caloriesPer100g'); const calories = normalizeNumber(value.calories, 'calories'); const price = normalizeNumber(value.price, 'price'); return definedOnly({ name, + // Bez jednotky je množstvím gramáž, samostatné množství by ji jen zdvojovalo + amount: unit ? normalizeAmount(value.amount, index) : undefined, + unit, weight, caloriesPer100g, // Ručně zadaná energie platí jen tam, kde ji nejde dopočítat z gramáže @@ -194,8 +259,35 @@ const normalizeIngredient = (raw: unknown, index: number): RecipeIngredient => { }); }; +/** Ověří seznam propojených receptů — musí existovat a nesmí to být recept sám. */ +const normalizeLinkIds = (value: unknown, recipeIds: Set, selfId: string): string[] => { + if (value == null) return []; + if (!Array.isArray(value) || value.length > MAX_LINKS) { + throw new BadRequestError(`Recept může mít nejvýše ${MAX_LINKS} vazeb jednoho druhu`); + } + if (value.some(id => typeof id !== 'string' || id === selfId || !recipeIds.has(id))) { + throw new BadRequestError("Propojený recept nebyl nalezen"); + } + return [...new Set(value as string[])]; +}; + +/** + * Upraví vazby jiného receptu na recept `recipeId`. Vazby, které se ho týkají, + * určuje jen on: odeberou se a přílohou se jiný recept znovu stane jen tehdy, + * když je v `sideDishOfIds`. Podobnost si recept `recipeId` drží sám. + */ +const relinkOther = (other: OwnRecipe, recipeId: string, sideDishOfIds: string[] = []): OwnRecipe => ({ + ...other, + sideDishIds: [ + ...(other.sideDishIds ?? []).filter(id => id !== recipeId), + ...(sideDishOfIds.includes(other.id) ? [recipeId] : []), + ], + similarIds: (other.similarIds ?? []).filter(id => id !== recipeId), +}); + /** 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); +export const getRecipeKey = (recipe: Pick): string => + getNameSourceKey(recipe.name, recipe.source); /** * Založí nový recept, nebo přepíše existující podle předaného id. @@ -205,6 +297,8 @@ export const getRecipeKey = (recipe: MealTemplate): string => getNameSourceKey(r * * 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é. + * Vazby na jiné recepty (přílohy, podobné) se zapíšou i z druhé strany, takže + * vazbu jde přidat i odebrat v kterémkoli z obou receptů. * Zápis zároveň z úložiště odklidí zaniklé šablony hotových jídel. * * @param login přihlašovací jméno uživatele @@ -225,10 +319,18 @@ export const saveMealTemplate = async (login: string, input: unknown): Promise MAX_PREP_MINUTES) { + throw new BadRequestError("Čas přípravy může být nejvýše týden"); + } const summary = deriveRecipe(ingredients, servings, cookedWeight); + const id = typeof value.id === 'string' && value.id.length ? value.id : randomUUID(); + const recipeList = await getMealTemplates(login); + const recipeIds = new Set(recipeList.map(item => item.id)); + const sideDishOfIds = normalizeLinkIds(value.sideDishOfIds, recipeIds, id); - const template: MealTemplate = definedOnly({ - id: typeof value.id === 'string' && value.id.length ? value.id : randomUUID(), + const template: OwnRecipe = definedOnly({ + id, name, source: normalizeText(value.source, 'source'), category: normalizeText(value.category, 'category'), @@ -238,12 +340,15 @@ export const saveMealTemplate = async (login: string, input: unknown): Promise 1 ? servings : undefined, cookedWeight, + prepMinutes, + sideDishIds: normalizeLinkIds(value.sideDishIds, recipeIds, id), + similarIds: normalizeLinkIds(value.similarIds, recipeIds, id), weight: summary.weight, caloriesPer100g: summary.caloriesPer100g, calories: summary.calories, price: summary.price, }); - const otherRecipe = (await getMealTemplates(login)) + const otherRecipe = recipeList .find(item => item.id !== template.id && getRecipeKey(item) === getRecipeKey(template)); if (otherRecipe) { throw new BadRequestError("Toto jídlo už recept má"); @@ -255,14 +360,17 @@ export const saveMealTemplate = async (login: string, input: unknown): Promise(getKey(login), current => [ - ...loadRecipes(current).filter(item => item.id !== template.id).map(toStored), + ...loadRecipes(current) + .filter(item => item.id !== template.id) + .map(item => toStored(relinkOther(item, template.id, sideDishOfIds))), toStored(template), ]); return getMealTemplates(login); }; /** - * Smaže recept. Jídla z něj založená zůstávají, jsou samostatná. + * Smaže recept i vazby ostatních receptů na něj. Jídla z něj založená + * zůstávají, jsou samostatná. * * @param login přihlašovací jméno uživatele * @param id identifikátor receptu @@ -275,7 +383,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).map(toStored); + return recipeList.filter(item => item.id !== id).map(item => toStored(relinkOther(item, id))); }); if (!found) { throw new NotFoundError("Recept nebyl nalezen"); @@ -333,7 +441,7 @@ export const applyMealTemplate = async ( /** * Přejmenuje recept jídla spolu s jídlem — při hromadném přejmenování v seznamu - * jídel, aby recept zůstal u svého jídla. + * jídel, aby recept zůstal u svého jídla. Vazby jdou přes id, přejmenování je nemění. * * @param login přihlašovací jméno uživatele * @param fromKey dosavadní klíč jídla (`getNameSourceKey`) @@ -343,10 +451,9 @@ export const applyMealTemplate = async ( export const renameMealTemplate = async ( login: string, fromKey: string, name: string, source?: string, ): Promise => { - const recipeList = await getMealTemplates(login); - const recipe = recipeList.find(item => getRecipeKey(item) === fromKey); - if (!recipe) return; - const renamed: MealTemplate = definedOnly({ ...recipe, name, source }); + if (!(await getMealTemplates(login)).some(item => getRecipeKey(item) === fromKey)) return; await storage.updateData(getKey(login), current => - loadRecipes(current).map(item => toStored(item.id === recipe.id ? renamed : item))); + loadRecipes(current).map(item => toStored(getRecipeKey(item) === fromKey + ? definedOnly({ ...item, name, source }) + : item))); }; diff --git a/server/src/tests/recipeExtras.test.ts b/server/src/tests/recipeExtras.test.ts new file mode 100644 index 0000000..edb50c1 --- /dev/null +++ b/server/src/tests/recipeExtras.test.ts @@ -0,0 +1,157 @@ +process.env.CALORIE_PROVIDER = 'none'; + +import { deleteMealTemplate, getMealTemplates, saveMealTemplate } from "../mealTemplates"; +import { addMeal } from "../meals"; +import { renameMealGroup } from "../mealGroups"; +import { resetMemoryStorage } from "../storage/memory"; +import getStorage from "../storage"; +import { IngredientUnit, MealTemplate, MealType } from "../../../types/gen/types.gen"; + +const LOGIN = 'tester'; + +beforeEach(() => resetMemoryStorage()); + +const createRecipe = async (name: string, extra: Record = {}): Promise => { + const recipeList = await saveMealTemplate(LOGIN, { + name, source: 'doma', ingredients: [{ name: 'Sůl', weight: 5 }], ...extra, + }); + return recipeList.find(item => item.name === name)!; +}; + +const findRecipe = async (name: string): Promise => + (await getMealTemplates(LOGIN)).find(item => item.name === name)!; + +describe('jednotky surovin', () => { + + it('surovina v kusech nese množství a přepočet na gramy počítá výživu', async () => { + const recipe = await createRecipe('Omeleta', { + ingredients: [ + { name: 'Vejce', amount: 3, unit: IngredientUnit.KS, weight: 180, caloriesPer100g: 140 }, + { name: 'Mléko', amount: 0.125, unit: IngredientUnit.L }, + ], + }); + + expect(recipe.ingredients).toEqual([ + { name: 'Vejce', amount: 3, unit: IngredientUnit.KS, weight: 180, caloriesPer100g: 140 }, + // Bez přepočtu na gramy do výživy nevstupuje, množství se zaokrouhlí na setiny + { name: 'Mléko', amount: 0.13, unit: IngredientUnit.L }, + ]); + expect(recipe).toMatchObject({ weight: 180, calories: 252 }); + }); + + it('bez jednotky je množstvím gramáž a samostatné množství se zahodí', async () => { + const recipe = await createRecipe('Rýže', { ingredients: [{ name: 'Rýže', amount: 3, weight: 200 }] }); + + expect(recipe.ingredients).toEqual([{ name: 'Rýže', weight: 200 }]); + }); + + it('jednotka bez množství zůstane (špetka soli)', async () => { + const recipe = await createRecipe('Polévka', { ingredients: [{ name: 'Sůl', unit: IngredientUnit.SPETKA }] }); + + expect(recipe.ingredients).toEqual([{ name: 'Sůl', unit: IngredientUnit.SPETKA }]); + }); + + it('odmítne neznámou jednotku a nekladné množství', async () => { + await expect(createRecipe('X', { ingredients: [{ name: 'A', unit: 'VEDRO', amount: 1 }] })) + .rejects.toThrow(/jednotku/); + await expect(createRecipe('X', { ingredients: [{ name: 'A', unit: IngredientUnit.KS, amount: 0 }] })) + .rejects.toThrow(/množství/); + }); +}); + +describe('čas přípravy', () => { + + it('uloží se v minutách, nula znamená nevyplněný', async () => { + expect((await createRecipe('Guláš', { prepMinutes: 150 })).prepMinutes).toBe(150); + expect((await createRecipe('Salát', { prepMinutes: 0 })).prepMinutes).toBeUndefined(); + }); + + it('odmítne čas delší než týden', async () => { + await expect(createRecipe('Kimči', { prepMinutes: 8 * 24 * 60 })).rejects.toThrow(/týden/); + }); +}); + +describe('vazby mezi recepty', () => { + + it('příloha se u druhého receptu ukáže jako "přílohou k"', async () => { + const ryze = await createRecipe('Rýže'); + const kure = await createRecipe('Kuře na kari', { sideDishIds: [ryze.id] }); + + expect(kure.sideDishIds).toEqual([ryze.id]); + expect(kure.sideDishOfIds).toBeUndefined(); + expect((await findRecipe('Rýže')).sideDishOfIds).toEqual([kure.id]); + }); + + it('přílohu jde zapsat i z přílohy a uloží se u hlavního jídla', async () => { + const kure = await createRecipe('Kuře na kari'); + const ryze = await createRecipe('Rýže', { sideDishOfIds: [kure.id] }); + + expect((await findRecipe('Kuře na kari')).sideDishIds).toEqual([ryze.id]); + + // Uložení přílohy bez vazby ji odebere i z hlavního jídla + await createRecipe('Rýže', { id: ryze.id }); + expect((await findRecipe('Kuře na kari')).sideDishIds).toBeUndefined(); + }); + + it('podobnost je oboustranná a odebrat jde z kterékoli strany', async () => { + const gulas = await createRecipe('Guláš'); + const segedin = await createRecipe('Segedín', { similarIds: [gulas.id] }); + + expect((await findRecipe('Guláš')).similarIds).toEqual([segedin.id]); + + await createRecipe('Guláš', { id: gulas.id, similarIds: [] }); + expect((await findRecipe('Guláš')).similarIds).toBeUndefined(); + expect((await findRecipe('Segedín')).similarIds).toBeUndefined(); + }); + + it('úprava receptu, který vazbu zná jen z druhé strany, ji zachová', async () => { + const gulas = await createRecipe('Guláš'); + const segedin = await createRecipe('Segedín', { similarIds: [gulas.id] }); + const knedlik = await createRecipe('Knedlík', { sideDishOfIds: [gulas.id] }); + + // Klient posílá zpět, co dostal — vazby uložené u druhého receptu + const loaded = await findRecipe('Guláš'); + await saveMealTemplate(LOGIN, { ...loaded, note: 'Dlouho dusit' }); + + expect(await findRecipe('Guláš')).toMatchObject({ similarIds: [segedin.id], sideDishIds: [knedlik.id] }); + expect(await findRecipe('Segedín')).toMatchObject({ similarIds: [gulas.id] }); + }); + + it('odmítne vazbu na sebe sama a na neexistující recept', async () => { + const gulas = await createRecipe('Guláš'); + + await expect(createRecipe('Guláš', { id: gulas.id, similarIds: [gulas.id] })).rejects.toThrow(/nebyl nalezen/); + await expect(createRecipe('Segedín', { sideDishIds: ['neni'] })).rejects.toThrow(/nebyl nalezen/); + }); + + it('smazání receptu odebere vazby na něj', async () => { + const ryze = await createRecipe('Rýže'); + await createRecipe('Kuře na kari', { sideDishIds: [ryze.id], similarIds: [ryze.id] }); + + await deleteMealTemplate(LOGIN, ryze.id); + + const stored = await getStorage().getData[]>(`mealTemplates:${LOGIN}`); + expect(stored).toHaveLength(1); + expect(stored![0]).not.toHaveProperty('sideDishIds'); + expect(stored![0]).not.toHaveProperty('similarIds'); + }); + + it('přejmenování jídla vazby zachová', async () => { + await addMeal(LOGIN, { date: '2025-03-01', mealType: MealType.OBED, name: 'Rýže', source: 'doma' }); + const ryze = await createRecipe('Rýže'); + const kure = await createRecipe('Kuře na kari', { sideDishIds: [ryze.id] }); + + await renameMealGroup(LOGIN, 'Rýže', 'doma', 'Jasmínová rýže', 'doma'); + + expect(await findRecipe('Jasmínová rýže')).toMatchObject({ id: ryze.id, sideDishOfIds: [kure.id] }); + }); + + it('do úložiště se druhá strana vazby nezapisuje', async () => { + const gulas = await createRecipe('Guláš'); + await createRecipe('Segedín', { similarIds: [gulas.id] }); + + const stored = await getStorage().getData[]>(`mealTemplates:${LOGIN}`); + expect(stored!.find(item => item.name === 'Guláš')).not.toHaveProperty('similarIds'); + expect(stored!.find(item => item.name === 'Guláš')).not.toHaveProperty('sideDishOfIds'); + }); +}); diff --git a/types/schemas/_index.yml b/types/schemas/_index.yml index 37f9954..ef10a41 100644 --- a/types/schemas/_index.yml +++ b/types/schemas/_index.yml @@ -1206,6 +1206,26 @@ MealTemplate: Hmotnost hotového jídla v gramech. Vařením se voda odpaří nebo nasákne, takže se liší od součtu surovin; bez ní se použije součet surovin. type: integer + prepMinutes: + description: Čas přípravy v minutách + type: integer + sideDishIds: + description: Recepty, které se k tomuto jídlu dávají jako příloha + type: array + items: + type: string + sideDishOfIds: + description: | + Recepty, ke kterým je toto jídlo přílohou — opačná strana `sideDishIds` + jiných receptů. Při uložení se podle něj upraví ty recepty. + type: array + items: + type: string + similarIds: + description: Podobné recepty. Vazba je oboustranná, uvádí se u obou receptů. + type: array + items: + type: string MealTemplateInput: description: | @@ -1240,6 +1260,35 @@ MealTemplateInput: type: integer cookedWeight: type: integer + prepMinutes: + type: integer + sideDishIds: + type: array + items: + type: string + sideDishOfIds: + type: array + items: + type: string + similarIds: + type: array + items: + type: string + +IngredientUnit: + description: | + Jednotka suroviny receptu, když dává smysl víc než gramy (vejce, mléko). + Bez jednotky je množstvím gramáž. + type: string + enum: + - KS + - ML + - L + - LZICE + - LZICKA + - HRNEK + - BALENI + - SPETKA RecipeFlavor: description: Zda je recept slaný, nebo sladký @@ -1276,8 +1325,17 @@ RecipeIngredient: name: description: Název suroviny type: string + amount: + description: | + Množství v jednotce `unit` (2 ks, 0,5 l). Bez jednotky se nepoužije, + množstvím je gramáž. + type: number + unit: + $ref: "#/IngredientUnit" weight: - description: Použité množství v gramech + description: | + Použité množství v gramech. U suroviny v jiné jednotce je to přepočet + na gramy, ze kterého se počítá energie a hmotnost hotového jídla. type: integer caloriesPer100g: description: Energie suroviny na 100 g v kcal