feat: předělání historie a profilu
This commit is contained in:
1 parent
9e72f431a1
commit
8fa7f601f2
97 files changed
+7502
-1034
No files matched your search
+37
-8
@@ -510,6 +510,35 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
// Nutriční hodnoty jsou volitelné, ve formuláři jídla jsou sbalené pod odkazem
|
||||
.nutrients-toggle,
|
||||
.nutrients-legend-toggle {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--ft-text-muted);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: var(--ft-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.nutrients-legend-toggle {
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
text-transform: inherit;
|
||||
letter-spacing: inherit;
|
||||
}
|
||||
|
||||
// "z toho" položky jsou součástí živiny nad nimi
|
||||
.nutrient-sub-label {
|
||||
padding-left: 10px;
|
||||
color: var(--ft-text-secondary);
|
||||
}
|
||||
|
||||
// Dopočtená hodnota se needituje, tak vypadá jako text, ne jako pole
|
||||
.derived-value {
|
||||
display: flex;
|
||||
@@ -853,17 +882,17 @@ body {
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ŠABLONY JÍDEL — návrhy a skupiny
|
||||
// NÁVRHY JÍDEL A RECEPTY
|
||||
// ============================================
|
||||
|
||||
// Návrhy šablon pod názvem jídla ve formuláři
|
||||
.template-suggestions {
|
||||
// Návrhy z historie a receptů pod názvem jídla ve formuláři
|
||||
.meal-suggestions {
|
||||
margin-top: 6px;
|
||||
border: 1px solid var(--ft-border-light);
|
||||
border-radius: var(--ft-radius);
|
||||
overflow: hidden;
|
||||
|
||||
.template-suggestions-title {
|
||||
.meal-suggestions-title {
|
||||
padding: 4px 10px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
@@ -873,7 +902,7 @@ body {
|
||||
background: var(--ft-bg-hover);
|
||||
}
|
||||
|
||||
.template-suggestion {
|
||||
.meal-suggestion {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
@@ -892,16 +921,16 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.template-suggestion-icon {
|
||||
.meal-suggestion-icon {
|
||||
color: var(--ft-primary);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.template-suggestion-name {
|
||||
.meal-suggestion-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.template-suggestion-detail {
|
||||
.meal-suggestion-detail {
|
||||
margin-left: auto;
|
||||
color: var(--ft-text-muted);
|
||||
font-size: 0.8rem;
|
||||
|
||||
+56
-8
@@ -1,4 +1,5 @@
|
||||
import { MealTemplate } from "../../types";
|
||||
import { MealSuggestion, MealTemplate, Nutrients } from "../../types";
|
||||
import { NUTRIENTS_IN_ORDER } from "./enums";
|
||||
|
||||
const TOKEN_KEY = "token";
|
||||
|
||||
@@ -341,17 +342,64 @@ export const deriveRecipe = (
|
||||
export const normalizeSearch = (value: string): string =>
|
||||
value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim();
|
||||
|
||||
/** Popíše recept jednou řádkou — zdroj a hodnoty jedné porce, co je vyplněné. */
|
||||
export const describeMealTemplate = (template: MealTemplate): string => [
|
||||
template.source,
|
||||
template.weight != null ? `porce ${formatWeight(template.weight)}` : undefined,
|
||||
template.calories != null ? formatCalories(template.calories) : undefined,
|
||||
template.price != null ? formatPrice(template.price) : undefined,
|
||||
].filter(item => !!item).join(' · ');
|
||||
|
||||
/**
|
||||
* Popíše šablonu jídla jednou řádkou — zdroj, gramáž porce, kalorie a cena,
|
||||
* co je vyplněné. U receptu jsou to hodnoty jedné porce, které dopočítal server.
|
||||
* Popíše jídlo z historie jednou řádkou — zdroj, gramáž, kalorie a cena, co je
|
||||
* vyplněné. Gramáž a kalorie se dopočtou stejně, jako je dopočte nový záznam.
|
||||
*/
|
||||
export const describeMealTemplate = (template: MealTemplate): string => {
|
||||
const derived = deriveAmounts(template.price, template.pricePer100g, template.weight, template.caloriesPer100g);
|
||||
const calories = derived.calories ?? template.calories;
|
||||
export const describeMealSuggestion = (suggestion: MealSuggestion): string => {
|
||||
const derived = deriveAmounts(suggestion.price, suggestion.pricePer100g, suggestion.weight, suggestion.caloriesPer100g);
|
||||
const calories = derived.calories ?? suggestion.calories;
|
||||
return [
|
||||
template.source,
|
||||
suggestion.source,
|
||||
derived.weight != null ? formatWeight(derived.weight) : undefined,
|
||||
calories != null ? formatCalories(calories) : undefined,
|
||||
template.price != null ? formatPrice(template.price) : undefined,
|
||||
suggestion.price != null ? formatPrice(suggestion.price) : undefined,
|
||||
].filter(item => !!item).join(' · ');
|
||||
};
|
||||
|
||||
/** Zaokrouhlí na desetiny gramu — přesnost, kterou uvádějí etikety. */
|
||||
const roundTenth = (value: number): number => Math.round(value * 10) / 10;
|
||||
|
||||
/**
|
||||
* Spočítá živiny porce z hodnot na 100 g, gramáže a násobku.
|
||||
* Musí dávat stejné výsledky jako `getPortionNutrients` na serveru.
|
||||
*/
|
||||
export const getPortionNutrients = (
|
||||
nutrientsPer100g: Nutrients | undefined,
|
||||
weight: number | undefined,
|
||||
multiplier?: number,
|
||||
): Nutrients | undefined => {
|
||||
if (!nutrientsPer100g || weight == null) return undefined;
|
||||
const result: Nutrients = {};
|
||||
for (const key of NUTRIENTS_IN_ORDER) {
|
||||
const per100g = nutrientsPer100g[key];
|
||||
if (per100g != null) {
|
||||
result[key] = roundTenth(per100g * weight / 100 * (multiplier ?? 1));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/** Naformátuje množství živiny v gramech, např. 6.3 → "6,3 g". */
|
||||
export const formatNutrient = (grams?: number): string =>
|
||||
grams == null ? '—' : `${grams.toLocaleString('cs-CZ', { maximumFractionDigits: 1 })} g`;
|
||||
|
||||
/** Převede gramy živiny zadané uživatelem (i s desetinnou čárkou) na číslo; záporný vstup odmítne. */
|
||||
export const parseNutrientInput = (value: string): number | undefined => {
|
||||
const trimmed = value.trim().replace(',', '.');
|
||||
if (!trimmed.length) return undefined;
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? roundTenth(parsed) : undefined;
|
||||
};
|
||||
|
||||
/** Převede gramy živiny na text pro editační pole. */
|
||||
export const formatNutrientInput = (grams?: number): string =>
|
||||
grams == null ? '' : String(grams).replace('.', ',');
|
||||
@@ -0,0 +1,74 @@
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faWeightScale, faRulerCombined, faPercent, faFire, faBullseye } from "@fortawesome/free-solid-svg-icons";
|
||||
import { UserSettings } from "../../../types";
|
||||
import { BMI_KATEGORIE_NAMES } from "../enums";
|
||||
import { formatBalance, formatCalories, formatWeightKg, getHumanDate } from "../Utils";
|
||||
|
||||
type Props = {
|
||||
settings: UserSettings,
|
||||
};
|
||||
|
||||
/**
|
||||
* Přehled tělesných ukazatelů — hmotnost, BMI, podíl tuku, klidový výdej a cíl.
|
||||
* Všechno počítá server; dlaždice bez údajů říká, co doplnit.
|
||||
*/
|
||||
const BodyMetricsTiles = ({ settings }: Readonly<Props>) => {
|
||||
const metrics = settings.bodyMetrics ?? {};
|
||||
const formatDecimal = (value: number) => value.toLocaleString('cs-CZ', { maximumFractionDigits: 1 });
|
||||
|
||||
return (
|
||||
<div className="summary-grid">
|
||||
<div className="summary-tile">
|
||||
<div className="summary-label"><FontAwesomeIcon icon={faWeightScale} /> Hmotnost</div>
|
||||
<div className="summary-value">{formatWeightKg(metrics.weightGrams)}</div>
|
||||
<div className="summary-hint">
|
||||
{metrics.weightDate && `zváženo ${getHumanDate(metrics.weightDate)}`}
|
||||
{!metrics.weightDate && (metrics.weightGrams != null ? 'z profilu, zapište vážení' : 'zapište vážení')}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`summary-tile bmi-tile bmi-${metrics.bmiCategory?.toLowerCase() ?? 'none'}`}>
|
||||
<div className="summary-label"><FontAwesomeIcon icon={faRulerCombined} /> BMI</div>
|
||||
<div className="summary-value">{metrics.bmi != null ? formatDecimal(metrics.bmi) : '—'}</div>
|
||||
<div className="summary-hint">
|
||||
{metrics.bmiCategory && <>{BMI_KATEGORIE_NAMES[metrics.bmiCategory]}<br /></>}
|
||||
{metrics.healthyWeightMinGrams != null && metrics.healthyWeightMaxGrams != null
|
||||
? `zdravá hmotnost ${formatDecimal(metrics.healthyWeightMinGrams / 1000)}–${formatWeightKg(metrics.healthyWeightMaxGrams)}`
|
||||
: 'vyplňte výšku'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="summary-tile">
|
||||
<div className="summary-label"><FontAwesomeIcon icon={faPercent} /> Podíl tuku</div>
|
||||
<div className="summary-value">
|
||||
{metrics.bodyFatPercent != null ? `${formatDecimal(metrics.bodyFatPercent)} %` : '—'}
|
||||
</div>
|
||||
<div className="summary-hint">
|
||||
{metrics.bodyFatPercent != null ? 'odhad US Navy, ±3–4 %' : 'vyplňte obvody níže'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="summary-tile">
|
||||
<div className="summary-label"><FontAwesomeIcon icon={faFire} /> Klidový výdej</div>
|
||||
<div className="summary-value">{formatCalories(settings.effectiveBasalCalories)}</div>
|
||||
<div className="summary-hint">
|
||||
{settings.basalCalories != null && 'ručně zadaný'}
|
||||
{settings.basalCalories == null && (settings.computedBasalCalories != null
|
||||
? 'Mifflin–St Jeor, odhad ±10 %'
|
||||
: 'vyplňte údaje a vážení')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="summary-tile accent">
|
||||
<div className="summary-label"><FontAwesomeIcon icon={faBullseye} /> Cíl</div>
|
||||
<div className="summary-value">
|
||||
{metrics.targetDailyBalance != null ? formatBalance(metrics.targetDailyBalance) : '—'}
|
||||
</div>
|
||||
<div className="summary-hint">
|
||||
{metrics.targetDailyBalance == null && 'nastavte tempo níže'}
|
||||
{metrics.targetDailyBalance != null && 'bilance za den'}
|
||||
{metrics.weeksToTarget != null && `, cílová hmotnost za ${metrics.weeksToTarget} týd.`}
|
||||
{metrics.proteinTargetGrams != null && <><br />bílkoviny {metrics.proteinTargetGrams} g denně</>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BodyMetricsTiles;
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, Button } from "react-bootstrap";
|
||||
import { FillSameNameResult, MealEntry, fillSameNameMeals } from "../../../types";
|
||||
|
||||
type Props = {
|
||||
/** Právě uložené jídlo s kaloriemi nebo živinami. */
|
||||
entry: MealEntry,
|
||||
/** Zavolá se po doplnění s počtem doplněných jídel. */
|
||||
onFilled: (count: number) => void,
|
||||
onDismiss: () => void,
|
||||
};
|
||||
|
||||
/**
|
||||
* Nabídka doplnit kalorie a živiny právě uloženého jídla do ostatních jídel
|
||||
* stejného názvu, která je nemají — typicky naimportovaných z Luncheru.
|
||||
* Když takové jídlo není, nic se nezobrazí.
|
||||
*/
|
||||
const FillSameNameOffer = ({ entry, onFilled, onDismiss }: Readonly<Props>) => {
|
||||
const [preview, setPreview] = useState<FillSameNameResult | undefined>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setPreview(undefined);
|
||||
fillSameNameMeals({ body: { id: entry.id, date: entry.date, dryRun: true } })
|
||||
.then(response => setPreview(response.data));
|
||||
}, [entry]);
|
||||
|
||||
if (!preview?.count) return null;
|
||||
|
||||
const fill = async () => {
|
||||
setBusy(true);
|
||||
const response = await fillSameNameMeals({ body: { id: entry.id, date: entry.date } });
|
||||
setBusy(false);
|
||||
if (response.data) onFilled(response.data.count);
|
||||
};
|
||||
|
||||
return (
|
||||
<Alert
|
||||
variant="info"
|
||||
className="py-2 small d-flex align-items-center gap-2"
|
||||
dismissible
|
||||
onClose={() => onDismiss()}
|
||||
>
|
||||
<span className="me-auto">
|
||||
{preview.count === 1
|
||||
? `Další jídlo „${entry.name}“ nemá kalorie.`
|
||||
: `Další jídla „${entry.name}“ (${preview.count}) nemají kalorie.`}
|
||||
{' '}Doplnit stejné hodnoty?
|
||||
</span>
|
||||
<Button size="sm" onClick={() => fill()} disabled={busy}>Doplnit</Button>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
export default FillSameNameOffer;
|
||||
@@ -2,7 +2,7 @@ import { useState } from "react";
|
||||
import { Navbar, Nav, NavDropdown } from "react-bootstrap";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faSun, faMoon, faGear, faUser } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faSun, faMoon, faGear } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useAuth } from "../context/auth";
|
||||
import { useSettings, ThemePreference } from "../context/settings";
|
||||
import { DEN_URL, PROFIL_URL, SABLONY_URL, STATISTIKY_URL } from "../routes";
|
||||
@@ -13,6 +13,7 @@ const NAV_ITEMS = [
|
||||
{ url: DEN_URL, label: 'Můj den' },
|
||||
{ url: SABLONY_URL, label: 'Šablony' },
|
||||
{ url: STATISTIKY_URL, label: 'Statistiky' },
|
||||
{ url: PROFIL_URL, label: 'Profil' },
|
||||
];
|
||||
|
||||
export default function Header() {
|
||||
@@ -53,12 +54,6 @@ export default function Header() {
|
||||
<FontAwesomeIcon icon={effectiveDark ? faSun : faMoon} />
|
||||
</button>
|
||||
<NavDropdown align="end" title={auth?.login} id="user-nav-dropdown">
|
||||
<NavDropdown.Item onClick={() => navigate(DEN_URL)}>Můj den</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => navigate(SABLONY_URL)}>Šablony tréninků</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => navigate(STATISTIKY_URL)}>Statistiky</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => navigate(PROFIL_URL)}>
|
||||
<FontAwesomeIcon icon={faUser} /> Osobní údaje
|
||||
</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => setSettingsOpen(true)}>
|
||||
<FontAwesomeIcon icon={faGear} /> Nastavení
|
||||
</NavDropdown.Item>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Form } from "react-bootstrap";
|
||||
import { ImportMode, ImportResult, MealEntry } from "../../../types";
|
||||
import { MEAL_TYPE_ICONS } from "../enums";
|
||||
import { formatPrice, getDayOfWeek, getHumanDate } from "../Utils";
|
||||
import { formatCalories, formatPrice, getDayOfWeek, getHumanDate } from "../Utils";
|
||||
|
||||
type Props = {
|
||||
preview: ImportResult,
|
||||
@@ -17,7 +17,8 @@ type Props = {
|
||||
* Náhled importu rozdělený po dnech.
|
||||
*
|
||||
* U každého dne je vidět, co v něm už je — ručně zadaná jídla i dřívější import —
|
||||
* aby šlo posoudit, jestli se něco nezdvojí, a řádek odškrtnout.
|
||||
* aby šlo posoudit, jestli se něco nezdvojí, a řádek odškrtnout. Luncher kalorie
|
||||
* nezná, takže kalorie u řádku znamenají, že se doplnily z historie.
|
||||
*/
|
||||
const ImportPreview = ({ preview, selected, onToggle, onToggleDay, isReplaced }: Readonly<Props>) => {
|
||||
const dateList = Array.from(new Set(preview.entries.map(entry => entry.date))).sort();
|
||||
@@ -67,6 +68,14 @@ const ImportPreview = ({ preview, selected, onToggle, onToggleDay, isReplaced }:
|
||||
<span className="import-preview-name">
|
||||
{entry.name}
|
||||
{entry.source && <span className="text-body-secondary"> · {entry.source}</span>}
|
||||
{entry.calories != null && (
|
||||
<span
|
||||
className="import-preview-note"
|
||||
title="Doplněno z posledního jídla stejného názvu"
|
||||
>
|
||||
{' · '}{formatCalories(entry.calories)} z historie
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="import-preview-price">{formatPrice(entry.price)}</span>
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { MealEntry } from "../../../types";
|
||||
import { applyMultiplier, formatCalories, formatPrice, formatWeight } from "../Utils";
|
||||
import { NUTRIENTS_IN_ORDER, NUTRIENT_NAMES, NUTRIENT_SHORT_NAMES } from "../enums";
|
||||
import { applyMultiplier, formatCalories, formatNutrient, formatPrice, formatWeight, getPortionNutrients } from "../Utils";
|
||||
import RowActions from "./RowActions";
|
||||
import MultiplierControl from "./MultiplierControl";
|
||||
|
||||
@@ -7,13 +8,14 @@ type Props = {
|
||||
entry: MealEntry,
|
||||
onEdit: () => void,
|
||||
onDelete: () => void,
|
||||
onSaveTemplate: () => void,
|
||||
onMultiplierChange: (multiplier: number) => void,
|
||||
};
|
||||
|
||||
/** Jeden řádek seznamu jídel v přehledu dne. */
|
||||
const MealRow = ({ entry, onEdit, onDelete, onSaveTemplate, onMultiplierChange }: Readonly<Props>) => {
|
||||
const MealRow = ({ entry, onEdit, onDelete, onMultiplierChange }: Readonly<Props>) => {
|
||||
const calories = applyMultiplier(entry.calories, entry.multiplier);
|
||||
const nutrients = getPortionNutrients(entry.nutrientsPer100g, entry.weight, entry.multiplier);
|
||||
const nutrientKeys = NUTRIENTS_IN_ORDER.filter(key => nutrients?.[key] != null);
|
||||
return (
|
||||
<div className="meal-row">
|
||||
<div className="meal-main">
|
||||
@@ -30,6 +32,17 @@ const MealRow = ({ entry, onEdit, onDelete, onSaveTemplate, onMultiplierChange }
|
||||
{formatCalories(calories)}
|
||||
</span>
|
||||
)}
|
||||
{nutrients && nutrientKeys.some(key => NUTRIENT_SHORT_NAMES[key]) && (
|
||||
<span
|
||||
className="tag"
|
||||
title={nutrientKeys.map(key => `${NUTRIENT_NAMES[key]} ${formatNutrient(nutrients[key])}`).join(', ')}
|
||||
>
|
||||
{nutrientKeys
|
||||
.filter(key => NUTRIENT_SHORT_NAMES[key])
|
||||
.map(key => `${NUTRIENT_SHORT_NAMES[key]} ${formatNutrient(nutrients[key])}`)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{entry.note && <div className="meal-note">{entry.note}</div>}
|
||||
</div>
|
||||
@@ -40,7 +53,7 @@ const MealRow = ({ entry, onEdit, onDelete, onSaveTemplate, onMultiplierChange }
|
||||
label="jídla"
|
||||
/>
|
||||
<div className="meal-price">{formatPrice(entry.price)}</div>
|
||||
<RowActions onEdit={onEdit} onDelete={onDelete} onSaveTemplate={onSaveTemplate} label="jídlo" />
|
||||
<RowActions onEdit={onEdit} onDelete={onDelete} label="jídlo" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faBookOpen, faClockRotateLeft } from "@fortawesome/free-solid-svg-icons";
|
||||
import { MealSuggestion, MealTemplate } from "../../../types";
|
||||
import { describeMealSuggestion, describeMealTemplate, normalizeSearch } from "../Utils";
|
||||
|
||||
type Props = {
|
||||
recipes: MealTemplate[],
|
||||
history: MealSuggestion[],
|
||||
/** Rozepsaný název jídla, podle kterého se hledá. */
|
||||
query: string,
|
||||
onPickRecipe: (recipe: MealTemplate) => void,
|
||||
onPickHistory: (suggestion: MealSuggestion) => void,
|
||||
};
|
||||
|
||||
/** Kolik návrhů se nabídne — víc by formulář zbytečně natahovalo. */
|
||||
const SUGGESTION_LIMIT = 5;
|
||||
|
||||
/**
|
||||
* Návrhy k rozepsanému názvu jídla — recepty a jídla z historie.
|
||||
*
|
||||
* Když uživatel jí něco, co už někdy měl, nemusí hodnoty dohledávat znovu —
|
||||
* stačí začít psát název a vybrat. Jídlo z historie, které se jmenuje stejně
|
||||
* jako recept, se nenabízí podruhé; vzniklo nejspíš právě z receptu.
|
||||
*/
|
||||
const MealSuggestions = ({ recipes, history, query, onPickRecipe, onPickHistory }: Readonly<Props>) => {
|
||||
const key = normalizeSearch(query);
|
||||
if (key.length < 2) return null;
|
||||
const matches = (name: string) => normalizeSearch(name).includes(key);
|
||||
|
||||
const recipeList = recipes.filter(recipe => matches(recipe.name)).slice(0, SUGGESTION_LIMIT);
|
||||
const recipeNames = new Set(recipes.map(recipe => normalizeSearch(recipe.name)));
|
||||
const historyList = history
|
||||
.filter(item => matches(item.name) && !recipeNames.has(normalizeSearch(item.name)))
|
||||
.slice(0, SUGGESTION_LIMIT - recipeList.length);
|
||||
if (!recipeList.length && !historyList.length) return null;
|
||||
|
||||
return (
|
||||
<div className="meal-suggestions">
|
||||
<div className="meal-suggestions-title">Už jste to měli?</div>
|
||||
{recipeList.map(recipe => (
|
||||
<button
|
||||
type="button"
|
||||
className="meal-suggestion"
|
||||
key={recipe.id}
|
||||
onClick={() => onPickRecipe(recipe)}
|
||||
title="Vyplnit hodnoty jedné porce receptu"
|
||||
>
|
||||
<FontAwesomeIcon icon={faBookOpen} className="meal-suggestion-icon" />
|
||||
<span className="meal-suggestion-name">{recipe.name}</span>
|
||||
<span className="meal-suggestion-detail">{describeMealTemplate(recipe)}</span>
|
||||
</button>
|
||||
))}
|
||||
{historyList.map(item => (
|
||||
<button
|
||||
type="button"
|
||||
className="meal-suggestion"
|
||||
key={item.name}
|
||||
onClick={() => onPickHistory(item)}
|
||||
title="Vyplnit hodnoty z posledního záznamu"
|
||||
>
|
||||
<FontAwesomeIcon icon={faClockRotateLeft} className="meal-suggestion-icon" />
|
||||
<span className="meal-suggestion-name">{item.name}</span>
|
||||
<span className="meal-suggestion-detail">{describeMealSuggestion(item)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MealSuggestions;
|
||||
@@ -1,10 +1,8 @@
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faPenToSquare, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { MealTemplate, MealTemplateKind } from "../../../types";
|
||||
import { MealTemplate } from "../../../types";
|
||||
import { MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../enums";
|
||||
import {
|
||||
deriveAmounts, deriveRecipe, formatCalories, formatPrice, formatWeight, getIngredientCalories,
|
||||
} from "../Utils";
|
||||
import { deriveRecipe, formatCalories, formatPrice, formatWeight, getIngredientCalories } from "../Utils";
|
||||
|
||||
type Props = {
|
||||
template: MealTemplate,
|
||||
@@ -14,18 +12,11 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Karta šablony jídla na stránce Šablony.
|
||||
*
|
||||
* U receptu ukazuje suroviny, hodnoty celého hrnce i jedné porce a postup;
|
||||
* u hotového jídla jen výsledné hodnoty, které se do dne zakládají.
|
||||
* Karta receptu na stránce Šablony — suroviny, hodnoty celého hrnce i jedné
|
||||
* porce a postup.
|
||||
*/
|
||||
const MealTemplateCard = ({ template, busy, onEdit, onDelete }: Readonly<Props>) => {
|
||||
const isRecipe = template.kind === MealTemplateKind.RECEPT;
|
||||
const derived = deriveAmounts(template.price, template.pricePer100g, template.weight, template.caloriesPer100g);
|
||||
const calories = derived.calories ?? template.calories;
|
||||
const recipe = isRecipe
|
||||
? deriveRecipe(template.ingredients ?? [], template.servings, template.cookedWeight)
|
||||
: undefined;
|
||||
const recipe = deriveRecipe(template.ingredients, template.servings, template.cookedWeight);
|
||||
|
||||
return (
|
||||
<div className="card-panel template-card">
|
||||
@@ -38,18 +29,18 @@ const MealTemplateCard = ({ template, busy, onEdit, onDelete }: Readonly<Props>)
|
||||
</span>
|
||||
)}
|
||||
{template.source && <span className="tag">{template.source}</span>}
|
||||
{derived.weight != null && (
|
||||
<span className="tag">{isRecipe ? 'porce ' : ''}{formatWeight(derived.weight)}</span>
|
||||
)}
|
||||
{isRecipe && (template.servings ?? 1) > 1 && (
|
||||
{template.weight != null && <span className="tag">porce {formatWeight(template.weight)}</span>}
|
||||
{(template.servings ?? 1) > 1 && (
|
||||
<span className="tag">{template.servings} porcí</span>
|
||||
)}
|
||||
</div>
|
||||
{calories != null && <span className="template-calories">{formatCalories(calories)}</span>}
|
||||
{template.calories != null && (
|
||||
<span className="template-calories">{formatCalories(template.calories)}</span>
|
||||
)}
|
||||
{template.price != null && (
|
||||
<span className="meal-template-price">{formatPrice(template.price)}</span>
|
||||
)}
|
||||
<button className="btn-icon" onClick={() => onEdit()} title="Upravit" aria-label="Upravit šablonu">
|
||||
<button className="btn-icon" onClick={() => onEdit()} title="Upravit" aria-label="Upravit recept">
|
||||
<FontAwesomeIcon icon={faPenToSquare} />
|
||||
</button>
|
||||
<button
|
||||
@@ -57,13 +48,13 @@ const MealTemplateCard = ({ template, busy, onEdit, onDelete }: Readonly<Props>)
|
||||
onClick={() => onDelete()}
|
||||
disabled={busy}
|
||||
title="Smazat"
|
||||
aria-label="Smazat šablonu"
|
||||
aria-label="Smazat recept"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isRecipe && !!template.ingredients?.length && (
|
||||
{!!template.ingredients.length && (
|
||||
<ul className="template-items">
|
||||
{template.ingredients.map((ingredient, index) => (
|
||||
// Suroviny nemají vlastní id, v rámci jednoho receptu je index stabilní
|
||||
@@ -80,14 +71,12 @@ const MealTemplateCard = ({ template, busy, onEdit, onDelete }: Readonly<Props>)
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{recipe && (
|
||||
<div className="recipe-summary">
|
||||
Celkem {formatCalories(recipe.totalCalories)}
|
||||
{recipe.totalWeight != null && <> · {formatWeight(recipe.totalWeight)}</>}
|
||||
{recipe.caloriesPer100g != null && <> · {recipe.caloriesPer100g} kcal/100 g</>}
|
||||
{recipe.totalPrice != null && <> · {formatPrice(recipe.totalPrice)}</>}
|
||||
</div>
|
||||
)}
|
||||
<div className="recipe-summary">
|
||||
Celkem {formatCalories(recipe.totalCalories)}
|
||||
{recipe.totalWeight != null && <> · {formatWeight(recipe.totalWeight)}</>}
|
||||
{recipe.caloriesPer100g != null && <> · {recipe.caloriesPer100g} kcal/100 g</>}
|
||||
{recipe.totalPrice != null && <> · {formatPrice(recipe.totalPrice)}</>}
|
||||
</div>
|
||||
|
||||
{template.note && <p className="meal-template-note">{template.note}</p>}
|
||||
</div>
|
||||
|
||||
@@ -1,47 +1,33 @@
|
||||
import { useState } from "react";
|
||||
import { Alert, Button, ButtonGroup, Form } from "react-bootstrap";
|
||||
import { MealTemplate, MealTemplateKind, MealType, saveMealTemplate } from "../../../types";
|
||||
import {
|
||||
MEAL_TEMPLATE_KINDS_IN_ORDER, MEAL_TEMPLATE_KIND_NAMES, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES,
|
||||
} from "../enums";
|
||||
import {
|
||||
deriveAmounts, deriveRecipe, formatCalories, formatPrice, formatPriceInput, formatWeight,
|
||||
parseIntInput, parsePriceInput,
|
||||
} from "../Utils";
|
||||
import CalorieLookup from "./CalorieLookup";
|
||||
import { Alert, Button, Form } from "react-bootstrap";
|
||||
import { MealTemplate, MealType, saveMealTemplate } from "../../../types";
|
||||
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../enums";
|
||||
import { deriveRecipe, formatCalories, formatPrice, formatWeight, parseIntInput } from "../Utils";
|
||||
import SourceInput from "./SourceInput";
|
||||
import RecipeIngredientsEditor, {
|
||||
DraftIngredient, emptyIngredient, toDraftIngredient, toIngredient,
|
||||
} from "./RecipeIngredientsEditor";
|
||||
|
||||
type Props = {
|
||||
/** Upravovaná šablona. Pokud chybí, zakládá se nová. */
|
||||
/** Upravovaný recept. Pokud chybí, zakládá se nový. */
|
||||
template?: MealTemplate,
|
||||
onSaved: (templates: MealTemplate[]) => void,
|
||||
onCancel: () => void,
|
||||
};
|
||||
|
||||
/** Naplní formulář z šablony, nebo ho nechá prázdný pro novou. */
|
||||
/** Naplní formulář z receptu, nebo ho nechá prázdný pro nový. */
|
||||
const createForm = (template?: MealTemplate) => ({
|
||||
kind: template?.kind ?? MealTemplateKind.HOTOVE,
|
||||
name: template?.name ?? '',
|
||||
source: template?.source ?? '',
|
||||
mealType: (template?.mealType ?? '') as MealType | '',
|
||||
weight: template?.weight != null ? String(template.weight) : '',
|
||||
caloriesPer100g: template?.caloriesPer100g != null ? String(template.caloriesPer100g) : '',
|
||||
calories: template?.calories != null ? String(template.calories) : '',
|
||||
price: formatPriceInput(template?.price),
|
||||
pricePer100g: formatPriceInput(template?.pricePer100g),
|
||||
note: template?.note ?? '',
|
||||
servings: template?.servings != null ? String(template.servings) : '',
|
||||
cookedWeight: template?.cookedWeight != null ? String(template.cookedWeight) : '',
|
||||
});
|
||||
|
||||
/**
|
||||
* Formulář šablony jídla.
|
||||
*
|
||||
* Hotové jídlo (objednané, koupené) si pamatuje jen výsledné hodnoty porce.
|
||||
* Recept je vařené jídlo: zapisují se suroviny a postup a hodnoty porce se
|
||||
* z nich dopočtou — stejně jako na serveru, tady jen jako živý náhled.
|
||||
* 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.
|
||||
*/
|
||||
const MealTemplateForm = ({ template, onSaved, onCancel }: Readonly<Props>) => {
|
||||
const [form, setForm] = useState(() => createForm(template));
|
||||
@@ -53,96 +39,57 @@ const MealTemplateForm = ({ template, onSaved, onCancel }: Readonly<Props>) => {
|
||||
const update = (changes: Partial<ReturnType<typeof createForm>>) =>
|
||||
setForm(current => ({ ...current, ...changes }));
|
||||
|
||||
const isRecipe = form.kind === MealTemplateKind.RECEPT;
|
||||
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 preview = deriveAmounts(
|
||||
parsePriceInput(form.price),
|
||||
parsePriceInput(form.pricePer100g),
|
||||
parseIntInput(form.weight),
|
||||
parseIntInput(form.caloriesPer100g));
|
||||
const previewCalories = preview.calories ?? parseIntInput(form.calories);
|
||||
|
||||
const canSave = form.name.trim().length > 0 && (!isRecipe || filledIngredients.length > 0) && !busy;
|
||||
const canSave = form.name.trim().length > 0 && filledIngredients.length > 0 && !busy;
|
||||
|
||||
const save = async () => {
|
||||
if (!canSave) return;
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
const common = {
|
||||
// Hodnoty porce si server dopočte ze surovin sám
|
||||
const body = {
|
||||
...(template ? { id: template.id } : {}),
|
||||
kind: form.kind,
|
||||
name: form.name.trim(),
|
||||
...(form.source.trim() ? { source: form.source.trim() } : {}),
|
||||
...(form.mealType ? { mealType: form.mealType } : {}),
|
||||
...(form.note.trim() ? { note: form.note.trim() } : {}),
|
||||
ingredients: filledIngredients,
|
||||
servings: parseIntInput(form.servings),
|
||||
cookedWeight: parseIntInput(form.cookedWeight),
|
||||
};
|
||||
// Hodnoty porce receptu si server dopočte ze surovin sám
|
||||
const body = isRecipe
|
||||
? {
|
||||
...common,
|
||||
ingredients: filledIngredients,
|
||||
servings: parseIntInput(form.servings),
|
||||
cookedWeight: parseIntInput(form.cookedWeight),
|
||||
}
|
||||
: {
|
||||
...common,
|
||||
weight: parseIntInput(form.weight),
|
||||
caloriesPer100g: parseIntInput(form.caloriesPer100g),
|
||||
calories: parseIntInput(form.calories),
|
||||
price: parsePriceInput(form.price),
|
||||
pricePer100g: parsePriceInput(form.pricePer100g),
|
||||
};
|
||||
const response = await saveMealTemplate({ body });
|
||||
setBusy(false);
|
||||
if (response.data) {
|
||||
onSaved(response.data);
|
||||
} else {
|
||||
setError((response.error as { error?: string })?.error ?? 'Šablonu se nepodařilo uložit');
|
||||
setError((response.error as { error?: string })?.error ?? 'Recept se nepodařilo uložit');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card-panel template-form">
|
||||
<h2 className="chart-title">{template ? 'Úprava šablony jídla' : 'Nová šablona jídla'}</h2>
|
||||
<h2 className="chart-title">{template ? 'Úprava receptu' : 'Nový recept'}</h2>
|
||||
|
||||
{error && <Alert variant="danger" className="py-2 small">{error}</Alert>}
|
||||
|
||||
<ButtonGroup className="mb-2">
|
||||
{MEAL_TEMPLATE_KINDS_IN_ORDER.map(kind => (
|
||||
<Button
|
||||
key={kind}
|
||||
variant={form.kind === kind ? 'primary' : 'outline-secondary'}
|
||||
size="sm"
|
||||
onClick={() => update({ kind })}
|
||||
>
|
||||
{MEAL_TEMPLATE_KIND_NAMES[kind]}
|
||||
</Button>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
<p className="text-body-secondary small">
|
||||
{isRecipe
|
||||
? 'Vařené jídlo — zapíšete suroviny a postup, kalorie a cena porce se dopočtou samy.'
|
||||
: 'Objednané nebo koupené jídlo — stačí jednou zapsat hodnoty a příště je nemusíte dohledávat.'}
|
||||
</p>
|
||||
|
||||
<div className="row g-3 mb-3">
|
||||
<div className="col-sm-5">
|
||||
<Form.Label>Název</Form.Label>
|
||||
<Form.Control
|
||||
value={form.name}
|
||||
placeholder={isRecipe ? 'Např. Kuřecí rizoto' : 'Např. Sušenka BeBe'}
|
||||
placeholder="Např. Kuřecí rizoto"
|
||||
onChange={event => update({ name: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
<Form.Label>Zdroj</Form.Label>
|
||||
<Form.Control
|
||||
<SourceInput
|
||||
value={form.source}
|
||||
placeholder={isRecipe ? 'doma' : 'Chefie, Albert…'}
|
||||
onChange={event => update({ source: event.target.value })}
|
||||
placeholder="doma"
|
||||
onChange={source => update({ source })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
@@ -161,159 +108,71 @@ const MealTemplateForm = ({ template, onSaved, onCancel }: Readonly<Props>) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isRecipe ? (
|
||||
<>
|
||||
<fieldset className="meal-fieldset mb-3">
|
||||
<legend>Suroviny</legend>
|
||||
<RecipeIngredientsEditor ingredients={ingredients} onChange={setIngredients} />
|
||||
</fieldset>
|
||||
<fieldset className="meal-fieldset mb-3">
|
||||
<legend>Suroviny</legend>
|
||||
<RecipeIngredientsEditor ingredients={ingredients} onChange={setIngredients} />
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="meal-fieldset mb-3">
|
||||
<legend>Výsledek</legend>
|
||||
<div className="row g-3">
|
||||
<div className="col-sm-3">
|
||||
<Form.Label>Počet porcí</Form.Label>
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={form.servings}
|
||||
placeholder="1"
|
||||
onChange={event => update({ servings: event.target.value.replace(/\D/g, '') })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
<Form.Label>Hmotnost hotového jídla</Form.Label>
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={form.cookedWeight}
|
||||
placeholder={ingredientWeight ? String(ingredientWeight) : 'volitelné'}
|
||||
onChange={event => update({ cookedWeight: event.target.value.replace(/\D/g, '') })}
|
||||
/>
|
||||
<span className="input-group-text">g</span>
|
||||
</div>
|
||||
<Form.Text>vařením se mění, bez ní se vezme součet surovin</Form.Text>
|
||||
</div>
|
||||
<div className="col-sm-5">
|
||||
<Form.Label>Celkem / jedna porce</Form.Label>
|
||||
<div className="recipe-summary">
|
||||
{formatCalories(recipe.totalCalories)}
|
||||
{recipe.totalWeight != null && <> · {formatWeight(recipe.totalWeight)}</>}
|
||||
{recipe.caloriesPer100g != null && <> · {recipe.caloriesPer100g} kcal/100 g</>}
|
||||
<div className="recipe-summary-portion">
|
||||
porce {formatWeight(recipe.weight)} · {formatCalories(recipe.calories)}
|
||||
{recipe.price != null && <> · {formatPrice(recipe.price)}</>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</>
|
||||
) : (
|
||||
<fieldset className="meal-fieldset mb-3">
|
||||
<legend>Kalorie, gramáž a cena porce</legend>
|
||||
<div className="row g-3">
|
||||
<div className="col-sm-3">
|
||||
<Form.Label>Gramáž</Form.Label>
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={form.weight}
|
||||
placeholder="350"
|
||||
onChange={event => update({ weight: event.target.value.replace(/\D/g, '') })}
|
||||
/>
|
||||
<span className="input-group-text">g</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
<Form.Label>Energie na 100 g</Form.Label>
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={form.caloriesPer100g}
|
||||
placeholder="160"
|
||||
onChange={event => update({ caloriesPer100g: event.target.value.replace(/\D/g, '') })}
|
||||
/>
|
||||
<span className="input-group-text">kcal</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
<Form.Label>Cena</Form.Label>
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="decimal"
|
||||
value={form.price}
|
||||
placeholder="129,00"
|
||||
onChange={event => update({ price: event.target.value })}
|
||||
/>
|
||||
<span className="input-group-text">Kč</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
<Form.Label>Cena za 100 g</Form.Label>
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="decimal"
|
||||
value={form.pricePer100g}
|
||||
placeholder="volitelné"
|
||||
onChange={event => update({ pricePer100g: event.target.value })}
|
||||
/>
|
||||
<span className="input-group-text">Kč</span>
|
||||
</div>
|
||||
</div>
|
||||
<fieldset className="meal-fieldset mb-3">
|
||||
<legend>Výsledek</legend>
|
||||
<div className="row g-3">
|
||||
<div className="col-sm-3">
|
||||
<Form.Label>Počet porcí</Form.Label>
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={form.servings}
|
||||
placeholder="1"
|
||||
onChange={event => update({ servings: event.target.value.replace(/\D/g, '') })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="row g-3 mt-0">
|
||||
{preview.calories == null && (
|
||||
<div className="col-sm-3">
|
||||
<Form.Label>Kalorie porce</Form.Label>
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={form.calories}
|
||||
placeholder="volitelné"
|
||||
onChange={event => update({ calories: event.target.value.replace(/\D/g, '') })}
|
||||
/>
|
||||
<span className="input-group-text">kcal</span>
|
||||
</div>
|
||||
<Form.Text>když neznáte gramáž ani hodnotu na 100 g</Form.Text>
|
||||
</div>
|
||||
)}
|
||||
<div className="col-sm-6 d-flex align-items-end">
|
||||
<CalorieLookup
|
||||
name={form.name}
|
||||
onPick={value => update({ caloriesPer100g: String(value) })}
|
||||
<div className="col-sm-4">
|
||||
<Form.Label>Hmotnost hotového jídla</Form.Label>
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={form.cookedWeight}
|
||||
placeholder={ingredientWeight ? String(ingredientWeight) : 'volitelné'}
|
||||
onChange={event => update({ cookedWeight: event.target.value.replace(/\D/g, '') })}
|
||||
/>
|
||||
<span className="input-group-text">g</span>
|
||||
</div>
|
||||
<Form.Text>vařením se mění, bez ní se vezme součet surovin</Form.Text>
|
||||
</div>
|
||||
<div className="col-sm-5">
|
||||
<Form.Label>Celkem / jedna porce</Form.Label>
|
||||
<div className="recipe-summary">
|
||||
{formatCalories(recipe.totalCalories)}
|
||||
{recipe.totalWeight != null && <> · {formatWeight(recipe.totalWeight)}</>}
|
||||
{recipe.caloriesPer100g != null && <> · {recipe.caloriesPer100g} kcal/100 g</>}
|
||||
<div className="recipe-summary-portion">
|
||||
porce {formatWeight(recipe.weight)} · {formatCalories(recipe.calories)}
|
||||
{recipe.price != null && <> · {formatPrice(recipe.price)}</>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{isRecipe ? 'Postup' : 'Poznámka'}</Form.Label>
|
||||
<Form.Label>Postup</Form.Label>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
rows={isRecipe ? 6 : 2}
|
||||
rows={6}
|
||||
value={form.note}
|
||||
placeholder={isRecipe ? 'Jak se jídlo připravuje…' : undefined}
|
||||
placeholder="Jak se jídlo připravuje…"
|
||||
onChange={event => update({ note: event.target.value })}
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
<div className="workout-form-footer">
|
||||
{!isRecipe && previewCalories != null && (
|
||||
<span className="workout-draft-calories">
|
||||
{preview.weight != null && `${formatWeight(preview.weight)} · `}
|
||||
{formatCalories(previewCalories)}
|
||||
</span>
|
||||
)}
|
||||
{isRecipe && recipe.calories != null && (
|
||||
{recipe.calories != null && (
|
||||
<span className="workout-draft-calories">porce {formatCalories(recipe.calories)}</span>
|
||||
)}
|
||||
{template && (
|
||||
<Button variant="outline-secondary" size="sm" onClick={() => onCancel()}>Zrušit úpravu</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={() => save()} disabled={!canSave}>
|
||||
{template ? 'Uložit změny' : 'Vytvořit šablonu'}
|
||||
{template ? 'Uložit změny' : 'Vytvořit recept'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Nutrients } from "../../../types";
|
||||
import { NUTRIENTS_IN_ORDER, NUTRIENT_NAMES } from "../enums";
|
||||
import { formatNutrient } from "../Utils";
|
||||
|
||||
type Props = {
|
||||
nutrients: Nutrients,
|
||||
/** Cílový příjem bílkovin dne v gramech, pokud si ho uživatel nastavil. */
|
||||
proteinTarget?: number,
|
||||
};
|
||||
|
||||
/**
|
||||
* Součet živin za den. Započítávají se jen jídla s gramáží a nutričními
|
||||
* hodnotami, takže jde o spodní odhad, pokud je některé jídlo nemá.
|
||||
*/
|
||||
const NutrientSummary = ({ nutrients, proteinTarget }: Readonly<Props>) => (
|
||||
<div className="nutrient-summary card-panel" title="Jen jídla s gramáží a nutričními hodnotami">
|
||||
{NUTRIENTS_IN_ORDER.filter(key => nutrients[key] != null).map(key => (
|
||||
<div className="nutrient-summary-item" key={key}>
|
||||
<span className="nutrient-summary-label">{NUTRIENT_NAMES[key]}</span>
|
||||
<span className="nutrient-summary-value">
|
||||
{formatNutrient(nutrients[key])}
|
||||
{key === 'protein' && proteinTarget != null && (
|
||||
<span className="nutrient-summary-target"> / {proteinTarget} g</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default NutrientSummary;
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Form } from "react-bootstrap";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons";
|
||||
import { Nutrients } from "../../../types";
|
||||
import { NUTRIENTS_IN_ORDER, NUTRIENT_NAMES } from "../enums";
|
||||
import { formatNutrient, getPortionNutrients, parseNutrientInput } from "../Utils";
|
||||
|
||||
/** Rozepsané hodnoty živin tak, jak je uživatel píše do polí. */
|
||||
export type NutrientInputs = Record<keyof Nutrients, string>;
|
||||
|
||||
type Props = {
|
||||
/** Hodnoty na 100 g, jak je uživatel zapsal. */
|
||||
values: NutrientInputs,
|
||||
/** Gramáž porce — zadaná, nebo dopočtená z ceny. Bez ní se porce nespočítá. */
|
||||
weight?: number,
|
||||
onChange: (values: NutrientInputs) => void,
|
||||
};
|
||||
|
||||
/** Položky "z toho" se odsadí pod živinu, jejíž jsou součástí. */
|
||||
const SUB_NUTRIENTS = new Set<keyof Nutrients>(['sugars', 'saturatedFat']);
|
||||
|
||||
/** Prázdné hodnoty pro nové jídlo. */
|
||||
export const emptyNutrientInputs = (): NutrientInputs => ({
|
||||
protein: '', carbohydrates: '', sugars: '', fat: '', saturatedFat: '', fiber: '', salt: '',
|
||||
});
|
||||
|
||||
/** Převede rozepsané hodnoty na živiny k odeslání; bez vyplněné hodnoty vrátí undefined. */
|
||||
export const toNutrients = (values: NutrientInputs): Nutrients | undefined => {
|
||||
const result: Nutrients = {};
|
||||
for (const key of NUTRIENTS_IN_ORDER) {
|
||||
const value = parseNutrientInput(values[key]);
|
||||
if (value != null) result[key] = value;
|
||||
}
|
||||
return Object.keys(result).length ? result : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Nutriční hodnoty jídla na 100 g, ve stejném členění jako na etiketě.
|
||||
*
|
||||
* Jsou volitelné, a proto ve výchozím stavu sbalené — rozbalí se samy, když
|
||||
* jídlo už nějaké hodnoty má. Hodnoty porce se dopočtou z gramáže stejně jako
|
||||
* kalorie; bez gramáže se jen uloží a do součtu dne nevstoupí.
|
||||
*/
|
||||
const NutrientsFieldset = ({ values, weight, onChange }: Readonly<Props>) => {
|
||||
const filled = NUTRIENTS_IN_ORDER.some(key => values[key].trim().length > 0);
|
||||
const [expanded, setExpanded] = useState(filled);
|
||||
const portion = getPortionNutrients(toNutrients(values), weight);
|
||||
|
||||
if (!expanded && !filled) {
|
||||
return (
|
||||
<Button variant="link" className="nutrients-toggle mb-3" onClick={() => setExpanded(true)}>
|
||||
<FontAwesomeIcon icon={faChevronRight} /> Nutriční hodnoty
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<fieldset className="meal-fieldset mb-3">
|
||||
<legend>
|
||||
{!filled ? (
|
||||
<button type="button" className="nutrients-legend-toggle" onClick={() => setExpanded(false)}>
|
||||
<FontAwesomeIcon icon={faChevronDown} /> Nutriční hodnoty na 100 g
|
||||
</button>
|
||||
) : 'Nutriční hodnoty na 100 g'}
|
||||
</legend>
|
||||
<div className="row g-2">
|
||||
{NUTRIENTS_IN_ORDER.map(key => (
|
||||
<div className="col-6 col-sm-3" key={key}>
|
||||
<Form.Label className={SUB_NUTRIENTS.has(key) ? 'nutrient-sub-label' : undefined}>
|
||||
{NUTRIENT_NAMES[key]}
|
||||
</Form.Label>
|
||||
<div className="input-group input-group-sm">
|
||||
<Form.Control
|
||||
inputMode="decimal"
|
||||
value={values[key]}
|
||||
isInvalid={values[key].trim().length > 0 && parseNutrientInput(values[key]) == null}
|
||||
onChange={event => onChange({ ...values, [key]: event.target.value })}
|
||||
/>
|
||||
<span className="input-group-text">g</span>
|
||||
</div>
|
||||
{portion?.[key] != null && <Form.Text>porce {formatNutrient(portion[key])}</Form.Text>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{filled && weight == null && (
|
||||
<Form.Text>Bez gramáže se hodnoty porce nespočítají a do součtu dne nevstoupí.</Form.Text>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
};
|
||||
|
||||
export default NutrientsFieldset;
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { Form } from "react-bootstrap";
|
||||
import { getMealSources, getSettings } from "../../../types";
|
||||
import { normalizeSearch } from "../Utils";
|
||||
|
||||
type Props = {
|
||||
value: string,
|
||||
onChange: (value: string) => void,
|
||||
placeholder?: string,
|
||||
};
|
||||
|
||||
/**
|
||||
* Pole pro zdroj jídla s nabídkou už použitých zdrojů.
|
||||
*
|
||||
* Zdroj je v praxi číselník — většinou se vybírá podnik, který už v historii je,
|
||||
* a jen občas se zapíše nový. Nabízejí se zdroje z historie (nejčastější první)
|
||||
* a podniky s nastavenou sazbou za 100 g; psát jde i cokoli jiného.
|
||||
*/
|
||||
const SourceInput = ({ value, onChange, placeholder }: Readonly<Props>) => {
|
||||
const listId = useId();
|
||||
const [sources, setSources] = useState<string[]>([]);
|
||||
|
||||
// Načítá se při každém zobrazení pole, zdroje mohly mezitím přibýt
|
||||
useEffect(() => {
|
||||
Promise.all([getMealSources(), getSettings()]).then(([sourceResponse, settingsResponse]) => {
|
||||
const resultList = [...(sourceResponse.data ?? [])];
|
||||
const known = new Set(resultList.map(source => normalizeSearch(source)));
|
||||
for (const rate of settingsResponse.data?.sourceRates ?? []) {
|
||||
if (!known.has(normalizeSearch(rate.source))) resultList.push(rate.source);
|
||||
}
|
||||
setSources(resultList);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Control
|
||||
list={listId}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
/>
|
||||
<datalist id={listId}>
|
||||
{sources.map(source => <option key={source} value={source} />)}
|
||||
</datalist>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SourceInput;
|
||||
@@ -1,53 +0,0 @@
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faBookmark, faBookOpen } from "@fortawesome/free-solid-svg-icons";
|
||||
import { MealTemplate, MealTemplateKind } from "../../../types";
|
||||
import { describeMealTemplate, normalizeSearch } from "../Utils";
|
||||
|
||||
type Props = {
|
||||
templates: MealTemplate[],
|
||||
/** Rozepsaný název jídla, podle kterého se šablony hledají. */
|
||||
query: string,
|
||||
onPick: (template: MealTemplate) => void,
|
||||
};
|
||||
|
||||
/** Kolik návrhů se nabídne — víc by formulář zbytečně natahovalo. */
|
||||
const SUGGESTION_LIMIT = 5;
|
||||
|
||||
/**
|
||||
* Návrhy šablon k rozepsanému názvu jídla.
|
||||
*
|
||||
* Když uživatel jí něco, co už někdy měl (sušenka, oblíbená objednávka),
|
||||
* nemusí hodnoty dohledávat znovu — stačí začít psát název a vybrat šablonu.
|
||||
*/
|
||||
const TemplateSuggestions = ({ templates, query, onPick }: Readonly<Props>) => {
|
||||
const key = normalizeSearch(query);
|
||||
if (key.length < 2) return null;
|
||||
const matchList = templates
|
||||
.filter(template => normalizeSearch(template.name).includes(key))
|
||||
.slice(0, SUGGESTION_LIMIT);
|
||||
if (!matchList.length) return null;
|
||||
|
||||
return (
|
||||
<div className="template-suggestions">
|
||||
<div className="template-suggestions-title">Už jste to měli?</div>
|
||||
{matchList.map(template => (
|
||||
<button
|
||||
type="button"
|
||||
className="template-suggestion"
|
||||
key={template.id}
|
||||
onClick={() => onPick(template)}
|
||||
title="Vyplnit hodnoty ze šablony"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={template.kind === MealTemplateKind.RECEPT ? faBookOpen : faBookmark}
|
||||
className="template-suggestion-icon"
|
||||
/>
|
||||
<span className="template-suggestion-name">{template.name}</span>
|
||||
<span className="template-suggestion-detail">{describeMealTemplate(template)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateSuggestions;
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button, Form } from "react-bootstrap";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
CartesianGrid, Line, LineChart, ReferenceArea, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis,
|
||||
} from "recharts";
|
||||
import { BodyMetrics, Goals, WeightEntry, getWeights, saveWeight } from "../../../types";
|
||||
import {
|
||||
formatDate, formatDateString, formatWeightKg, getHumanDate, parseWeightKgInput, shiftDate,
|
||||
} from "../Utils";
|
||||
import { TOOLTIP_STYLE } from "../pages/statsShared";
|
||||
|
||||
type Props = {
|
||||
metrics?: BodyMetrics,
|
||||
goals?: Goals,
|
||||
/** Zavolá se po změně vážení — ukazatele se počítají z posledního. */
|
||||
onChanged: () => void,
|
||||
};
|
||||
|
||||
/** Kolik posledních vážení se vypíše pod grafem. */
|
||||
const RECENT_COUNT = 5;
|
||||
|
||||
/**
|
||||
* Změna hmotnosti za posledních N dní — proti poslednímu vážení, které je
|
||||
* aspoň N dní staré. Bez takového vážení nic.
|
||||
*/
|
||||
const getChange = (weightList: WeightEntry[], days: number): number | undefined => {
|
||||
const latest = weightList[weightList.length - 1];
|
||||
if (!latest) return undefined;
|
||||
const limit = shiftDate(latest.date, -days);
|
||||
const previous = weightList.filter(entry => entry.date <= limit).pop();
|
||||
return previous ? latest.weightGrams - previous.weightGrams : undefined;
|
||||
};
|
||||
|
||||
/** Naformátuje změnu hmotnosti se znaménkem, např. −1,2 kg. */
|
||||
const formatChange = (grams?: number): string => {
|
||||
if (grams == null) return '—';
|
||||
const kg = (grams / 1000).toLocaleString('cs-CZ', { minimumFractionDigits: 1, maximumFractionDigits: 1 });
|
||||
return grams > 0 ? `+${kg} kg` : `${kg.replace('-', '−')} kg`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Vývoj hmotnosti — graf vážení se zdravým rozmezím a cílovou hmotností,
|
||||
* zápis nového vážení a změna za týden a měsíc. Vážení jde zapsat i v přehledu
|
||||
* dne; zápis ke dni, který už vážení má, ho přepíše.
|
||||
*/
|
||||
const WeightHistoryPanel = ({ metrics, goals, onChanged }: Readonly<Props>) => {
|
||||
const [weightList, setWeightList] = useState<WeightEntry[] | undefined>();
|
||||
const [date, setDate] = useState(() => formatDate(new Date()));
|
||||
const [weightInput, setWeightInput] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const response = await getWeights();
|
||||
setWeightList(response.data ?? []);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const weightGrams = parseWeightKgInput(weightInput);
|
||||
|
||||
const save = async (forDate: string, grams?: number) => {
|
||||
setBusy(true);
|
||||
await saveWeight({ body: { date: forDate, ...(grams != null ? { weightGrams: grams } : {}) } });
|
||||
setBusy(false);
|
||||
setWeightInput('');
|
||||
await load();
|
||||
onChanged();
|
||||
};
|
||||
|
||||
// Do načtení nic, ať neproblikne hláška pro uživatele bez vážení
|
||||
if (!weightList) return null;
|
||||
|
||||
const chartData = weightList.map(entry => ({
|
||||
label: formatDateString(entry.date).slice(0, 6),
|
||||
kg: entry.weightGrams / 1000,
|
||||
}));
|
||||
const minKg = metrics?.healthyWeightMinGrams != null ? metrics.healthyWeightMinGrams / 1000 : undefined;
|
||||
const maxKg = metrics?.healthyWeightMaxGrams != null ? metrics.healthyWeightMaxGrams / 1000 : undefined;
|
||||
const targetKg = goals?.targetWeightGrams != null ? goals.targetWeightGrams / 1000 : undefined;
|
||||
|
||||
return (
|
||||
<div className="card-panel chart-panel">
|
||||
<h2 className="chart-title">Vývoj hmotnosti</h2>
|
||||
|
||||
<div className="weight-form">
|
||||
<Form.Control
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={event => event.target.value && setDate(event.target.value)}
|
||||
aria-label="Den vážení"
|
||||
/>
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="decimal"
|
||||
value={weightInput}
|
||||
placeholder="90,0"
|
||||
aria-label="Hmotnost v kilogramech"
|
||||
onChange={event => setWeightInput(event.target.value)}
|
||||
/>
|
||||
<span className="input-group-text">kg</span>
|
||||
</div>
|
||||
<Button onClick={() => save(date, weightGrams)} disabled={busy || weightGrams == null}>
|
||||
Zapsat vážení
|
||||
</Button>
|
||||
<div className="weight-changes">
|
||||
<span>týden <strong>{formatChange(getChange(weightList, 7))}</strong></span>
|
||||
<span>měsíc <strong>{formatChange(getChange(weightList, 30))}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{weightList.length > 1 && (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<LineChart data={chartData} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--ft-border)" vertical={false} />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 12 }} stroke="var(--ft-text-muted)" />
|
||||
<YAxis
|
||||
domain={['dataMin - 2', 'dataMax + 2']}
|
||||
tickFormatter={value => Math.round(value).toString()}
|
||||
tick={{ fontSize: 12 }}
|
||||
stroke="var(--ft-text-muted)"
|
||||
width={40}
|
||||
/>
|
||||
{minKg != null && maxKg != null && (
|
||||
<ReferenceArea
|
||||
y1={minKg}
|
||||
y2={maxKg}
|
||||
ifOverflow="hidden"
|
||||
fill="var(--ft-primary)"
|
||||
fillOpacity={0.08}
|
||||
/>
|
||||
)}
|
||||
{targetKg != null && (
|
||||
<ReferenceLine
|
||||
y={targetKg}
|
||||
stroke="var(--ft-warning)"
|
||||
strokeDasharray="4 4"
|
||||
ifOverflow="extendDomain"
|
||||
label={{ value: 'cíl', position: 'insideTopRight', fontSize: 12 }}
|
||||
/>
|
||||
)}
|
||||
<Tooltip
|
||||
formatter={value => [`${Number(value).toLocaleString('cs-CZ')} kg`, 'Hmotnost']}
|
||||
contentStyle={TOOLTIP_STYLE}
|
||||
/>
|
||||
<Line type="monotone" dataKey="kg" stroke="var(--ft-primary)" strokeWidth={2} dot={{ r: 3 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
{weightList.length <= 1 && (
|
||||
<p className="text-body-secondary small mb-0">
|
||||
Graf se ukáže od druhého vážení. Vážit se jde i v přehledu dne vedle spánku.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!!weightList.length && (
|
||||
<ul className="weight-list">
|
||||
{weightList.slice(-RECENT_COUNT).reverse().map(entry => (
|
||||
<li key={entry.date}>
|
||||
<span>{getHumanDate(entry.date)}</span>
|
||||
<strong>{formatWeightKg(entry.weightGrams)}</strong>
|
||||
<button
|
||||
className="btn-icon danger"
|
||||
onClick={() => save(entry.date)}
|
||||
disabled={busy}
|
||||
title="Smazat vážení"
|
||||
aria-label={`Smazat vážení ${getHumanDate(entry.date)}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WeightHistoryPanel;
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
+34
-8
@@ -1,4 +1,4 @@
|
||||
import { ActivityUnit, ImportMode, MealTemplateKind, MealType, Pohlavi } from "../../types";
|
||||
import { ActivityUnit, BmiKategorie, ImportMode, MealType, Nutrients, Pohlavi } from "../../types";
|
||||
|
||||
/** Lidsky čitelné názvy typů jídel. */
|
||||
export const MEAL_TYPE_NAMES: Record<MealType, string> = {
|
||||
@@ -110,24 +110,50 @@ export const POHLAVI_NAMES: Record<Pohlavi, string> = {
|
||||
|
||||
export const POHLAVI_IN_ORDER: Pohlavi[] = [Pohlavi.MUZ, Pohlavi.ZENA];
|
||||
|
||||
/** Názvy pásem BMI. */
|
||||
export const BMI_KATEGORIE_NAMES: Record<BmiKategorie, string> = {
|
||||
PODVAHA: 'podváha',
|
||||
NORMA: 'normální hmotnost',
|
||||
NADVAHA: 'nadváha',
|
||||
OBEZITA: 'obezita',
|
||||
};
|
||||
|
||||
/** Nabízená týdenní tempa změny hmotnosti v gramech; záporné je hubnutí. */
|
||||
export const WEEKLY_CHANGE_OPTIONS = [-1000, -750, -500, -250, 0, 250, 500];
|
||||
|
||||
/** Záložky stránky se šablonami. */
|
||||
export enum TemplateTab {
|
||||
TRENINKY = 'TRENINKY',
|
||||
JIDLA = 'JIDLA',
|
||||
RECEPTY = 'RECEPTY',
|
||||
}
|
||||
|
||||
export const TEMPLATE_TAB_NAMES: Record<TemplateTab, string> = {
|
||||
TRENINKY: 'Tréninky',
|
||||
JIDLA: 'Jídla',
|
||||
RECEPTY: 'Recepty',
|
||||
};
|
||||
|
||||
/** Lidsky čitelné názvy druhů šablon jídel. */
|
||||
export const MEAL_TEMPLATE_KIND_NAMES: Record<MealTemplateKind, string> = {
|
||||
HOTOVE: 'Hotové jídlo',
|
||||
RECEPT: 'Recept',
|
||||
/** Živiny v pořadí, v jakém je uvádí etiketa potravin. */
|
||||
export const NUTRIENTS_IN_ORDER: (keyof Nutrients)[] = [
|
||||
'protein', 'carbohydrates', 'sugars', 'fat', 'saturatedFat', 'fiber', 'salt',
|
||||
];
|
||||
|
||||
/** Názvy živin; "z toho" položky jsou součástí té nad nimi. */
|
||||
export const NUTRIENT_NAMES: Record<keyof Nutrients, string> = {
|
||||
protein: 'Bílkoviny',
|
||||
carbohydrates: 'Sacharidy',
|
||||
sugars: 'z toho cukry',
|
||||
fat: 'Tuky',
|
||||
saturatedFat: 'z toho nasycené',
|
||||
fiber: 'Vláknina',
|
||||
salt: 'Sůl',
|
||||
};
|
||||
|
||||
export const MEAL_TEMPLATE_KINDS_IN_ORDER: MealTemplateKind[] = [MealTemplateKind.HOTOVE, MealTemplateKind.RECEPT];
|
||||
/** Krátké značky hlavních živin pro řádek jídla. */
|
||||
export const NUTRIENT_SHORT_NAMES: Partial<Record<keyof Nutrients, string>> = {
|
||||
protein: 'B',
|
||||
carbohydrates: 'S',
|
||||
fat: 'T',
|
||||
};
|
||||
|
||||
/** Názvy režimů importu. */
|
||||
export const IMPORT_MODE_NAMES: Record<ImportMode, string> = {
|
||||
|
||||
@@ -66,6 +66,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Součet živin dne nad seznamem jídel
|
||||
.nutrient-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 20px;
|
||||
padding: 10px 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.nutrient-summary-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nutrient-summary-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--ft-text-muted);
|
||||
}
|
||||
|
||||
.nutrient-summary-value {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.nutrient-summary-target {
|
||||
font-weight: 400;
|
||||
color: var(--ft-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.meal-list {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -3,16 +3,20 @@ import { Alert, Button, Spinner } from "react-bootstrap";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faChevronLeft, faChevronRight, faPlus, faUtensils, faPersonRunning,
|
||||
faCoins, faFire, faScaleBalanced, faDumbbell, faBed, faBowlFood, faBookmark,
|
||||
faCoins, faFire, faScaleBalanced, faDumbbell, faBed, faClockRotateLeft, faBookmark,
|
||||
faWeightScale,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
ActivityEntry, DayOverview, MealEntry, createMealTemplateFromMeal,
|
||||
deleteActivity, deleteMeal, getDayOverview, saveSleep, setActivityMultiplier, setMealMultiplier,
|
||||
ActivityEntry, DayOverview, MealEntry,
|
||||
deleteActivity, deleteMeal, getDayOverview, saveSleep, saveWeight, setActivityMultiplier, setMealMultiplier,
|
||||
} from "../../../types";
|
||||
import { DayTab, DAY_TAB_NAMES, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../enums";
|
||||
import NutrientSummary from "../components/NutrientSummary";
|
||||
import FillSameNameOffer from "../components/FillSameNameOffer";
|
||||
import {
|
||||
applyMultiplier, formatBalance, formatCalories, formatDate, formatPrice, formatSleep,
|
||||
formatSleepInput, getDayOfWeek, getHumanDate, parseSleepInput, shiftDate,
|
||||
formatSleepInput, formatWeightKgInput, getDayOfWeek, getHumanDate, parseSleepInput, parseWeightKgInput,
|
||||
shiftDate,
|
||||
} from "../Utils";
|
||||
import MealModal from "../components/modals/MealModal";
|
||||
import ActivityModal from "../components/modals/ActivityModal";
|
||||
@@ -43,12 +47,14 @@ export default function DayPage() {
|
||||
const [editedActivity, setEditedActivity] = useState<ActivityEntry | undefined>();
|
||||
const [activityModalOpen, setActivityModalOpen] = useState(false);
|
||||
const [workoutModalOpen, setWorkoutModalOpen] = useState(false);
|
||||
const [mealTemplateModalOpen, setMealTemplateModalOpen] = useState(false);
|
||||
const [mealHistoryModalOpen, setMealHistoryModalOpen] = useState(false);
|
||||
const [sleepInput, setSleepInput] = useState('');
|
||||
const [weightInput, setWeightInput] = useState('');
|
||||
const [saveWorkoutOpen, setSaveWorkoutOpen] = useState(false);
|
||||
const [preselectedActivities, setPreselectedActivities] = useState<string[]>([]);
|
||||
const [savedNotice, setSavedNotice] = useState<string | undefined>();
|
||||
const [errorNotice, setErrorNotice] = useState<string | undefined>();
|
||||
/** Jídlo, jehož kalorie jde nabídnout ostatním jídlům stejného názvu. */
|
||||
const [fillOfferEntry, setFillOfferEntry] = useState<MealEntry | undefined>();
|
||||
|
||||
const loadDay = useCallback(async (forDate: string) => {
|
||||
setLoading(true);
|
||||
@@ -70,6 +76,21 @@ export default function DayPage() {
|
||||
if (response.data) setDay(response.data);
|
||||
};
|
||||
|
||||
useEffect(() => { setWeightInput(formatWeightKgInput(day.weightGrams)); }, [day.date, day.weightGrams]);
|
||||
|
||||
/** Uloží vážení při opuštění pole; prázdné pole vážení dne smaže. */
|
||||
const commitWeight = async () => {
|
||||
const grams = parseWeightKgInput(weightInput);
|
||||
if ((grams ?? null) === (day.weightGrams ?? null)) return;
|
||||
const response = await saveWeight({ body: { date, ...(grams != null ? { weightGrams: grams } : {}) } });
|
||||
if (response.data) {
|
||||
setDay(response.data);
|
||||
} else {
|
||||
// Neplatnou hodnotu vrátíme na uloženou, ať pole nelže
|
||||
setWeightInput(formatWeightKgInput(day.weightGrams));
|
||||
}
|
||||
};
|
||||
|
||||
const isToday = date === formatDate(new Date());
|
||||
|
||||
/** Jídla rozdělená do chodů — zobrazují se jen chody, které něco obsahují. */
|
||||
@@ -94,17 +115,6 @@ export default function DayPage() {
|
||||
setTimeout(() => setSavedNotice(undefined), 3000);
|
||||
};
|
||||
|
||||
const saveMealAsTemplate = async (entry: MealEntry) => {
|
||||
setErrorNotice(undefined);
|
||||
const response = await createMealTemplateFromMeal({ body: { id: entry.id, date: entry.date } });
|
||||
if (response.data) {
|
||||
showNotice(`„${entry.name}" uloženo jako šablonu jídla.`);
|
||||
} else {
|
||||
// Typicky recept stejného názvu, který se ze záznamu dne přepsat nesmí
|
||||
setErrorNotice((response.error as { error?: string })?.error ?? 'Šablonu se nepodařilo uložit');
|
||||
}
|
||||
};
|
||||
|
||||
const changeMealMultiplier = async (entry: MealEntry, multiplier: number) => {
|
||||
const response = await setMealMultiplier({ body: { id: entry.id, date: entry.date, multiplier } });
|
||||
if (response.data) setDay(response.data);
|
||||
@@ -132,8 +142,8 @@ export default function DayPage() {
|
||||
<h1>Můj den</h1>
|
||||
{tab === DayTab.PRIJEM ? (
|
||||
<div className="header-actions">
|
||||
<Button variant="outline-secondary" onClick={() => setMealTemplateModalOpen(true)}>
|
||||
<FontAwesomeIcon icon={faBowlFood} /> Ze šablony
|
||||
<Button variant="outline-secondary" onClick={() => setMealHistoryModalOpen(true)}>
|
||||
<FontAwesomeIcon icon={faClockRotateLeft} /> Z historie
|
||||
</Button>
|
||||
<Button onClick={() => { setEditedMeal(undefined); setMealModalOpen(true); }}>
|
||||
<FontAwesomeIcon icon={faPlus} /> Přidat jídlo
|
||||
@@ -209,9 +219,13 @@ export default function DayPage() {
|
||||
<div className="summary-label"><FontAwesomeIcon icon={faScaleBalanced} /> Bilance</div>
|
||||
<div className="summary-value">{formatBalance(energy.balance)}</div>
|
||||
<div className="summary-hint">
|
||||
{energy.hasBasal
|
||||
? (isDeficit ? 'jste v deficitu' : 'jste v přebytku')
|
||||
: 'nastavte klidový výdej pro skutečný deficit'}
|
||||
{!energy.hasBasal && 'nastavte klidový výdej pro skutečný deficit'}
|
||||
{energy.hasBasal && energy.targetBalance == null && (isDeficit ? 'jste v deficitu' : 'jste v přebytku')}
|
||||
{energy.hasBasal && energy.targetBalance != null && (
|
||||
energy.balance <= energy.targetBalance
|
||||
? `cíl ${formatBalance(energy.targetBalance)}, zbývá ${energy.targetBalance - energy.balance} kcal`
|
||||
: `cíl ${formatBalance(energy.targetBalance)}, o ${energy.balance - energy.targetBalance} kcal nad`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="summary-tile accent">
|
||||
@@ -236,13 +250,35 @@ export default function DayPage() {
|
||||
{day.sleepMinutes ? formatSleep(day.sleepMinutes) : 'do bilance nevstupuje'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="summary-tile">
|
||||
<div className="summary-label"><FontAwesomeIcon icon={faWeightScale} /> Vážení</div>
|
||||
<div className="sleep-input-row">
|
||||
<input
|
||||
className="form-control sleep-input"
|
||||
inputMode="decimal"
|
||||
value={weightInput}
|
||||
placeholder="90,0"
|
||||
aria-label="Hmotnost v kilogramech"
|
||||
onChange={event => setWeightInput(event.target.value)}
|
||||
onBlur={() => commitWeight()}
|
||||
/>
|
||||
<span className="sleep-unit">kg</span>
|
||||
</div>
|
||||
<div className="summary-hint">vývoj na stránce Profil</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{savedNotice && <Alert variant="success" className="py-2 small">{savedNotice}</Alert>}
|
||||
{errorNotice && (
|
||||
<Alert variant="danger" className="py-2 small" dismissible onClose={() => setErrorNotice(undefined)}>
|
||||
{errorNotice}
|
||||
</Alert>
|
||||
{fillOfferEntry && (
|
||||
<FillSameNameOffer
|
||||
entry={fillOfferEntry}
|
||||
onFilled={count => {
|
||||
setFillOfferEntry(undefined);
|
||||
showNotice(count === 1 ? 'Kalorie doplněny do 1 dalšího jídla.' : `Kalorie doplněny do ${count} dalších jídel.`);
|
||||
loadDay(date);
|
||||
}}
|
||||
onDismiss={() => setFillOfferEntry(undefined)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="day-tabs">
|
||||
@@ -279,6 +315,9 @@ export default function DayPage() {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{day.meals.totalNutrients && (
|
||||
<NutrientSummary nutrients={day.meals.totalNutrients} proteinTarget={day.proteinTargetGrams} />
|
||||
)}
|
||||
{mealGroups.map(group => (
|
||||
<div className="meal-group" key={group.mealType}>
|
||||
<h2 className="meal-group-title">
|
||||
@@ -298,7 +337,6 @@ export default function DayPage() {
|
||||
entry={entry}
|
||||
onEdit={() => { setEditedMeal(entry); setMealModalOpen(true); }}
|
||||
onDelete={() => removeMeal(entry)}
|
||||
onSaveTemplate={() => saveMealAsTemplate(entry)}
|
||||
onMultiplierChange={multiplier => changeMealMultiplier(entry, multiplier)}
|
||||
/>
|
||||
))}
|
||||
@@ -341,8 +379,10 @@ export default function DayPage() {
|
||||
date={date}
|
||||
entry={editedMeal}
|
||||
onClose={() => setMealModalOpen(false)}
|
||||
onSaved={saved => {
|
||||
onSaved={(saved, savedEntry) => {
|
||||
setMealModalOpen(false);
|
||||
const hasNutrition = savedEntry?.calories != null || !!savedEntry?.nutrientsPer100g;
|
||||
setFillOfferEntry(hasNutrition ? savedEntry : undefined);
|
||||
// Úprava mohla jídlo přesunout jinam — pak přepneme na jeho nový den
|
||||
if (saved.date !== date) {
|
||||
setDate(saved.date);
|
||||
@@ -380,12 +420,12 @@ export default function DayPage() {
|
||||
/>
|
||||
|
||||
<ApplyMealModal
|
||||
isOpen={mealTemplateModalOpen}
|
||||
isOpen={mealHistoryModalOpen}
|
||||
date={date}
|
||||
onClose={() => setMealTemplateModalOpen(false)}
|
||||
onApplied={saved => {
|
||||
setDay(saved);
|
||||
setMealTemplateModalOpen(false);
|
||||
onClose={() => setMealHistoryModalOpen(false)}
|
||||
onApplied={() => {
|
||||
setMealHistoryModalOpen(false);
|
||||
loadDay(date);
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Spinner } from "react-bootstrap";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faBowlFood } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faBookOpen } from "@fortawesome/free-solid-svg-icons";
|
||||
import { MealTemplate, deleteMealTemplate, getMealTemplates } from "../../../types";
|
||||
import { MEAL_TEMPLATE_KINDS_IN_ORDER, MEAL_TEMPLATE_KIND_NAMES } from "../enums";
|
||||
import MealTemplateCard from "../components/MealTemplateCard";
|
||||
import MealTemplateForm from "../components/MealTemplateForm";
|
||||
|
||||
/**
|
||||
* Panel se správou šablon jídel.
|
||||
*
|
||||
* Šablony jsou dvojího druhu: hotová jídla (objednané, koupené — pamatují si
|
||||
* výsledné hodnoty) a recepty (vařené — nesou suroviny a postup a hodnoty porce
|
||||
* se z nich dopočtou). Na rozdíl od šablon tréninků se neskládají z jiných.
|
||||
* Panel se správou receptů — vařených jídel se surovinami a postupem, ze kterých
|
||||
* se hodnoty porce dopočtou. Objednaná a koupená jídla šablony nemají, při
|
||||
* zakládání jídla se nabízejí z historie.
|
||||
*/
|
||||
const MealTemplatesPanel = () => {
|
||||
const [templates, setTemplates] = useState<MealTemplate[]>([]);
|
||||
@@ -46,17 +43,12 @@ const MealTemplatesPanel = () => {
|
||||
globalThis.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const groupList = MEAL_TEMPLATE_KINDS_IN_ORDER
|
||||
.map(kind => ({ kind, templates: templates.filter(template => template.kind === kind) }))
|
||||
.filter(group => group.templates.length > 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="text-body-secondary small">
|
||||
Šablona jídla se v přehledu dne založí jedním kliknutím, nebo se nabídne, když začnete
|
||||
psát název jídla. <strong>Hotové jídlo</strong> je objednávka nebo koupená věc, u které si
|
||||
jen pamatujete kalorie a cenu. <strong>Recept</strong> je vařené jídlo — zapíšete suroviny
|
||||
a postup a kalorie porce se spočítají samy.
|
||||
Recept je vařené jídlo — zapíšete suroviny a postup a kalorie porce se spočítají samy.
|
||||
V přehledu dne se porce založí jedním kliknutím, nebo se recept nabídne, když začnete psát
|
||||
název jídla. Objednaná a koupená jídla si zapisovat nemusíte, nabízejí se z historie.
|
||||
</p>
|
||||
|
||||
{loading && (
|
||||
@@ -67,25 +59,20 @@ const MealTemplatesPanel = () => {
|
||||
|
||||
{!loading && !templates.length && (
|
||||
<div className="card-panel empty-state">
|
||||
<div className="empty-icon"><FontAwesomeIcon icon={faBowlFood} /></div>
|
||||
<div className="empty-title">Zatím nemáte žádnou šablonu jídla</div>
|
||||
<p className="empty-description">Sestavte si ji ve formuláři níže.</p>
|
||||
<div className="empty-icon"><FontAwesomeIcon icon={faBookOpen} /></div>
|
||||
<div className="empty-title">Zatím nemáte žádný recept</div>
|
||||
<p className="empty-description">Zapište si ho ve formuláři níže.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && groupList.map(group => (
|
||||
<div key={group.kind}>
|
||||
<h3 className="apply-template-group-title">{MEAL_TEMPLATE_KIND_NAMES[group.kind]}</h3>
|
||||
{group.templates.map(template => (
|
||||
<MealTemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
busy={busy}
|
||||
onEdit={() => edit(template)}
|
||||
onDelete={() => remove(template.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!loading && templates.map(template => (
|
||||
<MealTemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
busy={busy}
|
||||
onEdit={() => edit(template)}
|
||||
onDelete={() => remove(template.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<MealTemplateForm
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
.profile-subtitle {
|
||||
margin: 20px 0 10px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--ft-text-muted);
|
||||
}
|
||||
|
||||
// Zápis vážení: den, hmotnost, tlačítko a vpravo změna za týden a měsíc
|
||||
.weight-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
|
||||
input[type='date'] {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.weight-changes {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-left: auto;
|
||||
font-size: 0.85rem;
|
||||
color: var(--ft-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.weight-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 12px 0 0;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
border-top: 1px solid var(--ft-border-light);
|
||||
font-size: 0.88rem;
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
color: var(--ft-text-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pásmo BMI barví hodnotu dlaždice — norma zeleně, mimo ni varovně
|
||||
.bmi-tile {
|
||||
&.bmi-norma .summary-value {
|
||||
color: var(--ft-success);
|
||||
}
|
||||
|
||||
&.bmi-podvaha .summary-value,
|
||||
&.bmi-nadvaha .summary-value {
|
||||
color: var(--ft-warning);
|
||||
}
|
||||
|
||||
&.bmi-obezita .summary-value {
|
||||
color: var(--ft-danger);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,31 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Alert, Button, Form, Spinner } from "react-bootstrap";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faFire, faCalculator } from "@fortawesome/free-solid-svg-icons";
|
||||
import { Pohlavi, UserSettings, getSettings, saveBasalCalories, saveProfile } from "../../../types";
|
||||
import { POHLAVI_IN_ORDER, POHLAVI_NAMES } from "../enums";
|
||||
import { formatCalories, formatWeightKgInput, parseIntInput, parseWeightKgInput } from "../Utils";
|
||||
import {
|
||||
Pohlavi, UserSettings, getSettings, saveBasalCalories, saveGoals, saveProfile,
|
||||
} from "../../../types";
|
||||
import { POHLAVI_IN_ORDER, POHLAVI_NAMES, WEEKLY_CHANGE_OPTIONS } from "../enums";
|
||||
import {
|
||||
formatBalance, formatWeightKgInput, parseIntInput, parseMultiplierInput, parseWeightKgInput,
|
||||
} from "../Utils";
|
||||
import BodyMetricsTiles from "../components/BodyMetricsTiles";
|
||||
import WeightHistoryPanel from "../components/WeightHistoryPanel";
|
||||
import "./ProfilePage.scss";
|
||||
|
||||
/** Text týdenního tempa do výběru, např. −500 → "−0,5 kg týdně (−550 kcal/den)". */
|
||||
const formatWeeklyChange = (grams: number): string => {
|
||||
if (grams === 0) return 'udržovat hmotnost';
|
||||
const kg = (grams / 1000).toLocaleString('cs-CZ', { maximumFractionDigits: 2 });
|
||||
const perDay = formatBalance(Math.round(grams / 1000 * 7700 / 7));
|
||||
return `${grams > 0 ? '+' : ''}${kg.replace('-', '−')} kg týdně (${perDay}/den)`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Osobní údaje a klidový výdej.
|
||||
* Profil — tělesné údaje, ukazatele z nich, vývoj hmotnosti a cíle.
|
||||
*
|
||||
* Z pohlaví, věku, výšky a hmotnosti se rovnicí Mifflin–St Jeor spočítá bazální
|
||||
* metabolismus, který pak vstupuje do bilance dne. Kdo má změřenou vlastní
|
||||
* hodnotu, může ji zadat ručně a ta má přednost.
|
||||
* Z pohlaví, věku, výšky a hmotnosti se rovnicí Mifflin–St Jeor spočítá
|
||||
* klidový výdej, který vstupuje do bilance dne; z výšky a obvodů BMI a odhad
|
||||
* podílu tuku. Hmotnost se bere z posledního vážení. Kdo má změřený vlastní
|
||||
* klidový výdej, může ho zadat ručně a ten má přednost.
|
||||
*/
|
||||
const ProfilePage = () => {
|
||||
const [settings, setSettings] = useState<UserSettings | undefined>();
|
||||
@@ -19,7 +33,12 @@ const ProfilePage = () => {
|
||||
const [sex, setSex] = useState('');
|
||||
const [birthYear, setBirthYear] = useState('');
|
||||
const [heightCm, setHeightCm] = useState('');
|
||||
const [weightKg, setWeightKg] = useState('');
|
||||
const [waistCm, setWaistCm] = useState('');
|
||||
const [neckCm, setNeckCm] = useState('');
|
||||
const [hipCm, setHipCm] = useState('');
|
||||
const [weeklyChange, setWeeklyChange] = useState('');
|
||||
const [targetWeightKg, setTargetWeightKg] = useState('');
|
||||
const [proteinPerKg, setProteinPerKg] = useState('');
|
||||
const [manualBasal, setManualBasal] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
@@ -27,12 +46,18 @@ const ProfilePage = () => {
|
||||
|
||||
/** Naplní formulář z nastavení vráceného serverem. */
|
||||
const fillForm = (data?: UserSettings) => {
|
||||
const intText = (value?: number) => value != null ? String(value) : '';
|
||||
setSettings(data);
|
||||
setSex(data?.profile?.sex ?? '');
|
||||
setBirthYear(data?.profile?.birthYear != null ? String(data.profile.birthYear) : '');
|
||||
setHeightCm(data?.profile?.heightCm != null ? String(data.profile.heightCm) : '');
|
||||
setWeightKg(formatWeightKgInput(data?.profile?.weightGrams));
|
||||
setManualBasal(data?.basalCalories != null ? String(data.basalCalories) : '');
|
||||
setBirthYear(intText(data?.profile?.birthYear));
|
||||
setHeightCm(intText(data?.profile?.heightCm));
|
||||
setWaistCm(intText(data?.profile?.waistCm));
|
||||
setNeckCm(intText(data?.profile?.neckCm));
|
||||
setHipCm(intText(data?.profile?.hipCm));
|
||||
setWeeklyChange(intText(data?.goals?.weeklyChangeGrams));
|
||||
setTargetWeightKg(formatWeightKgInput(data?.goals?.targetWeightGrams));
|
||||
setProteinPerKg(data?.goals?.proteinPerKg != null ? String(data.goals.proteinPerKg).replace('.', ',') : '');
|
||||
setManualBasal(intText(data?.basalCalories));
|
||||
};
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -44,22 +69,48 @@ const ProfilePage = () => {
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
/** Po změně vážení se jen přenačtou ukazatele, rozepsaný formulář zůstane. */
|
||||
const reloadMetrics = async () => {
|
||||
const response = await getSettings();
|
||||
if (response.data) setSettings(response.data);
|
||||
};
|
||||
|
||||
const saveAll = async () => {
|
||||
setSaving(true);
|
||||
setError(undefined);
|
||||
setSaved(false);
|
||||
const errorOf = (response: { error?: unknown }, fallback: string) =>
|
||||
(response.error as { error?: string })?.error ?? fallback;
|
||||
|
||||
const profileResponse = await saveProfile({
|
||||
body: {
|
||||
...(sex ? { sex: sex as Pohlavi } : {}),
|
||||
...(parseIntInput(birthYear) != null ? { birthYear: parseIntInput(birthYear) } : {}),
|
||||
...(parseIntInput(heightCm) != null ? { heightCm: parseIntInput(heightCm) } : {}),
|
||||
...(parseWeightKgInput(weightKg) != null ? { weightGrams: parseWeightKgInput(weightKg) } : {}),
|
||||
...(parseIntInput(waistCm) != null ? { waistCm: parseIntInput(waistCm) } : {}),
|
||||
...(parseIntInput(neckCm) != null ? { neckCm: parseIntInput(neckCm) } : {}),
|
||||
...(parseIntInput(hipCm) != null ? { hipCm: parseIntInput(hipCm) } : {}),
|
||||
// Hmotnost z doby před vážením po dnech se nepřepisuje, dokud se vážit nezačne
|
||||
...(settings?.profile?.weightGrams != null ? { weightGrams: settings.profile.weightGrams } : {}),
|
||||
},
|
||||
});
|
||||
if (!profileResponse.data) {
|
||||
setSaving(false);
|
||||
setError((profileResponse.error as { error?: string })?.error ?? 'Údaje se nepodařilo uložit');
|
||||
setError(errorOf(profileResponse, 'Údaje se nepodařilo uložit'));
|
||||
return;
|
||||
}
|
||||
|
||||
const goalsResponse = await saveGoals({
|
||||
body: {
|
||||
...(weeklyChange !== '' ? { weeklyChangeGrams: Number(weeklyChange) } : {}),
|
||||
...(parseWeightKgInput(targetWeightKg) != null
|
||||
? { targetWeightGrams: parseWeightKgInput(targetWeightKg) } : {}),
|
||||
...(parseMultiplierInput(proteinPerKg) != null ? { proteinPerKg: parseMultiplierInput(proteinPerKg) } : {}),
|
||||
},
|
||||
});
|
||||
if (!goalsResponse.data) {
|
||||
setSaving(false);
|
||||
setError(errorOf(goalsResponse, 'Cíle se nepodařilo uložit'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -67,14 +118,14 @@ const ProfilePage = () => {
|
||||
const basalResponse = await saveBasalCalories({ body: { basalCalories: parseIntInput(manualBasal) ?? null } });
|
||||
setSaving(false);
|
||||
if (!basalResponse.data) {
|
||||
setError((basalResponse.error as { error?: string })?.error ?? 'Klidový výdej se nepodařilo uložit');
|
||||
setError(errorOf(basalResponse, 'Klidový výdej se nepodařilo uložit'));
|
||||
return;
|
||||
}
|
||||
fillForm(basalResponse.data);
|
||||
setSaved(true);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
if (loading || !settings) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<Spinner animation="border" variant="success" />
|
||||
@@ -82,25 +133,22 @@ const ProfilePage = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const computed = settings?.computedBasalCalories;
|
||||
const effective = settings?.effectiveBasalCalories;
|
||||
const manualWins = settings?.basalCalories != null;
|
||||
const isFemale = sex === Pohlavi.ZENA;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<h1>Osobní údaje</h1>
|
||||
<h1>Profil</h1>
|
||||
</div>
|
||||
|
||||
<p className="text-body-secondary small">
|
||||
Z těchto údajů se spočítá klidový výdej (bazální metabolismus) rovnicí
|
||||
Mifflin–St Jeor. Ten pak v přehledu dne tvoří základ výdeje, ke kterému
|
||||
se přičítá zaznamenaný pohyb.
|
||||
</p>
|
||||
<BodyMetricsTiles settings={settings} />
|
||||
|
||||
<div className="card-panel mb-3">
|
||||
<WeightHistoryPanel metrics={settings.bodyMetrics} goals={settings.goals} onChanged={() => reloadMetrics()} />
|
||||
|
||||
<div className="card-panel chart-panel">
|
||||
<h2 className="chart-title">Tělesné údaje</h2>
|
||||
<div className="row g-3">
|
||||
<div className="col-sm-3">
|
||||
<div className="col-sm-4">
|
||||
<Form.Label>Pohlaví</Form.Label>
|
||||
<Form.Select value={sex} onChange={event => setSex(event.target.value)}>
|
||||
<option value="">— nevyplněno —</option>
|
||||
@@ -109,7 +157,7 @@ const ProfilePage = () => {
|
||||
))}
|
||||
</Form.Select>
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
<div className="col-sm-4">
|
||||
<Form.Label>Rok narození</Form.Label>
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
@@ -118,7 +166,7 @@ const ProfilePage = () => {
|
||||
onChange={event => setBirthYear(event.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
<div className="col-sm-4">
|
||||
<Form.Label>Výška</Form.Label>
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
@@ -130,39 +178,94 @@ const ProfilePage = () => {
|
||||
<span className="input-group-text">cm</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="profile-subtitle">Obvody pro odhad podílu tuku</h3>
|
||||
<div className="row g-3">
|
||||
<div className="col-sm-4">
|
||||
<Form.Label>Pas</Form.Label>
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={waistCm}
|
||||
placeholder="90"
|
||||
onChange={event => setWaistCm(event.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
<span className="input-group-text">cm</span>
|
||||
</div>
|
||||
<Form.Text>{isFemale ? 'v nejužším místě' : 'v úrovni pupku'}</Form.Text>
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
<Form.Label>Krk</Form.Label>
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={neckCm}
|
||||
placeholder="40"
|
||||
onChange={event => setNeckCm(event.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
<span className="input-group-text">cm</span>
|
||||
</div>
|
||||
<Form.Text>pod ohryzkem</Form.Text>
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
<Form.Label>Boky</Form.Label>
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={hipCm}
|
||||
placeholder="100"
|
||||
onChange={event => setHipCm(event.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
<span className="input-group-text">cm</span>
|
||||
</div>
|
||||
<Form.Text>v nejširším místě; do výpočtu jen u žen</Form.Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-panel chart-panel">
|
||||
<h2 className="chart-title">Cíle</h2>
|
||||
<div className="row g-3">
|
||||
<div className="col-sm-5">
|
||||
<Form.Label>Tempo</Form.Label>
|
||||
<Form.Select value={weeklyChange} onChange={event => setWeeklyChange(event.target.value)}>
|
||||
<option value="">— bez cíle —</option>
|
||||
{WEEKLY_CHANGE_OPTIONS.map(grams => (
|
||||
<option key={grams} value={grams}>{formatWeeklyChange(grams)}</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
<Form.Text>v přehledu dne se bilance porovná s cílem</Form.Text>
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
<Form.Label>Hmotnost</Form.Label>
|
||||
<Form.Label>Cílová hmotnost</Form.Label>
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="decimal"
|
||||
value={weightKg}
|
||||
placeholder="90,0"
|
||||
onChange={event => setWeightKg(event.target.value)}
|
||||
value={targetWeightKg}
|
||||
placeholder="85,0"
|
||||
onChange={event => setTargetWeightKg(event.target.value)}
|
||||
/>
|
||||
<span className="input-group-text">kg</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="summary-grid">
|
||||
<div className="summary-tile">
|
||||
<div className="summary-label"><FontAwesomeIcon icon={faCalculator} /> Spočítaný klidový výdej</div>
|
||||
<div className="summary-value">{formatCalories(computed)}</div>
|
||||
<div className="summary-hint">
|
||||
{computed != null ? 'Mifflin–St Jeor, odhad ±10 %' : 'vyplňte všechny čtyři údaje'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="summary-tile accent">
|
||||
<div className="summary-label"><FontAwesomeIcon icon={faFire} /> Použije se v bilanci</div>
|
||||
<div className="summary-value">{formatCalories(effective)}</div>
|
||||
<div className="summary-hint">
|
||||
{manualWins ? 'ručně zadaná hodnota' : (computed != null ? 'ze spočítané hodnoty' : 'zatím nic')}
|
||||
<div className="col-sm-4">
|
||||
<Form.Label>Bílkoviny</Form.Label>
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="decimal"
|
||||
value={proteinPerKg}
|
||||
placeholder="1,6"
|
||||
onChange={event => setProteinPerKg(event.target.value)}
|
||||
/>
|
||||
<span className="input-group-text">g / kg</span>
|
||||
</div>
|
||||
<Form.Text>běžně 1,2–2 g na kg, při hubnutí a silovém tréninku víc</Form.Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-panel mb-3">
|
||||
<div className="card-panel chart-panel">
|
||||
<h2 className="chart-title">Ruční klidový výdej</h2>
|
||||
<p className="text-body-secondary small">
|
||||
Máte-li hodnotu změřenou přesněji, zadejte ji sem — přebije spočítanou.
|
||||
@@ -173,7 +276,8 @@ const ProfilePage = () => {
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={manualBasal}
|
||||
placeholder={computed != null ? String(computed) : 'např. 1800'}
|
||||
placeholder={settings.computedBasalCalories != null
|
||||
? String(settings.computedBasalCalories) : 'např. 1800'}
|
||||
onChange={event => setManualBasal(event.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
<span className="input-group-text">kcal / den</span>
|
||||
@@ -185,14 +289,15 @@ const ProfilePage = () => {
|
||||
{saved && !error && <Alert variant="success" className="py-2 small">Uloženo.</Alert>}
|
||||
|
||||
<Button onClick={() => saveAll()} disabled={saving}>
|
||||
{saving ? 'Ukládám…' : 'Uložit'}
|
||||
{saving ? 'Ukládám…' : 'Uložit údaje a cíle'}
|
||||
</Button>
|
||||
|
||||
<p className="text-body-secondary small mt-4">
|
||||
Poznámka k výpočtu: klidový výdej je hodnota za celých 24 hodin včetně spánku.
|
||||
Poznámka k výpočtům: klidový výdej je hodnota za celých 24 hodin včetně spánku.
|
||||
Záměrně se nenásobí koeficientem fyzické aktivity, jak to dělají kalkulačky TDEE —
|
||||
pohyb si aplikace eviduje zvlášť a přičítá ho k tomuto číslu, takže vynásobení
|
||||
by ho započítalo dvakrát.
|
||||
by ho započítalo dvakrát. Tempo počítá s 7 700 kcal na kilogram; skutečná změna
|
||||
hmotnosti kolísá s vodou, proto se vyplatí sledovat spíš trend než jednotlivá vážení.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import { useState } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faDumbbell, faBowlFood } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faDumbbell, faBookOpen } from "@fortawesome/free-solid-svg-icons";
|
||||
import { TemplateTab, TEMPLATE_TAB_NAMES } from "../enums";
|
||||
import WorkoutTemplatesPanel from "./WorkoutTemplatesPanel";
|
||||
import MealTemplatesPanel from "./MealTemplatesPanel";
|
||||
import "./TemplatesPage.scss";
|
||||
|
||||
/**
|
||||
* Šablony — tréninky a jídla vedle sebe.
|
||||
* Šablony — tréninky a recepty vedle sebe.
|
||||
*
|
||||
* Obojí jsou předpřipravené položky zakládané do dne jedním kliknutím, ale mají
|
||||
* jinou strukturu (tréninky se skládají z jiných šablon, jídla ne), proto mají
|
||||
* vlastní panely a společné jen záložky.
|
||||
* jinou strukturu (tréninky se skládají z jiných šablon, recepty ze surovin),
|
||||
* proto mají vlastní panely a společné jen záložky. Hotová jídla šablony
|
||||
* nemají, nabízejí se z historie.
|
||||
*/
|
||||
const TemplatesPage = () => {
|
||||
const [tab, setTab] = useState<TemplateTab>(TemplateTab.TRENINKY);
|
||||
// Odkaz může rovnou otevřít konkrétní záložku, třeba recepty z výběru jídla
|
||||
const location = useLocation();
|
||||
const [tab, setTab] = useState<TemplateTab>(() =>
|
||||
(location.state as { tab?: TemplateTab } | null)?.tab ?? TemplateTab.TRENINKY);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -30,10 +35,10 @@ const TemplatesPage = () => {
|
||||
<FontAwesomeIcon icon={faDumbbell} /> {TEMPLATE_TAB_NAMES[TemplateTab.TRENINKY]}
|
||||
</button>
|
||||
<button
|
||||
className={`day-tab ${tab === TemplateTab.JIDLA ? 'active' : ''}`}
|
||||
onClick={() => setTab(TemplateTab.JIDLA)}
|
||||
className={`day-tab ${tab === TemplateTab.RECEPTY ? 'active' : ''}`}
|
||||
onClick={() => setTab(TemplateTab.RECEPTY)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBowlFood} /> {TEMPLATE_TAB_NAMES[TemplateTab.JIDLA]}
|
||||
<FontAwesomeIcon icon={faBookOpen} /> {TEMPLATE_TAB_NAMES[TemplateTab.RECEPTY]}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in new issue
Block a user