feat: split jídel

feat: graf příjmů
This commit is contained in:
Ondřej Anděl committed 2026-10-09 15:15:04 +02:00
1 parent bc85a37fc0
commit b880b36edd
32 files changed
+2048 -262

No files matched your search

+70
View File
@@ -950,3 +950,73 @@ body {
margin-top: 0;
}
}
// ============================================
// ROZDĚLENÍ JÍDLA — původní a nové vedle sebe
// ============================================
.meal-split-intro {
font-size: 0.88rem;
color: var(--ft-text-secondary);
margin-bottom: 14px;
}
.meal-split-grid {
display: grid;
grid-template-columns: 120px minmax(0, 1fr) 34px minmax(0, 1fr);
gap: 8px;
// Nahoru, ne na střed — nabídka návrhů pod názvem jinak odsune popisek doprostřed
align-items: start;
.meal-split-head {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--ft-text-muted);
}
.meal-split-label {
padding-top: 9px;
font-size: 0.85rem;
font-weight: 600;
color: var(--ft-text-secondary);
}
.meal-split-copy {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding-top: 4px;
}
.meal-split-half {
font-weight: 700;
font-size: 0.8rem;
}
.derived-value {
margin: 0;
}
// Na telefonu se popisek přesune nad řádek, ať na pole zbude šířka
@media (max-width: 575.98px) {
grid-template-columns: minmax(0, 1fr) 30px minmax(0, 1fr);
> div:empty:first-child {
display: none;
}
.meal-split-label {
grid-column: 1 / -1;
margin-top: 6px;
}
}
}
.meal-split-alert {
margin: 12px 0 0;
padding: 8px 12px;
font-size: 0.85rem;
}
+38
View File
@@ -0,0 +1,38 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
import { MealGroupDay } from "../../../types";
import { formatDateString, getDayOfWeek } from "../Utils";
import { getDayUrl } from "../routes";
type Props = {
days: MealGroupDay[];
};
/**
* Dny, kdy se jídlo skupiny jedlo, jako odkazy na přehled dne v nové záložce —
* aby šlo podívat se, co se ten den dělo, a stránku s doplňováním neopustit.
*/
const MealGroupDays = ({ days }: Readonly<Props>) => (
<div className="meal-group-days">
<div className="meal-group-days-title">
Otevřít den v nové záložce{days.some(day => day.incomplete) && ' · zvýrazněné dny mají neúplné údaje'}
</div>
<div className="meal-meta">
{days.map(day => (
<a
key={day.date}
href={getDayUrl(day.date)}
target="_blank"
rel="noopener noreferrer"
className={`tag meal-group-day ${day.incomplete ? 'tag-missing' : ''}`}
title={day.incomplete ? 'Některé jídlo tohoto dne nemá kalorie, gramáž nebo živiny' : undefined}
>
{getDayOfWeek(day.date).substring(0, 2)} {formatDateString(day.date)}
<FontAwesomeIcon icon={faArrowUpRightFromSquare} />
</a>
))}
</div>
</div>
);
export default MealGroupDays;
+49 -30
View File
@@ -1,9 +1,11 @@
import { useState } from "react";
import { Button } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faPenToSquare } from "@fortawesome/free-solid-svg-icons";
import { faCalendarDays, faPenToSquare } from "@fortawesome/free-solid-svg-icons";
import { MealGroup } from "../../../types";
import { formatDateString } from "../Utils";
import MealGroupForm from "./MealGroupForm";
import MealGroupDays from "./MealGroupDays";
type Props = {
group: MealGroup,
@@ -18,39 +20,56 @@ const describeMissing = (missing: number, count: number): string =>
/**
* Řádek skupiny jídel (stejný název a zdroj) s přehledem chybějících údajů.
* Po rozbalení nabídne formulář, kterým se údaje doplní všem jídlům skupiny.
* Po rozbalení nabídne formulář, kterým se údaje doplní všem jídlům skupiny,
* a dny, kdy se jídlo jedlo, jako odkazy na přehled dne.
*/
const MealGroupRow = ({ group, expanded, onToggle, onFilled }: Readonly<Props>) => (
<div className="card-panel meal-group">
<div className="meal-group-head">
<div className="meal-group-info">
<div className="meal-group-name">{group.name}</div>
<div className="meal-meta">
{group.source && <span className="tag">{group.source}</span>}
<span className="tag">{group.count}× · naposledy {formatDateString(group.lastDate)}</span>
{group.missingCaloriesCount > 0 && (
<span className="tag tag-missing">
bez kalorií {describeMissing(group.missingCaloriesCount, group.count)}
</span>
)}
{group.missingWeightCount > 0 && (
<span className="tag tag-missing">
bez gramáže {describeMissing(group.missingWeightCount, group.count)}
</span>
)}
{group.missingNutrientsCount > 0 && (
<span className="tag tag-missing">
bez živin {describeMissing(group.missingNutrientsCount, group.count)}
</span>
const MealGroupRow = ({ group, expanded, onToggle, onFilled }: Readonly<Props>) => {
const [daysOpen, setDaysOpen] = useState(false);
return (
<div className="card-panel meal-group">
<div className="meal-group-head">
<div className="meal-group-info">
<div className="meal-group-name">{group.name}</div>
<div className="meal-meta">
{group.source && <span className="tag">{group.source}</span>}
<span className="tag">{group.count}× · naposledy {formatDateString(group.lastDate)}</span>
{group.missingCaloriesCount > 0 && (
<span className="tag tag-missing">
bez kalorií {describeMissing(group.missingCaloriesCount, group.count)}
</span>
)}
{group.missingWeightCount > 0 && (
<span className="tag tag-missing">
bez gramáže {describeMissing(group.missingWeightCount, group.count)}
</span>
)}
{group.missingNutrientsCount > 0 && (
<span className="tag tag-missing">
bez živin {describeMissing(group.missingNutrientsCount, group.count)}
</span>
)}
</div>
</div>
<div className="meal-group-actions">
{!expanded && (
<Button
variant="outline-secondary"
size="sm"
active={daysOpen}
onClick={() => setDaysOpen(open => !open)}
>
<FontAwesomeIcon icon={faCalendarDays} /> Dny
</Button>
)}
<Button variant={expanded ? 'outline-secondary' : 'primary'} size="sm" onClick={() => onToggle()}>
<FontAwesomeIcon icon={faPenToSquare} /> {expanded ? 'Zavřít' : 'Doplnit'}
</Button>
</div>
</div>
<Button variant={expanded ? 'outline-secondary' : 'primary'} size="sm" onClick={() => onToggle()}>
<FontAwesomeIcon icon={faPenToSquare} /> {expanded ? 'Zavřít' : 'Doplnit'}
</Button>
{(expanded || daysOpen) && <MealGroupDays days={group.days} />}
{expanded && <MealGroupForm group={group} onFilled={onFilled} onCancel={() => onToggle()} />}
</div>
{expanded && <MealGroupForm group={group} onFilled={onFilled} onCancel={() => onToggle()} />}
</div>
);
);
};
export default MealGroupRow;
+3 -2
View File
@@ -8,11 +8,12 @@ type Props = {
entry: MealEntry,
onEdit: () => void,
onDelete: () => void,
onSplit: () => void,
onMultiplierChange: (multiplier: number) => void,
};
/** Jeden řádek seznamu jídel v přehledu dne. */
const MealRow = ({ entry, onEdit, onDelete, onMultiplierChange }: Readonly<Props>) => {
const MealRow = ({ entry, onEdit, onDelete, onSplit, 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);
@@ -53,7 +54,7 @@ const MealRow = ({ entry, onEdit, onDelete, onMultiplierChange }: Readonly<Props
label="jídla"
/>
<div className="meal-price">{formatPrice(entry.price)}</div>
<RowActions onEdit={onEdit} onDelete={onDelete} label="jídlo" />
<RowActions onEdit={onEdit} onDelete={onDelete} onSplit={onSplit} label="jídlo" />
</div>
);
};
+40
View File
@@ -0,0 +1,40 @@
import { ReactNode } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowRight } from "@fortawesome/free-solid-svg-icons";
type Props = {
label: string,
/** Pole původního jídla. */
original: ReactNode,
/** Pole odštěpené části. */
split: ReactNode,
/** Zkopíruje hodnotu původního jídla do odštěpené části; bez něj se šipka nezobrazí. */
onCopy?: () => void,
/** Doplňkové tlačítko pod šipkou, např. rozdělení ceny napůl. */
extra?: ReactNode,
};
/** Jeden řádek rozdělení jídla — stejný údaj vlevo u původního a vpravo u nového jídla. */
const MealSplitRow = ({ label, original, split, onCopy, extra }: Readonly<Props>) => (
<>
<div className="meal-split-label">{label}</div>
<div className="meal-split-cell">{original}</div>
<div className="meal-split-copy">
{onCopy && (
<button
type="button"
className="btn-icon"
title="Zkopírovat doprava"
aria-label={`Zkopírovat ${label.toLowerCase()} do nového jídla`}
onClick={() => onCopy()}
>
<FontAwesomeIcon icon={faArrowRight} />
</button>
)}
{extra}
</div>
<div className="meal-split-cell">{split}</div>
</>
);
export default MealSplitRow;
+14 -2
View File
@@ -1,17 +1,19 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faPenToSquare, faTrash, faBookmark } from "@fortawesome/free-solid-svg-icons";
import { faPenToSquare, faTrash, faBookmark, faScissors } from "@fortawesome/free-solid-svg-icons";
type Props = {
onEdit: () => void,
onDelete: () => void,
/** Když je vyplněné, přibude tlačítko pro uložení záznamu jako šablony. */
onSaveTemplate?: () => void,
/** Když je vyplněné, přibude tlačítko pro rozdělení záznamu na dva. */
onSplit?: () => void,
/** Doplní se do popisku pro čtečky, např. "Upravit jídlo". */
label: string,
};
/** Tlačítka pro úpravu a smazání řádku seznamu. */
const RowActions = ({ onEdit, onDelete, onSaveTemplate, label }: Readonly<Props>) => (
const RowActions = ({ onEdit, onDelete, onSaveTemplate, onSplit, label }: Readonly<Props>) => (
<div className="meal-actions">
{onSaveTemplate && (
<button
@@ -23,6 +25,16 @@ const RowActions = ({ onEdit, onDelete, onSaveTemplate, label }: Readonly<Props>
<FontAwesomeIcon icon={faBookmark} />
</button>
)}
{onSplit && (
<button
className="btn-icon"
onClick={() => onSplit()}
title="Rozdělit na dvě jídla"
aria-label={`Rozdělit ${label}`}
>
<FontAwesomeIcon icon={faScissors} />
</button>
)}
<button className="btn-icon" onClick={() => onEdit()} title="Upravit" aria-label={`Upravit ${label}`}>
<FontAwesomeIcon icon={faPenToSquare} />
</button>
@@ -0,0 +1,388 @@
import { useEffect, useState } from "react";
import { Alert, Button, Form, Modal } from "react-bootstrap";
import {
DayRecord, MealEntry, MealInput, MealSuggestion, MealTemplate, MealType, getMealHistory, getMealTemplates, splitMeal,
} from "../../../../types";
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums";
import {
deriveAmounts, formatCalories, formatMultiplier, formatPrice, formatPriceInput, formatWeight, normalizeSearch,
parseIntInput, parsePriceInput,
} from "../../Utils";
import MealSplitRow from "../MealSplitRow";
import MealSuggestions from "../MealSuggestions";
import SourceInput from "../SourceInput";
type Props = {
isOpen: boolean,
/** Rozdělované jídlo; bez něj je dialog zavřený. */
entry?: MealEntry,
onClose: () => void,
onSaved: (day: DayRecord) => void,
};
/** Údaje jedné části rozdělovaného jídla tak, jak je uživatel píše. */
type SplitForm = {
name: string,
mealType: MealType,
source: string,
price: string,
pricePer100g: string,
weight: string,
caloriesPer100g: string,
calories: string,
note: string,
};
/**
* Rozdělí název podle prvního "+" — Luncher tak píše jídlo s přílohou
* ("Chicken tikka massala+garlic nun"). Bez "+" zůstane celý název vlevo.
*/
const splitName = (name: string): [string, string] => {
const index = name.indexOf('+');
if (index < 0) return [name, ''];
return [name.substring(0, index).trim(), name.substring(index + 1).trim()];
};
const createOriginalForm = (source: MealEntry, name: string): SplitForm => ({
name,
mealType: source.mealType,
source: source.source ?? '',
price: formatPriceInput(source.price),
pricePer100g: formatPriceInput(source.pricePer100g),
weight: source.weight != null ? String(source.weight) : '',
caloriesPer100g: source.caloriesPer100g != null ? String(source.caloriesPer100g) : '',
calories: source.calories != null ? String(source.calories) : '',
note: source.note ?? '',
});
/** Nová část — ze stejného chodu a zdroje, ostatní je na uživateli. */
const createSplitForm = (source: MealEntry, name: string): SplitForm => ({
name,
mealType: source.mealType,
source: source.source ?? '',
price: '',
pricePer100g: '',
weight: '',
caloriesPer100g: '',
calories: '',
note: '',
});
/** Dopočty části — stejně jako ve formuláři jídla. */
const deriveForm = (form: SplitForm) => {
const price = parsePriceInput(form.price);
const pricePer100g = parsePriceInput(form.pricePer100g);
const weight = parseIntInput(form.weight);
const caloriesPer100g = parseIntInput(form.caloriesPer100g);
const derived = deriveAmounts(price, pricePer100g, weight, caloriesPer100g);
const manualCalories = parseIntInput(form.calories);
return {
price, pricePer100g, weight, caloriesPer100g, manualCalories, derived,
calories: derived.calories ?? manualCalories,
};
};
/** Sestaví vstup serveru z části formuláře. Datum doplní server podle rozdělovaného jídla. */
const createMealInput = (form: SplitForm, date: string): MealInput => {
const values = deriveForm(form);
return {
date,
mealType: form.mealType,
name: form.name.trim(),
source: form.source.trim() || undefined,
price: values.price,
pricePer100g: values.pricePer100g,
weight: values.weight,
caloriesPer100g: values.caloriesPer100g,
// Server si kalorie dopočte sám, ručně poslané použije jen jako zálohu
calories: values.derived.calories != null ? undefined : values.manualCalories,
note: form.note.trim() || undefined,
};
};
/**
* Doplní do nové části hodnoty z návrhu, ale jen do prázdných polí. Cena se
* nepřebírá — za jídlo se platilo jednou a cena z jiného dne by ji zdvojila.
*/
const fillEmpty = (current: SplitForm, values: Partial<SplitForm>): SplitForm => {
const result = { ...current };
for (const [key, value] of Object.entries(values) as [keyof SplitForm, string][]) {
if (key === 'name' || !String(current[key]).trim().length) {
(result as Record<string, string>)[key] = value;
}
}
return result;
};
/**
* Rozdělení jídla, které je zapsané jako jedno, na dvě — typicky import
* z Luncheru, kde je jídlo s přílohou v jednom řádku. Vlevo jsou hodnoty
* původního jídla, vpravo nové části; šipka zkopíruje údaj zleva doprava.
*
* Nic se nedopočítává mezi sloupci, cena se rozdělí jen na vyžádání — někdy
* zůstane celá u původního jídla, jindy se dělí.
*/
const MealSplitModal = ({ isOpen, entry, onClose, onSaved }: Readonly<Props>) => {
const [original, setOriginal] = useState<SplitForm | undefined>();
const [split, setSplit] = useState<SplitForm | undefined>();
const [recipes, setRecipes] = useState<MealTemplate[]>([]);
const [history, setHistory] = useState<MealSuggestion[]>([]);
const [pickedSuggestion, setPickedSuggestion] = useState<string | undefined>();
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!isOpen || !entry) return;
const [originalName, splitPartName] = splitName(entry.name);
setOriginal(createOriginalForm(entry, originalName));
setSplit(createSplitForm(entry, splitPartName));
setPickedSuggestion(undefined);
getMealTemplates().then(response => setRecipes(response.data ?? []));
getMealHistory().then(response => setHistory(response.data ?? []));
}, [isOpen, entry]);
if (!entry || !original || !split) return null;
const updateOriginal = (changes: Partial<SplitForm>) => setOriginal(current => current && { ...current, ...changes });
const updateSplit = (changes: Partial<SplitForm>) => setSplit(current => current && { ...current, ...changes });
const copy = (key: keyof SplitForm) => updateSplit({ [key]: original[key] });
/** Rozdělí současný součet ceny obou částí napůl; lichý haléř zůstane u původního jídla. */
const halvePrice = () => {
const total = (parsePriceInput(original.price) ?? 0) + (parsePriceInput(split.price) ?? 0);
const half = Math.floor(total / 2);
updateOriginal({ price: formatPriceInput(total - half) });
updateSplit({ price: formatPriceInput(half) });
};
const applyHistory = (suggestion: MealSuggestion) => {
setPickedSuggestion(suggestion.name);
setSplit(current => current && fillEmpty(current, {
name: suggestion.name,
source: suggestion.source ?? '',
weight: suggestion.weight != null ? String(suggestion.weight) : '',
caloriesPer100g: suggestion.caloriesPer100g != null ? String(suggestion.caloriesPer100g) : '',
calories: suggestion.calories != null ? String(suggestion.calories) : '',
}));
};
const applyRecipe = (recipe: MealTemplate) => {
setPickedSuggestion(recipe.name);
setSplit(current => current && fillEmpty(current, {
name: recipe.name,
weight: recipe.weight != null ? String(recipe.weight) : '',
caloriesPer100g: recipe.caloriesPer100g != null ? String(recipe.caloriesPer100g) : '',
calories: recipe.calories != null ? String(recipe.calories) : '',
}));
};
const originalValues = deriveForm(original);
const splitValues = deriveForm(split);
const totalPrice = (originalValues.price ?? 0) + (splitValues.price ?? 0);
const priceChanged = totalPrice !== (entry.price ?? 0);
const canSave = !!original.name.trim().length && !!split.name.trim().length && !saving;
const save = async () => {
if (!canSave) return;
setSaving(true);
const response = await splitMeal({
body: {
id: entry.id,
date: entry.date,
original: {
...createMealInput(original, entry.date),
// Živiny a násobek se v dialogu nemění a zůstávají původnímu jídlu
nutrientsPer100g: entry.nutrientsPer100g,
multiplier: entry.multiplier,
},
split: createMealInput(split, entry.date),
},
});
setSaving(false);
if (response.data) onSaved(response.data);
};
/** Textové pole části; u čísel bez desetinné čárky propustí jen číslice. */
const textInput = (
form: SplitForm, update: (changes: Partial<SplitForm>) => void, key: keyof SplitForm,
options: { placeholder?: string, unit?: string, numeric?: boolean } = {},
) => {
const control = (
<Form.Control
inputMode={options.numeric ? 'numeric' : (options.unit ? 'decimal' : undefined)}
value={form[key]}
placeholder={options.placeholder}
onChange={event => update({
[key]: options.numeric ? event.target.value.replace(/\D/g, '') : event.target.value,
})}
/>
);
if (!options.unit) return control;
return (
<div className="input-group">
{control}
<span className="input-group-text">{options.unit}</span>
</div>
);
};
const mealTypeSelect = (form: SplitForm, update: (changes: Partial<SplitForm>) => void) => (
<Form.Select value={form.mealType} onChange={event => update({ mealType: event.target.value as MealType })}>
{MEAL_TYPES_IN_ORDER.map(mealType => (
<option key={mealType} value={mealType}>
{MEAL_TYPE_ICONS[mealType]} {MEAL_TYPE_NAMES[mealType]}
</option>
))}
</Form.Select>
);
/** Kalorie porce — dopočtené z gramáže se jen ukážou, jinak jdou zadat ručně. */
const caloriesCell = (
form: SplitForm, update: (changes: Partial<SplitForm>) => void, values: ReturnType<typeof deriveForm>,
) => values.derived.calories != null
? <div className="derived-value">{formatCalories(values.derived.calories)}</div>
: textInput(form, update, 'calories', { numeric: true, unit: 'kcal', placeholder: 'volitelné' });
return (
<Modal show={isOpen} onHide={onClose} centered size="lg">
<Modal.Header closeButton>
<Modal.Title>Rozdělit jídlo</Modal.Title>
</Modal.Header>
<Modal.Body>
<p className="meal-split-intro">
Z jednoho záznamu vzniknou dva ve stejném dni. Šipkou zkopírujete údaj z původního jídla do nového.
</p>
<Form onSubmit={event => { event.preventDefault(); save(); }}>
<div className="meal-split-grid">
<div />
<div className="meal-split-head">Původní jídlo</div>
<div />
<div className="meal-split-head">Nové jídlo</div>
<MealSplitRow
label="Jídlo"
original={textInput(original, updateOriginal, 'name')}
split={
<>
<Form.Control
autoFocus
value={split.name}
placeholder="např. Garlic naan"
onChange={event => { updateSplit({ name: event.target.value }); setPickedSuggestion(undefined); }}
/>
{pickedSuggestion !== split.name && (
<MealSuggestions
recipes={recipes}
// Rozdělované jídlo samo sebe nenabízí
history={history.filter(item =>
normalizeSearch(item.name) !== normalizeSearch(entry.name))}
query={split.name}
onPickRecipe={recipe => applyRecipe(recipe)}
onPickHistory={suggestion => applyHistory(suggestion)}
/>
)}
{pickedSuggestion === split.name && (
<Form.Text>doplněna prázdná pole z dřívějška, cena ne</Form.Text>
)}
</>
}
onCopy={() => copy('name')}
/>
<MealSplitRow
label="Chod"
original={mealTypeSelect(original, updateOriginal)}
split={mealTypeSelect(split, updateSplit)}
onCopy={() => copy('mealType')}
/>
<MealSplitRow
label="Zdroj"
original={<SourceInput value={original.source} onChange={source => updateOriginal({ source })} />}
split={<SourceInput value={split.source} onChange={source => updateSplit({ source })} />}
onCopy={() => copy('source')}
/>
<MealSplitRow
label="Cena"
original={textInput(original, updateOriginal, 'price', { unit: 'Kč', placeholder: '0,00' })}
split={textInput(split, updateSplit, 'price', { unit: 'Kč', placeholder: '0,00' })}
onCopy={() => copy('price')}
extra={
<button
type="button"
className="btn-icon meal-split-half"
title="Rozdělit cenu napůl"
aria-label="Rozdělit cenu napůl"
onClick={() => halvePrice()}
>
½
</button>
}
/>
<MealSplitRow
label="Cena za 100 g"
original={textInput(original, updateOriginal, 'pricePer100g', { unit: 'Kč' })}
split={textInput(split, updateSplit, 'pricePer100g', { unit: 'Kč' })}
onCopy={() => copy('pricePer100g')}
/>
<MealSplitRow
label="Gramáž"
original={textInput(original, updateOriginal, 'weight', {
numeric: true, unit: 'g', placeholder: originalValues.derived.weight?.toString() ?? 'volitelné',
})}
split={textInput(split, updateSplit, 'weight', {
numeric: true, unit: 'g', placeholder: splitValues.derived.weight?.toString() ?? 'volitelné',
})}
onCopy={() => copy('weight')}
/>
<MealSplitRow
label="Energie na 100 g"
original={textInput(original, updateOriginal, 'caloriesPer100g', { numeric: true, unit: 'kcal' })}
split={textInput(split, updateSplit, 'caloriesPer100g', { numeric: true, unit: 'kcal' })}
onCopy={() => copy('caloriesPer100g')}
/>
<MealSplitRow
label="Kalorie porce"
original={caloriesCell(original, updateOriginal, originalValues)}
split={caloriesCell(split, updateSplit, splitValues)}
onCopy={() => copy('calories')}
/>
<MealSplitRow
label="Poznámka"
original={textInput(original, updateOriginal, 'note')}
split={textInput(split, updateSplit, 'note')}
onCopy={() => copy('note')}
/>
</div>
</Form>
{(entry.nutrientsPer100g || entry.multiplier != null) && (
<p className="usage-note">
{entry.multiplier != null && `Násobek ${formatMultiplier(entry.multiplier)}`}
{entry.multiplier != null && entry.nutrientsPer100g && ' a '}
{entry.nutrientsPer100g && (entry.multiplier != null ? 'živiny' : 'Živiny')}
{' '}zůstávají u původního jídla, novému je doplníte v úpravě.
</p>
)}
{priceChanged && (
<Alert variant="warning" className="meal-split-alert">
Součet cen {formatPrice(totalPrice)} nesedí na původní {formatPrice(entry.price ?? 0)}.
</Alert>
)}
</Modal.Body>
<Modal.Footer>
<div className="meal-modal-summary">
<span>{formatPrice(originalValues.price ?? 0)} + {formatPrice(splitValues.price ?? 0)}</span>
{(originalValues.calories != null || splitValues.calories != null) && (
<span>
{formatCalories(originalValues.calories)} + {formatCalories(splitValues.calories)}
</span>
)}
{splitValues.derived.weight != null && <span>nové {formatWeight(splitValues.derived.weight)}</span>}
</div>
<Button variant="outline-secondary" onClick={onClose}>Zrušit</Button>
<Button onClick={() => save()} disabled={!canSave}>
{saving ? 'Ukládám…' : 'Rozdělit'}
</Button>
</Modal.Footer>
</Modal>
);
};
export default MealSplitModal;
+14 -1
View File
@@ -78,7 +78,7 @@ export const ACTIVITY_PRESETS: { name: string, unit: ActivityUnit, caloriesPer10
{ name: 'Shyby', unit: ActivityUnit.OPAKOVANI, caloriesPer100Units: 100 },
];
/** Záložky příjem/výdej — v přehledu dne i ve statistikách. */
/** Záložky příjem/výdej v přehledu dne. */
export enum DayTab {
PRIJEM = 'PRIJEM',
VYDEJ = 'VYDEJ',
@@ -89,6 +89,19 @@ export const DAY_TAB_NAMES: Record<DayTab, string> = {
VYDEJ: 'Výdej',
};
/** Záložky statistik — energie jídla, pohyb a peníze za jídlo. */
export enum StatsTab {
PRIJEM = 'PRIJEM',
VYDEJ = 'VYDEJ',
UTRATA = 'UTRATA',
}
export const STATS_TAB_NAMES: Record<StatsTab, string> = {
PRIJEM: 'Příjem',
VYDEJ: 'Výdej',
UTRATA: 'Útrata',
};
/** Rozsahy, za které lze zobrazit statistiky. */
export enum StatsRange {
MESIC = 'MESIC',
+22 -1
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, Button, Spinner } from "react-bootstrap";
import { useSearchParams } from "react-router";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faChevronLeft, faChevronRight, faPlus, faUtensils, faPersonRunning,
@@ -19,12 +20,14 @@ import {
shiftDate,
} from "../Utils";
import MealModal from "../components/modals/MealModal";
import MealSplitModal from "../components/modals/MealSplitModal";
import ActivityModal from "../components/modals/ActivityModal";
import ApplyWorkoutModal from "../components/modals/ApplyWorkoutModal";
import ApplyMealModal from "../components/modals/ApplyMealModal";
import SaveWorkoutTemplateModal from "../components/modals/SaveWorkoutTemplateModal";
import MealRow from "../components/MealRow";
import ActivityRow from "../components/ActivityRow";
import { DAY_DATE_PARAM } from "../routes";
import "./DayPage.scss";
/** Prázdný přehled dne — použije se, než dorazí data ze serveru. */
@@ -37,8 +40,13 @@ function emptyOverview(date: string): DayOverview {
};
}
/** Den z adresy (odkaz ze statistik nebo z doplnění údajů), jinak dnešek. */
const getInitialDate = (param: string | null): string =>
param && /^\d{4}-\d{2}-\d{2}$/.test(param) ? param : formatDate(new Date());
export default function DayPage() {
const [date, setDate] = useState<string>(() => formatDate(new Date()));
const [searchParams] = useSearchParams();
const [date, setDate] = useState<string>(() => getInitialDate(searchParams.get(DAY_DATE_PARAM)));
const [day, setDay] = useState<DayOverview>(() => emptyOverview(date));
const [tab, setTab] = useState<DayTab>(DayTab.PRIJEM);
const [loading, setLoading] = useState(true);
@@ -55,6 +63,7 @@ export default function DayPage() {
const [savedNotice, setSavedNotice] = 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 [splitMealEntry, setSplitMealEntry] = useState<MealEntry | undefined>();
const loadDay = useCallback(async (forDate: string) => {
setLoading(true);
@@ -337,6 +346,7 @@ export default function DayPage() {
entry={entry}
onEdit={() => { setEditedMeal(entry); setMealModalOpen(true); }}
onDelete={() => removeMeal(entry)}
onSplit={() => setSplitMealEntry(entry)}
onMultiplierChange={multiplier => changeMealMultiplier(entry, multiplier)}
/>
))}
@@ -374,6 +384,17 @@ export default function DayPage() {
</>
)}
<MealSplitModal
isOpen={!!splitMealEntry}
entry={splitMealEntry}
onClose={() => setSplitMealEntry(undefined)}
onSaved={() => {
setSplitMealEntry(undefined);
showNotice('Jídlo rozděleno.');
loadDay(date);
}}
/>
<MealModal
isOpen={mealModalOpen}
date={date}
+248
View File
@@ -0,0 +1,248 @@
import { useEffect, useMemo, useState } from "react";
import { Button, Spinner } from "react-bootstrap";
import { useNavigate } from "react-router";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCalendarDay, faChartColumn, faDrumstickBite, faFire, faPenToSquare, faScaleBalanced, faTriangleExclamation,
} from "@fortawesome/free-solid-svg-icons";
import {
Bar, CartesianGrid, Cell, ComposedChart, Legend, Line, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
} from "recharts";
import { IntakeStats, MealType, getIntakeStats } from "../../../types";
import { MEAL_TYPE_NAMES, NUTRIENT_NAMES, NUTRIENTS_IN_ORDER, StatsRange } from "../enums";
import { formatBalance, formatCalories, formatNutrient } from "../Utils";
import { DOPLNENI_JIDEL_URL } from "../routes";
import { StatsBounds, TOOLTIP_STYLE } from "./statsShared";
import { MEAL_TYPE_COLORS, buildIntakeSeries, buildSourceSlices, getUsedMealTypes } from "./intakeChartSeries";
import IntakeTooltip from "./IntakeTooltip";
type Props = {
range: StatsBounds;
mode: StatsRange;
/** Změna vynutí nové načtení — po importu. */
reloadKey: number;
};
/** Živiny, které jsou součástí jiné ("z toho cukry") — v tabulce odsazené. */
const SUB_NUTRIENTS = new Set(['sugars', 'saturatedFat']);
/**
* Statistiky energetického příjmu: kolik se snědlo, v jakých chodech a jídlech,
* jaké živiny a jak to vychází proti výdeji. Protějšek statistik výdeje.
*/
const IntakeStatsPanel = ({ range, mode, reloadKey }: Readonly<Props>) => {
const navigate = useNavigate();
const [stats, setStats] = useState<IntakeStats | undefined>();
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
getIntakeStats({ query: range }).then(response => {
if (cancelled) return;
setStats(response.data);
setLoading(false);
});
return () => { cancelled = true; };
}, [range, reloadKey]);
const series = useMemo(() => stats ? buildIntakeSeries(stats, range, mode) : [], [stats, range, mode]);
const mealTypes = useMemo(() => stats ? getUsedMealTypes(stats) : [], [stats]);
if (loading) {
return (
<div className="empty-state">
<Spinner animation="border" variant="success" />
</div>
);
}
if (!stats?.entryCount) {
return (
<div className="card-panel empty-state">
<div className="empty-icon"><FontAwesomeIcon icon={faChartColumn} /></div>
<div className="empty-title">Za zvolené období nemáte zapsané žádné jídlo</div>
<p className="empty-description">
Jídla přidáte v přehledu dne na záložce Příjem, nebo si je naimportujte z Luncheru.
</p>
</div>
);
}
// Průměry jen ze dnů s kaloriemi — den, kde je žádné jídlo nemá, by průměr stáhl k nule
const calorieDays = stats.caloriesDayCount;
const averageIntake = calorieDays ? Math.round(stats.totalCalories / calorieDays) : undefined;
const averageBurn = calorieDays ? Math.round(stats.burnCalories / calorieDays) : undefined;
const averageBalance = averageIntake != null && averageBurn != null ? averageIntake - averageBurn : undefined;
const averageProtein = stats.totalNutrients?.protein != null && stats.nutrientsDayCount
? stats.totalNutrients.protein / stats.nutrientsDayCount
: undefined;
const pieData = stats.byMealType.map(group => ({
key: group.key as MealType,
name: MEAL_TYPE_NAMES[group.key as MealType] ?? group.key,
value: group.calories,
}));
const sourceSlices = buildSourceSlices(stats.bySource);
return (
<>
<div className="summary-grid">
<div className="summary-tile accent">
<div className="summary-label"><FontAwesomeIcon icon={faFire} /> Snědeno</div>
<div className="summary-value">{formatCalories(stats.totalCalories)}</div>
<div className="summary-hint">
za {stats.dayCount} dní se zápisem
{calorieDays < stats.dayCount && `, kalorie má ${calorieDays}`}
</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faCalendarDay} /> Průměr na den</div>
<div className="summary-value">{formatCalories(averageIntake)}</div>
<div className="summary-hint">
{calorieDays ? `výdej v průměru ${formatCalories(averageBurn)}` : 'žádný den nemá kalorie'}
</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faScaleBalanced} /> Průměrná bilance</div>
<div className="summary-value">{averageBalance != null ? formatBalance(averageBalance) : '—'}</div>
<div className="summary-hint">
{!stats.hasBasal && 'jen jídlo proti pohybu, bez klidového výdeje'}
{stats.hasBasal && (stats.targetDailyBalance != null
? `cíl ${formatBalance(stats.targetDailyBalance)} denně`
: 'cíl není nastavený')}
</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faDrumstickBite} /> Bílkoviny na den</div>
<div className="summary-value">{formatNutrient(averageProtein)}</div>
<div className="summary-hint">
{stats.proteinTargetGrams != null
? `cíl ${formatNutrient(stats.proteinTargetGrams)}`
: `z ${stats.nutrientsDayCount} dní s živinami`}
</div>
</div>
</div>
{stats.missingCaloriesCount > 0 && (
<div className="card-panel intake-missing">
<FontAwesomeIcon icon={faTriangleExclamation} className="plan-note-icon" />
<span>
{stats.missingCaloriesCount} z {stats.entryCount} jídel nemá kalorie, do součtů nevstupují
a příjem tak vychází nižší, než byl.
</span>
<Button variant="outline-secondary" size="sm" onClick={() => navigate(DOPLNENI_JIDEL_URL)}>
<FontAwesomeIcon icon={faPenToSquare} /> Doplnit
</Button>
</div>
)}
<div className="card-panel chart-panel">
<h2 className="chart-title">Příjem v čase</h2>
<ResponsiveContainer width="100%" height={280}>
<ComposedChart data={series} margin={{ top: 12, 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 tick={{ fontSize: 12 }} stroke="var(--ft-text-muted)" width={56} />
<Tooltip content={<IntakeTooltip />} cursor={{ fill: 'var(--ft-bg-hover)' }} />
<Legend />
{mealTypes.map((mealType, index) => (
<Bar
key={mealType}
dataKey={mealType}
name={MEAL_TYPE_NAMES[mealType]}
stackId="prijem"
fill={MEAL_TYPE_COLORS[mealType]}
stroke="var(--ft-bg-card)"
strokeWidth={1}
radius={index === mealTypes.length - 1 ? [4, 4, 0, 0] : undefined}
/>
))}
<Line
dataKey="burn"
name="Výdej"
stroke="var(--ft-text-secondary)"
strokeWidth={2}
strokeDasharray="5 4"
dot={{ r: 2, fill: 'var(--ft-text-secondary)' }}
activeDot={false}
type="linear"
/>
{stats.targetDailyBalance != null && (
<Line
dataKey="target"
name="Cílový příjem"
stroke="var(--ft-primary)"
strokeWidth={2}
strokeDasharray="2 3"
dot={false}
activeDot={false}
type="linear"
/>
)}
</ComposedChart>
</ResponsiveContainer>
<p className="usage-note">
Výdej je klidový metabolismus a pohyb, jen za dny, kdy má aspoň jedno jídlo kalorie —
den bez nich by jinak vypadal jako velký deficit.
{!stats.hasBasal && ' Klidový výdej nemáte nastavený, čára ukazuje jen pohyb; doplníte ho v Profilu.'}
</p>
</div>
<div className="chart-row">
<div className="card-panel chart-panel">
<h2 className="chart-title">Podle chodu</h2>
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie data={pieData} dataKey="value" nameKey="name" innerRadius={50} outerRadius={85}>
{pieData.map(entry => (
<Cell key={entry.key} fill={MEAL_TYPE_COLORS[entry.key] ?? 'var(--ft-series-other)'} />
))}
</Pie>
<Legend />
<Tooltip formatter={value => formatCalories(Number(value))} contentStyle={TOOLTIP_STYLE} />
</PieChart>
</ResponsiveContainer>
</div>
<div className="card-panel chart-panel">
<h2 className="chart-title">Podle zdroje</h2>
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie data={sourceSlices} dataKey="value" nameKey="name" innerRadius={50} outerRadius={85}>
{sourceSlices.map(slice => <Cell key={slice.name} fill={slice.color} />)}
</Pie>
<Legend />
<Tooltip formatter={value => formatCalories(Number(value))} contentStyle={TOOLTIP_STYLE} />
</PieChart>
</ResponsiveContainer>
</div>
</div>
{stats.totalNutrients && (
<div className="card-panel chart-panel">
<h2 className="chart-title">Živiny — průměr na den</h2>
<table className="source-table nutrient-table">
<tbody>
{NUTRIENTS_IN_ORDER.map(key => (
<tr key={key} className={SUB_NUTRIENTS.has(key) ? 'nutrient-sub' : undefined}>
<td>{NUTRIENT_NAMES[key]}</td>
<td className="numeric">
{formatNutrient(stats.totalNutrients?.[key] != null
? stats.totalNutrients[key]! / stats.nutrientsDayCount
: undefined)}
</td>
</tr>
))}
</tbody>
</table>
<p className="usage-note">
Průměr z {stats.nutrientsDayCount} dní, kdy aspoň jedno jídlo mělo gramáž a nutriční
hodnoty. Jídla bez nich se nezapočítávají, skutečný příjem bývá vyšší.
</p>
</div>
)}
</>
);
};
export default IntakeStatsPanel;
+55
View File
@@ -0,0 +1,55 @@
import { TooltipContentProps } from "recharts";
import { formatBalance, formatCalories } from "../Utils";
import { TOOLTIP_STYLE } from "./statsShared";
import { IntakeSeriesRow } from "./intakeChartSeries";
/** Klíče řad, které nejsou chody — v bublině mají vlastní řádky. */
const LINE_KEYS = new Set(['burn', 'target']);
/** Bublina sloupce příjmu — díly po chodech, výdej a bilance za dny s kaloriemi. */
const IntakeTooltip = ({ active, payload, label }: Readonly<Partial<TooltipContentProps<number, string>>>) => {
if (!active || !payload?.length) return null;
const row = payload[0].payload as IntakeSeriesRow;
const parts = payload.filter(item => !LINE_KEYS.has(String(item.dataKey)) && Number(item.value) > 0);
return (
<div className="activity-tooltip" style={TOOLTIP_STYLE}>
<div className="activity-tooltip-title">{label}</div>
{!row.dayCount && <div className="activity-tooltip-empty">nic nezapsáno</div>}
{parts.map(item => (
<div key={String(item.dataKey)} className="activity-tooltip-row">
<span className="activity-tooltip-swatch" style={{ background: item.color }} />
<span>{item.name}</span>
<span className="activity-tooltip-value">{formatCalories(Number(item.value))}</span>
</div>
))}
{row.burn != null && (
<>
<div className="activity-tooltip-row activity-tooltip-plan">
<span className="activity-tooltip-swatch plan" />
<span>Výdej</span>
<span className="activity-tooltip-value">{formatCalories(row.burn)}</span>
</div>
<div className="activity-tooltip-row intake-tooltip-balance">
<span>Bilance</span>
<span className="activity-tooltip-value">{formatBalance(row.calories - row.burn)}</span>
</div>
</>
)}
{row.targetBalance != null && (
<div className="activity-tooltip-row activity-tooltip-plan">
<span>Cílová bilance</span>
<span className="activity-tooltip-value">{formatBalance(row.targetBalance)}</span>
</div>
)}
{row.dayCount > row.caloriesDayCount && (
<div className="activity-tooltip-empty">
{row.caloriesDayCount
? `bilance jen za ${row.caloriesDayCount} z ${row.dayCount} dní, ostatní nemají kalorie`
: 'jídla bez kalorií'}
</div>
)}
</div>
);
};
export default IntakeTooltip;
+35
View File
@@ -54,3 +54,38 @@
border-top: 1px solid var(--ft-border-light);
}
}
.meal-group {
.meal-group-actions {
display: flex;
gap: 6px;
flex: 0 0 auto;
}
.meal-group-days {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--ft-border-light);
}
.meal-group-days-title {
font-size: 0.8rem;
color: var(--ft-text-muted);
margin-bottom: 6px;
}
.meal-group-day {
text-decoration: none;
font-variant-numeric: tabular-nums;
svg {
font-size: 0.65rem;
opacity: 0.6;
}
&:hover {
background: var(--ft-primary-light);
color: var(--ft-primary);
}
}
}
+160
View File
@@ -0,0 +1,160 @@
import { useEffect, useMemo, useState } from "react";
import { Spinner } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCalendarDay, faChartColumn, faCoins, faListCheck, faReceipt } from "@fortawesome/free-solid-svg-icons";
import { Bar, BarChart, CartesianGrid, Cell, Legend, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { FoodStats, MealType, getFoodStats } from "../../../types";
import { MEAL_TYPE_NAMES, StatsRange } from "../enums";
import { formatPrice, formatPriceShort } from "../Utils";
import { StatsBounds, TOOLTIP_STYLE, formatPeriodLabel, pickPeriods } from "./statsShared";
type Props = {
range: StatsBounds;
mode: StatsRange;
/** Změna vynutí nové načtení — po importu. */
reloadKey: number;
};
/** Barvy výsečí grafu dle typu jídla — laděné do zelené palety aplikace. */
const PIE_COLORS = ['#16a34a', '#22c55e', '#4ade80', '#86efac', '#0ea5e9', '#6366f1', '#a3a3a3'];
/** Naformátuje hodnotu v bublině grafu na koruny. Hodnoty chodí z rechartsu jako `unknown`. */
const formatChartPrice = (value: unknown): string => `${Number(value).toLocaleString('cs-CZ')} Kč`;
/** Statistiky peněz za jídlo: kolik, kdy, za jaké chody a kde. */
const SpendingStatsPanel = ({ range, mode, reloadKey }: Readonly<Props>) => {
const [stats, setStats] = useState<FoodStats | undefined>();
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
getFoodStats({ query: range }).then(response => {
if (cancelled) return;
setStats(response.data);
setLoading(false);
});
return () => { cancelled = true; };
}, [range, reloadKey]);
/** Řada pro sloupcový graf — dny u měsíce, měsíce u roku, roky u celého přehledu. */
const series = useMemo(() => stats ? pickPeriods(stats, mode).map(total => ({
label: formatPeriodLabel(total.period, mode),
price: total.price / 100,
})) : [], [stats, mode]);
const pieData = useMemo(() => (stats?.byMealType ?? [])
.filter(total => total.price > 0)
.map(total => ({
name: MEAL_TYPE_NAMES[total.key as MealType] ?? total.key,
value: total.price / 100,
})), [stats]);
const averagePerDay = stats?.dayCount ? Math.round(stats.totalPrice / stats.dayCount) : 0;
const pricedCount = (stats?.byMealType ?? []).reduce((sum, total) => sum + (total.price > 0 ? total.count : 0), 0);
// Jídla bez ceny (vlastní, doma) by průměr na jídlo stahovala k nule
const averagePerMeal = pricedCount ? Math.round(stats!.totalPrice / pricedCount) : 0;
return (
<>
<div className="summary-grid">
<div className="summary-tile accent">
<div className="summary-label"><FontAwesomeIcon icon={faCoins} /> Celkem utraceno</div>
<div className="summary-value">{formatPrice(stats?.totalPrice ?? 0)}</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faCalendarDay} /> Průměr na den</div>
<div className="summary-value">{formatPrice(averagePerDay)}</div>
<div className="summary-hint">z {stats?.dayCount ?? 0} vykázaných dní</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faReceipt} /> Průměr na jídlo</div>
<div className="summary-value">{formatPrice(averagePerMeal)}</div>
<div className="summary-hint">jen jídla s cenou</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faListCheck} /> Jídel</div>
<div className="summary-value">{stats?.entryCount ?? 0}</div>
<div className="summary-hint">z toho placených {pricedCount}</div>
</div>
</div>
{loading && (
<div className="empty-state">
<Spinner animation="border" variant="success" />
</div>
)}
{!loading && !stats?.entryCount && (
<div className="card-panel empty-state">
<div className="empty-icon"><FontAwesomeIcon icon={faChartColumn} /></div>
<div className="empty-title">Za zvolené období nemáte žádná data</div>
<p className="empty-description">
Přidejte jídla v přehledu dne, nebo si sem naimportujte měsíční přehled z Luncheru.
</p>
</div>
)}
{!loading && !!stats?.entryCount && (
<>
<div className="card-panel chart-panel">
<h2 className="chart-title">Útrata v čase</h2>
<ResponsiveContainer width="100%" height={260}>
<BarChart data={series} 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 tick={{ fontSize: 12 }} stroke="var(--ft-text-muted)" width={56} />
<Tooltip
formatter={value => [formatChartPrice(value), 'Útrata']}
contentStyle={TOOLTIP_STYLE}
/>
<Bar dataKey="price" fill="var(--ft-primary)" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="chart-row">
<div className="card-panel chart-panel">
<h2 className="chart-title">Podle chodu</h2>
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie data={pieData} dataKey="value" nameKey="name" innerRadius={50} outerRadius={85}>
{pieData.map((entry, index) => (
<Cell key={entry.name} fill={PIE_COLORS[index % PIE_COLORS.length]} />
))}
</Pie>
<Legend />
<Tooltip formatter={value => formatChartPrice(value)} contentStyle={TOOLTIP_STYLE} />
</PieChart>
</ResponsiveContainer>
</div>
<div className="card-panel chart-panel">
<h2 className="chart-title">Kde nejvíc utrácíte</h2>
<table className="source-table">
<thead>
<tr>
<th>Zdroj</th>
<th className="numeric">Jídel</th>
<th className="numeric">Útrata</th>
</tr>
</thead>
<tbody>
{stats.bySource.slice(0, 8).map(total => (
<tr key={total.key}>
<td>{total.key}</td>
<td className="numeric">{total.count}</td>
<td className="numeric">{formatPriceShort(total.price)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
)}
</>
);
};
export default SpendingStatsPanel;
+28
View File
@@ -314,3 +314,31 @@
font-size: 0.8rem;
color: var(--ft-text-muted);
}
// Upozornění, že část jídel nemá kalorie a příjem vychází nižší
.intake-missing {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 16px;
padding: 12px 16px;
font-size: 0.9rem;
span {
flex: 1 1 240px;
}
}
.activity-tooltip .intake-tooltip-balance {
font-weight: 700;
margin-top: 2px;
}
.nutrient-table .nutrient-sub td {
color: var(--ft-text-secondary);
&:first-child {
padding-left: 16px;
}
}
+28 -163
View File
@@ -1,45 +1,40 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Spinner } from "react-bootstrap";
import { useMemo, useState } from "react";
import { Button } from "react-bootstrap";
import { useNavigate } from "react-router";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCoins, faFire, faListCheck, faCalendarDay, faFileImport, faChartColumn, faUtensils, faPersonRunning,
faPenToSquare,
faCoins, faFileImport, faPenToSquare, faPersonRunning, faUtensils,
} from "@fortawesome/free-solid-svg-icons";
import {
Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis, Legend,
} from "recharts";
import { FoodStats, MealType, getFoodStats } from "../../../types";
import { DAY_TAB_NAMES, DayTab, MEAL_TYPE_NAMES, StatsRange, STATS_RANGE_NAMES } from "../enums";
import {
formatCalories, formatMonth, formatPrice, formatPriceShort, getMonthEnd, getMonthStart,
} from "../Utils";
import { StatsRange, STATS_RANGE_NAMES, STATS_TAB_NAMES, StatsTab } from "../enums";
import { formatMonth, getMonthEnd, getMonthStart } from "../Utils";
import { DOPLNENI_JIDEL_URL } from "../routes";
import ImportModal from "../components/modals/ImportModal";
import PeriodPicker, { EARLIEST_YEAR } from "../components/PeriodPicker";
import ActivityStatsPanel from "./ActivityStatsPanel";
import { TOOLTIP_STYLE, formatPeriodLabel, pickPeriods } from "./statsShared";
import IntakeStatsPanel from "./IntakeStatsPanel";
import SpendingStatsPanel from "./SpendingStatsPanel";
import "./StatsPage.scss";
/** Barvy výsečí grafu dle typu jídla — laděné do zelené palety aplikace. */
const PIE_COLORS = ['#16a34a', '#22c55e', '#4ade80', '#86efac', '#0ea5e9', '#6366f1', '#a3a3a3'];
const TAB_ICONS: Record<StatsTab, typeof faCoins> = {
PRIJEM: faUtensils,
VYDEJ: faPersonRunning,
UTRATA: faCoins,
};
/** Naformátuje hodnotu v bublině grafu na koruny. Hodnoty chodí z rechartsu jako `unknown`. */
function formatChartPrice(value: unknown): string {
return `${Number(value).toLocaleString('cs-CZ')} Kč`;
}
/** Ikony záložek statistik — stejné dělení jako přehled dne. */
const TAB_ICONS = { [DayTab.PRIJEM]: faUtensils, [DayTab.VYDEJ]: faPersonRunning };
const TAB_SUBTITLES: Record<StatsTab, string> = {
PRIJEM: 'Kolik jste snědli, v čem a jak to vychází proti výdeji',
VYDEJ: 'Kolik jste spálili pohybem a jak se držíte šablon',
UTRATA: 'Kolik jste utratili za jídlo a za co',
};
export default function StatsPage() {
const [tab, setTab] = useState<DayTab>(DayTab.PRIJEM);
const [tab, setTab] = useState<StatsTab>(StatsTab.PRIJEM);
const [mode, setMode] = useState<StatsRange>(StatsRange.MESIC);
const [month, setMonth] = useState<string>(() => formatMonth(new Date()));
const [year, setYear] = useState<number>(() => new Date().getFullYear());
const [stats, setStats] = useState<FoodStats | undefined>();
const [loading, setLoading] = useState(true);
const [importOpen, setImportOpen] = useState(false);
/** Zvyšuje se po importu, aby si panely jídel načetly data znovu. */
const [reloadKey, setReloadKey] = useState(0);
const navigate = useNavigate();
/** Přepočte hranice období z aktuálně zvoleného rozsahu. */
@@ -53,44 +48,14 @@ export default function StatsPage() {
return { from: `${EARLIEST_YEAR}-01-01`, to: `${new Date().getFullYear()}-12-31` };
}, [mode, month, year]);
const load = useCallback(async () => {
setLoading(true);
const response = await getFoodStats({ query: range });
setStats(response.data);
setLoading(false);
}, [range]);
// Výdej si data načítá sám ve svém panelu
useEffect(() => { if (tab === DayTab.PRIJEM) load(); }, [load, tab]);
/** Řada pro sloupcový graf — dny u měsíce, měsíce u roku, roky u celého přehledu. */
const series = useMemo(() => stats ? pickPeriods(stats, mode).map(total => ({
label: formatPeriodLabel(total.period, mode),
price: total.price / 100,
calories: total.calories,
})) : [], [stats, mode]);
const pieData = useMemo(() => (stats?.byMealType ?? [])
.filter(total => total.price > 0)
.map(total => ({
name: MEAL_TYPE_NAMES[total.key as MealType] ?? total.key,
value: total.price / 100,
})), [stats]);
const averagePerDay = stats?.dayCount ? Math.round(stats.totalPrice / stats.dayCount) : 0;
return (
<>
<div className="page-header">
<div>
<h1>Statistiky</h1>
<p className="page-subtitle">
{tab === DayTab.PRIJEM
? 'Kolik jste utratili za jídlo a za co'
: 'Kolik jste spálili pohybem a jak se držíte šablon'}
</p>
<p className="page-subtitle">{TAB_SUBTITLES[tab]}</p>
</div>
{tab === DayTab.PRIJEM && (
{tab !== StatsTab.VYDEJ && (
<div className="d-flex flex-wrap gap-2">
<Button variant="outline-secondary" onClick={() => navigate(DOPLNENI_JIDEL_URL)}>
<FontAwesomeIcon icon={faPenToSquare} /> Doplnit údaje jídel
@@ -104,13 +69,13 @@ export default function StatsPage() {
<div className="range-picker card-panel">
<div className="range-modes">
{Object.values(DayTab).map(value => (
{Object.values(StatsTab).map(value => (
<button
key={value}
className={`range-mode ${tab === value ? 'active' : ''}`}
onClick={() => setTab(value)}
>
<FontAwesomeIcon icon={TAB_ICONS[value]} /> {DAY_TAB_NAMES[value]}
<FontAwesomeIcon icon={TAB_ICONS[value]} /> {STATS_TAB_NAMES[value]}
</button>
))}
</div>
@@ -128,114 +93,14 @@ export default function StatsPage() {
<PeriodPicker mode={mode} month={month} year={year} onMonthChange={setMonth} onYearChange={setYear} />
</div>
{tab === DayTab.VYDEJ && <ActivityStatsPanel range={range} mode={mode} />}
{tab === DayTab.PRIJEM && (
<>
<div className="summary-grid">
<div className="summary-tile accent">
<div className="summary-label"><FontAwesomeIcon icon={faCoins} /> Celkem utraceno</div>
<div className="summary-value">{formatPrice(stats?.totalPrice ?? 0)}</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faCalendarDay} /> Průměr na den</div>
<div className="summary-value">{formatPrice(averagePerDay)}</div>
<div className="summary-hint">z {stats?.dayCount ?? 0} vykázaných dní</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faListCheck} /> Jídel</div>
<div className="summary-value">{stats?.entryCount ?? 0}</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faFire} /> Kalorie</div>
<div className="summary-value">{formatCalories(stats?.totalCalories)}</div>
</div>
</div>
{loading && (
<div className="empty-state">
<Spinner animation="border" variant="success" />
</div>
)}
{!loading && !stats?.entryCount && (
<div className="card-panel empty-state">
<div className="empty-icon"><FontAwesomeIcon icon={faChartColumn} /></div>
<div className="empty-title">Za zvolené období nemáte žádná data</div>
<p className="empty-description">
Přidejte jídla v přehledu dne, nebo si sem naimportujte měsíční přehled z Luncheru.
</p>
</div>
)}
{!loading && !!stats?.entryCount && (
<>
<div className="card-panel chart-panel">
<h2 className="chart-title">Útrata v čase</h2>
<ResponsiveContainer width="100%" height={260}>
<BarChart data={series} 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 tick={{ fontSize: 12 }} stroke="var(--ft-text-muted)" width={56} />
<Tooltip
formatter={value => [formatChartPrice(value), 'Útrata']}
contentStyle={TOOLTIP_STYLE}
/>
<Bar dataKey="price" fill="var(--ft-primary)" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="chart-row">
<div className="card-panel chart-panel">
<h2 className="chart-title">Podle chodu</h2>
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie data={pieData} dataKey="value" nameKey="name" innerRadius={50} outerRadius={85}>
{pieData.map((entry, index) => (
<Cell key={entry.name} fill={PIE_COLORS[index % PIE_COLORS.length]} />
))}
</Pie>
<Legend />
<Tooltip
formatter={value => formatChartPrice(value)}
contentStyle={TOOLTIP_STYLE}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="card-panel chart-panel">
<h2 className="chart-title">Kde nejvíc utrácíte</h2>
<table className="source-table">
<thead>
<tr>
<th>Zdroj</th>
<th className="numeric">Jídel</th>
<th className="numeric">Útrata</th>
</tr>
</thead>
<tbody>
{stats.bySource.slice(0, 8).map(total => (
<tr key={total.key}>
<td>{total.key}</td>
<td className="numeric">{total.count}</td>
<td className="numeric">{formatPriceShort(total.price)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
)}
</>
)}
{tab === StatsTab.PRIJEM && <IntakeStatsPanel range={range} mode={mode} reloadKey={reloadKey} />}
{tab === StatsTab.VYDEJ && <ActivityStatsPanel range={range} mode={mode} />}
{tab === StatsTab.UTRATA && <SpendingStatsPanel range={range} mode={mode} reloadKey={reloadKey} />}
<ImportModal
isOpen={importOpen}
onClose={() => setImportOpen(false)}
onImported={load}
onImported={() => setReloadKey(key => key + 1)}
/>
</>
);
+3 -32
View File
@@ -1,7 +1,7 @@
import { ActivityDayStats, PlanDeviationKind } from "../../../types";
import { StatsRange } from "../enums";
import { formatDateString, shiftDate } from "../Utils";
import { StatsBounds, formatPeriodLabel } from "./statsShared";
import { formatDateString } from "../Utils";
import { StatsBounds, formatPeriodLabel, listPeriods, periodOf } from "./statsShared";
/** Počet barev kategoriální palety (`--ft-series-1` až `-8`). Další šablony se slijí do "Ostatní šablony". */
export const SERIES_COLOR_COUNT = 8;
@@ -65,35 +65,6 @@ export function buildTemplateSeries(allTemplateIds: string[], used: Map<string,
return { series, keyOf };
}
/**
* Vrátí označení všech období rozsahu, i těch bez pohybu — prázdný den v grafu
* je informace sama o sobě.
*
* U "Vše" se začíná prvním rokem s pohybem, jinak by graf otevíralo deset let nul.
*/
function listPeriods(range: StatsBounds, mode: StatsRange, days: ActivityDayStats[]): string[] {
if (mode === StatsRange.MESIC) {
const periods: string[] = [];
for (let date = range.from; date <= range.to; date = shiftDate(date, 1)) periods.push(date);
return periods;
}
if (mode === StatsRange.ROK) {
const year = range.from.substring(0, 4);
return Array.from({ length: 12 }, (_, index) => `${year}-${String(index + 1).padStart(2, '0')}`);
}
if (!days.length) return [];
const first = Number(days[0].date.substring(0, 4));
const last = Number(range.to.substring(0, 4));
return Array.from({ length: last - first + 1 }, (_, index) => String(first + index));
}
/** Zkrátí datum dne na označení období grafu. */
const periodOf = (date: string, mode: StatsRange): string => {
if (mode === StatsRange.MESIC) return date;
if (mode === StatsRange.ROK) return date.substring(0, 7);
return date.substring(0, 4);
};
/**
* Sestaví sloupce grafu výdeje: každá šablona vlastní díl sloupce, nad ní
* aktivity mimo šablony, čára plánu a poznámky o nesplněných cvicích.
@@ -106,7 +77,7 @@ const periodOf = (date: string, mode: StatsRange): string => {
export function buildActivitySeries(
days: ActivityDayStats[], range: StatsBounds, mode: StatsRange, keyOf: (templateId: string) => string,
): ActivitySeriesRow[] {
const rows = new Map<string, ActivitySeriesRow>(listPeriods(range, mode, days).map(period => [period, {
const rows = new Map<string, ActivitySeriesRow>(listPeriods(range, mode, days[0]?.date).map(period => [period, {
label: formatPeriodLabel(period, mode), extra: 0, planned: 0, notes: [],
}]));
+96
View File
@@ -0,0 +1,96 @@
import { IntakeGroupTotal, IntakeStats, MealType } from "../../../types";
import { MEAL_TYPES_IN_ORDER, StatsRange } from "../enums";
import { StatsBounds, formatPeriodLabel, listPeriods, pickPeriods } from "./statsShared";
/**
* Barva chodu v grafech příjmu. Pořadí sleduje průběh dne, takže sousední
* chody mají od sebe dobře odlišitelné barvy palety.
*/
export const MEAL_TYPE_COLORS: Record<MealType, string> = {
SNIDANE: 'var(--ft-series-4)',
DOPOLEDNI_SVACINA: 'var(--ft-series-5)',
OBED: 'var(--ft-series-1)',
ODPOLEDNI_SVACINA: 'var(--ft-series-3)',
VECERE: 'var(--ft-series-7)',
PULNOCNI_SVACINA: 'var(--ft-series-2)',
JINE: 'var(--ft-series-other)',
};
/** Jeden sloupec grafu příjmu — kcal po chodech pod klíčem chodu (`OBED`, ...). */
export type IntakeSeriesRow = {
label: string;
calories: number;
/** Výdej dní s kaloriemi; v období bez nich chybí, čára se tam přeruší místo pádu na nulu. */
burn: number | null;
/** Výdej posunutý o cílovou bilanci — kolik se mělo sníst; jen s nastaveným cílem. */
target: number | null;
/** Cílová bilance za dny s kaloriemi. */
targetBalance: number | null;
dayCount: number;
caloriesDayCount: number;
} & Partial<Record<MealType, number>>;
/**
* Sestaví sloupce grafu příjmu: každý chod vlastní díl sloupce, nad nimi čára
* výdeje a případně cílového příjmu.
*
* Období bez zapsaného jídla zůstávají v grafu jako prázdná, ale výdej v nich
* chybí — nezapsaný den (nebo den s jídly bez kalorií) není den, kdy se nejedlo.
*
* @param stats statistiky příjmu ze serveru
* @param range zobrazené období
* @param mode rozsah statistik
*/
export const buildIntakeSeries = (stats: IntakeStats, range: StatsBounds, mode: StatsRange): IntakeSeriesRow[] => {
const totalMap = new Map(pickPeriods(stats, mode).map(total => [total.period, total]));
return listPeriods(range, mode, stats.byDay[0]?.period).map(period => {
const total = totalMap.get(period);
const row: IntakeSeriesRow = {
label: formatPeriodLabel(period, mode),
calories: total?.calories ?? 0,
burn: null,
target: null,
targetBalance: null,
dayCount: total?.dayCount ?? 0,
caloriesDayCount: total?.caloriesDayCount ?? 0,
};
if (!total?.caloriesDayCount) return row;
row.burn = total.burnCalories;
if (stats.targetDailyBalance != null) {
row.targetBalance = stats.targetDailyBalance * total.caloriesDayCount;
row.target = total.burnCalories + row.targetBalance;
}
for (const group of total.byMealType) {
row[group.key as MealType] = group.calories;
}
return row;
});
};
/** Kolik zdrojů má v koláči vlastní výseč; zbytek se slije do "Ostatní". */
const SOURCE_SLICE_COUNT = 7;
/** Výseč koláče zdrojů. */
export type SourceSlice = { name: string, value: number, color: string };
/**
* Sestaví výseče koláče příjmu dle zdroje. Barva jde podle pořadí ve výběru,
* zdrojů bývá hodně a pevná barva pro každý by paletu nestačila.
*
* @param bySource příjem dle zdroje, sestupně dle kcal
*/
export const buildSourceSlices = (bySource: IntakeGroupTotal[]): SourceSlice[] => {
const slices = bySource
.filter(group => group.calories > 0)
.slice(0, SOURCE_SLICE_COUNT)
.map((group, index) => ({ name: group.key, value: group.calories, color: `var(--ft-series-${index + 1})` }));
const rest = bySource.slice(SOURCE_SLICE_COUNT).reduce((sum, group) => sum + group.calories, 0);
if (rest > 0) slices.push({ name: 'Ostatní', value: rest, color: 'var(--ft-series-other)' });
return slices;
};
/** Chody, které v období něco dodaly, v pořadí průběhu dne — pořadí dílů sloupce i legendy. */
export const getUsedMealTypes = (stats: IntakeStats): MealType[] => {
const used = new Set(stats.byMealType.map(group => group.key));
return MEAL_TYPES_IN_ORDER.filter(mealType => used.has(mealType));
};
+34 -1
View File
@@ -1,5 +1,5 @@
import { StatsRange } from "../enums";
import { formatDateString, getHumanMonth } from "../Utils";
import { formatDateString, getHumanMonth, shiftDate } from "../Utils";
/** Vzhled bubliny s hodnotou — recharts ji kreslí inline stylem, ne přes CSS třídu. */
export const TOOLTIP_STYLE = {
@@ -30,3 +30,36 @@ export function pickPeriods<T>(stats: { byDay: T[], byMonth: T[], byYear: T[] },
if (mode === StatsRange.ROK) return stats.byMonth;
return stats.byYear;
}
/**
* Vrátí označení všech období rozsahu, i těch bez dat — prázdný den v grafu
* je informace sama o sobě.
*
* U "Vše" se začíná rokem prvního dne s daty, jinak by graf otevíralo deset let nul.
*
* @param range zobrazené období
* @param mode rozsah statistik
* @param firstDate první den s daty, YYYY-MM-DD; bez něj je u "Vše" výsledek prázdný
*/
export const listPeriods = (range: StatsBounds, mode: StatsRange, firstDate?: string): string[] => {
if (mode === StatsRange.MESIC) {
const periods: string[] = [];
for (let date = range.from; date <= range.to; date = shiftDate(date, 1)) periods.push(date);
return periods;
}
if (mode === StatsRange.ROK) {
const year = range.from.substring(0, 4);
return Array.from({ length: 12 }, (_, index) => `${year}-${String(index + 1).padStart(2, '0')}`);
}
if (!firstDate) return [];
const first = Number(firstDate.substring(0, 4));
const last = Number(range.to.substring(0, 4));
return Array.from({ length: last - first + 1 }, (_, index) => String(first + index));
};
/** Zkrátí datum dne na označení období grafu. */
export const periodOf = (date: string, mode: StatsRange): string => {
if (mode === StatsRange.MESIC) return date;
if (mode === StatsRange.ROK) return date.substring(0, 7);
return date.substring(0, 4);
};
+6
View File
@@ -9,3 +9,9 @@ export const STATISTIKY_URL = '/statistiky';
export const SABLONY_URL = '/sablony';
export const PROFIL_URL = '/profil';
export const DOPLNENI_JIDEL_URL = '/statistiky/doplneni';
/** Parametr adresy přehledu dne s datem YYYY-MM-DD. */
export const DAY_DATE_PARAM = 'datum';
/** Adresa přehledu konkrétního dne — pro odkazy, které se otevírají v nové záložce. */
export const getDayUrl = (date: string): string => `${DEN_URL}?${DAY_DATE_PARAM}=${date}`;