fix: rozdílné jednotky u receptů
feat: vazby a trvání receptů
This commit is contained in:
1 parent
caffb6d8b4
commit
865c2c3b22
18 files changed
+952
-78
No files matched your search
@@ -330,7 +330,7 @@ psát jde i nový.
|
|||||||
vařené jídlo se surovinami (`ingredients`), počtem porcí (`servings`), hmotností
|
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,
|
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
|
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`,
|
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
|
`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
|
`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ý
|
Recept má volitelný **typ** (`category` — polévka, omáčka, pečení…) jako volný
|
||||||
text; formulář nabízí typy z ostatních receptů (`RecipeCategoryInput`), pevný
|
text; formulář nabízí typy z ostatních receptů (`RecipeCategoryInput`), pevný
|
||||||
číselník to záměrně není. **Chuť** (`flavor`, `RecipeFlavor`: SLANE/SLADKE) je
|
čí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í.
|
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`)
|
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é.
|
a jídlo stejného jména z historie se vedle receptu nenabízí podruhé.
|
||||||
|
|
||||||
|
|||||||
+41
-2
@@ -1,5 +1,5 @@
|
|||||||
import { MealSuggestion, MealTemplate, Nutrients } from "../../types";
|
import { MealSuggestion, MealTemplate, Nutrients, RecipeIngredient } from "../../types";
|
||||||
import { NUTRIENTS_IN_ORDER } from "./enums";
|
import { INGREDIENT_UNIT_FORMS, NUTRIENTS_IN_ORDER } from "./enums";
|
||||||
|
|
||||||
const TOKEN_KEY = "token";
|
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. */
|
/** Znormalizuje text pro hledání — bez diakritiky a malými písmeny. */
|
||||||
export const normalizeSearch = (value: string): string =>
|
export const normalizeSearch = (value: string): string =>
|
||||||
value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim();
|
value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim();
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { MealGroup, deleteMealTemplate } from "../../../types";
|
import { MealGroup, MealTemplate, deleteMealTemplate } from "../../../types";
|
||||||
import MealTemplateCard from "./MealTemplateCard";
|
import MealTemplateCard from "./MealTemplateCard";
|
||||||
import MealTemplateForm from "./MealTemplateForm";
|
import MealTemplateForm from "./MealTemplateForm";
|
||||||
import { useRecipeExport } from "../useRecipeExport";
|
import { useRecipeExport } from "../useRecipeExport";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
group: MealGroup,
|
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. */
|
/** Zavolá se po uložení nebo smazání receptu, aby se seznam načetl znovu. */
|
||||||
onChanged: () => void,
|
onChanged: () => void,
|
||||||
onClose: () => 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
|
* 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í.
|
* 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 [editing, setEditing] = useState(!group.recipe);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const { exporting, exportRecipes } = useRecipeExport();
|
const { exporting, exportRecipes } = useRecipeExport();
|
||||||
@@ -33,6 +36,8 @@ const MealGroupRecipe = ({ group, onChanged, onClose }: Readonly<Props>) => {
|
|||||||
<MealTemplateCard
|
<MealTemplateCard
|
||||||
template={group.recipe}
|
template={group.recipe}
|
||||||
busy={busy || exporting}
|
busy={busy || exporting}
|
||||||
|
recipeList={recipeList}
|
||||||
|
onOpenRecipe={onOpenRecipe}
|
||||||
onExport={() => exportRecipes([{ recipe: group.recipe!, photoCount: group.photoCount }])}
|
onExport={() => exportRecipes([{ recipe: group.recipe!, photoCount: group.photoCount }])}
|
||||||
onEdit={() => setEditing(true)}
|
onEdit={() => setEditing(true)}
|
||||||
onDelete={() => remove(group.recipe!.id)}
|
onDelete={() => remove(group.recipe!.id)}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { useState } from "react";
|
|||||||
import { Button } from "react-bootstrap";
|
import { Button } from "react-bootstrap";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faBookOpen, faCalendarDays, faCamera, faPen, faPenToSquare } from "@fortawesome/free-solid-svg-icons";
|
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 { MealGroupAction, RECIPE_FLAVOR_NAMES } from "../enums";
|
||||||
import { formatDateString } from "../Utils";
|
import { formatDateString, getMealKey } from "../Utils";
|
||||||
import MealGroupForm from "./MealGroupForm";
|
import MealGroupForm from "./MealGroupForm";
|
||||||
import MealGroupRenameForm from "./MealGroupRenameForm";
|
import MealGroupRenameForm from "./MealGroupRenameForm";
|
||||||
import MealGroupDays from "./MealGroupDays";
|
import MealGroupDays from "./MealGroupDays";
|
||||||
@@ -15,6 +15,10 @@ type Props = {
|
|||||||
group: MealGroup,
|
group: MealGroup,
|
||||||
/** Všechny skupiny — přejmenování podle nich pozná sloučení s jinou. */
|
/** Všechny skupiny — přejmenování podle nich pozná sloučení s jinou. */
|
||||||
groupList: MealGroup[],
|
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. */
|
/** Rozbalená akce; nejvýš jeden řádek seznamu má nějakou otevřenou. */
|
||||||
action?: MealGroupAction,
|
action?: MealGroupAction,
|
||||||
onAction: (action?: MealGroupAction) => void,
|
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
|
* 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.
|
* 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 [daysOpen, setDaysOpen] = useState(false);
|
||||||
const toggle = (value: MealGroupAction) => onAction(action === value ? undefined : value);
|
const toggle = (value: MealGroupAction) => onAction(action === value ? undefined : value);
|
||||||
return (
|
return (
|
||||||
<div className="card-panel meal-group">
|
<div className="card-panel meal-group" data-meal-key={getMealKey(group)}>
|
||||||
<div className="meal-group-head">
|
<div className="meal-group-head">
|
||||||
{group.thumbnail && (
|
{group.thumbnail && (
|
||||||
<button
|
<button
|
||||||
@@ -136,7 +142,13 @@ const MealGroupRow = ({ group, groupList, action, onAction, onChanged, onRefresh
|
|||||||
<MealGroupForm group={group} onFilled={onChanged} onCancel={() => onAction(undefined)} />
|
<MealGroupForm group={group} onFilled={onChanged} onCancel={() => onAction(undefined)} />
|
||||||
)}
|
)}
|
||||||
{action === MealGroupAction.RECEPT && (
|
{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.FOTKY && <MealGroupPhotos group={group} onChanged={onRefresh} />}
|
||||||
{action === MealGroupAction.PREJMENOVAT && (
|
{action === MealGroupAction.PREJMENOVAT && (
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
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 { MealTemplate } from "../../../types";
|
||||||
import { RECIPE_FLAVOR_NAMES } from "../enums";
|
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 RecipeNote from "./RecipeNote";
|
||||||
|
import RecipeLinks from "./RecipeLinks";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
template: MealTemplate,
|
template: MealTemplate,
|
||||||
busy?: boolean,
|
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í). */
|
/** Bez úpravy a smazání je karta jen k nahlédnutí (kolo štěstí). */
|
||||||
onEdit?: () => void,
|
onEdit?: () => void,
|
||||||
onDelete?: () => void,
|
onDelete?: () => void,
|
||||||
@@ -18,7 +25,7 @@ type Props = {
|
|||||||
* Karta receptu uvnitř jídla v seznamu jídel — suroviny, hodnoty celého hrnce
|
* 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.
|
* 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);
|
const recipe = deriveRecipe(template.ingredients, template.servings, template.cookedWeight);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -28,6 +35,11 @@ const MealTemplateCard = ({ template, busy, onEdit, onDelete, onExport }: Readon
|
|||||||
Recept
|
Recept
|
||||||
{template.category && <span className="tag">{template.category}</span>}
|
{template.category && <span className="tag">{template.category}</span>}
|
||||||
<span className="tag">{RECIPE_FLAVOR_NAMES[template.flavor].toLowerCase()}</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.weight != null && <span className="tag">porce {formatWeight(template.weight)}</span>}
|
||||||
{(template.servings ?? 1) > 1 && (
|
{(template.servings ?? 1) > 1 && (
|
||||||
<span className="tag">{formatCount(template.servings ?? 1, 'porce', 'porce', 'porcí')}</span>
|
<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í
|
// Suroviny nemají vlastní id, v rámci jednoho receptu je index stabilní
|
||||||
<li key={index}>
|
<li key={index}>
|
||||||
<span className="template-item-name">{ingredient.name}</span>
|
<span className="template-item-name">{ingredient.name}</span>
|
||||||
<span className="template-item-quantity">
|
<span className="template-item-quantity">{formatIngredientAmount(ingredient)}</span>
|
||||||
{ingredient.weight != null ? formatWeight(ingredient.weight) : ''}
|
|
||||||
</span>
|
|
||||||
<span className="template-item-calories">
|
<span className="template-item-calories">
|
||||||
{formatCalories(getIngredientCalories(ingredient))}
|
{formatCalories(getIngredientCalories(ingredient))}
|
||||||
</span>
|
</span>
|
||||||
@@ -92,6 +102,8 @@ const MealTemplateCard = ({ template, busy, onEdit, onDelete, onExport }: Readon
|
|||||||
{recipe.totalPrice != null && <> · {formatPrice(recipe.totalPrice)}</>}
|
{recipe.totalPrice != null && <> · {formatPrice(recipe.totalPrice)}</>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{recipeList && <RecipeLinks template={template} recipeList={recipeList} onOpen={onOpenRecipe} />}
|
||||||
|
|
||||||
{template.note && <RecipeNote note={template.note} />}
|
{template.note && <RecipeNote note={template.note} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 { Alert, Button, Form, ToggleButton, ToggleButtonGroup } from "react-bootstrap";
|
||||||
import { MealTemplate, RecipeFlavor, saveMealTemplate } from "../../../types";
|
import { MealTemplate, RecipeFlavor, getMealTemplates, saveMealTemplate } from "../../../types";
|
||||||
import { RECIPE_FLAVOR_NAMES } from "../enums";
|
import { RECIPE_FLAVOR_NAMES, RECIPE_LINK_FIELDS, RECIPE_LINK_NAMES, RecipeLinkKind, getRecipeLinkIds } from "../enums";
|
||||||
import { deriveRecipe, formatCalories, formatPrice, formatWeight, parseIntInput } from "../Utils";
|
import { deriveRecipe, formatCalories, formatPrepTime, formatPrice, formatWeight, parseIntInput } from "../Utils";
|
||||||
import SourceInput from "./SourceInput";
|
import SourceInput from "./SourceInput";
|
||||||
import RecipeCategoryInput from "./RecipeCategoryInput";
|
import RecipeCategoryInput from "./RecipeCategoryInput";
|
||||||
import RecipeNote from "./RecipeNote";
|
import RecipeNote from "./RecipeNote";
|
||||||
|
import RecipeLinksInput from "./RecipeLinksInput";
|
||||||
import RecipeIngredientsEditor, {
|
import RecipeIngredientsEditor, {
|
||||||
DraftIngredient, emptyIngredient, toDraftIngredient, toIngredient,
|
DraftIngredient, emptyIngredient, toDraftIngredient, toIngredient,
|
||||||
} from "./RecipeIngredientsEditor";
|
} from "./RecipeIngredientsEditor";
|
||||||
@@ -34,11 +35,34 @@ const createForm = (template?: MealTemplate, meal?: Props['meal']) => ({
|
|||||||
note: template?.note ?? '',
|
note: template?.note ?? '',
|
||||||
servings: template?.servings != null ? String(template.servings) : '',
|
servings: template?.servings != null ? String(template.servings) : '',
|
||||||
cookedWeight: template?.cookedWeight != null ? String(template.cookedWeight) : '',
|
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
|
* 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.
|
* 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 MealTemplateForm = ({ template, meal, onSaved, onCancel }: Readonly<Props>) => {
|
||||||
const [form, setForm] = useState(() => createForm(template, meal));
|
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 [error, setError] = useState<string | undefined>();
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [notePreview, setNotePreview] = useState(false);
|
const [notePreview, setNotePreview] = useState(false);
|
||||||
|
const [links, setLinks] = useState(() => createLinks(template));
|
||||||
|
const [otherRecipeList, setOtherRecipeList] = useState<MealTemplate[]>([]);
|
||||||
const flavorId = useId();
|
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>>) =>
|
const update = (changes: Partial<ReturnType<typeof createForm>>) =>
|
||||||
setForm(current => ({ ...current, ...changes }));
|
setForm(current => ({ ...current, ...changes }));
|
||||||
|
|
||||||
const filledIngredients = ingredients.filter(item => item.name.trim().length > 0).map(toIngredient);
|
const filledIngredients = ingredients.filter(item => item.name.trim().length > 0).map(toIngredient);
|
||||||
const recipe = deriveRecipe(filledIngredients, parseIntInput(form.servings), parseIntInput(form.cookedWeight));
|
const recipe = deriveRecipe(filledIngredients, parseIntInput(form.servings), parseIntInput(form.cookedWeight));
|
||||||
const ingredientWeight = filledIngredients.reduce((sum, item) => sum + (item.weight ?? 0), 0);
|
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;
|
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,
|
ingredients: filledIngredients,
|
||||||
servings: parseIntInput(form.servings),
|
servings: parseIntInput(form.servings),
|
||||||
cookedWeight: parseIntInput(form.cookedWeight),
|
cookedWeight: parseIntInput(form.cookedWeight),
|
||||||
|
prepMinutes,
|
||||||
|
...Object.fromEntries(Object.values(RecipeLinkKind).map(kind => [RECIPE_LINK_FIELDS[kind], links[kind]])),
|
||||||
};
|
};
|
||||||
const response = await saveMealTemplate({ body });
|
const response = await saveMealTemplate({ body });
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
@@ -111,11 +145,24 @@ const MealTemplateForm = ({ template, meal, onSaved, onCancel }: Readonly<Props>
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="row g-3 mb-3">
|
<div className="row g-3 mb-3">
|
||||||
<div className="col-sm-7">
|
<div className="col-sm-5">
|
||||||
<Form.Label>Typ</Form.Label>
|
<Form.Label>Typ</Form.Label>
|
||||||
<RecipeCategoryInput value={form.category} onChange={category => update({ category })} />
|
<RecipeCategoryInput value={form.category} onChange={category => update({ category })} />
|
||||||
</div>
|
</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>
|
<Form.Label className="d-block">Chuť</Form.Label>
|
||||||
<ToggleButtonGroup
|
<ToggleButtonGroup
|
||||||
type="radio"
|
type="radio"
|
||||||
@@ -182,6 +229,26 @@ const MealTemplateForm = ({ template, meal, onSaved, onCancel }: Readonly<Props>
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</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">
|
<Form.Group className="mb-3">
|
||||||
<div className="recipe-note-label">
|
<div className="recipe-note-label">
|
||||||
<Form.Label>Postup</Form.Label>
|
<Form.Label>Postup</Form.Label>
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
import { Button, Form } from "react-bootstrap";
|
import { Button, Form } from "react-bootstrap";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { RecipeIngredient } from "../../../types";
|
import { IngredientUnit, RecipeIngredient } from "../../../types";
|
||||||
import { formatCalories, formatPriceInput, getIngredientCalories, parseIntInput, parsePriceInput } from "../Utils";
|
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 = {
|
export type DraftIngredient = {
|
||||||
name: string,
|
name: string,
|
||||||
|
amount: string,
|
||||||
|
unit: IngredientUnit | '',
|
||||||
weight: string,
|
weight: string,
|
||||||
caloriesPer100g: string,
|
caloriesPer100g: string,
|
||||||
calories: string,
|
calories: string,
|
||||||
@@ -15,6 +24,8 @@ export type DraftIngredient = {
|
|||||||
|
|
||||||
export const emptyIngredient = (): DraftIngredient => ({
|
export const emptyIngredient = (): DraftIngredient => ({
|
||||||
name: '',
|
name: '',
|
||||||
|
amount: '',
|
||||||
|
unit: '',
|
||||||
weight: '',
|
weight: '',
|
||||||
caloriesPer100g: '',
|
caloriesPer100g: '',
|
||||||
calories: '',
|
calories: '',
|
||||||
@@ -24,6 +35,8 @@ export const emptyIngredient = (): DraftIngredient => ({
|
|||||||
/** Převede uloženou surovinu na rozpracovanou pro editaci. */
|
/** Převede uloženou surovinu na rozpracovanou pro editaci. */
|
||||||
export const toDraftIngredient = (ingredient: RecipeIngredient): DraftIngredient => ({
|
export const toDraftIngredient = (ingredient: RecipeIngredient): DraftIngredient => ({
|
||||||
name: ingredient.name,
|
name: ingredient.name,
|
||||||
|
amount: formatAmountInput(ingredient.amount),
|
||||||
|
unit: ingredient.unit ?? '',
|
||||||
weight: ingredient.weight != null ? String(ingredient.weight) : '',
|
weight: ingredient.weight != null ? String(ingredient.weight) : '',
|
||||||
caloriesPer100g: ingredient.caloriesPer100g != null ? String(ingredient.caloriesPer100g) : '',
|
caloriesPer100g: ingredient.caloriesPer100g != null ? String(ingredient.caloriesPer100g) : '',
|
||||||
calories: ingredient.calories != null ? String(ingredient.calories) : '',
|
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í. */
|
/** Převede rozpracovanou surovinu na hodnoty pro výpočet a uložení. */
|
||||||
export const toIngredient = (draft: DraftIngredient): RecipeIngredient => ({
|
export const toIngredient = (draft: DraftIngredient): RecipeIngredient => ({
|
||||||
name: draft.name.trim(),
|
name: draft.name.trim(),
|
||||||
|
...(draft.unit ? { amount: parseAmountInput(draft.amount), unit: draft.unit } : {}),
|
||||||
weight: parseIntInput(draft.weight),
|
weight: parseIntInput(draft.weight),
|
||||||
caloriesPer100g: parseIntInput(draft.caloriesPer100g),
|
caloriesPer100g: parseIntInput(draft.caloriesPer100g),
|
||||||
calories: parseIntInput(draft.calories),
|
calories: parseIntInput(draft.calories),
|
||||||
price: parsePriceInput(draft.price),
|
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 = {
|
type Props = {
|
||||||
ingredients: DraftIngredient[],
|
ingredients: DraftIngredient[],
|
||||||
onChange: (ingredients: DraftIngredient[]) => void,
|
onChange: (ingredients: DraftIngredient[]) => void,
|
||||||
@@ -47,6 +74,9 @@ type Props = {
|
|||||||
/**
|
/**
|
||||||
* Editor surovin receptu.
|
* 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
|
* 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í).
|
* 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"
|
aria-label="Název suroviny"
|
||||||
onChange={event => updateAt(index, { name: event.target.value })}
|
onChange={event => updateAt(index, { name: event.target.value })}
|
||||||
/>
|
/>
|
||||||
<div className="input-group recipe-ingredient-weight">
|
<div className="input-group recipe-ingredient-amount">
|
||||||
<Form.Control
|
{ingredient.unit ? (
|
||||||
inputMode="numeric"
|
<Form.Control
|
||||||
value={ingredient.weight}
|
inputMode="decimal"
|
||||||
placeholder="200"
|
value={ingredient.amount}
|
||||||
aria-label="Množství v gramech"
|
placeholder="2"
|
||||||
onChange={event => updateAt(index, { weight: event.target.value.replace(/\D/g, '') })}
|
aria-label="Množství"
|
||||||
/>
|
onChange={event => updateAt(index, { amount: event.target.value.replace(/[^\d,.]/g, '') })}
|
||||||
<span className="input-group-text">g</span>
|
/>
|
||||||
|
) : (
|
||||||
|
<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>
|
</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
|
<Form.Control
|
||||||
className="recipe-ingredient-rate"
|
className="recipe-ingredient-rate"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -6,18 +6,23 @@ import MealTemplateCard from "./MealTemplateCard";
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
group: MealGroup & { recipe: MealTemplate },
|
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
|
* 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.
|
* 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 [photo, setPhoto] = useState<MealPhoto | undefined>();
|
||||||
const [loading, setLoading] = useState(group.photoCount > 0);
|
const [loading, setLoading] = useState(group.photoCount > 0);
|
||||||
const { exporting, exportRecipes } = useRecipeExport();
|
const { exporting, exportRecipes } = useRecipeExport();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setPhoto(undefined);
|
||||||
|
setLoading(group.photoCount > 0);
|
||||||
if (!group.photoCount) return;
|
if (!group.photoCount) return;
|
||||||
getMealPhotos({ query: { name: group.name, source: group.source } }).then(response => {
|
getMealPhotos({ query: { name: group.name, source: group.source } }).then(response => {
|
||||||
setPhoto(response.data?.[0]);
|
setPhoto(response.data?.[0]);
|
||||||
@@ -32,6 +37,8 @@ const WheelRecipe = ({ group }: Readonly<Props>) => {
|
|||||||
<MealTemplateCard
|
<MealTemplateCard
|
||||||
template={group.recipe}
|
template={group.recipe}
|
||||||
busy={exporting}
|
busy={exporting}
|
||||||
|
recipeList={recipeList}
|
||||||
|
onOpenRecipe={onOpenRecipe}
|
||||||
onExport={() => exportRecipes([{ recipe: group.recipe, photoCount: group.photoCount }])}
|
onExport={() => exportRecipes([{ recipe: group.recipe, photoCount: group.photoCount }])}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ const SpinWheelModal = ({ isOpen, onClose }: Readonly<Props>) => {
|
|||||||
const [excludedKeys, setExcludedKeys] = useState<Set<string>>(new Set());
|
const [excludedKeys, setExcludedKeys] = useState<Set<string>>(new Set());
|
||||||
const [picked, setPicked] = useState<WheelItem | undefined>();
|
const [picked, setPicked] = useState<WheelItem | undefined>();
|
||||||
const [recipeShown, setRecipeShown] = useState(false);
|
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 [rotation, setRotation] = useState(0);
|
||||||
const [spinning, setSpinning] = useState(false);
|
const [spinning, setSpinning] = useState(false);
|
||||||
const [respinRequested, setRespinRequested] = useState(false);
|
const [respinRequested, setRespinRequested] = useState(false);
|
||||||
@@ -134,9 +136,14 @@ const SpinWheelModal = ({ isOpen, onClose }: Readonly<Props>) => {
|
|||||||
const excludedCount = candidateList.length - wheelList.length;
|
const excludedCount = candidateList.length - wheelList.length;
|
||||||
const onlyOne = wheelList.length === 1;
|
const onlyOne = wheelList.length === 1;
|
||||||
|
|
||||||
|
const showRecipe = (shown: boolean) => {
|
||||||
|
setRecipeShown(shown);
|
||||||
|
setLinkedKey(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
const clearResult = () => {
|
const clearResult = () => {
|
||||||
setPicked(undefined);
|
setPicked(undefined);
|
||||||
setRecipeShown(false);
|
showRecipe(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const changePool = (value: WheelPool) => {
|
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 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 pickedSource = sourceStage ? picked : undefined;
|
||||||
|
|
||||||
const renderWheel = () => {
|
const renderWheel = () => {
|
||||||
@@ -235,12 +246,18 @@ const SpinWheelModal = ({ isOpen, onClose }: Readonly<Props>) => {
|
|||||||
<Modal show={isOpen} onHide={onClose} size="lg" className="spin-wheel-modal">
|
<Modal show={isOpen} onHide={onClose} size="lg" className="spin-wheel-modal">
|
||||||
<Modal.Header closeButton>
|
<Modal.Header closeButton>
|
||||||
<Modal.Title>
|
<Modal.Title>
|
||||||
{recipeShown && pickedRecipe ? pickedRecipe.name : 'Kolo štěstí: co dnes?'}
|
{shownRecipe ? shownRecipe.name : 'Kolo štěstí: co dnes?'}
|
||||||
</Modal.Title>
|
</Modal.Title>
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
<WheelConfetti theme={theme} burstKey={confettiKey} />
|
<WheelConfetti theme={theme} burstKey={confettiKey} />
|
||||||
<Modal.Body>
|
<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">
|
<div className="spin-filters">
|
||||||
<ButtonGroup className="spin-source" aria-label="Z čeho losovat">
|
<ButtonGroup className="spin-source" aria-label="Z čeho losovat">
|
||||||
@@ -372,10 +389,10 @@ const SpinWheelModal = ({ isOpen, onClose }: Readonly<Props>) => {
|
|||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
<Modal.Footer>
|
<Modal.Footer>
|
||||||
{recipeShown && (
|
{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 && (
|
{!recipeShown && pickedRecipe && !spinning && (
|
||||||
<Button onClick={() => setRecipeShown(true)}>
|
<Button onClick={() => showRecipe(true)}>
|
||||||
<FontAwesomeIcon icon={faBookOpen} /> Zobrazit recept
|
<FontAwesomeIcon icon={faBookOpen} /> Zobrazit recept
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+57
-1
@@ -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. */
|
/** Lidsky čitelné názvy typů jídel. */
|
||||||
export const MEAL_TYPE_NAMES: Record<MealType, string> = {
|
export const MEAL_TYPE_NAMES: Record<MealType, string> = {
|
||||||
@@ -193,6 +195,60 @@ export const RECIPE_FLAVOR_NAMES: Record<RecipeFlavor, string> = {
|
|||||||
SLADKE: 'Sladké',
|
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. */
|
/** Zvláštní volby filtru zdroje v seznamu jídel; jinak se filtruje konkrétním zdrojem. */
|
||||||
export enum SourceFilter {
|
export enum SourceFilter {
|
||||||
VSE = 'VSE',
|
VSE = 'VSE',
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ const MealGroupsPage = () => {
|
|||||||
(location.state as LocationState)?.recipeFilter ?? RecipeFilter.VSE);
|
(location.state as LocationState)?.recipeFilter ?? RecipeFilter.VSE);
|
||||||
const [opened, setOpened] = useState<{ key: string, action: MealGroupAction } | undefined>();
|
const [opened, setOpened] = useState<{ key: string, action: MealGroupAction } | undefined>();
|
||||||
const [newRecipeOpen, setNewRecipeOpen] = useState(false);
|
const [newRecipeOpen, setNewRecipeOpen] = useState(false);
|
||||||
|
const [scrollKey, setScrollKey] = useState<string | undefined>();
|
||||||
const { exporting, exportRecipes } = useRecipeExport();
|
const { exporting, exportRecipes } = useRecipeExport();
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -85,6 +86,10 @@ const MealGroupsPage = () => {
|
|||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
const sourceList = useMemo(() => listSources(groupList), [groupList]);
|
const sourceList = useMemo(() => listSources(groupList), [groupList]);
|
||||||
|
const recipeList = useMemo(
|
||||||
|
() => groupList.flatMap(group => group.recipe ? [group.recipe] : []),
|
||||||
|
[groupList],
|
||||||
|
);
|
||||||
|
|
||||||
const visibleList = useMemo(() => {
|
const visibleList = useMemo(() => {
|
||||||
const search = normalizeSearch(query);
|
const search = normalizeSearch(query);
|
||||||
@@ -100,6 +105,27 @@ const MealGroupsPage = () => {
|
|||||||
.filter((group): group is MealGroup & { recipe: MealTemplate } => !!group.recipe)
|
.filter((group): group is MealGroup & { recipe: MealTemplate } => !!group.recipe)
|
||||||
.map(group => ({ recipe: group.recipe, photoCount: group.photoCount })), [visibleList]);
|
.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;
|
const filtered = !!query.trim().length || sourceFilter !== SourceFilter.VSE || recipeFilter !== RecipeFilter.VSE;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -196,6 +222,8 @@ const MealGroupsPage = () => {
|
|||||||
key={key}
|
key={key}
|
||||||
group={group}
|
group={group}
|
||||||
groupList={groupList}
|
groupList={groupList}
|
||||||
|
recipeList={recipeList}
|
||||||
|
onOpenRecipe={recipe => openRecipe(recipe)}
|
||||||
action={opened?.key === key ? opened.action : undefined}
|
action={opened?.key === key ? opened.action : undefined}
|
||||||
onAction={action => setOpened(action ? { key, action } : undefined)}
|
onAction={action => setOpened(action ? { key, action } : undefined)}
|
||||||
onChanged={() => { setOpened(undefined); load(); }}
|
onChanged={() => { setOpened(undefined); load(); }}
|
||||||
|
|||||||
@@ -160,8 +160,24 @@
|
|||||||
.recipe-ingredient-row {
|
.recipe-ingredient-row {
|
||||||
align-items: center;
|
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 {
|
.recipe-ingredient-weight {
|
||||||
width: 110px;
|
width: 90px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +203,7 @@
|
|||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.recipe-ingredient-amount,
|
||||||
.recipe-ingredient-weight,
|
.recipe-ingredient-weight,
|
||||||
.recipe-ingredient-rate,
|
.recipe-ingredient-rate,
|
||||||
.recipe-ingredient-calories,
|
.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
|
// Popisek postupu s přepínačem psaní a náhledu
|
||||||
.recipe-note-label {
|
.recipe-note-label {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
+59
-9
@@ -1,9 +1,10 @@
|
|||||||
import type { Content, TDocumentDefinitions } from "pdfmake/interfaces";
|
import type { Content, TDocumentDefinitions } from "pdfmake/interfaces";
|
||||||
import { MealGroup, MealPhoto, MealTemplate, getMealPhotos } from "../../types";
|
import { MealGroup, MealPhoto, MealTemplate, getMealPhotos, getMealTemplates } from "../../types";
|
||||||
import { RECIPE_FLAVOR_NAMES } from "./enums";
|
import { RECIPE_FLAVOR_NAMES, RECIPE_LINK_NAMES, RecipeLinkKind, getRecipeLinkIds } from "./enums";
|
||||||
import { convertMarkdownToPdf } from "./markdownPdf";
|
import { convertMarkdownToPdf } from "./markdownPdf";
|
||||||
import {
|
import {
|
||||||
deriveRecipe, formatCalories, formatCount, formatDate, formatDateString, formatPrice, formatWeight, getIngredientCalories,
|
deriveRecipe, formatCalories, formatCount, formatDate, formatDateString, formatIngredientAmount, formatPrepTime,
|
||||||
|
formatPrice, formatWeight, getIngredientCalories,
|
||||||
} from "./Utils";
|
} from "./Utils";
|
||||||
|
|
||||||
/** Recept s počtem fotek jídla — fotky se stahují jen tam, kde nějaké jsou. */
|
/** 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 [
|
return [
|
||||||
recipe.category,
|
recipe.category,
|
||||||
RECIPE_FLAVOR_NAMES[recipe.flavor].toLowerCase(),
|
RECIPE_FLAVOR_NAMES[recipe.flavor].toLowerCase(),
|
||||||
|
recipe.prepMinutes != null ? `příprava ${formatPrepTime(recipe.prepMinutes)}` : undefined,
|
||||||
formatCount(recipe.servings ?? 1, 'porce', 'porce', 'porcí'),
|
formatCount(recipe.servings ?? 1, 'porce', 'porce', 'porcí'),
|
||||||
recipe.weight != null ? `porce ${formatWeight(recipe.weight)}` : undefined,
|
recipe.weight != null ? `porce ${formatWeight(recipe.weight)}` : undefined,
|
||||||
recipe.calories != null ? `${formatCalories(recipe.calories)} na porci` : undefined,
|
recipe.calories != null ? `${formatCalories(recipe.calories)} na porci` : undefined,
|
||||||
@@ -34,6 +36,33 @@ const getRecipeMeta = (recipe: MealTemplate): string => {
|
|||||||
].filter(item => !!item).join(' · ');
|
].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. */
|
/** První fotka přes celou šířku, ostatní menší v řadách po třech. */
|
||||||
const createPhotos = (photoList: MealPhoto[]): Content[] => {
|
const createPhotos = (photoList: MealPhoto[]): Content[] => {
|
||||||
const [first, ...rest] = photoList;
|
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 }));
|
.map((text, index) => ({ text, style: 'tableHeader', alignment: index ? 'right' as const : 'left' as const }));
|
||||||
const rowList = recipe.ingredients.map(item => [
|
const rowList = recipe.ingredients.map(item => [
|
||||||
item.name,
|
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 },
|
{ text: formatCalories(getIngredientCalories(item)), alignment: 'right' as const },
|
||||||
...(hasPrice ? [{ text: item.price != null ? formatPrice(item.price) : '', 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: {
|
table: {
|
||||||
headerRows: 1,
|
headerRows: 1,
|
||||||
widths: ['*', 70, 70, ...(hasPrice ? [70] : [])],
|
widths: ['*', 100, 70, ...(hasPrice ? [70] : [])],
|
||||||
body: [header, ...rowList],
|
body: [header, ...rowList],
|
||||||
},
|
},
|
||||||
layout: {
|
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: [
|
stack: [
|
||||||
{
|
{
|
||||||
text: recipe.name,
|
text: recipe.name,
|
||||||
style: 'recipeTitle',
|
style: 'recipeTitle',
|
||||||
id: `recept-${index}`,
|
id: getRecipeAnchor(index),
|
||||||
tocItem: withToc,
|
tocItem: withToc,
|
||||||
},
|
},
|
||||||
...(recipe.source ? [{ text: recipe.source, style: 'recipeSource' }] : []),
|
...(recipe.source ? [{ text: recipe.source, style: 'recipeSource' }] : []),
|
||||||
{ text: getRecipeMeta(recipe), style: 'recipeMeta' },
|
{ text: getRecipeMeta(recipe), style: 'recipeMeta' },
|
||||||
|
...createLinks(recipe, recipeList, indexMap),
|
||||||
...createPhotos(photoList),
|
...createPhotos(photoList),
|
||||||
...createIngredients(recipe),
|
...createIngredients(recipe),
|
||||||
...(recipe.note?.trim()
|
...(recipe.note?.trim()
|
||||||
@@ -139,7 +178,13 @@ const loadPdfMake = async () => {
|
|||||||
*/
|
*/
|
||||||
export const exportRecipesPdf = async (itemList: RecipeItem[]): Promise<void> => {
|
export const exportRecipesPdf = async (itemList: RecipeItem[]): Promise<void> => {
|
||||||
const sortedList = [...itemList].sort((a, b) => a.recipe.name.localeCompare(b.recipe.name, 'cs'));
|
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 withToc = sortedList.length > 1;
|
||||||
const title = withToc ? 'Recepty' : sortedList[0]?.recipe.name ?? 'Recept';
|
const title = withToc ? 'Recepty' : sortedList[0]?.recipe.name ?? 'Recept';
|
||||||
|
|
||||||
@@ -159,7 +204,11 @@ export const exportRecipesPdf = async (itemList: RecipeItem[]): Promise<void> =>
|
|||||||
info: { title },
|
info: { title },
|
||||||
pageSize: 'A4',
|
pageSize: 'A4',
|
||||||
pageMargins: [40, 40, 40, 50],
|
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) => ({
|
footer: (currentPage, pageCount) => ({
|
||||||
text: `${currentPage} / ${pageCount}`,
|
text: `${currentPage} / ${pageCount}`,
|
||||||
alignment: 'center',
|
alignment: 'center',
|
||||||
@@ -172,6 +221,7 @@ export const exportRecipesPdf = async (itemList: RecipeItem[]): Promise<void> =>
|
|||||||
recipeTitle: { fontSize: 20, bold: true, color: PRIMARY_COLOR },
|
recipeTitle: { fontSize: 20, bold: true, color: PRIMARY_COLOR },
|
||||||
recipeSource: { fontSize: 11, color: MUTED_COLOR },
|
recipeSource: { fontSize: 11, color: MUTED_COLOR },
|
||||||
recipeMeta: { fontSize: 9, color: MUTED_COLOR, margin: [0, 4, 0, 12] },
|
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] },
|
sectionHeading: { fontSize: 13, bold: true, margin: [0, 10, 0, 6] },
|
||||||
tableHeader: { bold: true, fontSize: 9, color: MUTED_COLOR },
|
tableHeader: { bold: true, fontSize: 9, color: MUTED_COLOR },
|
||||||
summary: { bold: true, fontSize: 9, color: MUTED_COLOR, margin: [0, 6, 0, 0] },
|
summary: { bold: true, fontSize: 9, color: MUTED_COLOR, margin: [0, 6, 0, 0] },
|
||||||
|
|||||||
+132
-25
@@ -1,5 +1,5 @@
|
|||||||
import { randomUUID } from 'crypto';
|
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 getStorage from "./storage";
|
||||||
import { addMeal, getNameSourceKey, isMealType } from "./meals";
|
import { addMeal, getNameSourceKey, isMealType } from "./meals";
|
||||||
import { rememberCalories } from "./calories";
|
import { rememberCalories } from "./calories";
|
||||||
@@ -14,15 +14,34 @@ const MAX_NOTE_LENGTH = 4000;
|
|||||||
/** Maximální počet surovin receptu — pojistka proti nesmyslnému vstupu. */
|
/** Maximální počet surovin receptu — pojistka proti nesmyslnému vstupu. */
|
||||||
const MAX_INGREDIENTS = 50;
|
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. */
|
/** Klíč úložiště s recepty jednoho uživatele. */
|
||||||
const getKey = (login: string): string => `mealTemplates:${login}`;
|
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
|
* 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
|
* i šablony hotových jídel (druh `HOTOVE`, nebo bez druhu), recepty nesly
|
||||||
* `kind: 'RECEPT'` a měly i chod.
|
* `kind: 'RECEPT'` a měly i chod.
|
||||||
*/
|
*/
|
||||||
type StoredMealTemplate = Omit<MealTemplate, 'ingredients' | 'flavor'> & {
|
type StoredMealTemplate = Omit<OwnRecipe, 'ingredients' | 'flavor'> & {
|
||||||
ingredients?: RecipeIngredient[];
|
ingredients?: RecipeIngredient[];
|
||||||
flavor?: RecipeFlavor;
|
flavor?: RecipeFlavor;
|
||||||
kind?: string;
|
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.
|
* 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.
|
* 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;
|
const { kind, pricePer100g, mealType, ...rest } = stored;
|
||||||
return { ...rest, flavor: rest.flavor ?? RecipeFlavor.SLANE };
|
return { ...rest, flavor: rest.flavor ?? RecipeFlavor.SLANE };
|
||||||
};
|
};
|
||||||
@@ -56,13 +75,41 @@ const normalizeFlavor = (value: unknown): RecipeFlavor => {
|
|||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Recept tak, jak se ukládá — výchozí slanou chuť neukládá. */
|
/** Seznam vazeb do úložiště i API — prázdný se vynechá. */
|
||||||
const toStored = ({ flavor, ...rest }: MealTemplate): StoredMealTemplate =>
|
const toLinkIds = (idList?: string[]): string[] | undefined => (idList?.length ? idList : undefined);
|
||||||
flavor === RecipeFlavor.SLANE ? rest : { ...rest, flavor };
|
|
||||||
|
/** 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á. */
|
/** Načte recepty z úložiště; šablony hotových jídel vynechá. */
|
||||||
const loadRecipes = (storedList: StoredMealTemplate[] | undefined): MealTemplate[] =>
|
const loadRecipes = (storedList: StoredMealTemplate[] | undefined): OwnRecipe[] =>
|
||||||
(storedList ?? []).filter(isRecipe).map(toMealTemplate);
|
(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.
|
* 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
|
* @param login přihlašovací jméno uživatele
|
||||||
*/
|
*/
|
||||||
export const getMealTemplates = async (login: string): Promise<MealTemplate[]> =>
|
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'));
|
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
|
||||||
|
|
||||||
/** Hodnoty hotového jídla a jedné porce receptu dopočtené ze surovin. */
|
/** 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ě.
|
* 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.
|
* 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.
|
* Klient má stejnou funkci v `Utils.tsx` pro živý náhled — musí dávat stejné výsledky.
|
||||||
*
|
*
|
||||||
* @param ingredients suroviny receptu
|
* @param ingredients suroviny receptu
|
||||||
@@ -169,9 +219,17 @@ const normalizeNumber = (value: unknown, name: string): number | undefined => {
|
|||||||
return rounded;
|
return rounded;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Vynechá nevyplněné hodnoty, aby se do úložiště nepsaly prázdné klíče. */
|
const isIngredientUnit = (value: unknown): value is IngredientUnit =>
|
||||||
const definedOnly = <T extends object>(value: T): T =>
|
Object.values(IngredientUnit).includes(value as IngredientUnit);
|
||||||
Object.fromEntries(Object.entries(value).filter(([, item]) => item != null)) as T;
|
|
||||||
|
/** 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. */
|
/** Ověří a znormalizuje jednu surovinu receptu. */
|
||||||
const normalizeIngredient = (raw: unknown, index: number): RecipeIngredient => {
|
const normalizeIngredient = (raw: unknown, index: number): RecipeIngredient => {
|
||||||
@@ -180,12 +238,19 @@ const normalizeIngredient = (raw: unknown, index: number): RecipeIngredient => {
|
|||||||
if (!name) {
|
if (!name) {
|
||||||
throw new BadRequestError(`Surovina ${index + 1} nemá název`);
|
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 weight = normalizeNumber(value.weight, 'weight');
|
||||||
const caloriesPer100g = normalizeNumber(value.caloriesPer100g, 'caloriesPer100g');
|
const caloriesPer100g = normalizeNumber(value.caloriesPer100g, 'caloriesPer100g');
|
||||||
const calories = normalizeNumber(value.calories, 'calories');
|
const calories = normalizeNumber(value.calories, 'calories');
|
||||||
const price = normalizeNumber(value.price, 'price');
|
const price = normalizeNumber(value.price, 'price');
|
||||||
return definedOnly({
|
return definedOnly({
|
||||||
name,
|
name,
|
||||||
|
// Bez jednotky je množstvím gramáž, samostatné množství by ji jen zdvojovalo
|
||||||
|
amount: unit ? normalizeAmount(value.amount, index) : undefined,
|
||||||
|
unit,
|
||||||
weight,
|
weight,
|
||||||
caloriesPer100g,
|
caloriesPer100g,
|
||||||
// Ručně zadaná energie platí jen tam, kde ji nejde dopočítat z gramáže
|
// 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. */
|
/** 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.
|
* 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ží
|
* 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é.
|
* 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.
|
* Zápis zároveň z úložiště odklidí zaniklé šablony hotových jídel.
|
||||||
*
|
*
|
||||||
* @param login přihlašovací jméno uživatele
|
* @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 ingredients = value.ingredients.map(normalizeIngredient);
|
||||||
const servings = normalizeNumber(value.servings, 'servings');
|
const servings = normalizeNumber(value.servings, 'servings');
|
||||||
const cookedWeight = normalizeNumber(value.cookedWeight, 'cookedWeight') || undefined;
|
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 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({
|
const template: OwnRecipe = definedOnly({
|
||||||
id: typeof value.id === 'string' && value.id.length ? value.id : randomUUID(),
|
id,
|
||||||
name,
|
name,
|
||||||
source: normalizeText(value.source, 'source'),
|
source: normalizeText(value.source, 'source'),
|
||||||
category: normalizeText(value.category, 'category'),
|
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
|
// Jedna porce je výchozí stav, ukládat ji zvlášť nemá smysl
|
||||||
servings: servings && servings > 1 ? servings : undefined,
|
servings: servings && servings > 1 ? servings : undefined,
|
||||||
cookedWeight,
|
cookedWeight,
|
||||||
|
prepMinutes,
|
||||||
|
sideDishIds: normalizeLinkIds(value.sideDishIds, recipeIds, id),
|
||||||
|
similarIds: normalizeLinkIds(value.similarIds, recipeIds, id),
|
||||||
weight: summary.weight,
|
weight: summary.weight,
|
||||||
caloriesPer100g: summary.caloriesPer100g,
|
caloriesPer100g: summary.caloriesPer100g,
|
||||||
calories: summary.calories,
|
calories: summary.calories,
|
||||||
price: summary.price,
|
price: summary.price,
|
||||||
});
|
});
|
||||||
const otherRecipe = (await getMealTemplates(login))
|
const otherRecipe = recipeList
|
||||||
.find(item => item.id !== template.id && getRecipeKey(item) === getRecipeKey(template));
|
.find(item => item.id !== template.id && getRecipeKey(item) === getRecipeKey(template));
|
||||||
if (otherRecipe) {
|
if (otherRecipe) {
|
||||||
throw new BadRequestError("Toto jídlo už recept má");
|
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 => [
|
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),
|
toStored(template),
|
||||||
]);
|
]);
|
||||||
return getMealTemplates(login);
|
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 login přihlašovací jméno uživatele
|
||||||
* @param id identifikátor receptu
|
* @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 => {
|
await storage.updateData<StoredMealTemplate[]>(getKey(login), current => {
|
||||||
const recipeList = loadRecipes(current);
|
const recipeList = loadRecipes(current);
|
||||||
found = recipeList.some(item => item.id === id);
|
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) {
|
if (!found) {
|
||||||
throw new NotFoundError("Recept nebyl nalezen");
|
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
|
* 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 login přihlašovací jméno uživatele
|
||||||
* @param fromKey dosavadní klíč jídla (`getNameSourceKey`)
|
* @param fromKey dosavadní klíč jídla (`getNameSourceKey`)
|
||||||
@@ -343,10 +451,9 @@ export const applyMealTemplate = async (
|
|||||||
export const renameMealTemplate = async (
|
export const renameMealTemplate = async (
|
||||||
login: string, fromKey: string, name: string, source?: string,
|
login: string, fromKey: string, name: string, source?: string,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const recipeList = await getMealTemplates(login);
|
if (!(await getMealTemplates(login)).some(item => getRecipeKey(item) === fromKey)) return;
|
||||||
const recipe = recipeList.find(item => getRecipeKey(item) === fromKey);
|
|
||||||
if (!recipe) return;
|
|
||||||
const renamed: MealTemplate = definedOnly({ ...recipe, name, source });
|
|
||||||
await storage.updateData<StoredMealTemplate[]>(getKey(login), current =>
|
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)));
|
||||||
};
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1206,6 +1206,26 @@ MealTemplate:
|
|||||||
Hmotnost hotového jídla v gramech. Vařením se voda odpaří nebo nasákne,
|
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.
|
takže se liší od součtu surovin; bez ní se použije součet surovin.
|
||||||
type: integer
|
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:
|
MealTemplateInput:
|
||||||
description: |
|
description: |
|
||||||
@@ -1240,6 +1260,35 @@ MealTemplateInput:
|
|||||||
type: integer
|
type: integer
|
||||||
cookedWeight:
|
cookedWeight:
|
||||||
type: integer
|
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:
|
RecipeFlavor:
|
||||||
description: Zda je recept slaný, nebo sladký
|
description: Zda je recept slaný, nebo sladký
|
||||||
@@ -1276,8 +1325,17 @@ RecipeIngredient:
|
|||||||
name:
|
name:
|
||||||
description: Název suroviny
|
description: Název suroviny
|
||||||
type: string
|
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:
|
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
|
type: integer
|
||||||
caloriesPer100g:
|
caloriesPer100g:
|
||||||
description: Energie suroviny na 100 g v kcal
|
description: Energie suroviny na 100 g v kcal
|
||||||
|
|||||||
Reference in new issue
Block a user