fix: Jen s chybějícímí údaji -> k doplnění
feat: zobrazení aktuálního počtu jídel feat: chuť a typ receptu
This commit is contained in:
1 parent
b637e243d5
commit
35cbc5a9ca
11 files changed
+206
-21
No files matched your search
@@ -335,6 +335,11 @@ uložení spočítá hodnoty **jedné porce** a uloží je do plochých polí (`
|
|||||||
`caloriesPer100g`, `calories`, `price`); použitím vznikne běžný `MealEntry` přes
|
`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
|
||||||
surovin — vařením se voda odpaří nebo nasákne. Postup se do záznamu dne nekopíruje.
|
surovin — vařením se voda odpaří nebo nasákne. Postup se do záznamu dne nekopíruje.
|
||||||
|
Recept má volitelný **typ** (`category` — polévka, omáčka, pečení…) jako volný
|
||||||
|
text; formulář nabízí typy z ostatních receptů (`RecipeCategoryInput`), pevný
|
||||||
|
číselník to záměrně není. **Chuť** (`flavor`, `RecipeFlavor`: SLANE/SLADKE) je
|
||||||
|
v API vždy vyplněná; slaná je výchozí a do úložiště se nezapisuje, `toMealTemplate`
|
||||||
|
ji při čtení doplní.
|
||||||
Recepty se nabízejí v našeptávání i ve výběru z historie (`ApplyMealModal`)
|
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é.
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ 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 } from "../../../types";
|
import { MealGroup, RecipeFlavor } from "../../../types";
|
||||||
import { MealGroupAction } from "../enums";
|
import { MealGroupAction, RECIPE_FLAVOR_NAMES } from "../enums";
|
||||||
import { formatDateString } from "../Utils";
|
import { formatDateString } from "../Utils";
|
||||||
import MealGroupForm from "./MealGroupForm";
|
import MealGroupForm from "./MealGroupForm";
|
||||||
import MealGroupRenameForm from "./MealGroupRenameForm";
|
import MealGroupRenameForm from "./MealGroupRenameForm";
|
||||||
@@ -57,7 +57,12 @@ const MealGroupRow = ({ group, groupList, action, onAction, onChanged, onRefresh
|
|||||||
? <span className="tag">{group.count}× · naposledy {formatDateString(group.lastDate)}</span>
|
? <span className="tag">{group.count}× · naposledy {formatDateString(group.lastDate)}</span>
|
||||||
: <span className="tag">zatím nejedeno</span>}
|
: <span className="tag">zatím nejedeno</span>}
|
||||||
{group.recipe && (
|
{group.recipe && (
|
||||||
<span className="tag tag-recipe"><FontAwesomeIcon icon={faBookOpen} /> recept</span>
|
<span className="tag tag-recipe">
|
||||||
|
<FontAwesomeIcon icon={faBookOpen} /> {group.recipe.category ?? 'recept'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{group.recipe?.flavor === RecipeFlavor.SLADKE && (
|
||||||
|
<span className="tag tag-recipe">{RECIPE_FLAVOR_NAMES[RecipeFlavor.SLADKE].toLowerCase()}</span>
|
||||||
)}
|
)}
|
||||||
{group.missingCaloriesCount > 0 && (
|
{group.missingCaloriesCount > 0 && (
|
||||||
<span className="tag tag-missing">
|
<span className="tag tag-missing">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faPenToSquare, faTrash } from "@fortawesome/free-solid-svg-icons";
|
import { faPenToSquare, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { MealTemplate } from "../../../types";
|
import { MealTemplate } from "../../../types";
|
||||||
|
import { RECIPE_FLAVOR_NAMES } from "../enums";
|
||||||
import { deriveRecipe, formatCalories, formatPrice, formatWeight, getIngredientCalories } from "../Utils";
|
import { deriveRecipe, formatCalories, formatPrice, formatWeight, getIngredientCalories } from "../Utils";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -22,6 +23,8 @@ const MealTemplateCard = ({ template, busy, onEdit, onDelete }: Readonly<Props>)
|
|||||||
<div className="template-head">
|
<div className="template-head">
|
||||||
<div className="template-title">
|
<div className="template-title">
|
||||||
Recept
|
Recept
|
||||||
|
{template.category && <span className="tag">{template.category}</span>}
|
||||||
|
<span className="tag">{RECIPE_FLAVOR_NAMES[template.flavor].toLowerCase()}</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">{template.servings} porcí</span>
|
<span className="tag">{template.servings} porcí</span>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useState } from "react";
|
import { useId, useState } from "react";
|
||||||
import { Alert, Button, Form } from "react-bootstrap";
|
import { Alert, Button, Form, ToggleButton, ToggleButtonGroup } from "react-bootstrap";
|
||||||
import { MealTemplate, saveMealTemplate } from "../../../types";
|
import { MealTemplate, RecipeFlavor, saveMealTemplate } from "../../../types";
|
||||||
|
import { RECIPE_FLAVOR_NAMES } from "../enums";
|
||||||
import { deriveRecipe, formatCalories, formatPrice, formatWeight, parseIntInput } from "../Utils";
|
import { deriveRecipe, formatCalories, formatPrice, formatWeight, parseIntInput } from "../Utils";
|
||||||
import SourceInput from "./SourceInput";
|
import SourceInput from "./SourceInput";
|
||||||
|
import RecipeCategoryInput from "./RecipeCategoryInput";
|
||||||
import RecipeIngredientsEditor, {
|
import RecipeIngredientsEditor, {
|
||||||
DraftIngredient, emptyIngredient, toDraftIngredient, toIngredient,
|
DraftIngredient, emptyIngredient, toDraftIngredient, toIngredient,
|
||||||
} from "./RecipeIngredientsEditor";
|
} from "./RecipeIngredientsEditor";
|
||||||
@@ -23,6 +25,8 @@ type Props = {
|
|||||||
const createForm = (template?: MealTemplate, meal?: Props['meal']) => ({
|
const createForm = (template?: MealTemplate, meal?: Props['meal']) => ({
|
||||||
name: meal?.name ?? template?.name ?? '',
|
name: meal?.name ?? template?.name ?? '',
|
||||||
source: meal?.source ?? template?.source ?? '',
|
source: meal?.source ?? template?.source ?? '',
|
||||||
|
category: template?.category ?? '',
|
||||||
|
flavor: template?.flavor ?? RecipeFlavor.SLANE,
|
||||||
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) : '',
|
||||||
@@ -38,6 +42,7 @@ const MealTemplateForm = ({ template, meal, onSaved, onCancel }: Readonly<Props>
|
|||||||
template?.ingredients?.length ? template.ingredients.map(toDraftIngredient) : [emptyIngredient()]);
|
template?.ingredients?.length ? template.ingredients.map(toDraftIngredient) : [emptyIngredient()]);
|
||||||
const [error, setError] = useState<string | undefined>();
|
const [error, setError] = useState<string | undefined>();
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const flavorId = useId();
|
||||||
|
|
||||||
const update = (changes: Partial<ReturnType<typeof createForm>>) =>
|
const update = (changes: Partial<ReturnType<typeof createForm>>) =>
|
||||||
setForm(current => ({ ...current, ...changes }));
|
setForm(current => ({ ...current, ...changes }));
|
||||||
@@ -57,6 +62,8 @@ const MealTemplateForm = ({ template, meal, onSaved, onCancel }: Readonly<Props>
|
|||||||
...(template ? { id: template.id } : {}),
|
...(template ? { id: template.id } : {}),
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
...(form.source.trim() ? { source: form.source.trim() } : {}),
|
...(form.source.trim() ? { source: form.source.trim() } : {}),
|
||||||
|
...(form.category.trim() ? { category: form.category.trim() } : {}),
|
||||||
|
flavor: form.flavor,
|
||||||
...(form.note.trim() ? { note: form.note.trim() } : {}),
|
...(form.note.trim() ? { note: form.note.trim() } : {}),
|
||||||
ingredients: filledIngredients,
|
ingredients: filledIngredients,
|
||||||
servings: parseIntInput(form.servings),
|
servings: parseIntInput(form.servings),
|
||||||
@@ -98,6 +105,33 @@ const MealTemplateForm = ({ template, meal, onSaved, onCancel }: Readonly<Props>
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="row g-3 mb-3">
|
||||||
|
<div className="col-sm-7">
|
||||||
|
<Form.Label>Typ</Form.Label>
|
||||||
|
<RecipeCategoryInput value={form.category} onChange={category => update({ category })} />
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-5">
|
||||||
|
<Form.Label className="d-block">Chuť</Form.Label>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
type="radio"
|
||||||
|
name={flavorId}
|
||||||
|
value={form.flavor}
|
||||||
|
onChange={(flavor: RecipeFlavor) => update({ flavor })}
|
||||||
|
>
|
||||||
|
{Object.values(RecipeFlavor).map(flavor => (
|
||||||
|
<ToggleButton
|
||||||
|
key={flavor}
|
||||||
|
id={`${flavorId}-${flavor}`}
|
||||||
|
value={flavor}
|
||||||
|
variant="outline-secondary"
|
||||||
|
>
|
||||||
|
{RECIPE_FLAVOR_NAMES[flavor]}
|
||||||
|
</ToggleButton>
|
||||||
|
))}
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<fieldset className="meal-fieldset mb-3">
|
<fieldset className="meal-fieldset mb-3">
|
||||||
<legend>Suroviny</legend>
|
<legend>Suroviny</legend>
|
||||||
<RecipeIngredientsEditor ingredients={ingredients} onChange={setIngredients} />
|
<RecipeIngredientsEditor ingredients={ingredients} onChange={setIngredients} />
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useEffect, useId, useState } from "react";
|
||||||
|
import { Form } from "react-bootstrap";
|
||||||
|
import { MealTemplate, getMealTemplates } from "../../../types";
|
||||||
|
import { normalizeSearch } from "../Utils";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: string,
|
||||||
|
onChange: (value: string) => void,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typy z ostatních receptů, každý jednou, od nejpoužívanějšího. Zápis je
|
||||||
|
* z prvního receptu, ve kterém se typ našel.
|
||||||
|
*/
|
||||||
|
const listCategories = (templateList: MealTemplate[]): string[] => {
|
||||||
|
const countMap = new Map<string, { label: string, count: number }>();
|
||||||
|
for (const template of templateList) {
|
||||||
|
if (!template.category) continue;
|
||||||
|
const key = normalizeSearch(template.category);
|
||||||
|
const existing = countMap.get(key);
|
||||||
|
countMap.set(key, { label: existing?.label ?? template.category, count: (existing?.count ?? 0) + 1 });
|
||||||
|
}
|
||||||
|
return [...countMap.values()]
|
||||||
|
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, 'cs'))
|
||||||
|
.map(item => item.label);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pole pro typ receptu (polévka, omáčka, pečení…). Typ není pevný číselník —
|
||||||
|
* nabízejí se typy už zapsané u ostatních receptů a psát jde i nový.
|
||||||
|
*/
|
||||||
|
const RecipeCategoryInput = ({ value, onChange }: Readonly<Props>) => {
|
||||||
|
const listId = useId();
|
||||||
|
const [categoryList, setCategoryList] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getMealTemplates().then(response => setCategoryList(listCategories(response.data ?? [])));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form.Control
|
||||||
|
list={listId}
|
||||||
|
value={value}
|
||||||
|
placeholder="Např. polévka, omáčka"
|
||||||
|
onChange={event => onChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
<datalist id={listId}>
|
||||||
|
{categoryList.map(category => <option key={category} value={category} />)}
|
||||||
|
</datalist>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RecipeCategoryInput;
|
||||||
+7
-1
@@ -1,4 +1,4 @@
|
|||||||
import { ActivityUnit, BmiKategorie, ImportMode, MealType, Nutrients, Pohlavi } from "../../types";
|
import { ActivityUnit, BmiKategorie, ImportMode, MealType, Nutrients, Pohlavi, RecipeFlavor } from "../../types";
|
||||||
|
|
||||||
/** Lidsky čitelné názvy typů jídel. */
|
/** Lidsky čitelné názvy typů jídel. */
|
||||||
export const MEAL_TYPE_NAMES: Record<MealType, string> = {
|
export const MEAL_TYPE_NAMES: Record<MealType, string> = {
|
||||||
@@ -187,6 +187,12 @@ export const RECIPE_FILTER_NAMES: Record<RecipeFilter, string> = {
|
|||||||
BEZ_RECEPTU: 'Bez receptu',
|
BEZ_RECEPTU: 'Bez receptu',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Popisky chuti receptu. */
|
||||||
|
export const RECIPE_FLAVOR_NAMES: Record<RecipeFlavor, string> = {
|
||||||
|
SLANE: 'Slané',
|
||||||
|
SLADKE: 'Sladké',
|
||||||
|
};
|
||||||
|
|
||||||
/** 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',
|
||||||
|
|||||||
@@ -19,6 +19,14 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.meal-groups-count {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--ft-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.meal-group {
|
.meal-group {
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ const matchesRecipe = (group: MealGroup, recipeFilter: RecipeFilter): boolean =>
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Hledá se v názvu jídla a v typu jeho receptu (polévka, omáčka…). */
|
||||||
|
const matchesSearch = (group: MealGroup, search: string): boolean =>
|
||||||
|
normalizeSearch(group.name).includes(search)
|
||||||
|
|| (!!group.recipe?.category && normalizeSearch(group.recipe.category).includes(search));
|
||||||
|
|
||||||
/** Stav, se kterým stránku může otevřít odkaz odjinud (statistiky, výběr z historie). */
|
/** Stav, se kterým stránku může otevřít odkaz odjinud (statistiky, výběr z historie). */
|
||||||
type LocationState = { onlyIncomplete?: boolean, recipeFilter?: RecipeFilter } | null;
|
type LocationState = { onlyIncomplete?: boolean, recipeFilter?: RecipeFilter } | null;
|
||||||
|
|
||||||
@@ -85,10 +90,9 @@ const MealGroupsPage = () => {
|
|||||||
(!onlyIncomplete || isIncomplete(group))
|
(!onlyIncomplete || isIncomplete(group))
|
||||||
&& matchesSource(group, sourceFilter)
|
&& matchesSource(group, sourceFilter)
|
||||||
&& matchesRecipe(group, recipeFilter)
|
&& matchesRecipe(group, recipeFilter)
|
||||||
&& (!search.length || normalizeSearch(group.name).includes(search)));
|
&& (!search.length || matchesSearch(group, search)));
|
||||||
}, [groupList, query, sourceFilter, recipeFilter, onlyIncomplete]);
|
}, [groupList, query, sourceFilter, recipeFilter, onlyIncomplete]);
|
||||||
|
|
||||||
const incompleteCount = groupList.filter(group => isIncomplete(group)).length;
|
|
||||||
const filtered = !!query.trim().length || sourceFilter !== SourceFilter.VSE || recipeFilter !== RecipeFilter.VSE;
|
const filtered = !!query.trim().length || sourceFilter !== SourceFilter.VSE || recipeFilter !== RecipeFilter.VSE;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -115,7 +119,7 @@ const MealGroupsPage = () => {
|
|||||||
<div className="card-panel meal-groups-filter">
|
<div className="card-panel meal-groups-filter">
|
||||||
<Form.Control
|
<Form.Control
|
||||||
value={query}
|
value={query}
|
||||||
placeholder="Hledat jídlo…"
|
placeholder="Hledat jídlo nebo typ receptu…"
|
||||||
onChange={event => setQuery(event.target.value)}
|
onChange={event => setQuery(event.target.value)}
|
||||||
/>
|
/>
|
||||||
<Form.Select
|
<Form.Select
|
||||||
@@ -139,10 +143,17 @@ const MealGroupsPage = () => {
|
|||||||
<Form.Check
|
<Form.Check
|
||||||
type="switch"
|
type="switch"
|
||||||
id="meal-groups-incomplete"
|
id="meal-groups-incomplete"
|
||||||
label={`Jen s chybějícími údaji (${incompleteCount})`}
|
label="K doplnění"
|
||||||
checked={onlyIncomplete}
|
checked={onlyIncomplete}
|
||||||
onChange={event => setOnlyIncomplete(event.target.checked)}
|
onChange={event => setOnlyIncomplete(event.target.checked)}
|
||||||
/>
|
/>
|
||||||
|
{!loading && (
|
||||||
|
<span className="meal-groups-count">
|
||||||
|
Zobrazeno {visibleList.length === groupList.length
|
||||||
|
? groupList.length
|
||||||
|
: `${visibleList.length} z ${groupList.length}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { MealTemplate, MealType, RecipeIngredient } from "../../types/gen/types.gen";
|
import { MealTemplate, MealType, RecipeFlavor, RecipeIngredient } from "../../types/gen/types.gen";
|
||||||
import getStorage from "./storage";
|
import getStorage from "./storage";
|
||||||
import { addMeal, getNameSourceKey, isMealType } from "./meals";
|
import { addMeal, getNameSourceKey, isMealType } from "./meals";
|
||||||
import { rememberCalories } from "./calories";
|
import { rememberCalories } from "./calories";
|
||||||
@@ -22,8 +22,9 @@ const getKey = (login: string): string => `mealTemplates:${login}`;
|
|||||||
* i šablony hotových jídel (druh `HOTOVE`, nebo bez druhu), recepty nesly
|
* 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'> & {
|
type StoredMealTemplate = Omit<MealTemplate, 'ingredients' | 'flavor'> & {
|
||||||
ingredients?: RecipeIngredient[];
|
ingredients?: RecipeIngredient[];
|
||||||
|
flavor?: RecipeFlavor;
|
||||||
kind?: string;
|
kind?: string;
|
||||||
pricePer100g?: number;
|
pricePer100g?: number;
|
||||||
mealType?: MealType;
|
mealType?: MealType;
|
||||||
@@ -37,12 +38,28 @@ type StoredRecipe = StoredMealTemplate & { ingredients: RecipeIngredient[] };
|
|||||||
*/
|
*/
|
||||||
const isRecipe = (stored: StoredMealTemplate): stored is StoredRecipe => !!stored.ingredients?.length;
|
const isRecipe = (stored: StoredMealTemplate): stored is StoredRecipe => !!stored.ingredients?.length;
|
||||||
|
|
||||||
/** Odstraní z receptu pole, která patřila jen šablonám hotových jídel, a zaniklý chod. */
|
/**
|
||||||
|
* Odstraní z receptu pole, která patřila jen šablonám hotových jídel, a zaniklý chod.
|
||||||
|
* Slaný recept je výchozí stav a v úložišti chuť nemá, doplní se tady.
|
||||||
|
*/
|
||||||
const toMealTemplate = (stored: StoredRecipe): MealTemplate => {
|
const toMealTemplate = (stored: StoredRecipe): MealTemplate => {
|
||||||
const { kind, pricePer100g, mealType, ...result } = stored;
|
const { kind, pricePer100g, mealType, ...rest } = stored;
|
||||||
return result;
|
return { ...rest, flavor: rest.flavor ?? RecipeFlavor.SLANE };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Chuť receptu ze vstupu; bez ní slaný. */
|
||||||
|
const normalizeFlavor = (value: unknown): RecipeFlavor => {
|
||||||
|
if (value == null) return RecipeFlavor.SLANE;
|
||||||
|
if (value !== RecipeFlavor.SLANE && value !== RecipeFlavor.SLADKE) {
|
||||||
|
throw new BadRequestError("Nebyla předána platná chuť receptu");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Recept tak, jak se ukládá — výchozí slanou chuť neukládá. */
|
||||||
|
const toStored = ({ flavor, ...rest }: MealTemplate): StoredMealTemplate =>
|
||||||
|
flavor === RecipeFlavor.SLANE ? rest : { ...rest, flavor };
|
||||||
|
|
||||||
/** Načte recepty z úložiště; šablony hotových jídel vynechá. */
|
/** Načte recepty z úložiště; šablony hotových jídel vynechá. */
|
||||||
const loadRecipes = (storedList: StoredMealTemplate[] | undefined): MealTemplate[] =>
|
const loadRecipes = (storedList: StoredMealTemplate[] | undefined): MealTemplate[] =>
|
||||||
(storedList ?? []).filter(isRecipe).map(toMealTemplate);
|
(storedList ?? []).filter(isRecipe).map(toMealTemplate);
|
||||||
@@ -214,6 +231,8 @@ export const saveMealTemplate = async (login: string, input: unknown): Promise<M
|
|||||||
id: typeof value.id === 'string' && value.id.length ? value.id : randomUUID(),
|
id: typeof value.id === 'string' && value.id.length ? value.id : randomUUID(),
|
||||||
name,
|
name,
|
||||||
source: normalizeText(value.source, 'source'),
|
source: normalizeText(value.source, 'source'),
|
||||||
|
category: normalizeText(value.category, 'category'),
|
||||||
|
flavor: normalizeFlavor(value.flavor),
|
||||||
note: normalizeText(value.note, 'note', MAX_NOTE_LENGTH),
|
note: normalizeText(value.note, 'note', MAX_NOTE_LENGTH),
|
||||||
ingredients,
|
ingredients,
|
||||||
// Jedna porce je výchozí stav, ukládat ji zvlášť nemá smysl
|
// Jedna porce je výchozí stav, ukládat ji zvlášť nemá smysl
|
||||||
@@ -236,8 +255,8 @@ 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),
|
...loadRecipes(current).filter(item => item.id !== template.id).map(toStored),
|
||||||
template,
|
toStored(template),
|
||||||
]);
|
]);
|
||||||
return getMealTemplates(login);
|
return getMealTemplates(login);
|
||||||
};
|
};
|
||||||
@@ -256,7 +275,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);
|
return recipeList.filter(item => item.id !== id).map(toStored);
|
||||||
});
|
});
|
||||||
if (!found) {
|
if (!found) {
|
||||||
throw new NotFoundError("Recept nebyl nalezen");
|
throw new NotFoundError("Recept nebyl nalezen");
|
||||||
@@ -329,5 +348,5 @@ export const renameMealTemplate = async (
|
|||||||
if (!recipe) return;
|
if (!recipe) return;
|
||||||
const renamed: MealTemplate = definedOnly({ ...recipe, name, source });
|
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 => item.id === recipe.id ? renamed : item));
|
loadRecipes(current).map(item => toStored(item.id === recipe.id ? renamed : item)));
|
||||||
};
|
};
|
||||||
@@ -5,7 +5,7 @@ import { getMealGroups, renameMealGroup } from "../mealGroups";
|
|||||||
import { getMealTemplates, saveMealTemplate } from "../mealTemplates";
|
import { getMealTemplates, saveMealTemplate } from "../mealTemplates";
|
||||||
import { addMealPhoto, deleteMealPhoto, getMealPhotos } from "../mealPhotos";
|
import { addMealPhoto, deleteMealPhoto, getMealPhotos } from "../mealPhotos";
|
||||||
import { resetMemoryStorage } from "../storage/memory";
|
import { resetMemoryStorage } from "../storage/memory";
|
||||||
import { MealType } from "../../../types/gen/types.gen";
|
import { MealType, RecipeFlavor } from "../../../types/gen/types.gen";
|
||||||
|
|
||||||
const LOGIN = 'tester';
|
const LOGIN = 'tester';
|
||||||
const IMAGE = 'data:image/jpeg;base64,AAAA';
|
const IMAGE = 'data:image/jpeg;base64,AAAA';
|
||||||
@@ -62,6 +62,25 @@ describe('recept jídla v seznamu jídel', () => {
|
|||||||
expect(await getMealPhotos(LOGIN, 'Rizoto', 'doma')).toHaveLength(0);
|
expect(await getMealPhotos(LOGIN, 'Rizoto', 'doma')).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('recept je bez zadané chuti slaný a typ i chuť přežijí přejmenování', async () => {
|
||||||
|
const [created] = await saveMealTemplate(LOGIN, rizoto);
|
||||||
|
expect(created).toMatchObject({ flavor: RecipeFlavor.SLANE });
|
||||||
|
expect(created).not.toHaveProperty('category');
|
||||||
|
|
||||||
|
const [saved] = await saveMealTemplate(LOGIN, {
|
||||||
|
...rizoto, id: created.id, category: ' Pečení ', flavor: RecipeFlavor.SLADKE,
|
||||||
|
});
|
||||||
|
await renameMealGroup(LOGIN, 'Rizoto', 'doma', 'Sladké rizoto', 'doma');
|
||||||
|
|
||||||
|
expect(await getMealTemplates(LOGIN)).toMatchObject([
|
||||||
|
{ id: saved.id, name: 'Sladké rizoto', category: 'Pečení', flavor: RecipeFlavor.SLADKE },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('odmítne neznámou chuť receptu', async () => {
|
||||||
|
await expect(saveMealTemplate(LOGIN, { ...rizoto, flavor: 'KYSELE' })).rejects.toThrow(/chuť/);
|
||||||
|
});
|
||||||
|
|
||||||
it('dvě jídla s recepty přejmenováním nesloučí', async () => {
|
it('dvě jídla s recepty přejmenováním nesloučí', async () => {
|
||||||
await saveMealTemplate(LOGIN, rizoto);
|
await saveMealTemplate(LOGIN, rizoto);
|
||||||
await saveMealTemplate(LOGIN, { ...rizoto, name: 'Rizoto 2' });
|
await saveMealTemplate(LOGIN, { ...rizoto, name: 'Rizoto 2' });
|
||||||
|
|||||||
@@ -1161,6 +1161,7 @@ MealTemplate:
|
|||||||
- id
|
- id
|
||||||
- name
|
- name
|
||||||
- ingredients
|
- ingredients
|
||||||
|
- flavor
|
||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
type: string
|
type: string
|
||||||
@@ -1170,6 +1171,13 @@ MealTemplate:
|
|||||||
source:
|
source:
|
||||||
description: Zdroj jídla, typicky "doma"
|
description: Zdroj jídla, typicky "doma"
|
||||||
type: string
|
type: string
|
||||||
|
category:
|
||||||
|
description: |
|
||||||
|
Typ receptu (polévka, omáčka, pečení, ...). Volný text — nabízí se
|
||||||
|
typy z ostatních receptů, ale zapsat jde i nový.
|
||||||
|
type: string
|
||||||
|
flavor:
|
||||||
|
$ref: "#/RecipeFlavor"
|
||||||
calories:
|
calories:
|
||||||
description: Energetická hodnota jedné porce v kcal
|
description: Energetická hodnota jedné porce v kcal
|
||||||
type: integer
|
type: integer
|
||||||
@@ -1217,6 +1225,11 @@ MealTemplateInput:
|
|||||||
type: string
|
type: string
|
||||||
source:
|
source:
|
||||||
type: string
|
type: string
|
||||||
|
category:
|
||||||
|
type: string
|
||||||
|
flavor:
|
||||||
|
description: Bez něj slané
|
||||||
|
$ref: "#/RecipeFlavor"
|
||||||
note:
|
note:
|
||||||
type: string
|
type: string
|
||||||
ingredients:
|
ingredients:
|
||||||
@@ -1228,6 +1241,13 @@ MealTemplateInput:
|
|||||||
cookedWeight:
|
cookedWeight:
|
||||||
type: integer
|
type: integer
|
||||||
|
|
||||||
|
RecipeFlavor:
|
||||||
|
description: Zda je recept slaný, nebo sladký
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- SLANE
|
||||||
|
- SLADKE
|
||||||
|
|
||||||
ExtraWeightGrams:
|
ExtraWeightGrams:
|
||||||
description: |
|
description: |
|
||||||
Dodatečná zátěž v gramech (činky, zátěžová vesta). Eviduje se jako
|
Dodatečná zátěž v gramech (činky, zátěžová vesta). Eviduje se jako
|
||||||
|
|||||||
Reference in new issue
Block a user