fix: rozdílné jednotky u receptů

feat: vazby a trvání receptů
This commit is contained in:
Ondřej Anděl committed 2026-10-19 18:40:13 +02:00
1 parent caffb6d8b4
commit 865c2c3b22
18 files changed
+952 -78

No files matched your search

+21 -2
View File
@@ -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é.
+41 -2
View File
@@ -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();
+7 -2
View File
@@ -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<Props>) => {
const MealGroupRecipe = ({ group, recipeList, onOpenRecipe, onChanged, onClose }: Readonly<Props>) => {
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<Props>) => {
<MealTemplateCard
template={group.recipe}
busy={busy || exporting}
recipeList={recipeList}
onOpenRecipe={onOpenRecipe}
onExport={() => exportRecipes([{ recipe: group.recipe!, photoCount: group.photoCount }])}
onEdit={() => setEditing(true)}
onDelete={() => remove(group.recipe!.id)}
+17 -5
View File
@@ -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<Props>) => {
const MealGroupRow = ({
group, groupList, recipeList, onOpenRecipe, action, onAction, onChanged, onRefresh,
}: Readonly<Props>) => {
const [daysOpen, setDaysOpen] = useState(false);
const toggle = (value: MealGroupAction) => onAction(action === value ? undefined : value);
return (
<div className="card-panel meal-group">
<div className="card-panel meal-group" data-meal-key={getMealKey(group)}>
<div className="meal-group-head">
{group.thumbnail && (
<button
@@ -136,7 +142,13 @@ const MealGroupRow = ({ group, groupList, action, onAction, onChanged, onRefresh
<MealGroupForm group={group} onFilled={onChanged} onCancel={() => onAction(undefined)} />
)}
{action === MealGroupAction.RECEPT && (
<MealGroupRecipe group={group} onChanged={onChanged} onClose={() => onAction(undefined)} />
<MealGroupRecipe
group={group}
recipeList={recipeList}
onOpenRecipe={onOpenRecipe}
onChanged={onChanged}
onClose={() => onAction(undefined)}
/>
)}
{action === MealGroupAction.FOTKY && <MealGroupPhotos group={group} onChanged={onRefresh} />}
{action === MealGroupAction.PREJMENOVAT && (
+18 -6
View File
@@ -1,13 +1,20 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faFilePdf, faPenToSquare, faTrash } from "@fortawesome/free-solid-svg-icons";
import { faClock, faFilePdf, faPenToSquare, faTrash } from "@fortawesome/free-solid-svg-icons";
import { MealTemplate } from "../../../types";
import { RECIPE_FLAVOR_NAMES } from "../enums";
import { deriveRecipe, formatCalories, formatCount, formatPrice, formatWeight, getIngredientCalories } from "../Utils";
import {
deriveRecipe, formatCalories, formatCount, formatIngredientAmount, formatPrepTime, formatPrice, formatWeight,
getIngredientCalories,
} from "../Utils";
import RecipeNote from "./RecipeNote";
import RecipeLinks from "./RecipeLinks";
type Props = {
template: MealTemplate,
busy?: boolean,
/** Všechny recepty — bez nich se vazby na jiné recepty nezobrazí. */
recipeList?: MealTemplate[],
onOpenRecipe?: (recipe: MealTemplate) => void,
/** Bez úpravy a smazání je karta jen k nahlédnutí (kolo štěstí). */
onEdit?: () => void,
onDelete?: () => void,
@@ -18,7 +25,7 @@ type Props = {
* Karta receptu uvnitř jídla v seznamu jídel — suroviny, hodnoty celého hrnce
* i jedné porce a postup. Název a zdroj neopakuje, nese je jídlo.
*/
const MealTemplateCard = ({ template, busy, onEdit, onDelete, onExport }: Readonly<Props>) => {
const MealTemplateCard = ({ template, busy, recipeList, onOpenRecipe, onEdit, onDelete, onExport }: Readonly<Props>) => {
const recipe = deriveRecipe(template.ingredients, template.servings, template.cookedWeight);
return (
@@ -28,6 +35,11 @@ const MealTemplateCard = ({ template, busy, onEdit, onDelete, onExport }: Readon
Recept
{template.category && <span className="tag">{template.category}</span>}
<span className="tag">{RECIPE_FLAVOR_NAMES[template.flavor].toLowerCase()}</span>
{template.prepMinutes != null && (
<span className="tag" title="Čas přípravy">
<FontAwesomeIcon icon={faClock} /> {formatPrepTime(template.prepMinutes)}
</span>
)}
{template.weight != null && <span className="tag">porce {formatWeight(template.weight)}</span>}
{(template.servings ?? 1) > 1 && (
<span className="tag">{formatCount(template.servings ?? 1, 'porce', 'porce', 'porcí')}</span>
@@ -74,9 +86,7 @@ const MealTemplateCard = ({ template, busy, onEdit, onDelete, onExport }: Readon
// Suroviny nemají vlastní id, v rámci jednoho receptu je index stabilní
<li key={index}>
<span className="template-item-name">{ingredient.name}</span>
<span className="template-item-quantity">
{ingredient.weight != null ? formatWeight(ingredient.weight) : ''}
</span>
<span className="template-item-quantity">{formatIngredientAmount(ingredient)}</span>
<span className="template-item-calories">
{formatCalories(getIngredientCalories(ingredient))}
</span>
@@ -92,6 +102,8 @@ const MealTemplateCard = ({ template, busy, onEdit, onDelete, onExport }: Readon
{recipe.totalPrice != null && <> · {formatPrice(recipe.totalPrice)}</>}
</div>
{recipeList && <RecipeLinks template={template} recipeList={recipeList} onOpen={onOpenRecipe} />}
{template.note && <RecipeNote note={template.note} />}
</div>
);
+73 -6
View File
@@ -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<RecipeLinkKind, string[]> => ({
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<RecipeLinkKind, string[]>, 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<RecipeLinkKind, string> = {
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<Props>) => {
const [form, setForm] = useState(() => createForm(template, meal));
@@ -47,14 +71,22 @@ const MealTemplateForm = ({ template, meal, onSaved, onCancel }: Readonly<Props>
const [error, setError] = useState<string | undefined>();
const [busy, setBusy] = useState(false);
const [notePreview, setNotePreview] = useState(false);
const [links, setLinks] = useState(() => createLinks(template));
const [otherRecipeList, setOtherRecipeList] = useState<MealTemplate[]>([]);
const flavorId = useId();
useEffect(() => {
getMealTemplates().then(response =>
setOtherRecipeList((response.data ?? []).filter(recipe => recipe.id !== template?.id)));
}, [template?.id]);
const update = (changes: Partial<ReturnType<typeof createForm>>) =>
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<Props>
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<Props>
)}
<div className="row g-3 mb-3">
<div className="col-sm-7">
<div className="col-sm-5">
<Form.Label>Typ</Form.Label>
<RecipeCategoryInput value={form.category} onChange={category => update({ category })} />
</div>
<div className="col-sm-5">
<div className="col-sm-3">
<Form.Label>Čas přípravy</Form.Label>
<div className="input-group">
<Form.Control
inputMode="numeric"
value={form.prepMinutes}
placeholder="volitelné"
onChange={event => update({ prepMinutes: event.target.value.replace(/\D/g, '') })}
/>
<span className="input-group-text">min</span>
</div>
{!!prepMinutes && prepMinutes >= 60 && <Form.Text>{formatPrepTime(prepMinutes)}</Form.Text>}
</div>
<div className="col-sm-4">
<Form.Label className="d-block">Chuť</Form.Label>
<ToggleButtonGroup
type="radio"
@@ -182,6 +229,26 @@ const MealTemplateForm = ({ template, meal, onSaved, onCancel }: Readonly<Props>
</div>
</fieldset>
{!!otherRecipeList.length && (
<fieldset className="meal-fieldset mb-3">
<legend>Souvisí s</legend>
{Object.values(RecipeLinkKind).map(kind => (
<div className="row g-2 mb-2 align-items-center" key={kind}>
<Form.Label className="col-sm-3 mb-0">{RECIPE_LINK_NAMES[kind]}</Form.Label>
<div className="col-sm-9">
<RecipeLinksInput
value={links[kind]}
recipeList={otherRecipeList.filter(recipe =>
!getOppositeLinkIds(links, kind).includes(recipe.id))}
placeholder={LINK_PLACEHOLDERS[kind]}
onChange={value => setLinks(current => ({ ...current, [kind]: value }))}
/>
</div>
</div>
))}
</fieldset>
)}
<Form.Group className="mb-3">
<div className="recipe-note-label">
<Form.Label>Postup</Form.Label>
@@ -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<DraftIngredient> => {
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<Props>) =>
aria-label="Název suroviny"
onChange={event => updateAt(index, { name: event.target.value })}
/>
<div className="input-group recipe-ingredient-weight">
<Form.Control
inputMode="numeric"
value={ingredient.weight}
placeholder="200"
aria-label="Množství v gramech"
onChange={event => updateAt(index, { weight: event.target.value.replace(/\D/g, '') })}
/>
<span className="input-group-text">g</span>
<div className="input-group recipe-ingredient-amount">
{ingredient.unit ? (
<Form.Control
inputMode="decimal"
value={ingredient.amount}
placeholder="2"
aria-label="Množství"
onChange={event => updateAt(index, { amount: event.target.value.replace(/[^\d,.]/g, '') })}
/>
) : (
<Form.Control
inputMode="numeric"
value={ingredient.weight}
placeholder="200"
aria-label="Množství v gramech"
onChange={event => updateAt(index, { weight: event.target.value.replace(/\D/g, '') })}
/>
)}
<Form.Select
value={ingredient.unit}
aria-label="Jednotka"
onChange={event => updateAt(index, changeUnit(ingredient, event.target.value as IngredientUnit | ''))}
>
<option value="">g</option>
{INGREDIENT_UNITS_IN_ORDER.map(unit => (
<option key={unit} value={unit}>{INGREDIENT_UNIT_FORMS[unit][0]}</option>
))}
</Form.Select>
</div>
{ingredient.unit && (
<div className="input-group recipe-ingredient-weight">
<Form.Control
inputMode="numeric"
value={ingredient.weight}
placeholder="≈"
aria-label="Přepočet na gramy"
title="Přepočet na gramy — z něj se počítá energie a hmotnost jídla"
onChange={event => updateAt(index, { weight: event.target.value.replace(/\D/g, '') })}
/>
<span className="input-group-text">g</span>
</div>
)}
<Form.Control
className="recipe-ingredient-rate"
inputMode="numeric"
+48
View File
@@ -0,0 +1,48 @@
import { MealTemplate } from "../../../types";
import { RECIPE_LINK_NAMES, RecipeLinkKind, getRecipeLinkIds } from "../enums";
type Props = {
template: MealTemplate,
/** Všechny recepty uživatele — z nich se berou názvy propojených */
recipeList: MealTemplate[],
/** Otevře propojený recept; bez něj jsou vazby jen popisky. */
onOpen?: (recipe: MealTemplate) => void,
};
/** Vazby receptu na jiné recepty — přílohy, k čemu je přílohou a podobné. */
const RecipeLinks = ({ template, recipeList, onOpen }: Readonly<Props>) => {
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 (
<div className="recipe-links">
{rowList.map(({ kind, linkedList }) => (
<div key={kind} className="recipe-links-row">
<span className="recipe-links-label">{RECIPE_LINK_NAMES[kind]}:</span>
{linkedList.map(recipe => onOpen ? (
<button
key={recipe.id}
type="button"
className="recipe-link"
title="Otevřít recept"
onClick={() => onOpen(recipe)}
>
{recipe.name}
</button>
) : (
<span key={recipe.id} className="recipe-link">{recipe.name}</span>
))}
</div>
))}
</div>
);
};
export default RecipeLinks;
@@ -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<Props>) => {
const selectedList = recipeList.filter(recipe => value.includes(recipe.id));
const offeredList = recipeList.filter(recipe => !value.includes(recipe.id));
return (
<div className="recipe-links-input">
{selectedList.map(recipe => (
<span key={recipe.id} className="recipe-link">
{recipe.name}
<button
type="button"
className="recipe-link-remove"
title="Odebrat"
aria-label={`Odebrat ${recipe.name}`}
onClick={() => onChange(value.filter(id => id !== recipe.id))}
>
<FontAwesomeIcon icon={faXmark} />
</button>
</span>
))}
{!!offeredList.length && (
<Form.Select
size="sm"
value=""
aria-label={placeholder}
onChange={event => event.target.value && onChange([...value, event.target.value])}
>
<option value="">{placeholder}</option>
{offeredList.map(recipe => (
<option key={recipe.id} value={recipe.id}>
{recipe.source ? `${recipe.name} (${recipe.source})` : recipe.name}
</option>
))}
</Form.Select>
)}
</div>
);
};
export default RecipeLinksInput;
+8 -1
View File
@@ -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<Props>) => {
const WheelRecipe = ({ group, recipeList, onOpenRecipe }: Readonly<Props>) => {
const [photo, setPhoto] = useState<MealPhoto | undefined>();
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<Props>) => {
<MealTemplateCard
template={group.recipe}
busy={exporting}
recipeList={recipeList}
onOpenRecipe={onOpenRecipe}
onExport={() => exportRecipes([{ recipe: group.recipe, photoCount: group.photoCount }])}
/>
</div>
@@ -93,6 +93,8 @@ const SpinWheelModal = ({ isOpen, onClose }: Readonly<Props>) => {
const [excludedKeys, setExcludedKeys] = useState<Set<string>>(new Set());
const [picked, setPicked] = useState<WheelItem | undefined>();
const [recipeShown, setRecipeShown] = useState(false);
// Recept otevřený z vazby padlého receptu (příloha, podobný)
const [linkedKey, setLinkedKey] = useState<string | undefined>();
const [rotation, setRotation] = useState(0);
const [spinning, setSpinning] = useState(false);
const [respinRequested, setRespinRequested] = useState(false);
@@ -134,9 +136,14 @@ const SpinWheelModal = ({ isOpen, onClose }: Readonly<Props>) => {
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<Props>) => {
};
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<Props>) => {
<Modal show={isOpen} onHide={onClose} size="lg" className="spin-wheel-modal">
<Modal.Header closeButton>
<Modal.Title>
{recipeShown && pickedRecipe ? pickedRecipe.name : 'Kolo štěstí: co dnes?'}
{shownRecipe ? shownRecipe.name : 'Kolo štěstí: co dnes?'}
</Modal.Title>
</Modal.Header>
<WheelConfetti theme={theme} burstKey={confettiKey} />
<Modal.Body>
{recipeShown && pickedRecipe ? <WheelRecipe group={pickedRecipe} /> : (
{shownRecipe ? (
<WheelRecipe
group={shownRecipe}
recipeList={recipeGroupList.map(group => group.recipe)}
onOpenRecipe={recipe => setLinkedKey(getMealKey(recipe))}
/>
) : (
<>
<div className="spin-filters">
<ButtonGroup className="spin-source" aria-label="Z čeho losovat">
@@ -372,10 +389,10 @@ const SpinWheelModal = ({ isOpen, onClose }: Readonly<Props>) => {
</Modal.Body>
<Modal.Footer>
{recipeShown && (
<Button variant="outline-secondary" onClick={() => setRecipeShown(false)}>Zpět na kolo</Button>
<Button variant="outline-secondary" onClick={() => showRecipe(false)}>Zpět na kolo</Button>
)}
{!recipeShown && pickedRecipe && !spinning && (
<Button onClick={() => setRecipeShown(true)}>
<Button onClick={() => showRecipe(true)}>
<FontAwesomeIcon icon={faBookOpen} /> Zobrazit recept
</Button>
)}
+57 -1
View File
@@ -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<MealType, string> = {
@@ -193,6 +195,60 @@ export const RECIPE_FLAVOR_NAMES: Record<RecipeFlavor, string> = {
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<IngredientUnit, [string, string, string, string]> = {
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<RecipeLinkKind, string> = {
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<RecipeLinkKind, 'sideDishIds' | 'sideDishOfIds' | 'similarIds'> = {
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',
+28
View File
@@ -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<string | undefined>();
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(); }}
+76 -1
View File
@@ -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;
+59 -9
View File
@@ -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<string, number>): 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<string, number>,
};
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<void> => {
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<void> =>
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<void> =>
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] },
+132 -25
View File
@@ -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 = <T extends object>(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<MealTemplate, 'sideDishOfIds'>;
/**
* 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<MealTemplate, 'ingredients' | 'flavor'> & {
type StoredMealTemplate = Omit<OwnRecipe, 'ingredients' | 'flavor'> & {
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<MealTemplate[]> =>
loadRecipes(await storage.getData<StoredMealTemplate[]>(getKey(login)))
withLinks(loadRecipes(await storage.getData<StoredMealTemplate[]>(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 = <T extends object>(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<string>, 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<MealTemplate, 'name' | 'source'>): 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<M
const ingredients = value.ingredients.map(normalizeIngredient);
const servings = normalizeNumber(value.servings, 'servings');
const cookedWeight = normalizeNumber(value.cookedWeight, 'cookedWeight') || undefined;
const prepMinutes = normalizeNumber(value.prepMinutes, 'prepMinutes') || undefined;
if (prepMinutes != null && prepMinutes > 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<M
// Jedna porce je výchozí stav, ukládat ji zvlášť nemá smysl
servings: servings && servings > 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<M
}
await storage.updateData<StoredMealTemplate[]>(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<Me
await storage.updateData<StoredMealTemplate[]>(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<void> => {
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<StoredMealTemplate[]>(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)));
};
+157
View File
@@ -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<string, unknown> = {}): Promise<MealTemplate> => {
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<MealTemplate> =>
(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<Record<string, unknown>[]>(`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<Record<string, unknown>[]>(`mealTemplates:${LOGIN}`);
expect(stored!.find(item => item.name === 'Guláš')).not.toHaveProperty('similarIds');
expect(stored!.find(item => item.name === 'Guláš')).not.toHaveProperty('sideDishOfIds');
});
});
+59 -1
View File
@@ -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