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í
|
||||
hotového jídla (`cookedWeight`) a postupem v `note`. **Chod nemá** — patří ke dni,
|
||||
ne k jídlu; použije se zvolený při použití, jinak oběd (starší recepty ho v úložišti
|
||||
mít můžou, `toMealTemplate` ho zahodí). `deriveRecipe` z nich při
|
||||
mít můžou, `toOwnRecipe` ho zahodí). `deriveRecipe` z nich při
|
||||
uložení spočítá hodnoty **jedné porce** a uloží je do plochých polí (`weight`,
|
||||
`caloriesPer100g`, `calories`, `price`); použitím vznikne běžný `MealEntry` přes
|
||||
`addMeal`. Energie na 100 g se bere z hmotnosti hotového jídla, ne ze součtu
|
||||
@@ -338,8 +338,27 @@ surovin — vařením se voda odpaří nebo nasákne. Postup se do záznamu dne
|
||||
Recept má volitelný **typ** (`category` — polévka, omáčka, pečení…) jako volný
|
||||
text; formulář nabízí typy z ostatních receptů (`RecipeCategoryInput`), pevný
|
||||
číselník to záměrně není. **Chuť** (`flavor`, `RecipeFlavor`: SLANE/SLADKE) je
|
||||
v API vždy vyplněná; slaná je výchozí a do úložiště se nezapisuje, `toMealTemplate`
|
||||
v API vždy vyplněná; slaná je výchozí a do úložiště se nezapisuje, `toOwnRecipe`
|
||||
ji při čtení doplní.
|
||||
Volitelný je i **čas přípravy** (`prepMinutes`, v minutách).
|
||||
|
||||
**Jednotky surovin:** surovina má množství v gramech (`weight`), nebo v jiné
|
||||
jednotce (`amount` + `unit`, `IngredientUnit`: ks, ml, l, lžíce…) — vejce
|
||||
na kusy, mléko v litrech. U jiné jednotky je `weight` jen nepovinný **přepočet
|
||||
na gramy pro výživu**; energie i hmotnost jídla se počítají vždy z gramů
|
||||
a surovina bez přepočtu do nich nevstupuje. Hustotu ani hmotnost kusu aplikace
|
||||
neodhaduje, bylo by to vymyšlené číslo. Bez jednotky se `amount` neukládá.
|
||||
|
||||
**Vazby mezi recepty:** přílohy (`sideDishIds`, směrové — ukládají se u hlavního
|
||||
jídla, druhá strana je `sideDishOfIds`) a podobné (`similarIds`, oboustranné,
|
||||
uložené jen u jednoho z dvojice). Druhou stranu dopočte při čtení `withLinks`,
|
||||
do úložiště se nezapisuje. Uložení receptu je pro všechny vazby, které se ho
|
||||
týkají, autoritativní (`relinkOther` je odebere ostatním a znovu zapíše jen
|
||||
podle vstupu), takže vazbu jde přidat i odebrat z kteréhokoli z obou receptů.
|
||||
Smazání receptu vazby na něj odklidí. Karta receptu vazby ukazuje jako odkazy
|
||||
(v seznamu jídel i na kole štěstí), PDF jako odkazy na stránku receptu, pokud
|
||||
je v tomtéž exportu.
|
||||
|
||||
Recepty se nabízejí v našeptávání i ve výběru z historie (`ApplyMealModal`)
|
||||
a jídlo stejného jména z historie se vedle receptu nenabízí podruhé.
|
||||
|
||||
|
||||
+41
-2
@@ -1,5 +1,5 @@
|
||||
import { MealSuggestion, MealTemplate, Nutrients } from "../../types";
|
||||
import { NUTRIENTS_IN_ORDER } from "./enums";
|
||||
import { MealSuggestion, MealTemplate, Nutrients, RecipeIngredient } from "../../types";
|
||||
import { INGREDIENT_UNIT_FORMS, NUTRIENTS_IN_ORDER } from "./enums";
|
||||
|
||||
const TOKEN_KEY = "token";
|
||||
|
||||
@@ -338,6 +338,45 @@ export const deriveRecipe = (
|
||||
};
|
||||
};
|
||||
|
||||
/** Převede množství suroviny zadané uživatelem (i s desetinnou čárkou) na číslo; nekladný vstup odmítne. */
|
||||
export const parseAmountInput = (value: string): number | undefined => {
|
||||
const trimmed = value.trim().replace(',', '.');
|
||||
if (!trimmed.length) return undefined;
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed * 100) / 100 : undefined;
|
||||
};
|
||||
|
||||
/** Převede množství suroviny na text pro editační pole. */
|
||||
export const formatAmountInput = (amount?: number): string =>
|
||||
amount == null ? '' : String(amount).replace('.', ',');
|
||||
|
||||
/**
|
||||
* Naformátuje množství suroviny receptu: "200 g", "3 ks (180 g)", "špetka".
|
||||
* Gramáž u jiné jednotky je v závorce — je to přepočet pro výživu, ne údaj receptu.
|
||||
*/
|
||||
export const formatIngredientAmount = (ingredient: RecipeIngredient): string => {
|
||||
const { amount, unit, weight } = ingredient;
|
||||
if (!unit) return weight != null ? formatWeight(weight) : '';
|
||||
const [one, few, many, fraction] = INGREDIENT_UNIT_FORMS[unit];
|
||||
let text = one;
|
||||
if (amount != null) {
|
||||
let form = many;
|
||||
if (!Number.isInteger(amount)) form = fraction;
|
||||
else if (amount === 1) form = one;
|
||||
else if (amount >= 2 && amount <= 4) form = few;
|
||||
text = `${amount.toLocaleString('cs-CZ', { maximumFractionDigits: 2 })} ${form}`;
|
||||
}
|
||||
return weight != null ? `${text} (${formatWeight(weight)})` : text;
|
||||
};
|
||||
|
||||
/** Naformátuje čas přípravy z minut, např. 90 → "1 h 30 min". */
|
||||
export const formatPrepTime = (minutes: number): string => {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const rest = minutes % 60;
|
||||
if (!hours) return `${rest} min`;
|
||||
return rest ? `${hours} h ${rest} min` : `${hours} h`;
|
||||
};
|
||||
|
||||
/** Znormalizuje text pro hledání — bez diakritiky a malými písmeny. */
|
||||
export const normalizeSearch = (value: string): string =>
|
||||
value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim();
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useState } from "react";
|
||||
import { MealGroup, deleteMealTemplate } from "../../../types";
|
||||
import { MealGroup, MealTemplate, deleteMealTemplate } from "../../../types";
|
||||
import MealTemplateCard from "./MealTemplateCard";
|
||||
import MealTemplateForm from "./MealTemplateForm";
|
||||
import { useRecipeExport } from "../useRecipeExport";
|
||||
|
||||
type Props = {
|
||||
group: MealGroup,
|
||||
/** Všechny recepty — pro vazby na jiné recepty */
|
||||
recipeList: MealTemplate[],
|
||||
onOpenRecipe: (recipe: MealTemplate) => void,
|
||||
/** Zavolá se po uložení nebo smazání receptu, aby se seznam načetl znovu. */
|
||||
onChanged: () => void,
|
||||
onClose: () => void,
|
||||
@@ -15,7 +18,7 @@ type Props = {
|
||||
* Recept jídla v seznamu jídel. Recept rozšiřuje jídlo (název a zdroj) o suroviny
|
||||
* a postup; jídlo bez receptu nabídne rovnou formulář na jeho založení.
|
||||
*/
|
||||
const MealGroupRecipe = ({ group, onChanged, onClose }: Readonly<Props>) => {
|
||||
const MealGroupRecipe = ({ group, recipeList, onOpenRecipe, onChanged, onClose }: Readonly<Props>) => {
|
||||
const [editing, setEditing] = useState(!group.recipe);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const { exporting, exportRecipes } = useRecipeExport();
|
||||
@@ -33,6 +36,8 @@ const MealGroupRecipe = ({ group, onChanged, onClose }: Readonly<Props>) => {
|
||||
<MealTemplateCard
|
||||
template={group.recipe}
|
||||
busy={busy || exporting}
|
||||
recipeList={recipeList}
|
||||
onOpenRecipe={onOpenRecipe}
|
||||
onExport={() => exportRecipes([{ recipe: group.recipe!, photoCount: group.photoCount }])}
|
||||
onEdit={() => setEditing(true)}
|
||||
onDelete={() => remove(group.recipe!.id)}
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useState } from "react";
|
||||
import { Button } from "react-bootstrap";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faBookOpen, faCalendarDays, faCamera, faPen, faPenToSquare } from "@fortawesome/free-solid-svg-icons";
|
||||
import { MealGroup, RecipeFlavor } from "../../../types";
|
||||
import { MealGroup, MealTemplate, RecipeFlavor } from "../../../types";
|
||||
import { MealGroupAction, RECIPE_FLAVOR_NAMES } from "../enums";
|
||||
import { formatDateString } from "../Utils";
|
||||
import { formatDateString, getMealKey } from "../Utils";
|
||||
import MealGroupForm from "./MealGroupForm";
|
||||
import MealGroupRenameForm from "./MealGroupRenameForm";
|
||||
import MealGroupDays from "./MealGroupDays";
|
||||
@@ -15,6 +15,10 @@ type Props = {
|
||||
group: MealGroup,
|
||||
/** Všechny skupiny — přejmenování podle nich pozná sloučení s jinou. */
|
||||
groupList: MealGroup[],
|
||||
/** Všechny recepty — pro vazby receptu na jiné */
|
||||
recipeList: MealTemplate[],
|
||||
/** Otevře recept jiného jídla — z vazby receptu */
|
||||
onOpenRecipe: (recipe: MealTemplate) => void,
|
||||
/** Rozbalená akce; nejvýš jeden řádek seznamu má nějakou otevřenou. */
|
||||
action?: MealGroupAction,
|
||||
onAction: (action?: MealGroupAction) => void,
|
||||
@@ -33,11 +37,13 @@ const describeMissing = (missing: number, count: number): string =>
|
||||
* Nabízí recept a fotky jídla, doplnění údajů a změnu názvu a zdroje všem
|
||||
* jídlům skupiny a dny, kdy se jídlo jedlo, jako odkazy na přehled dne.
|
||||
*/
|
||||
const MealGroupRow = ({ group, groupList, action, onAction, onChanged, onRefresh }: Readonly<Props>) => {
|
||||
const MealGroupRow = ({
|
||||
group, groupList, recipeList, onOpenRecipe, action, onAction, onChanged, onRefresh,
|
||||
}: Readonly<Props>) => {
|
||||
const [daysOpen, setDaysOpen] = useState(false);
|
||||
const toggle = (value: MealGroupAction) => onAction(action === value ? undefined : value);
|
||||
return (
|
||||
<div className="card-panel meal-group">
|
||||
<div className="card-panel meal-group" data-meal-key={getMealKey(group)}>
|
||||
<div className="meal-group-head">
|
||||
{group.thumbnail && (
|
||||
<button
|
||||
@@ -136,7 +142,13 @@ const MealGroupRow = ({ group, groupList, action, onAction, onChanged, onRefresh
|
||||
<MealGroupForm group={group} onFilled={onChanged} onCancel={() => onAction(undefined)} />
|
||||
)}
|
||||
{action === MealGroupAction.RECEPT && (
|
||||
<MealGroupRecipe group={group} onChanged={onChanged} onClose={() => onAction(undefined)} />
|
||||
<MealGroupRecipe
|
||||
group={group}
|
||||
recipeList={recipeList}
|
||||
onOpenRecipe={onOpenRecipe}
|
||||
onChanged={onChanged}
|
||||
onClose={() => onAction(undefined)}
|
||||
/>
|
||||
)}
|
||||
{action === MealGroupAction.FOTKY && <MealGroupPhotos group={group} onChanged={onRefresh} />}
|
||||
{action === MealGroupAction.PREJMENOVAT && (
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faFilePdf, faPenToSquare, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faClock, faFilePdf, faPenToSquare, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { MealTemplate } from "../../../types";
|
||||
import { RECIPE_FLAVOR_NAMES } from "../enums";
|
||||
import { deriveRecipe, formatCalories, formatCount, formatPrice, formatWeight, getIngredientCalories } from "../Utils";
|
||||
import {
|
||||
deriveRecipe, formatCalories, formatCount, formatIngredientAmount, formatPrepTime, formatPrice, formatWeight,
|
||||
getIngredientCalories,
|
||||
} from "../Utils";
|
||||
import RecipeNote from "./RecipeNote";
|
||||
import RecipeLinks from "./RecipeLinks";
|
||||
|
||||
type Props = {
|
||||
template: MealTemplate,
|
||||
busy?: boolean,
|
||||
/** Všechny recepty — bez nich se vazby na jiné recepty nezobrazí. */
|
||||
recipeList?: MealTemplate[],
|
||||
onOpenRecipe?: (recipe: MealTemplate) => void,
|
||||
/** Bez úpravy a smazání je karta jen k nahlédnutí (kolo štěstí). */
|
||||
onEdit?: () => void,
|
||||
onDelete?: () => void,
|
||||
@@ -18,7 +25,7 @@ type Props = {
|
||||
* Karta receptu uvnitř jídla v seznamu jídel — suroviny, hodnoty celého hrnce
|
||||
* i jedné porce a postup. Název a zdroj neopakuje, nese je jídlo.
|
||||
*/
|
||||
const MealTemplateCard = ({ template, busy, onEdit, onDelete, onExport }: Readonly<Props>) => {
|
||||
const MealTemplateCard = ({ template, busy, recipeList, onOpenRecipe, onEdit, onDelete, onExport }: Readonly<Props>) => {
|
||||
const recipe = deriveRecipe(template.ingredients, template.servings, template.cookedWeight);
|
||||
|
||||
return (
|
||||
@@ -28,6 +35,11 @@ const MealTemplateCard = ({ template, busy, onEdit, onDelete, onExport }: Readon
|
||||
Recept
|
||||
{template.category && <span className="tag">{template.category}</span>}
|
||||
<span className="tag">{RECIPE_FLAVOR_NAMES[template.flavor].toLowerCase()}</span>
|
||||
{template.prepMinutes != null && (
|
||||
<span className="tag" title="Čas přípravy">
|
||||
<FontAwesomeIcon icon={faClock} /> {formatPrepTime(template.prepMinutes)}
|
||||
</span>
|
||||
)}
|
||||
{template.weight != null && <span className="tag">porce {formatWeight(template.weight)}</span>}
|
||||
{(template.servings ?? 1) > 1 && (
|
||||
<span className="tag">{formatCount(template.servings ?? 1, 'porce', 'porce', 'porcí')}</span>
|
||||
@@ -74,9 +86,7 @@ const MealTemplateCard = ({ template, busy, onEdit, onDelete, onExport }: Readon
|
||||
// Suroviny nemají vlastní id, v rámci jednoho receptu je index stabilní
|
||||
<li key={index}>
|
||||
<span className="template-item-name">{ingredient.name}</span>
|
||||
<span className="template-item-quantity">
|
||||
{ingredient.weight != null ? formatWeight(ingredient.weight) : ''}
|
||||
</span>
|
||||
<span className="template-item-quantity">{formatIngredientAmount(ingredient)}</span>
|
||||
<span className="template-item-calories">
|
||||
{formatCalories(getIngredientCalories(ingredient))}
|
||||
</span>
|
||||
@@ -92,6 +102,8 @@ const MealTemplateCard = ({ template, busy, onEdit, onDelete, onExport }: Readon
|
||||
{recipe.totalPrice != null && <> · {formatPrice(recipe.totalPrice)}</>}
|
||||
</div>
|
||||
|
||||
{recipeList && <RecipeLinks template={template} recipeList={recipeList} onOpen={onOpenRecipe} />}
|
||||
|
||||
{template.note && <RecipeNote note={template.note} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useId, useState } from "react";
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { Alert, Button, Form, ToggleButton, ToggleButtonGroup } from "react-bootstrap";
|
||||
import { MealTemplate, RecipeFlavor, saveMealTemplate } from "../../../types";
|
||||
import { RECIPE_FLAVOR_NAMES } from "../enums";
|
||||
import { deriveRecipe, formatCalories, formatPrice, formatWeight, parseIntInput } from "../Utils";
|
||||
import { MealTemplate, RecipeFlavor, getMealTemplates, saveMealTemplate } from "../../../types";
|
||||
import { RECIPE_FLAVOR_NAMES, RECIPE_LINK_FIELDS, RECIPE_LINK_NAMES, RecipeLinkKind, getRecipeLinkIds } from "../enums";
|
||||
import { deriveRecipe, formatCalories, formatPrepTime, formatPrice, formatWeight, parseIntInput } from "../Utils";
|
||||
import SourceInput from "./SourceInput";
|
||||
import RecipeCategoryInput from "./RecipeCategoryInput";
|
||||
import RecipeNote from "./RecipeNote";
|
||||
import RecipeLinksInput from "./RecipeLinksInput";
|
||||
import RecipeIngredientsEditor, {
|
||||
DraftIngredient, emptyIngredient, toDraftIngredient, toIngredient,
|
||||
} from "./RecipeIngredientsEditor";
|
||||
@@ -34,11 +35,34 @@ const createForm = (template?: MealTemplate, meal?: Props['meal']) => ({
|
||||
note: template?.note ?? '',
|
||||
servings: template?.servings != null ? String(template.servings) : '',
|
||||
cookedWeight: template?.cookedWeight != null ? String(template.cookedWeight) : '',
|
||||
prepMinutes: template?.prepMinutes != null ? String(template.prepMinutes) : '',
|
||||
});
|
||||
|
||||
/** Vazby receptu podle druhu, jak se editují ve formuláři. */
|
||||
const createLinks = (template?: MealTemplate): Record<RecipeLinkKind, string[]> => ({
|
||||
PRILOHY: template ? getRecipeLinkIds(template, RecipeLinkKind.PRILOHY) : [],
|
||||
PRILOHOU_K: template ? getRecipeLinkIds(template, RecipeLinkKind.PRILOHOU_K) : [],
|
||||
PODOBNE: template ? getRecipeLinkIds(template, RecipeLinkKind.PODOBNE) : [],
|
||||
});
|
||||
|
||||
/** Jídlo nemůže být zároveň přílohou receptu i jídlem, ke kterému je recept přílohou. */
|
||||
const getOppositeLinkIds = (links: Record<RecipeLinkKind, string[]>, kind: RecipeLinkKind): string[] => {
|
||||
if (kind === RecipeLinkKind.PRILOHY) return links.PRILOHOU_K;
|
||||
if (kind === RecipeLinkKind.PRILOHOU_K) return links.PRILOHY;
|
||||
return [];
|
||||
};
|
||||
|
||||
/** Výzva v nabídce receptů k propojení podle druhu vazby. */
|
||||
const LINK_PLACEHOLDERS: Record<RecipeLinkKind, string> = {
|
||||
PRILOHY: 'Přidat přílohu…',
|
||||
PRILOHOU_K: 'Přidat jídlo, ke kterému se hodí…',
|
||||
PODOBNE: 'Přidat podobný recept…',
|
||||
};
|
||||
|
||||
/**
|
||||
* Formulář receptu — vařeného jídla. Zapisují se suroviny a postup a hodnoty
|
||||
* porce se z nich dopočtou — stejně jako na serveru, tady jen jako živý náhled.
|
||||
* Vazby na jiné recepty jdou zapsat z kterékoli strany, server upraví i druhou.
|
||||
*/
|
||||
const MealTemplateForm = ({ template, meal, onSaved, onCancel }: Readonly<Props>) => {
|
||||
const [form, setForm] = useState(() => createForm(template, meal));
|
||||
@@ -47,14 +71,22 @@ const MealTemplateForm = ({ template, meal, onSaved, onCancel }: Readonly<Props>
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [notePreview, setNotePreview] = useState(false);
|
||||
const [links, setLinks] = useState(() => createLinks(template));
|
||||
const [otherRecipeList, setOtherRecipeList] = useState<MealTemplate[]>([]);
|
||||
const flavorId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
getMealTemplates().then(response =>
|
||||
setOtherRecipeList((response.data ?? []).filter(recipe => recipe.id !== template?.id)));
|
||||
}, [template?.id]);
|
||||
|
||||
const update = (changes: Partial<ReturnType<typeof createForm>>) =>
|
||||
setForm(current => ({ ...current, ...changes }));
|
||||
|
||||
const filledIngredients = ingredients.filter(item => item.name.trim().length > 0).map(toIngredient);
|
||||
const recipe = deriveRecipe(filledIngredients, parseIntInput(form.servings), parseIntInput(form.cookedWeight));
|
||||
const ingredientWeight = filledIngredients.reduce((sum, item) => sum + (item.weight ?? 0), 0);
|
||||
const prepMinutes = parseIntInput(form.prepMinutes);
|
||||
|
||||
const canSave = form.name.trim().length > 0 && filledIngredients.length > 0 && !busy;
|
||||
|
||||
@@ -73,6 +105,8 @@ const MealTemplateForm = ({ template, meal, onSaved, onCancel }: Readonly<Props>
|
||||
ingredients: filledIngredients,
|
||||
servings: parseIntInput(form.servings),
|
||||
cookedWeight: parseIntInput(form.cookedWeight),
|
||||
prepMinutes,
|
||||
...Object.fromEntries(Object.values(RecipeLinkKind).map(kind => [RECIPE_LINK_FIELDS[kind], links[kind]])),
|
||||
};
|
||||
const response = await saveMealTemplate({ body });
|
||||
setBusy(false);
|
||||
@@ -111,11 +145,24 @@ const MealTemplateForm = ({ template, meal, onSaved, onCancel }: Readonly<Props>
|
||||
)}
|
||||
|
||||
<div className="row g-3 mb-3">
|
||||
<div className="col-sm-7">
|
||||
<div className="col-sm-5">
|
||||
<Form.Label>Typ</Form.Label>
|
||||
<RecipeCategoryInput value={form.category} onChange={category => update({ category })} />
|
||||
</div>
|
||||
<div className="col-sm-5">
|
||||
<div className="col-sm-3">
|
||||
<Form.Label>Čas přípravy</Form.Label>
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={form.prepMinutes}
|
||||
placeholder="volitelné"
|
||||
onChange={event => update({ prepMinutes: event.target.value.replace(/\D/g, '') })}
|
||||
/>
|
||||
<span className="input-group-text">min</span>
|
||||
</div>
|
||||
{!!prepMinutes && prepMinutes >= 60 && <Form.Text>{formatPrepTime(prepMinutes)}</Form.Text>}
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
<Form.Label className="d-block">Chuť</Form.Label>
|
||||
<ToggleButtonGroup
|
||||
type="radio"
|
||||
@@ -182,6 +229,26 @@ const MealTemplateForm = ({ template, meal, onSaved, onCancel }: Readonly<Props>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{!!otherRecipeList.length && (
|
||||
<fieldset className="meal-fieldset mb-3">
|
||||
<legend>Souvisí s</legend>
|
||||
{Object.values(RecipeLinkKind).map(kind => (
|
||||
<div className="row g-2 mb-2 align-items-center" key={kind}>
|
||||
<Form.Label className="col-sm-3 mb-0">{RECIPE_LINK_NAMES[kind]}</Form.Label>
|
||||
<div className="col-sm-9">
|
||||
<RecipeLinksInput
|
||||
value={links[kind]}
|
||||
recipeList={otherRecipeList.filter(recipe =>
|
||||
!getOppositeLinkIds(links, kind).includes(recipe.id))}
|
||||
placeholder={LINK_PLACEHOLDERS[kind]}
|
||||
onChange={value => setLinks(current => ({ ...current, [kind]: value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<Form.Group className="mb-3">
|
||||
<div className="recipe-note-label">
|
||||
<Form.Label>Postup</Form.Label>
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { Button, Form } from "react-bootstrap";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { RecipeIngredient } from "../../../types";
|
||||
import { formatCalories, formatPriceInput, getIngredientCalories, parseIntInput, parsePriceInput } from "../Utils";
|
||||
import { IngredientUnit, RecipeIngredient } from "../../../types";
|
||||
import { INGREDIENT_UNIT_FORMS, INGREDIENT_UNITS_IN_ORDER } from "../enums";
|
||||
import {
|
||||
formatAmountInput, formatCalories, formatPriceInput, getIngredientCalories, parseAmountInput, parseIntInput,
|
||||
parsePriceInput,
|
||||
} from "../Utils";
|
||||
|
||||
/** Rozpracovaná surovina — hodnoty jsou texty, protože se editují v polích. */
|
||||
/**
|
||||
* Rozpracovaná surovina — hodnoty jsou texty, protože se editují v polích.
|
||||
* Prázdná jednotka znamená gramy; množstvím je pak `weight`.
|
||||
*/
|
||||
export type DraftIngredient = {
|
||||
name: string,
|
||||
amount: string,
|
||||
unit: IngredientUnit | '',
|
||||
weight: string,
|
||||
caloriesPer100g: string,
|
||||
calories: string,
|
||||
@@ -15,6 +24,8 @@ export type DraftIngredient = {
|
||||
|
||||
export const emptyIngredient = (): DraftIngredient => ({
|
||||
name: '',
|
||||
amount: '',
|
||||
unit: '',
|
||||
weight: '',
|
||||
caloriesPer100g: '',
|
||||
calories: '',
|
||||
@@ -24,6 +35,8 @@ export const emptyIngredient = (): DraftIngredient => ({
|
||||
/** Převede uloženou surovinu na rozpracovanou pro editaci. */
|
||||
export const toDraftIngredient = (ingredient: RecipeIngredient): DraftIngredient => ({
|
||||
name: ingredient.name,
|
||||
amount: formatAmountInput(ingredient.amount),
|
||||
unit: ingredient.unit ?? '',
|
||||
weight: ingredient.weight != null ? String(ingredient.weight) : '',
|
||||
caloriesPer100g: ingredient.caloriesPer100g != null ? String(ingredient.caloriesPer100g) : '',
|
||||
calories: ingredient.calories != null ? String(ingredient.calories) : '',
|
||||
@@ -33,12 +46,26 @@ export const toDraftIngredient = (ingredient: RecipeIngredient): DraftIngredient
|
||||
/** Převede rozpracovanou surovinu na hodnoty pro výpočet a uložení. */
|
||||
export const toIngredient = (draft: DraftIngredient): RecipeIngredient => ({
|
||||
name: draft.name.trim(),
|
||||
...(draft.unit ? { amount: parseAmountInput(draft.amount), unit: draft.unit } : {}),
|
||||
weight: parseIntInput(draft.weight),
|
||||
caloriesPer100g: parseIntInput(draft.caloriesPer100g),
|
||||
calories: parseIntInput(draft.calories),
|
||||
price: parsePriceInput(draft.price),
|
||||
});
|
||||
|
||||
/**
|
||||
* Změna jednotky přesune zapsané číslo do pole, které nově znamená množství —
|
||||
* typicky se napíše "3" a teprve pak vybere "ks". Gramáž u jiné jednotky je jen
|
||||
* přepočet pro výživu, takže při návratu ke gramům zůstane, pokud je vyplněná.
|
||||
*/
|
||||
const changeUnit = (ingredient: DraftIngredient, unit: IngredientUnit | ''): Partial<DraftIngredient> => {
|
||||
if (!ingredient.unit && unit) return { unit, amount: ingredient.weight, weight: '' };
|
||||
if (ingredient.unit && !unit) {
|
||||
return { unit, amount: '', weight: ingredient.weight || String(parseIntInput(ingredient.amount) ?? '') };
|
||||
}
|
||||
return { unit };
|
||||
};
|
||||
|
||||
type Props = {
|
||||
ingredients: DraftIngredient[],
|
||||
onChange: (ingredients: DraftIngredient[]) => void,
|
||||
@@ -47,6 +74,9 @@ type Props = {
|
||||
/**
|
||||
* Editor surovin receptu.
|
||||
*
|
||||
* Množství je v gramech, nebo v jiné jednotce (vejce na kusy, mléko v litrech).
|
||||
* U jiné jednotky jde zvlášť zapsat přepočet na gramy — z něj se počítá energie
|
||||
* a hmotnost jídla, recept ale ukazuje množství tak, jak se odměřuje.
|
||||
* Energie suroviny se dopočte z gramáže a hodnoty na 100 g; ručně jde zadat jen
|
||||
* tam, kde dopočítat nejde (třeba „lžíce oleje" bez vážení).
|
||||
*/
|
||||
@@ -70,16 +100,48 @@ const RecipeIngredientsEditor = ({ ingredients, onChange }: Readonly<Props>) =>
|
||||
aria-label="Název suroviny"
|
||||
onChange={event => updateAt(index, { name: event.target.value })}
|
||||
/>
|
||||
<div className="input-group recipe-ingredient-weight">
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={ingredient.weight}
|
||||
placeholder="200"
|
||||
aria-label="Množství v gramech"
|
||||
onChange={event => updateAt(index, { weight: event.target.value.replace(/\D/g, '') })}
|
||||
/>
|
||||
<span className="input-group-text">g</span>
|
||||
<div className="input-group recipe-ingredient-amount">
|
||||
{ingredient.unit ? (
|
||||
<Form.Control
|
||||
inputMode="decimal"
|
||||
value={ingredient.amount}
|
||||
placeholder="2"
|
||||
aria-label="Množství"
|
||||
onChange={event => updateAt(index, { amount: event.target.value.replace(/[^\d,.]/g, '') })}
|
||||
/>
|
||||
) : (
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={ingredient.weight}
|
||||
placeholder="200"
|
||||
aria-label="Množství v gramech"
|
||||
onChange={event => updateAt(index, { weight: event.target.value.replace(/\D/g, '') })}
|
||||
/>
|
||||
)}
|
||||
<Form.Select
|
||||
value={ingredient.unit}
|
||||
aria-label="Jednotka"
|
||||
onChange={event => updateAt(index, changeUnit(ingredient, event.target.value as IngredientUnit | ''))}
|
||||
>
|
||||
<option value="">g</option>
|
||||
{INGREDIENT_UNITS_IN_ORDER.map(unit => (
|
||||
<option key={unit} value={unit}>{INGREDIENT_UNIT_FORMS[unit][0]}</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</div>
|
||||
{ingredient.unit && (
|
||||
<div className="input-group recipe-ingredient-weight">
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={ingredient.weight}
|
||||
placeholder="≈"
|
||||
aria-label="Přepočet na gramy"
|
||||
title="Přepočet na gramy — z něj se počítá energie a hmotnost jídla"
|
||||
onChange={event => updateAt(index, { weight: event.target.value.replace(/\D/g, '') })}
|
||||
/>
|
||||
<span className="input-group-text">g</span>
|
||||
</div>
|
||||
)}
|
||||
<Form.Control
|
||||
className="recipe-ingredient-rate"
|
||||
inputMode="numeric"
|
||||
|
||||
@@ -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 = {
|
||||
group: MealGroup & { recipe: MealTemplate },
|
||||
/** Všechny recepty — pro vazby na jiné recepty */
|
||||
recipeList: MealTemplate[],
|
||||
onOpenRecipe: (recipe: MealTemplate) => void,
|
||||
};
|
||||
|
||||
/**
|
||||
* Recept jídla, které padlo na kole štěstí — jen k nahlédnutí, ať se podle
|
||||
* něj dá rovnou vařit. Upravuje se v seznamu jídel.
|
||||
*/
|
||||
const WheelRecipe = ({ group }: Readonly<Props>) => {
|
||||
const WheelRecipe = ({ group, recipeList, onOpenRecipe }: Readonly<Props>) => {
|
||||
const [photo, setPhoto] = useState<MealPhoto | undefined>();
|
||||
const [loading, setLoading] = useState(group.photoCount > 0);
|
||||
const { exporting, exportRecipes } = useRecipeExport();
|
||||
|
||||
useEffect(() => {
|
||||
setPhoto(undefined);
|
||||
setLoading(group.photoCount > 0);
|
||||
if (!group.photoCount) return;
|
||||
getMealPhotos({ query: { name: group.name, source: group.source } }).then(response => {
|
||||
setPhoto(response.data?.[0]);
|
||||
@@ -32,6 +37,8 @@ const WheelRecipe = ({ group }: Readonly<Props>) => {
|
||||
<MealTemplateCard
|
||||
template={group.recipe}
|
||||
busy={exporting}
|
||||
recipeList={recipeList}
|
||||
onOpenRecipe={onOpenRecipe}
|
||||
onExport={() => exportRecipes([{ recipe: group.recipe, photoCount: group.photoCount }])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -93,6 +93,8 @@ const SpinWheelModal = ({ isOpen, onClose }: Readonly<Props>) => {
|
||||
const [excludedKeys, setExcludedKeys] = useState<Set<string>>(new Set());
|
||||
const [picked, setPicked] = useState<WheelItem | undefined>();
|
||||
const [recipeShown, setRecipeShown] = useState(false);
|
||||
// Recept otevřený z vazby padlého receptu (příloha, podobný)
|
||||
const [linkedKey, setLinkedKey] = useState<string | undefined>();
|
||||
const [rotation, setRotation] = useState(0);
|
||||
const [spinning, setSpinning] = useState(false);
|
||||
const [respinRequested, setRespinRequested] = useState(false);
|
||||
@@ -134,9 +136,14 @@ const SpinWheelModal = ({ isOpen, onClose }: Readonly<Props>) => {
|
||||
const excludedCount = candidateList.length - wheelList.length;
|
||||
const onlyOne = wheelList.length === 1;
|
||||
|
||||
const showRecipe = (shown: boolean) => {
|
||||
setRecipeShown(shown);
|
||||
setLinkedKey(undefined);
|
||||
};
|
||||
|
||||
const clearResult = () => {
|
||||
setPicked(undefined);
|
||||
setRecipeShown(false);
|
||||
showRecipe(false);
|
||||
};
|
||||
|
||||
const changePool = (value: WheelPool) => {
|
||||
@@ -215,6 +222,10 @@ const SpinWheelModal = ({ isOpen, onClose }: Readonly<Props>) => {
|
||||
};
|
||||
|
||||
const pickedRecipe = picked?.group && hasRecipe(picked.group) ? picked.group : undefined;
|
||||
const recipeGroupList = (groupList ?? []).filter(hasRecipe);
|
||||
const shownRecipe = recipeShown
|
||||
? recipeGroupList.find(group => getMealKey(group) === linkedKey) ?? pickedRecipe
|
||||
: undefined;
|
||||
const pickedSource = sourceStage ? picked : undefined;
|
||||
|
||||
const renderWheel = () => {
|
||||
@@ -235,12 +246,18 @@ const SpinWheelModal = ({ isOpen, onClose }: Readonly<Props>) => {
|
||||
<Modal show={isOpen} onHide={onClose} size="lg" className="spin-wheel-modal">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>
|
||||
{recipeShown && pickedRecipe ? pickedRecipe.name : 'Kolo štěstí: co dnes?'}
|
||||
{shownRecipe ? shownRecipe.name : 'Kolo štěstí: co dnes?'}
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
<WheelConfetti theme={theme} burstKey={confettiKey} />
|
||||
<Modal.Body>
|
||||
{recipeShown && pickedRecipe ? <WheelRecipe group={pickedRecipe} /> : (
|
||||
{shownRecipe ? (
|
||||
<WheelRecipe
|
||||
group={shownRecipe}
|
||||
recipeList={recipeGroupList.map(group => group.recipe)}
|
||||
onOpenRecipe={recipe => setLinkedKey(getMealKey(recipe))}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="spin-filters">
|
||||
<ButtonGroup className="spin-source" aria-label="Z čeho losovat">
|
||||
@@ -372,10 +389,10 @@ const SpinWheelModal = ({ isOpen, onClose }: Readonly<Props>) => {
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
{recipeShown && (
|
||||
<Button variant="outline-secondary" onClick={() => setRecipeShown(false)}>Zpět na kolo</Button>
|
||||
<Button variant="outline-secondary" onClick={() => showRecipe(false)}>Zpět na kolo</Button>
|
||||
)}
|
||||
{!recipeShown && pickedRecipe && !spinning && (
|
||||
<Button onClick={() => setRecipeShown(true)}>
|
||||
<Button onClick={() => showRecipe(true)}>
|
||||
<FontAwesomeIcon icon={faBookOpen} /> Zobrazit recept
|
||||
</Button>
|
||||
)}
|
||||
|
||||
+57
-1
@@ -1,4 +1,6 @@
|
||||
import { ActivityUnit, BmiKategorie, ImportMode, MealType, Nutrients, Pohlavi, RecipeFlavor } from "../../types";
|
||||
import {
|
||||
ActivityUnit, BmiKategorie, ImportMode, IngredientUnit, MealTemplate, MealType, Nutrients, Pohlavi, RecipeFlavor,
|
||||
} from "../../types";
|
||||
|
||||
/** Lidsky čitelné názvy typů jídel. */
|
||||
export const MEAL_TYPE_NAMES: Record<MealType, string> = {
|
||||
@@ -193,6 +195,60 @@ export const RECIPE_FLAVOR_NAMES: Record<RecipeFlavor, string> = {
|
||||
SLADKE: 'Sladké',
|
||||
};
|
||||
|
||||
/**
|
||||
* Tvary jednotky suroviny za číslem: jedna, 2–4, 5 a víc, necelé množství
|
||||
* ("1 lžíce", "3 lžíce", "5 lžic", "1,5 lžíce").
|
||||
*/
|
||||
export const INGREDIENT_UNIT_FORMS: Record<IngredientUnit, [string, string, string, string]> = {
|
||||
KS: ['ks', 'ks', 'ks', 'ks'],
|
||||
ML: ['ml', 'ml', 'ml', 'ml'],
|
||||
L: ['l', 'l', 'l', 'l'],
|
||||
LZICE: ['lžíce', 'lžíce', 'lžic', 'lžíce'],
|
||||
LZICKA: ['lžička', 'lžičky', 'lžiček', 'lžičky'],
|
||||
HRNEK: ['hrnek', 'hrnky', 'hrnků', 'hrnku'],
|
||||
BALENI: ['balení', 'balení', 'balení', 'balení'],
|
||||
SPETKA: ['špetka', 'špetky', 'špetek', 'špetky'],
|
||||
};
|
||||
|
||||
/** Jednotky surovin v pořadí, v jakém se nabízejí. Gramy jsou výchozí a mezi nimi nejsou. */
|
||||
export const INGREDIENT_UNITS_IN_ORDER: IngredientUnit[] = [
|
||||
IngredientUnit.KS,
|
||||
IngredientUnit.ML,
|
||||
IngredientUnit.L,
|
||||
IngredientUnit.LZICE,
|
||||
IngredientUnit.LZICKA,
|
||||
IngredientUnit.HRNEK,
|
||||
IngredientUnit.BALENI,
|
||||
IngredientUnit.SPETKA,
|
||||
];
|
||||
|
||||
/**
|
||||
* Druh vazby receptu na jiný recept. Přílohy a „přílohou k" jsou dvě strany
|
||||
* téže vazby, podobnost je oboustranná.
|
||||
*/
|
||||
export enum RecipeLinkKind {
|
||||
PRILOHY = 'PRILOHY',
|
||||
PRILOHOU_K = 'PRILOHOU_K',
|
||||
PODOBNE = 'PODOBNE',
|
||||
}
|
||||
|
||||
export const RECIPE_LINK_NAMES: Record<RecipeLinkKind, string> = {
|
||||
PRILOHY: 'Přílohy',
|
||||
PRILOHOU_K: 'Přílohou k',
|
||||
PODOBNE: 'Podobné',
|
||||
};
|
||||
|
||||
/** Pole receptu, ve kterém API vazbu daného druhu nese. */
|
||||
export const RECIPE_LINK_FIELDS: Record<RecipeLinkKind, 'sideDishIds' | 'sideDishOfIds' | 'similarIds'> = {
|
||||
PRILOHY: 'sideDishIds',
|
||||
PRILOHOU_K: 'sideDishOfIds',
|
||||
PODOBNE: 'similarIds',
|
||||
};
|
||||
|
||||
/** Recepty propojené s receptem vazbou daného druhu. */
|
||||
export const getRecipeLinkIds = (recipe: MealTemplate, kind: RecipeLinkKind): string[] =>
|
||||
recipe[RECIPE_LINK_FIELDS[kind]] ?? [];
|
||||
|
||||
/** Zvláštní volby filtru zdroje v seznamu jídel; jinak se filtruje konkrétním zdrojem. */
|
||||
export enum SourceFilter {
|
||||
VSE = 'VSE',
|
||||
|
||||
@@ -74,6 +74,7 @@ const MealGroupsPage = () => {
|
||||
(location.state as LocationState)?.recipeFilter ?? RecipeFilter.VSE);
|
||||
const [opened, setOpened] = useState<{ key: string, action: MealGroupAction } | undefined>();
|
||||
const [newRecipeOpen, setNewRecipeOpen] = useState(false);
|
||||
const [scrollKey, setScrollKey] = useState<string | undefined>();
|
||||
const { exporting, exportRecipes } = useRecipeExport();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -85,6 +86,10 @@ const MealGroupsPage = () => {
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const sourceList = useMemo(() => listSources(groupList), [groupList]);
|
||||
const recipeList = useMemo(
|
||||
() => groupList.flatMap(group => group.recipe ? [group.recipe] : []),
|
||||
[groupList],
|
||||
);
|
||||
|
||||
const visibleList = useMemo(() => {
|
||||
const search = normalizeSearch(query);
|
||||
@@ -100,6 +105,27 @@ const MealGroupsPage = () => {
|
||||
.filter((group): group is MealGroup & { recipe: MealTemplate } => !!group.recipe)
|
||||
.map(group => ({ recipe: group.recipe, photoCount: group.photoCount })), [visibleList]);
|
||||
|
||||
// Recept otevřený z vazby se odroluje do pohledu, až se jeho řádek vykreslí
|
||||
useEffect(() => {
|
||||
if (!scrollKey) return;
|
||||
document.querySelector(`[data-meal-key="${CSS.escape(scrollKey)}"]`)
|
||||
?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
setScrollKey(undefined);
|
||||
}, [scrollKey]);
|
||||
|
||||
/** Otevře recept jiného jídla; když ho filtry skrývají, zruší je. */
|
||||
const openRecipe = (recipe: MealTemplate) => {
|
||||
const key = getMealKey(recipe);
|
||||
if (!visibleList.some(group => getMealKey(group) === key)) {
|
||||
setQuery('');
|
||||
setSourceFilter(SourceFilter.VSE);
|
||||
setRecipeFilter(RecipeFilter.VSE);
|
||||
setOnlyIncomplete(false);
|
||||
}
|
||||
setOpened({ key, action: MealGroupAction.RECEPT });
|
||||
setScrollKey(key);
|
||||
};
|
||||
|
||||
const filtered = !!query.trim().length || sourceFilter !== SourceFilter.VSE || recipeFilter !== RecipeFilter.VSE;
|
||||
|
||||
return (
|
||||
@@ -196,6 +222,8 @@ const MealGroupsPage = () => {
|
||||
key={key}
|
||||
group={group}
|
||||
groupList={groupList}
|
||||
recipeList={recipeList}
|
||||
onOpenRecipe={recipe => openRecipe(recipe)}
|
||||
action={opened?.key === key ? opened.action : undefined}
|
||||
onAction={action => setOpened(action ? { key, action } : undefined)}
|
||||
onChanged={() => { setOpened(undefined); load(); }}
|
||||
|
||||
@@ -160,8 +160,24 @@
|
||||
.recipe-ingredient-row {
|
||||
align-items: center;
|
||||
|
||||
// Množství s výběrem jednotky
|
||||
.recipe-ingredient-amount {
|
||||
width: 160px;
|
||||
flex: 0 0 auto;
|
||||
|
||||
.form-control {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.form-select {
|
||||
flex: 0 0 82px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
// Přepočet na gramy u jiné jednotky
|
||||
.recipe-ingredient-weight {
|
||||
width: 110px;
|
||||
width: 90px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@@ -187,6 +203,7 @@
|
||||
@media (max-width: 640px) {
|
||||
flex-wrap: wrap;
|
||||
|
||||
.recipe-ingredient-amount,
|
||||
.recipe-ingredient-weight,
|
||||
.recipe-ingredient-rate,
|
||||
.recipe-ingredient-calories,
|
||||
@@ -208,6 +225,64 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Vazby receptu na jiné recepty — ve formuláři výběr, na kartě odkazy
|
||||
.recipe-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 10px;
|
||||
font-size: 0.87rem;
|
||||
|
||||
.recipe-links-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.recipe-links-label {
|
||||
color: var(--ft-text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.recipe-link {
|
||||
border: none;
|
||||
background: var(--ft-primary-light);
|
||||
color: var(--ft-primary);
|
||||
border-radius: 999px;
|
||||
padding: 2px 10px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
|
||||
&:is(button):hover {
|
||||
filter: brightness(0.95);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.recipe-link-remove {
|
||||
margin-left: 6px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.recipe-links-input {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.form-select {
|
||||
width: auto;
|
||||
min-width: 180px;
|
||||
flex: 1 1 180px;
|
||||
}
|
||||
}
|
||||
|
||||
// Popisek postupu s přepínačem psaní a náhledu
|
||||
.recipe-note-label {
|
||||
display: flex;
|
||||
|
||||
+59
-9
@@ -1,9 +1,10 @@
|
||||
import type { Content, TDocumentDefinitions } from "pdfmake/interfaces";
|
||||
import { MealGroup, MealPhoto, MealTemplate, getMealPhotos } from "../../types";
|
||||
import { RECIPE_FLAVOR_NAMES } from "./enums";
|
||||
import { MealGroup, MealPhoto, MealTemplate, getMealPhotos, getMealTemplates } from "../../types";
|
||||
import { RECIPE_FLAVOR_NAMES, RECIPE_LINK_NAMES, RecipeLinkKind, getRecipeLinkIds } from "./enums";
|
||||
import { convertMarkdownToPdf } from "./markdownPdf";
|
||||
import {
|
||||
deriveRecipe, formatCalories, formatCount, formatDate, formatDateString, formatPrice, formatWeight, getIngredientCalories,
|
||||
deriveRecipe, formatCalories, formatCount, formatDate, formatDateString, formatIngredientAmount, formatPrepTime,
|
||||
formatPrice, formatWeight, getIngredientCalories,
|
||||
} from "./Utils";
|
||||
|
||||
/** Recept s počtem fotek jídla — fotky se stahují jen tam, kde nějaké jsou. */
|
||||
@@ -27,6 +28,7 @@ const getRecipeMeta = (recipe: MealTemplate): string => {
|
||||
return [
|
||||
recipe.category,
|
||||
RECIPE_FLAVOR_NAMES[recipe.flavor].toLowerCase(),
|
||||
recipe.prepMinutes != null ? `příprava ${formatPrepTime(recipe.prepMinutes)}` : undefined,
|
||||
formatCount(recipe.servings ?? 1, 'porce', 'porce', 'porcí'),
|
||||
recipe.weight != null ? `porce ${formatWeight(recipe.weight)}` : undefined,
|
||||
recipe.calories != null ? `${formatCalories(recipe.calories)} na porci` : undefined,
|
||||
@@ -34,6 +36,33 @@ const getRecipeMeta = (recipe: MealTemplate): string => {
|
||||
].filter(item => !!item).join(' · ');
|
||||
};
|
||||
|
||||
/** Kotva receptu v dokumentu — cíl odkazů z obsahu a z vazeb mezi recepty. */
|
||||
const getRecipeAnchor = (index: number): string => `recept-${index}`;
|
||||
|
||||
/**
|
||||
* Vazby na jiné recepty po řádcích podle druhu. Recept, který je v tomtéž PDF,
|
||||
* je odkaz na jeho stránku.
|
||||
*
|
||||
* @param recipe recept, jehož vazby se vypisují
|
||||
* @param recipeList všechny recepty uživatele — vazba může vést i mimo export
|
||||
* @param indexMap pořadí exportovaných receptů podle id
|
||||
*/
|
||||
const createLinks = (recipe: MealTemplate, recipeList: MealTemplate[], indexMap: Map<string, number>): Content[] =>
|
||||
Object.values(RecipeLinkKind).flatMap(kind => {
|
||||
const linkedList = getRecipeLinkIds(recipe, kind)
|
||||
.map(id => recipeList.find(item => item.id === id))
|
||||
.filter(item => !!item);
|
||||
if (!linkedList.length) return [];
|
||||
const nameList = linkedList.flatMap((linked, position) => {
|
||||
const index = indexMap.get(linked.id);
|
||||
const name = index != null
|
||||
? { text: linked.name, linkToDestination: getRecipeAnchor(index), color: PRIMARY_COLOR }
|
||||
: { text: linked.name };
|
||||
return position ? [{ text: ', ' }, name] : [name];
|
||||
});
|
||||
return [{ text: [{ text: `${RECIPE_LINK_NAMES[kind]}: `, bold: true }, ...nameList], style: 'recipeLinks' }];
|
||||
});
|
||||
|
||||
/** První fotka přes celou šířku, ostatní menší v řadách po třech. */
|
||||
const createPhotos = (photoList: MealPhoto[]): Content[] => {
|
||||
const [first, ...rest] = photoList;
|
||||
@@ -63,7 +92,7 @@ const createIngredients = (recipe: MealTemplate): Content[] => {
|
||||
.map((text, index) => ({ text, style: 'tableHeader', alignment: index ? 'right' as const : 'left' as const }));
|
||||
const rowList = recipe.ingredients.map(item => [
|
||||
item.name,
|
||||
{ text: item.weight != null ? formatWeight(item.weight) : '', alignment: 'right' as const },
|
||||
{ text: formatIngredientAmount(item), alignment: 'right' as const },
|
||||
{ text: formatCalories(getIngredientCalories(item)), alignment: 'right' as const },
|
||||
...(hasPrice ? [{ text: item.price != null ? formatPrice(item.price) : '', alignment: 'right' as const }] : []),
|
||||
]);
|
||||
@@ -78,7 +107,7 @@ const createIngredients = (recipe: MealTemplate): Content[] => {
|
||||
{
|
||||
table: {
|
||||
headerRows: 1,
|
||||
widths: ['*', 70, 70, ...(hasPrice ? [70] : [])],
|
||||
widths: ['*', 100, 70, ...(hasPrice ? [70] : [])],
|
||||
body: [header, ...rowList],
|
||||
},
|
||||
layout: {
|
||||
@@ -93,16 +122,26 @@ const createIngredients = (recipe: MealTemplate): Content[] => {
|
||||
];
|
||||
};
|
||||
|
||||
const createRecipe = (recipe: MealTemplate, photoList: MealPhoto[], index: number, withToc: boolean): Content => ({
|
||||
type RecipeContext = {
|
||||
index: number,
|
||||
withToc: boolean,
|
||||
recipeList: MealTemplate[],
|
||||
indexMap: Map<string, number>,
|
||||
};
|
||||
|
||||
const createRecipe = (
|
||||
recipe: MealTemplate, photoList: MealPhoto[], { index, withToc, recipeList, indexMap }: RecipeContext,
|
||||
): Content => ({
|
||||
stack: [
|
||||
{
|
||||
text: recipe.name,
|
||||
style: 'recipeTitle',
|
||||
id: `recept-${index}`,
|
||||
id: getRecipeAnchor(index),
|
||||
tocItem: withToc,
|
||||
},
|
||||
...(recipe.source ? [{ text: recipe.source, style: 'recipeSource' }] : []),
|
||||
{ text: getRecipeMeta(recipe), style: 'recipeMeta' },
|
||||
...createLinks(recipe, recipeList, indexMap),
|
||||
...createPhotos(photoList),
|
||||
...createIngredients(recipe),
|
||||
...(recipe.note?.trim()
|
||||
@@ -139,7 +178,13 @@ const loadPdfMake = async () => {
|
||||
*/
|
||||
export const exportRecipesPdf = async (itemList: RecipeItem[]): Promise<void> => {
|
||||
const sortedList = [...itemList].sort((a, b) => a.recipe.name.localeCompare(b.recipe.name, 'cs'));
|
||||
const [pdfMake, photoLists] = await Promise.all([loadPdfMake(), Promise.all(sortedList.map(loadPhotos))]);
|
||||
const [pdfMake, photoLists, recipeResponse] = await Promise.all([
|
||||
loadPdfMake(),
|
||||
Promise.all(sortedList.map(loadPhotos)),
|
||||
getMealTemplates(),
|
||||
]);
|
||||
const recipeList = recipeResponse.data ?? [];
|
||||
const indexMap = new Map(sortedList.map((item, index) => [item.recipe.id, index]));
|
||||
const withToc = sortedList.length > 1;
|
||||
const title = withToc ? 'Recepty' : sortedList[0]?.recipe.name ?? 'Recept';
|
||||
|
||||
@@ -159,7 +204,11 @@ export const exportRecipesPdf = async (itemList: RecipeItem[]): Promise<void> =>
|
||||
info: { title },
|
||||
pageSize: 'A4',
|
||||
pageMargins: [40, 40, 40, 50],
|
||||
content: [...cover, ...sortedList.map((item, index) => createRecipe(item.recipe, photoLists[index], index, withToc))],
|
||||
content: [
|
||||
...cover,
|
||||
...sortedList.map((item, index) =>
|
||||
createRecipe(item.recipe, photoLists[index], { index, withToc, recipeList, indexMap })),
|
||||
],
|
||||
footer: (currentPage, pageCount) => ({
|
||||
text: `${currentPage} / ${pageCount}`,
|
||||
alignment: 'center',
|
||||
@@ -172,6 +221,7 @@ export const exportRecipesPdf = async (itemList: RecipeItem[]): Promise<void> =>
|
||||
recipeTitle: { fontSize: 20, bold: true, color: PRIMARY_COLOR },
|
||||
recipeSource: { fontSize: 11, color: MUTED_COLOR },
|
||||
recipeMeta: { fontSize: 9, color: MUTED_COLOR, margin: [0, 4, 0, 12] },
|
||||
recipeLinks: { fontSize: 9, margin: [0, -8, 0, 12] },
|
||||
sectionHeading: { fontSize: 13, bold: true, margin: [0, 10, 0, 6] },
|
||||
tableHeader: { bold: true, fontSize: 9, color: MUTED_COLOR },
|
||||
summary: { bold: true, fontSize: 9, color: MUTED_COLOR, margin: [0, 6, 0, 0] },
|
||||
|
||||
+132
-25
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { MealTemplate, MealType, RecipeFlavor, RecipeIngredient } from "../../types/gen/types.gen";
|
||||
import { IngredientUnit, MealTemplate, MealType, RecipeFlavor, RecipeIngredient } from "../../types/gen/types.gen";
|
||||
import getStorage from "./storage";
|
||||
import { addMeal, getNameSourceKey, isMealType } from "./meals";
|
||||
import { rememberCalories } from "./calories";
|
||||
@@ -14,15 +14,34 @@ const MAX_NOTE_LENGTH = 4000;
|
||||
/** Maximální počet surovin receptu — pojistka proti nesmyslnému vstupu. */
|
||||
const MAX_INGREDIENTS = 50;
|
||||
|
||||
/** Maximální množství suroviny v jiné jednotce než gramech. */
|
||||
const MAX_AMOUNT = 100000;
|
||||
|
||||
/** Nejdelší čas přípravy v minutách (týden — kvašení, nakládání). */
|
||||
const MAX_PREP_MINUTES = 7 * 24 * 60;
|
||||
|
||||
/** Maximální počet vazeb jednoho druhu u receptu. */
|
||||
const MAX_LINKS = 50;
|
||||
|
||||
/** Vynechá nevyplněné hodnoty, aby se do úložiště nepsaly prázdné klíče. */
|
||||
const definedOnly = <T extends object>(value: T): T =>
|
||||
Object.fromEntries(Object.entries(value).filter(([, item]) => item != null)) as T;
|
||||
|
||||
/** Klíč úložiště s recepty jednoho uživatele. */
|
||||
const getKey = (login: string): string => `mealTemplates:${login}`;
|
||||
|
||||
/**
|
||||
* Recept s vazbami jen ze své strany, jak se ukládá. Přílohy nese hlavní jídlo
|
||||
* a podobnost jen jeden recept z dvojice; druhou stranu dopočte `withLinks`.
|
||||
*/
|
||||
type OwnRecipe = Omit<MealTemplate, 'sideDishOfIds'>;
|
||||
|
||||
/**
|
||||
* Záznam tak, jak může ležet v úložišti. Dřív se pod stejným klíčem ukládaly
|
||||
* i šablony hotových jídel (druh `HOTOVE`, nebo bez druhu), recepty nesly
|
||||
* `kind: 'RECEPT'` a měly i chod.
|
||||
*/
|
||||
type StoredMealTemplate = Omit<MealTemplate, 'ingredients' | 'flavor'> & {
|
||||
type StoredMealTemplate = Omit<OwnRecipe, 'ingredients' | 'flavor'> & {
|
||||
ingredients?: RecipeIngredient[];
|
||||
flavor?: RecipeFlavor;
|
||||
kind?: string;
|
||||
@@ -42,7 +61,7 @@ const isRecipe = (stored: StoredMealTemplate): stored is StoredRecipe => !!store
|
||||
* Odstraní z receptu pole, která patřila jen šablonám hotových jídel, a zaniklý chod.
|
||||
* Slaný recept je výchozí stav a v úložišti chuť nemá, doplní se tady.
|
||||
*/
|
||||
const toMealTemplate = (stored: StoredRecipe): MealTemplate => {
|
||||
const toOwnRecipe = (stored: StoredRecipe): OwnRecipe => {
|
||||
const { kind, pricePer100g, mealType, ...rest } = stored;
|
||||
return { ...rest, flavor: rest.flavor ?? RecipeFlavor.SLANE };
|
||||
};
|
||||
@@ -56,13 +75,41 @@ const normalizeFlavor = (value: unknown): RecipeFlavor => {
|
||||
return value;
|
||||
};
|
||||
|
||||
/** Recept tak, jak se ukládá — výchozí slanou chuť neukládá. */
|
||||
const toStored = ({ flavor, ...rest }: MealTemplate): StoredMealTemplate =>
|
||||
flavor === RecipeFlavor.SLANE ? rest : { ...rest, flavor };
|
||||
/** Seznam vazeb do úložiště i API — prázdný se vynechá. */
|
||||
const toLinkIds = (idList?: string[]): string[] | undefined => (idList?.length ? idList : undefined);
|
||||
|
||||
/** Recept tak, jak se ukládá — výchozí slanou chuť ani prázdné vazby neukládá. */
|
||||
const toStored = ({ flavor, sideDishIds, similarIds, ...rest }: OwnRecipe): StoredMealTemplate => definedOnly({
|
||||
...rest,
|
||||
flavor: flavor === RecipeFlavor.SLANE ? undefined : flavor,
|
||||
sideDishIds: toLinkIds(sideDishIds),
|
||||
similarIds: toLinkIds(similarIds),
|
||||
});
|
||||
|
||||
/** Načte recepty z úložiště; šablony hotových jídel vynechá. */
|
||||
const loadRecipes = (storedList: StoredMealTemplate[] | undefined): MealTemplate[] =>
|
||||
(storedList ?? []).filter(isRecipe).map(toMealTemplate);
|
||||
const loadRecipes = (storedList: StoredMealTemplate[] | undefined): OwnRecipe[] =>
|
||||
(storedList ?? []).filter(isRecipe).map(toOwnRecipe);
|
||||
|
||||
/**
|
||||
* Doplní receptům druhou stranu vazeb: přílohu všem jídlům, která ji uvádějí,
|
||||
* a podobnost oběma receptům dvojice. Vazby na smazané recepty vynechá,
|
||||
* ostatní seřadí podle názvu receptu.
|
||||
*/
|
||||
const withLinks = (recipeList: OwnRecipe[]): MealTemplate[] => {
|
||||
const nameMap = new Map(recipeList.map(recipe => [recipe.id, recipe.name]));
|
||||
const sortByName = (idList: string[]): string[] | undefined => toLinkIds([...new Set(idList)]
|
||||
.filter(id => nameMap.has(id))
|
||||
.sort((a, b) => nameMap.get(a)!.localeCompare(nameMap.get(b)!, 'cs')));
|
||||
const findLinking = (recipeId: string, getIds: (recipe: OwnRecipe) => string[] | undefined): string[] =>
|
||||
recipeList.filter(other => getIds(other)?.includes(recipeId)).map(other => other.id);
|
||||
|
||||
return recipeList.map(recipe => definedOnly({
|
||||
...recipe,
|
||||
sideDishIds: sortByName(recipe.sideDishIds ?? []),
|
||||
sideDishOfIds: sortByName(findLinking(recipe.id, other => other.sideDishIds)),
|
||||
similarIds: sortByName([...recipe.similarIds ?? [], ...findLinking(recipe.id, other => other.similarIds)]),
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Vrátí recepty uživatele seřazené podle názvu.
|
||||
@@ -70,7 +117,7 @@ const loadRecipes = (storedList: StoredMealTemplate[] | undefined): MealTemplate
|
||||
* @param login přihlašovací jméno uživatele
|
||||
*/
|
||||
export const getMealTemplates = async (login: string): Promise<MealTemplate[]> =>
|
||||
loadRecipes(await storage.getData<StoredMealTemplate[]>(getKey(login)))
|
||||
withLinks(loadRecipes(await storage.getData<StoredMealTemplate[]>(getKey(login))))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
|
||||
|
||||
/** Hodnoty hotového jídla a jedné porce receptu dopočtené ze surovin. */
|
||||
@@ -113,6 +160,9 @@ const sumDefined = (valueList: (number | undefined)[]): number | undefined => {
|
||||
* vařením se voda odpaří nebo nasákne (rýže, těstoviny), takže by vyšla špatně.
|
||||
* Porci si pak jde převážit a kalorie se dopočtou z gramáže jako u každého jídla.
|
||||
*
|
||||
* Počítá se vždy z gramáže; surovina v jiné jednotce (2 ks vajec) do výpočtu
|
||||
* vstupuje jen svým přepočtem na gramy, pokud ho má.
|
||||
*
|
||||
* Klient má stejnou funkci v `Utils.tsx` pro živý náhled — musí dávat stejné výsledky.
|
||||
*
|
||||
* @param ingredients suroviny receptu
|
||||
@@ -169,9 +219,17 @@ const normalizeNumber = (value: unknown, name: string): number | undefined => {
|
||||
return rounded;
|
||||
};
|
||||
|
||||
/** Vynechá nevyplněné hodnoty, aby se do úložiště nepsaly prázdné klíče. */
|
||||
const definedOnly = <T extends object>(value: T): T =>
|
||||
Object.fromEntries(Object.entries(value).filter(([, item]) => item != null)) as T;
|
||||
const isIngredientUnit = (value: unknown): value is IngredientUnit =>
|
||||
Object.values(IngredientUnit).includes(value as IngredientUnit);
|
||||
|
||||
/** Ověří volitelné kladné množství suroviny; necelé na setiny (0,25 l). */
|
||||
const normalizeAmount = (value: unknown, index: number): number | undefined => {
|
||||
if (value == null) return undefined;
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > MAX_AMOUNT) {
|
||||
throw new BadRequestError(`Surovina ${index + 1} nemá platné množství`);
|
||||
}
|
||||
return Math.round(value * 100) / 100;
|
||||
};
|
||||
|
||||
/** Ověří a znormalizuje jednu surovinu receptu. */
|
||||
const normalizeIngredient = (raw: unknown, index: number): RecipeIngredient => {
|
||||
@@ -180,12 +238,19 @@ const normalizeIngredient = (raw: unknown, index: number): RecipeIngredient => {
|
||||
if (!name) {
|
||||
throw new BadRequestError(`Surovina ${index + 1} nemá název`);
|
||||
}
|
||||
if (value.unit != null && !isIngredientUnit(value.unit)) {
|
||||
throw new BadRequestError(`Surovina ${index + 1} nemá platnou jednotku`);
|
||||
}
|
||||
const unit = value.unit ?? undefined;
|
||||
const weight = normalizeNumber(value.weight, 'weight');
|
||||
const caloriesPer100g = normalizeNumber(value.caloriesPer100g, 'caloriesPer100g');
|
||||
const calories = normalizeNumber(value.calories, 'calories');
|
||||
const price = normalizeNumber(value.price, 'price');
|
||||
return definedOnly({
|
||||
name,
|
||||
// Bez jednotky je množstvím gramáž, samostatné množství by ji jen zdvojovalo
|
||||
amount: unit ? normalizeAmount(value.amount, index) : undefined,
|
||||
unit,
|
||||
weight,
|
||||
caloriesPer100g,
|
||||
// Ručně zadaná energie platí jen tam, kde ji nejde dopočítat z gramáže
|
||||
@@ -194,8 +259,35 @@ const normalizeIngredient = (raw: unknown, index: number): RecipeIngredient => {
|
||||
});
|
||||
};
|
||||
|
||||
/** Ověří seznam propojených receptů — musí existovat a nesmí to být recept sám. */
|
||||
const normalizeLinkIds = (value: unknown, recipeIds: Set<string>, selfId: string): string[] => {
|
||||
if (value == null) return [];
|
||||
if (!Array.isArray(value) || value.length > MAX_LINKS) {
|
||||
throw new BadRequestError(`Recept může mít nejvýše ${MAX_LINKS} vazeb jednoho druhu`);
|
||||
}
|
||||
if (value.some(id => typeof id !== 'string' || id === selfId || !recipeIds.has(id))) {
|
||||
throw new BadRequestError("Propojený recept nebyl nalezen");
|
||||
}
|
||||
return [...new Set(value as string[])];
|
||||
};
|
||||
|
||||
/**
|
||||
* Upraví vazby jiného receptu na recept `recipeId`. Vazby, které se ho týkají,
|
||||
* určuje jen on: odeberou se a přílohou se jiný recept znovu stane jen tehdy,
|
||||
* když je v `sideDishOfIds`. Podobnost si recept `recipeId` drží sám.
|
||||
*/
|
||||
const relinkOther = (other: OwnRecipe, recipeId: string, sideDishOfIds: string[] = []): OwnRecipe => ({
|
||||
...other,
|
||||
sideDishIds: [
|
||||
...(other.sideDishIds ?? []).filter(id => id !== recipeId),
|
||||
...(sideDishOfIds.includes(other.id) ? [recipeId] : []),
|
||||
],
|
||||
similarIds: (other.similarIds ?? []).filter(id => id !== recipeId),
|
||||
});
|
||||
|
||||
/** Klíč jídla, ke kterému recept patří — stejně jako u skupin v seznamu jídel. */
|
||||
export const getRecipeKey = (recipe: MealTemplate): string => getNameSourceKey(recipe.name, recipe.source);
|
||||
export const getRecipeKey = (recipe: Pick<MealTemplate, 'name' | 'source'>): string =>
|
||||
getNameSourceKey(recipe.name, recipe.source);
|
||||
|
||||
/**
|
||||
* Založí nový recept, nebo přepíše existující podle předaného id.
|
||||
@@ -205,6 +297,8 @@ export const getRecipeKey = (recipe: MealTemplate): string => getNameSourceKey(r
|
||||
*
|
||||
* Hodnoty jedné porce (gramáž, energie, cena) se dopočtou ze surovin a uloží
|
||||
* do plochých polí, takže použití receptu založí běžné jídlo jako každé jiné.
|
||||
* Vazby na jiné recepty (přílohy, podobné) se zapíšou i z druhé strany, takže
|
||||
* vazbu jde přidat i odebrat v kterémkoli z obou receptů.
|
||||
* Zápis zároveň z úložiště odklidí zaniklé šablony hotových jídel.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
@@ -225,10 +319,18 @@ export const saveMealTemplate = async (login: string, input: unknown): Promise<M
|
||||
const ingredients = value.ingredients.map(normalizeIngredient);
|
||||
const servings = normalizeNumber(value.servings, 'servings');
|
||||
const cookedWeight = normalizeNumber(value.cookedWeight, 'cookedWeight') || undefined;
|
||||
const prepMinutes = normalizeNumber(value.prepMinutes, 'prepMinutes') || undefined;
|
||||
if (prepMinutes != null && prepMinutes > MAX_PREP_MINUTES) {
|
||||
throw new BadRequestError("Čas přípravy může být nejvýše týden");
|
||||
}
|
||||
const summary = deriveRecipe(ingredients, servings, cookedWeight);
|
||||
const id = typeof value.id === 'string' && value.id.length ? value.id : randomUUID();
|
||||
const recipeList = await getMealTemplates(login);
|
||||
const recipeIds = new Set(recipeList.map(item => item.id));
|
||||
const sideDishOfIds = normalizeLinkIds(value.sideDishOfIds, recipeIds, id);
|
||||
|
||||
const template: MealTemplate = definedOnly({
|
||||
id: typeof value.id === 'string' && value.id.length ? value.id : randomUUID(),
|
||||
const template: OwnRecipe = definedOnly({
|
||||
id,
|
||||
name,
|
||||
source: normalizeText(value.source, 'source'),
|
||||
category: normalizeText(value.category, 'category'),
|
||||
@@ -238,12 +340,15 @@ export const saveMealTemplate = async (login: string, input: unknown): Promise<M
|
||||
// Jedna porce je výchozí stav, ukládat ji zvlášť nemá smysl
|
||||
servings: servings && servings > 1 ? servings : undefined,
|
||||
cookedWeight,
|
||||
prepMinutes,
|
||||
sideDishIds: normalizeLinkIds(value.sideDishIds, recipeIds, id),
|
||||
similarIds: normalizeLinkIds(value.similarIds, recipeIds, id),
|
||||
weight: summary.weight,
|
||||
caloriesPer100g: summary.caloriesPer100g,
|
||||
calories: summary.calories,
|
||||
price: summary.price,
|
||||
});
|
||||
const otherRecipe = (await getMealTemplates(login))
|
||||
const otherRecipe = recipeList
|
||||
.find(item => item.id !== template.id && getRecipeKey(item) === getRecipeKey(template));
|
||||
if (otherRecipe) {
|
||||
throw new BadRequestError("Toto jídlo už recept má");
|
||||
@@ -255,14 +360,17 @@ export const saveMealTemplate = async (login: string, input: unknown): Promise<M
|
||||
}
|
||||
|
||||
await storage.updateData<StoredMealTemplate[]>(getKey(login), current => [
|
||||
...loadRecipes(current).filter(item => item.id !== template.id).map(toStored),
|
||||
...loadRecipes(current)
|
||||
.filter(item => item.id !== template.id)
|
||||
.map(item => toStored(relinkOther(item, template.id, sideDishOfIds))),
|
||||
toStored(template),
|
||||
]);
|
||||
return getMealTemplates(login);
|
||||
};
|
||||
|
||||
/**
|
||||
* Smaže recept. Jídla z něj založená zůstávají, jsou samostatná.
|
||||
* Smaže recept i vazby ostatních receptů na něj. Jídla z něj založená
|
||||
* zůstávají, jsou samostatná.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param id identifikátor receptu
|
||||
@@ -275,7 +383,7 @@ export const deleteMealTemplate = async (login: string, id: unknown): Promise<Me
|
||||
await storage.updateData<StoredMealTemplate[]>(getKey(login), current => {
|
||||
const recipeList = loadRecipes(current);
|
||||
found = recipeList.some(item => item.id === id);
|
||||
return recipeList.filter(item => item.id !== id).map(toStored);
|
||||
return recipeList.filter(item => item.id !== id).map(item => toStored(relinkOther(item, id)));
|
||||
});
|
||||
if (!found) {
|
||||
throw new NotFoundError("Recept nebyl nalezen");
|
||||
@@ -333,7 +441,7 @@ export const applyMealTemplate = async (
|
||||
|
||||
/**
|
||||
* Přejmenuje recept jídla spolu s jídlem — při hromadném přejmenování v seznamu
|
||||
* jídel, aby recept zůstal u svého jídla.
|
||||
* jídel, aby recept zůstal u svého jídla. Vazby jdou přes id, přejmenování je nemění.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param fromKey dosavadní klíč jídla (`getNameSourceKey`)
|
||||
@@ -343,10 +451,9 @@ export const applyMealTemplate = async (
|
||||
export const renameMealTemplate = async (
|
||||
login: string, fromKey: string, name: string, source?: string,
|
||||
): Promise<void> => {
|
||||
const recipeList = await getMealTemplates(login);
|
||||
const recipe = recipeList.find(item => getRecipeKey(item) === fromKey);
|
||||
if (!recipe) return;
|
||||
const renamed: MealTemplate = definedOnly({ ...recipe, name, source });
|
||||
if (!(await getMealTemplates(login)).some(item => getRecipeKey(item) === fromKey)) return;
|
||||
await storage.updateData<StoredMealTemplate[]>(getKey(login), current =>
|
||||
loadRecipes(current).map(item => toStored(item.id === recipe.id ? renamed : item)));
|
||||
loadRecipes(current).map(item => toStored(getRecipeKey(item) === fromKey
|
||||
? definedOnly({ ...item, name, source })
|
||||
: item)));
|
||||
};
|
||||
@@ -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,
|
||||
takže se liší od součtu surovin; bez ní se použije součet surovin.
|
||||
type: integer
|
||||
prepMinutes:
|
||||
description: Čas přípravy v minutách
|
||||
type: integer
|
||||
sideDishIds:
|
||||
description: Recepty, které se k tomuto jídlu dávají jako příloha
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
sideDishOfIds:
|
||||
description: |
|
||||
Recepty, ke kterým je toto jídlo přílohou — opačná strana `sideDishIds`
|
||||
jiných receptů. Při uložení se podle něj upraví ty recepty.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
similarIds:
|
||||
description: Podobné recepty. Vazba je oboustranná, uvádí se u obou receptů.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
MealTemplateInput:
|
||||
description: |
|
||||
@@ -1240,6 +1260,35 @@ MealTemplateInput:
|
||||
type: integer
|
||||
cookedWeight:
|
||||
type: integer
|
||||
prepMinutes:
|
||||
type: integer
|
||||
sideDishIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
sideDishOfIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
similarIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
IngredientUnit:
|
||||
description: |
|
||||
Jednotka suroviny receptu, když dává smysl víc než gramy (vejce, mléko).
|
||||
Bez jednotky je množstvím gramáž.
|
||||
type: string
|
||||
enum:
|
||||
- KS
|
||||
- ML
|
||||
- L
|
||||
- LZICE
|
||||
- LZICKA
|
||||
- HRNEK
|
||||
- BALENI
|
||||
- SPETKA
|
||||
|
||||
RecipeFlavor:
|
||||
description: Zda je recept slaný, nebo sladký
|
||||
@@ -1276,8 +1325,17 @@ RecipeIngredient:
|
||||
name:
|
||||
description: Název suroviny
|
||||
type: string
|
||||
amount:
|
||||
description: |
|
||||
Množství v jednotce `unit` (2 ks, 0,5 l). Bez jednotky se nepoužije,
|
||||
množstvím je gramáž.
|
||||
type: number
|
||||
unit:
|
||||
$ref: "#/IngredientUnit"
|
||||
weight:
|
||||
description: Použité množství v gramech
|
||||
description: |
|
||||
Použité množství v gramech. U suroviny v jiné jednotce je to přepočet
|
||||
na gramy, ze kterého se počítá energie a hmotnost hotového jídla.
|
||||
type: integer
|
||||
caloriesPer100g:
|
||||
description: Energie suroviny na 100 g v kcal
|
||||
|
||||
Reference in new issue
Block a user