feat: předělání historie a profilu

This commit is contained in:
Ondřej Anděl committed 2026-10-07 14:48:21 +02:00
1 parent 9e72f431a1
commit 8fa7f601f2
97 files changed
+7502 -1034

No files matched your search

+103 -51
View File
@@ -2,12 +2,12 @@ import { useEffect, useState } from "react";
import { Button, Form, Modal, Spinner } from "react-bootstrap";
import { useNavigate } from "react-router";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faPlay, faBowlFood } from "@fortawesome/free-solid-svg-icons";
import { DayOverview, MealTemplate, MealType, applyMealTemplate, getMealTemplates } from "../../../../types";
import { faPlay, faClockRotateLeft } from "@fortawesome/free-solid-svg-icons";
import {
MEAL_TEMPLATE_KINDS_IN_ORDER, MEAL_TEMPLATE_KIND_NAMES, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES,
} from "../../enums";
import { describeMealTemplate, normalizeSearch, parseMultiplierInput } from "../../Utils";
MealSuggestion, MealTemplate, MealType, addMeal, applyMealTemplate, getMealHistory, getMealTemplates,
} from "../../../../types";
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES, TemplateTab } from "../../enums";
import { describeMealSuggestion, describeMealTemplate, normalizeSearch, parseMultiplierInput } from "../../Utils";
import { SABLONY_URL } from "../../routes";
type Props = {
@@ -15,20 +15,24 @@ type Props = {
/** Den, do kterého se jídlo založí. */
date: string,
onClose: () => void,
/** Zavolá se po založení jídla s aktualizovaným přehledem dne. */
onApplied: (day: DayOverview) => void,
/** Zavolá se po založení jídla; přehled dne je potřeba načíst znovu. */
onApplied: () => void,
};
/** Kolik jídel z historie se vypíše — dlouhý seznam se zúží hledáním. */
const HISTORY_LIMIT = 30;
/**
* Výběr šablony jídla k založení do dne.
* Výběr jídla z historie nebo receptu k založení do dne jedním kliknutím.
*
* Zakládání a úpravy šablon se řeší na stránce Šablony, sem chodí uživatel jen
* vybrat hotové jídlo nebo uvařený recept. Chod lze při zakládání přepsat,
* protože totéž jídlo může být jednou oběd a podruhé večeře, a násobkem se
* zapíší třeba dvě stejné sušenky naráz.
* Jídlo z historie se založí s hodnotami z posledního záznamu toho jména, recept
* jako jedna porce. Chod lze zvolit, protože totéž jídlo může být jednou oběd
* a podruhé večeře, a násobkem se zapíší třeba dvě stejné sušenky naráz.
* Kdo chce hodnoty před založením upravit, použije našeptávání ve formuláři jídla.
*/
const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) => {
const [templates, setTemplates] = useState<MealTemplate[]>([]);
const [recipes, setRecipes] = useState<MealTemplate[]>([]);
const [history, setHistory] = useState<MealSuggestion[]>([]);
const [mealType, setMealType] = useState<MealType | ''>('');
const [multiplierInput, setMultiplierInput] = useState('');
const [search, setSearch] = useState('');
@@ -42,8 +46,9 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) =
setMealType('');
setMultiplierInput('');
setSearch('');
getMealTemplates().then(response => {
setTemplates(response.data ?? []);
Promise.all([getMealTemplates(), getMealHistory()]).then(([recipeResponse, historyResponse]) => {
setRecipes(recipeResponse.data ?? []);
setHistory(historyResponse.data ?? []);
setLoading(false);
});
}, [isOpen]);
@@ -51,30 +56,50 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) =
const multiplier = parseMultiplierInput(multiplierInput);
const multiplierInvalid = multiplierInput.trim().length > 0 && multiplier == null;
const apply = async (id: string) => {
const applyRecipe = async (recipe: MealTemplate) => {
setBusy(true);
const response = await applyMealTemplate({
body: { id, date, ...(mealType ? { mealType } : {}), ...(multiplier != null ? { multiplier } : {}) },
body: {
id: recipe.id, date, ...(mealType ? { mealType } : {}), ...(multiplier != null ? { multiplier } : {}),
},
});
setBusy(false);
if (response.data) {
onApplied(response.data);
}
if (response.data) onApplied();
};
const applyHistory = async (suggestion: MealSuggestion) => {
setBusy(true);
const response = await addMeal({
body: {
date,
mealType: mealType || MealType.OBED,
name: suggestion.name,
source: suggestion.source,
price: suggestion.price,
pricePer100g: suggestion.pricePer100g,
weight: suggestion.weight,
caloriesPer100g: suggestion.caloriesPer100g,
calories: suggestion.calories,
nutrientsPer100g: suggestion.nutrientsPer100g,
multiplier,
},
});
setBusy(false);
if (response.data) onApplied();
};
const key = normalizeSearch(search);
const groupList = MEAL_TEMPLATE_KINDS_IN_ORDER
.map(kind => ({
kind,
templates: templates.filter(template => template.kind === kind
&& (!key.length || normalizeSearch(template.name).includes(key))),
}))
.filter(group => group.templates.length > 0);
const matches = (name: string) => !key.length || normalizeSearch(name).includes(key);
const recipeList = recipes.filter(recipe => matches(recipe.name));
// Jídlo stejného jména jako recept vzniklo nejspíš z něj, nabízí se jen recept
const recipeNames = new Set(recipes.map(recipe => normalizeSearch(recipe.name)));
const historyMatchList = history.filter(item => matches(item.name) && !recipeNames.has(normalizeSearch(item.name)));
const historyList = historyMatchList.slice(0, HISTORY_LIMIT);
return (
<Modal show={isOpen} onHide={onClose} centered size="lg">
<Modal.Header closeButton>
<Modal.Title>Použít šablonu jídla</Modal.Title>
<Modal.Title>Vybrat z historie</Modal.Title>
</Modal.Header>
<Modal.Body>
{loading && (
@@ -83,18 +108,18 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) =
</div>
)}
{!loading && !templates.length && (
{!loading && !recipes.length && !history.length && (
<div className="empty-state">
<div className="empty-icon"><FontAwesomeIcon icon={faBowlFood} /></div>
<div className="empty-title">Zatím nemáte žádnou šablonu jídla</div>
<div className="empty-icon"><FontAwesomeIcon icon={faClockRotateLeft} /></div>
<div className="empty-title">Zatím nemáte co vybírat</div>
<p className="empty-description">
Vytvořit si ji můžete na stránce Šablony, nebo u jídla v přehledu dne tlačítkem
„Uložit jako šablonu".
Každé zapsané jídlo se tu příště nabídne. Vařená jídla si můžete zapsat jako
recept na stránce Šablony.
</p>
</div>
)}
{!loading && !!templates.length && (
{!loading && (!!recipes.length || !!history.length) && (
<>
<div className="row g-3 mb-3">
<div className="col-sm-5">
@@ -110,9 +135,10 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) =
<Form.Label>Založit jako</Form.Label>
<Form.Select
value={mealType}
title="Bez volby se jídlo z historie založí jako oběd a recept pod svým chodem"
onChange={event => setMealType(event.target.value as MealType | '')}
>
<option value="">— chod ze šablony —</option>
<option value="">— výchozí —</option>
{MEAL_TYPES_IN_ORDER.map(type => (
<option key={type} value={type}>
{MEAL_TYPE_ICONS[type]} {MEAL_TYPE_NAMES[type]}
@@ -133,25 +159,22 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) =
</div>
</div>
{!groupList.length && (
<p className="text-body-secondary small">Žádná šablona neodpovídá hledání.</p>
{!recipeList.length && !historyList.length && (
<p className="text-body-secondary small">Hledání nic neodpovídá.</p>
)}
{groupList.map(group => (
<div className="apply-template-group" key={group.kind}>
<h3 className="apply-template-group-title">{MEAL_TEMPLATE_KIND_NAMES[group.kind]}</h3>
{group.templates.map(template => (
<div className="workout-row" key={template.id}>
{!!recipeList.length && (
<div className="apply-template-group">
<h3 className="apply-template-group-title">Recepty</h3>
{recipeList.map(recipe => (
<div className="workout-row" key={recipe.id}>
<div className="workout-info">
<div className="workout-name">{template.name}</div>
<div className="workout-items">
{describeMealTemplate(template)}
{!!template.ingredients?.length && ` · ${template.ingredients.length} surovin, hodnoty za porci`}
</div>
<div className="workout-name">{recipe.name}</div>
<div className="workout-items">{describeMealTemplate(recipe)}</div>
</div>
<Button
size="sm"
onClick={() => apply(template.id)}
onClick={() => applyRecipe(recipe)}
disabled={busy || multiplierInvalid}
>
<FontAwesomeIcon icon={faPlay} /> Použít
@@ -159,16 +182,45 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) =
</div>
))}
</div>
))}
)}
{!!historyList.length && (
<div className="apply-template-group">
<h3 className="apply-template-group-title">Dříve snědené</h3>
{historyList.map(item => (
<div className="workout-row" key={item.name}>
<div className="workout-info">
<div className="workout-name">{item.name}</div>
<div className="workout-items">
{describeMealSuggestion(item)}
{item.count > 1 && ` · ${item.count}×`}
</div>
</div>
<Button
size="sm"
onClick={() => applyHistory(item)}
disabled={busy || multiplierInvalid}
>
<FontAwesomeIcon icon={faPlay} /> Použít
</Button>
</div>
))}
{historyMatchList.length > historyList.length && (
<p className="text-body-secondary small mt-2">
A dalších {historyMatchList.length - historyList.length}, upřesněte hledání.
</p>
)}
</div>
)}
</>
)}
</Modal.Body>
<Modal.Footer>
<Button
variant="outline-secondary"
onClick={() => { onClose(); navigate(SABLONY_URL); }}
onClick={() => { onClose(); navigate(SABLONY_URL, { state: { tab: TemplateTab.RECEPTY } }); }}
>
Spravovat šablony
Spravovat recepty
</Button>
<Button variant="outline-secondary" onClick={() => onClose()}>Zavřít</Button>
</Modal.Footer>
+117 -63
View File
@@ -1,16 +1,19 @@
import { useEffect, useMemo, useState } from "react";
import { Button, Form, Modal } from "react-bootstrap";
import {
DayRecord, MealEntry, MealTemplate, MealTemplateKind, MealType, UserSettings,
addMeal, createMealTemplateFromMeal, getMealTemplates, getSettings, updateMeal,
DayRecord, MealEntry, MealSuggestion, MealTemplate, MealType, Nutrients, UserSettings,
addMeal, getMealHistory, getMealTemplates, getSettings, updateMeal,
} from "../../../../types";
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums";
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES, NUTRIENTS_IN_ORDER } from "../../enums";
import {
applyMultiplier, deriveAmounts, formatCalories, formatMultiplier, formatMultiplierInput, formatPriceInput,
formatWeight, parseIntInput, parseMultiplierInput, parsePriceInput,
formatNutrientInput, formatWeight, normalizeSearch, parseIntInput, parseMultiplierInput, parseNutrientInput,
parsePriceInput,
} from "../../Utils";
import CalorieLookup from "../CalorieLookup";
import TemplateSuggestions from "../TemplateSuggestions";
import MealSuggestions from "../MealSuggestions";
import NutrientsFieldset, { NutrientInputs, emptyNutrientInputs, toNutrients } from "../NutrientsFieldset";
import SourceInput from "../SourceInput";
type Props = {
isOpen: boolean,
@@ -19,7 +22,8 @@ type Props = {
/** Upravovaný záznam. Pokud chybí, jde o založení nového. */
entry?: MealEntry,
onClose: () => void,
onSaved: (day: DayRecord) => void,
/** Zavolá se po uložení s přehledem dne a uloženým jídlem. */
onSaved: (day: DayRecord, saved?: MealEntry) => void,
};
/** Prázdný formulář pro nové jídlo v daném dni. */
@@ -36,9 +40,35 @@ function emptyForm(date: string) {
calories: '',
note: '',
multiplier: '',
nutrients: emptyNutrientInputs(),
};
}
type MealForm = ReturnType<typeof emptyForm>;
/**
* Spojí formulář s hodnotami z návrhu. U nového jídla návrh přepíše vše —
* uživatel může mezi návrhy přeskakovat. U upravovaného jídla jen doplní prázdná
* pole: cena a zdroj z importu jsou skutečné a návrh je nesmí přepsat.
*/
const mergeSuggestion = (current: MealForm, values: Partial<MealForm>, onlyEmpty: boolean): MealForm => {
if (!onlyEmpty) return { ...current, ...values };
const result = { ...current };
for (const [key, value] of Object.entries(values) as [keyof MealForm, MealForm[keyof MealForm]][]) {
if (key === 'nutrients') {
const empty = Object.values(current.nutrients).every(item => !item.trim().length);
if (empty) result.nutrients = value as NutrientInputs;
} else if (key === 'name' || !String(current[key]).trim().length) {
(result as Record<string, unknown>)[key] = value;
}
}
return result;
};
/** Převede uložené živiny na text pro editační pole. */
const toNutrientInputs = (nutrients?: Nutrients): NutrientInputs =>
Object.fromEntries(NUTRIENTS_IN_ORDER.map(key => [key, formatNutrientInput(nutrients?.[key])])) as NutrientInputs;
/** Znormalizuje název zdroje pro porovnání se sazbami z nastavení. */
function normalizeSource(source: string): string {
return source.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim();
@@ -52,23 +82,24 @@ function normalizeSource(source: string): string {
* pak dopočtou kalorie. Každá položka má vlastní sazbu, takže hlavní jídlo a salát
* s jiným cenováním můžou být ve stejném dni vedle sebe.
*
* U nového jídla se k rozepsanému názvu nabízejí šablony, takže jídlo, které už
* uživatel někdy měl, nemusí dohledávat znovu.
* K rozepsanému názvu se nabízejí recepty a jídla z historie, takže jídlo, které
* už uživatel někdy měl, nemusí dohledávat znovu. U upravovaného jídla se nabídnou
* až po změně názvu a doplní jen prázdná pole — hodí se k pojmenování
* naimportovaného jídla, jehož název Luncher složil z poznámky.
*/
export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Readonly<Props>) {
const [form, setForm] = useState(() => emptyForm(date));
const [settings, setSettings] = useState<UserSettings | undefined>();
const [saving, setSaving] = useState(false);
const [alsoTemplate, setAlsoTemplate] = useState(false);
const [templates, setTemplates] = useState<MealTemplate[]>([]);
/** Název šablony, ze které se formulář naposledy vyplnil — pak už se návrhy nenabízejí. */
const [pickedTemplate, setPickedTemplate] = useState<string | undefined>();
const [recipes, setRecipes] = useState<MealTemplate[]>([]);
const [history, setHistory] = useState<MealSuggestion[]>([]);
/** Název návrhu, ze kterého se formulář naposledy vyplnil — pak už se návrhy nenabízejí. */
const [pickedSuggestion, setPickedSuggestion] = useState<string | undefined>();
// Při každém otevření se formulář naplní dle toho, co se upravuje
useEffect(() => {
if (!isOpen) return;
setAlsoTemplate(false);
setPickedTemplate(undefined);
setPickedSuggestion(entry?.name);
setForm(entry
? {
date: entry.date,
@@ -82,15 +113,17 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
calories: entry.calories != null ? String(entry.calories) : '',
note: entry.note ?? '',
multiplier: formatMultiplierInput(entry.multiplier),
nutrients: toNutrientInputs(entry.nutrientsPer100g),
}
: emptyForm(date));
}, [isOpen, entry, date]);
// Šablony se nabízejí jen při zakládání; načtou se při každém otevření, mohly přibýt
// Návrhy se načtou při každém otevření, mohly přibýt
useEffect(() => {
if (!isOpen || entry) return;
getMealTemplates().then(response => setTemplates(response.data ?? []));
}, [isOpen, entry]);
if (!isOpen) return;
getMealTemplates().then(response => setRecipes(response.data ?? []));
getMealHistory().then(response => setHistory(response.data ?? []));
}, [isOpen]);
// Sazby podniků se načtou jednou při otevření a slouží k předvyplnění Kč/100 g
useEffect(() => {
@@ -100,22 +133,41 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
const update = (changes: Partial<typeof form>) => setForm(current => ({ ...current, ...changes }));
/** Vyplní formulář hodnotami ze šablony; datum a násobek zůstanou, jak jsou. */
const applyTemplate = (template: MealTemplate) => {
setPickedTemplate(template.name);
setForm(current => ({
...current,
name: template.name,
mealType: template.mealType ?? current.mealType,
source: template.source ?? '',
price: formatPriceInput(template.price),
pricePer100g: formatPriceInput(template.pricePer100g),
weight: template.weight != null ? String(template.weight) : '',
caloriesPer100g: template.caloriesPer100g != null ? String(template.caloriesPer100g) : '',
calories: template.calories != null ? String(template.calories) : '',
// Postup receptu do záznamu dne nepatří, zůstává u šablony
note: template.kind === MealTemplateKind.RECEPT ? '' : template.note ?? '',
}));
/**
* Vyplní formulář jednou porcí receptu. Datum a násobek zůstanou, jak jsou,
* a postup do záznamu dne nepatří, zůstává u receptu.
*/
const applyRecipe = (recipe: MealTemplate) => {
setPickedSuggestion(recipe.name);
setForm(current => mergeSuggestion(current, {
name: recipe.name,
...(recipe.mealType ? { mealType: recipe.mealType } : {}),
source: recipe.source ?? '',
price: formatPriceInput(recipe.price),
pricePer100g: '',
weight: recipe.weight != null ? String(recipe.weight) : '',
caloriesPer100g: recipe.caloriesPer100g != null ? String(recipe.caloriesPer100g) : '',
calories: recipe.calories != null ? String(recipe.calories) : '',
nutrients: emptyNutrientInputs(),
}, !!entry));
};
/**
* Vyplní formulář hodnotami jídla z historie. Chod, datum, násobek a poznámka
* patří ke konkrétnímu dni, takže zůstanou, jak je uživatel má.
*/
const applyHistory = (suggestion: MealSuggestion) => {
setPickedSuggestion(suggestion.name);
setForm(current => mergeSuggestion(current, {
name: suggestion.name,
source: suggestion.source ?? '',
price: formatPriceInput(suggestion.price),
pricePer100g: formatPriceInput(suggestion.pricePer100g),
weight: suggestion.weight != null ? String(suggestion.weight) : '',
caloriesPer100g: suggestion.caloriesPer100g != null ? String(suggestion.caloriesPer100g) : '',
calories: suggestion.calories != null ? String(suggestion.calories) : '',
nutrients: toNutrientInputs(suggestion.nutrientsPer100g),
}, !!entry));
};
/** Sazba nastavená pro právě zadaný zdroj, pokud nějaká je. */
@@ -147,8 +199,10 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
const multiplier = parseMultiplierInput(form.multiplier);
const multiplierInvalid = form.multiplier.trim().length > 0 && multiplier == null;
const totalCalories = applyMultiplier(effectiveCalories, multiplier);
const nutrientsInvalid = NUTRIENTS_IN_ORDER
.some(key => form.nutrients[key].trim().length > 0 && parseNutrientInput(form.nutrients[key]) == null);
const canSave = form.name.trim().length > 0 && !multiplierInvalid && !saving;
const canSave = form.name.trim().length > 0 && !multiplierInvalid && !nutrientsInvalid && !saving;
const save = async () => {
if (!canSave) return;
@@ -166,22 +220,18 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
calories: derived.calories != null ? undefined : manualCalories,
note: form.note.trim() || undefined,
multiplier,
nutrientsPer100g: toNutrients(form.nutrients),
};
const response = entry
? await updateMeal({ body: { id: entry.id, ...body } })
: await addMeal({ body });
if (response.data && alsoTemplate) {
setSaving(false);
if (response.data) {
// U nového jídla je ten správný záznam vždy ten poslední založený
const saved = entry
? response.data.entries.find(item => item.id === entry.id)
: [...response.data.entries].sort((a, b) => a.createdAt.localeCompare(b.createdAt)).pop();
if (saved) {
await createMealTemplateFromMeal({ body: { id: saved.id, date: saved.date } });
}
}
setSaving(false);
if (response.data) {
onSaved(response.data);
onSaved(response.data, saved);
}
};
@@ -198,13 +248,24 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
autoFocus
value={form.name}
placeholder="Např. Kuřecí gyros s hranolkami"
onChange={event => { update({ name: event.target.value }); setPickedTemplate(undefined); }}
onChange={event => { update({ name: event.target.value }); setPickedSuggestion(undefined); }}
/>
{!entry && pickedTemplate !== form.name && (
<TemplateSuggestions templates={templates} query={form.name} onPick={applyTemplate} />
{pickedSuggestion !== form.name && (
<MealSuggestions
recipes={recipes}
// Upravované jídlo samo sebe nenabízí
history={entry
? history.filter(item => normalizeSearch(item.name) !== normalizeSearch(entry.name))
: history}
query={form.name}
onPickRecipe={recipe => applyRecipe(recipe)}
onPickHistory={suggestion => applyHistory(suggestion)}
/>
)}
{!entry && pickedTemplate === form.name && (
<Form.Text>vyplněno ze šablony, hodnoty jde upravit</Form.Text>
{pickedSuggestion === form.name && pickedSuggestion !== entry?.name && (
<Form.Text>
{entry ? 'doplněna prázdná pole z dřívějška' : 'vyplněno z dřívějška, hodnoty jde upravit'}
</Form.Text>
)}
</Form.Group>
@@ -232,17 +293,11 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
</div>
<div className="col-sm-4">
<Form.Label>Zdroj</Form.Label>
<Form.Control
list="known-sources"
<SourceInput
value={form.source}
placeholder="TechTower, doma…"
onChange={event => update({ source: event.target.value })}
onChange={source => update({ source })}
/>
<datalist id="known-sources">
{settings?.sourceRates.map(rate => (
<option key={rate.source} value={rate.source} />
))}
</datalist>
</div>
</div>
@@ -353,6 +408,12 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
</div>
</fieldset>
<NutrientsFieldset
values={form.nutrients}
weight={derived.weight}
onChange={nutrients => update({ nutrients })}
/>
<Form.Group>
<Form.Label>Poznámka</Form.Label>
<Form.Control
@@ -365,13 +426,6 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
</Form>
</Modal.Body>
<Modal.Footer>
<Form.Check
className="modal-template-check"
id="meal-also-template"
checked={alsoTemplate}
label="Uložit i jako šablonu"
onChange={event => setAlsoTemplate(event.target.checked)}
/>
<div className="meal-modal-summary">
{derived.weight != null && <span>{formatWeight(derived.weight)}</span>}
{totalCalories != null && (