fix: graf cena per zdroj nahrazen koláčem

feat: stránka doplnit údaje předělána na samostatnou stránku jídel + přidáno přejmenování
This commit is contained in:
Ondřej Anděl committed 2026-10-12 09:49:41 +02:00
1 parent b880b36edd
commit efed5e6e86
30 files changed
+514 -153

No files matched your search

+2 -2
View File
@@ -8,7 +8,7 @@ import StatsPage from "./pages/StatsPage";
import TemplatesPage from "./pages/TemplatesPage";
import ProfilePage from "./pages/ProfilePage";
import MealGroupsPage from "./pages/MealGroupsPage";
import { DEN_URL, DOPLNENI_JIDEL_URL, PROFIL_URL, SABLONY_URL, STATISTIKY_URL } from "./routes";
import { DEN_URL, JIDLA_URL, PROFIL_URL, SABLONY_URL, STATISTIKY_URL } from "./routes";
export default function AppRoutes() {
const auth = useAuth();
@@ -23,10 +23,10 @@ export default function AppRoutes() {
<div className="wrapper">
<Routes>
<Route path={DEN_URL} element={<DayPage />} />
<Route path={JIDLA_URL} element={<MealGroupsPage />} />
<Route path={STATISTIKY_URL} element={<StatsPage />} />
<Route path={SABLONY_URL} element={<TemplatesPage />} />
<Route path={PROFIL_URL} element={<ProfilePage />} />
<Route path={DOPLNENI_JIDEL_URL} element={<MealGroupsPage />} />
<Route path="*" element={<Navigate to={DEN_URL} replace />} />
</Routes>
</div>
+7
View File
@@ -342,6 +342,13 @@ export const deriveRecipe = (
export const normalizeSearch = (value: string): string =>
value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim();
/**
* Klíč jídla podle názvu a zdroje. Stejnojmenné jídlo z jiného zdroje je jiné
* jídlo — jinde se vaří, nandává i účtuje jinak — a nesmí se s ním slučovat.
*/
export const getMealKey = (meal: { name: string, source?: string }): string =>
`${normalizeSearch(meal.name)}|${normalizeSearch(meal.source ?? '')}`;
/** Popíše recept jednou řádkou — zdroj a hodnoty jedné porce, co je vyplněné. */
export const describeMealTemplate = (template: MealTemplate): string => [
template.source,
+5 -3
View File
@@ -12,7 +12,7 @@ type Props = {
/**
* 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.
* stejného názvu a zdroje, která je nemají — typicky naimportovaných z Luncheru.
* Když takové jídlo není, nic se nezobrazí.
*/
const FillSameNameOffer = ({ entry, onFilled, onDismiss }: Readonly<Props>) => {
@@ -27,6 +27,8 @@ const FillSameNameOffer = ({ entry, onFilled, onDismiss }: Readonly<Props>) => {
if (!preview?.count) return null;
const label = entry.source ? `„${entry.name}“ z ${entry.source}` : `„${entry.name}“`;
const fill = async () => {
setBusy(true);
const response = await fillSameNameMeals({ body: { id: entry.id, date: entry.date } });
@@ -43,8 +45,8 @@ const FillSameNameOffer = ({ entry, onFilled, onDismiss }: Readonly<Props>) => {
>
<span className="me-auto">
{preview.count === 1
? `Další jídlo „${entry.name}“ nemá kalorie.`
: `Další jídla „${entry.name}“ (${preview.count}) nemají kalorie.`}
? `Další jídlo ${label} nemá kalorie.`
: `Další jídla ${label} (${preview.count}) nemají kalorie.`}
{' '}Doplnit stejné hodnoty?
</span>
<Button size="sm" onClick={() => fill()} disabled={busy}>Doplnit</Button>
+3 -2
View File
@@ -5,12 +5,13 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
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";
import { DEN_URL, JIDLA_URL, PROFIL_URL, SABLONY_URL, STATISTIKY_URL } from "../routes";
import SettingsModal from "./modals/SettingsModal";
/** Položky hlavní navigace. */
const NAV_ITEMS = [
{ url: DEN_URL, label: 'Můj den' },
{ url: JIDLA_URL, label: 'Jídla' },
{ url: SABLONY_URL, label: 'Šablony' },
{ url: STATISTIKY_URL, label: 'Statistiky' },
{ url: PROFIL_URL, label: 'Profil' },
@@ -25,7 +26,7 @@ export default function Header() {
const effectiveDark = settings?.effectiveDark ?? false;
/** Podstránka (doplnění údajů ve statistikách) nechá zvýrazněnou svou nadřazenou položku. */
/** Případná podstránka nechá zvýrazněnou svou nadřazenou položku. */
const isActive = (url: string) =>
location.pathname === url || (url !== DEN_URL && location.pathname.startsWith(`${url}/`));
@@ -0,0 +1,98 @@
import { useState } from "react";
import { Alert, Button, Form } from "react-bootstrap";
import { toast } from "react-toastify";
import { MealGroup, renameMealGroup } from "../../../types";
import { getMealKey } from "../Utils";
import SourceInput from "./SourceInput";
type Props = {
group: MealGroup,
/** Ostatní skupiny — podle nich se pozná, že se jídla po přejmenování s některou sloučí. */
groupList: MealGroup[],
onRenamed: () => void,
onCancel: () => void,
};
/** Popisek výsledku přejmenování se skloňovaným počtem jídel. */
const describeRenamed = (count: number): string => {
if (!count) return 'Název i zdroj už jídla mají, nic se nezměnilo.';
if (count === 1) return 'Změněno 1 jídlo.';
if (count < 5) return `Změněna ${count} jídla.`;
return `Změněno ${count} jídel.`;
};
/**
* Hromadná změna názvu a zdroje všech jídel jedné skupiny ve všech dnech.
*
* Na rozdíl od doplnění údajů přepisuje, proto je to samostatná akce. Když nový
* název a zdroj patří jiné skupině, jídla se do ní sloučí — formulář na to upozorní.
*/
const MealGroupRenameForm = ({ group, groupList, onRenamed, onCancel }: Readonly<Props>) => {
const [name, setName] = useState(group.name);
const [source, setSource] = useState(group.source ?? '');
const [saving, setSaving] = useState(false);
const targetKey = getMealKey({ name, source });
const mergeGroup = targetKey !== getMealKey(group)
? groupList.find(item => getMealKey(item) === targetKey)
: undefined;
const unchanged = name.trim() === group.name && source.trim() === (group.source ?? '');
const canSave = !!name.trim().length && !unchanged && !saving;
const save = async () => {
if (!canSave) return;
setSaving(true);
const response = await renameMealGroup({
body: {
name: group.name,
source: group.source,
newName: name.trim(),
...(source.trim().length ? { newSource: source.trim() } : {}),
},
});
setSaving(false);
if (response.data) {
toast.success(describeRenamed(response.data.count));
onRenamed();
}
};
return (
<Form className="meal-group-form" onSubmit={event => { event.preventDefault(); save(); }}>
<p className="text-body-secondary small">
{group.count === 1
? 'Změní se jen u tohoto jídla.'
: `Změní se u všech ${group.count} jídel ve všech dnech, kdy se jedla.`} Ostatní údaje zůstanou.
</p>
<div className="row g-3 mb-3">
<div className="col-sm-7">
<Form.Label>Název</Form.Label>
<Form.Control
autoFocus
value={name}
isInvalid={!name.trim().length}
onChange={event => setName(event.target.value)}
/>
</div>
<div className="col-sm-5">
<Form.Label>Zdroj</Form.Label>
<SourceInput value={source} placeholder="bez zdroje" onChange={value => setSource(value)} />
</div>
</div>
{mergeGroup && (
<Alert variant="warning" className="py-2 small">
Jídlo „{mergeGroup.name}“{mergeGroup.source ? ` (${mergeGroup.source})` : ' bez zdroje'} už
v seznamu je ({mergeGroup.count}×), jídla se s ním sloučí.
</Alert>
)}
<div className="d-flex justify-content-end gap-2">
<Button variant="outline-secondary" onClick={() => onCancel()}>Zrušit</Button>
<Button type="submit" disabled={!canSave}>
{saving ? 'Ukládám…' : 'Změnit u všech jídel'}
</Button>
</div>
</Form>
);
};
export default MealGroupRenameForm;
+42 -12
View File
@@ -1,17 +1,23 @@
import { useState } from "react";
import { Button } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCalendarDays, faPenToSquare } from "@fortawesome/free-solid-svg-icons";
import { faCalendarDays, faPen, faPenToSquare } from "@fortawesome/free-solid-svg-icons";
import { MealGroup } from "../../../types";
import { MealGroupAction } from "../enums";
import { formatDateString } from "../Utils";
import MealGroupForm from "./MealGroupForm";
import MealGroupRenameForm from "./MealGroupRenameForm";
import MealGroupDays from "./MealGroupDays";
type Props = {
group: MealGroup,
expanded: boolean,
onToggle: () => void,
onFilled: () => void,
/** Všechny skupiny — přejmenování podle nich pozná sloučení s jinou. */
groupList: MealGroup[],
/** Rozbalená akce; nejvýš jeden řádek seznamu má nějakou otevřenou. */
action?: MealGroupAction,
onAction: (action?: MealGroupAction) => void,
/** Zavolá se po doplnění nebo přejmenování, aby se seznam načetl znovu. */
onChanged: () => void,
};
/** Kolik jídel skupiny daný údaj nemá — "u všech", nebo "3 z 5". */
@@ -20,11 +26,12 @@ 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,
* a dny, kdy se jídlo jedlo, jako odkazy na přehled dne.
* Nabízí doplnění údajů a změnu názvu a zdroje všem jídlům skupiny a dny,
* kdy se jídlo jedlo, jako odkazy na přehled dne.
*/
const MealGroupRow = ({ group, expanded, onToggle, onFilled }: Readonly<Props>) => {
const MealGroupRow = ({ group, groupList, action, onAction, onChanged }: Readonly<Props>) => {
const [daysOpen, setDaysOpen] = useState(false);
const toggle = (value: MealGroupAction) => onAction(action === value ? undefined : value);
return (
<div className="card-panel meal-group">
<div className="meal-group-head">
@@ -51,7 +58,7 @@ const MealGroupRow = ({ group, expanded, onToggle, onFilled }: Readonly<Props>)
</div>
</div>
<div className="meal-group-actions">
{!expanded && (
{!action && (
<Button
variant="outline-secondary"
size="sm"
@@ -61,13 +68,36 @@ const MealGroupRow = ({ group, expanded, onToggle, onFilled }: Readonly<Props>)
<FontAwesomeIcon icon={faCalendarDays} /> Dny
</Button>
)}
<Button variant={expanded ? 'outline-secondary' : 'primary'} size="sm" onClick={() => onToggle()}>
<FontAwesomeIcon icon={faPenToSquare} /> {expanded ? 'Zavřít' : 'Doplnit'}
<Button
variant="outline-secondary"
size="sm"
active={action === MealGroupAction.PREJMENOVAT}
title="Změnit název a zdroj u všech jídel skupiny"
onClick={() => toggle(MealGroupAction.PREJMENOVAT)}
>
<FontAwesomeIcon icon={faPen} /> Přejmenovat
</Button>
<Button
variant={action === MealGroupAction.DOPLNIT ? 'outline-secondary' : 'primary'}
size="sm"
onClick={() => toggle(MealGroupAction.DOPLNIT)}
>
<FontAwesomeIcon icon={faPenToSquare} /> {action === MealGroupAction.DOPLNIT ? 'Zavřít' : 'Doplnit'}
</Button>
</div>
</div>
{(expanded || daysOpen) && <MealGroupDays days={group.days} />}
{expanded && <MealGroupForm group={group} onFilled={onFilled} onCancel={() => onToggle()} />}
{(action === MealGroupAction.DOPLNIT || (!action && daysOpen)) && <MealGroupDays days={group.days} />}
{action === MealGroupAction.DOPLNIT && (
<MealGroupForm group={group} onFilled={onChanged} onCancel={() => onAction(undefined)} />
)}
{action === MealGroupAction.PREJMENOVAT && (
<MealGroupRenameForm
group={group}
groupList={groupList}
onRenamed={onChanged}
onCancel={() => onAction(undefined)}
/>
)}
</div>
);
};
+2 -2
View File
@@ -1,7 +1,7 @@
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";
import { describeMealSuggestion, describeMealTemplate, getMealKey, normalizeSearch } from "../Utils";
type Props = {
recipes: MealTemplate[],
@@ -54,7 +54,7 @@ const MealSuggestions = ({ recipes, history, query, onPickRecipe, onPickHistory
<button
type="button"
className="meal-suggestion"
key={item.name}
key={getMealKey(item)}
onClick={() => onPickHistory(item)}
title="Vyplnit hodnoty z posledního záznamu"
>
@@ -7,7 +7,7 @@ import {
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 { describeMealSuggestion, describeMealTemplate, getMealKey, normalizeSearch, parseMultiplierInput } from "../../Utils";
import { SABLONY_URL } from "../../routes";
type Props = {
@@ -188,7 +188,7 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) =
<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-row" key={getMealKey(item)}>
<div className="workout-info">
<div className="workout-name">{item.name}</div>
<div className="workout-items">
+2 -2
View File
@@ -7,7 +7,7 @@ import {
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES, NUTRIENTS_IN_ORDER } from "../../enums";
import {
applyMultiplier, deriveAmounts, formatCalories, formatMultiplier, formatMultiplierInput, formatPriceInput,
formatWeight, normalizeSearch, parseIntInput, parseMultiplierInput, parseNutrientInput,
formatWeight, getMealKey, parseIntInput, parseMultiplierInput, parseNutrientInput,
parsePriceInput,
} from "../../Utils";
import CalorieLookup from "../CalorieLookup";
@@ -253,7 +253,7 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
recipes={recipes}
// Upravované jídlo samo sebe nenabízí
history={entry
? history.filter(item => normalizeSearch(item.name) !== normalizeSearch(entry.name))
? history.filter(item => getMealKey(item) !== getMealKey(entry))
: history}
query={form.name}
onPickRecipe={recipe => applyRecipe(recipe)}
@@ -5,7 +5,7 @@ import {
} from "../../../../types";
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums";
import {
deriveAmounts, formatCalories, formatMultiplier, formatPrice, formatPriceInput, formatWeight, normalizeSearch,
deriveAmounts, formatCalories, formatMultiplier, formatPrice, formatPriceInput, formatWeight, getMealKey,
parseIntInput, parsePriceInput,
} from "../../Utils";
import MealSplitRow from "../MealSplitRow";
@@ -273,8 +273,7 @@ const MealSplitModal = ({ isOpen, entry, onClose, onSaved }: Readonly<Props>) =>
<MealSuggestions
recipes={recipes}
// Rozdělované jídlo samo sebe nenabízí
history={history.filter(item =>
normalizeSearch(item.name) !== normalizeSearch(entry.name))}
history={history.filter(item => getMealKey(item) !== getMealKey(entry))}
query={split.name}
onPickRecipe={recipe => applyRecipe(recipe)}
onPickHistory={suggestion => applyHistory(suggestion)}
+12
View File
@@ -176,3 +176,15 @@ export const IMPORT_MODE_NAMES: Record<ImportMode, string> = {
/** Předvolby násobku porce nabízené v přehledu dne. */
export const MULTIPLIER_PRESETS = [0.5, 1, 1.5, 2, 3];
/** Akce nad skupinou jídel v seznamu jídel — doplnění jen připisuje, přejmenování přepisuje. */
export enum MealGroupAction {
DOPLNIT = 'DOPLNIT',
PREJMENOVAT = 'PREJMENOVAT',
}
/** Zvláštní volby filtru zdroje v seznamu jídel; jinak se filtruje konkrétním zdrojem. */
export enum SourceFilter {
VSE = 'VSE',
BEZ_ZDROJE = 'BEZ_ZDROJE',
}
+5 -5
View File
@@ -11,9 +11,9 @@ import {
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 { JIDLA_URL } from "../routes";
import { StatsBounds, TOOLTIP_STYLE, buildSourceSlices } from "./statsShared";
import { MEAL_TYPE_COLORS, buildIntakeSeries, getUsedMealTypes } from "./intakeChartSeries";
import IntakeTooltip from "./IntakeTooltip";
type Props = {
@@ -82,7 +82,7 @@ const IntakeStatsPanel = ({ range, mode, reloadKey }: Readonly<Props>) => {
name: MEAL_TYPE_NAMES[group.key as MealType] ?? group.key,
value: group.calories,
}));
const sourceSlices = buildSourceSlices(stats.bySource);
const sourceSlices = buildSourceSlices(stats.bySource, group => group.calories);
return (
<>
@@ -130,7 +130,7 @@ const IntakeStatsPanel = ({ range, mode, reloadKey }: Readonly<Props>) => {
{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)}>
<Button variant="outline-secondary" size="sm" onClick={() => navigate(JIDLA_URL, { state: { onlyIncomplete: true } })}>
<FontAwesomeIcon icon={faPenToSquare} /> Doplnit
</Button>
</div>
+4
View File
@@ -10,6 +10,10 @@
flex: 1 1 240px;
}
.meal-groups-source {
flex: 0 1 220px;
}
.form-check {
margin: 0;
white-space: nowrap;
+64 -28
View File
@@ -1,33 +1,58 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Form, Spinner } from "react-bootstrap";
import { useNavigate } from "react-router";
import { Form, Spinner } from "react-bootstrap";
import { useLocation } from "react-router";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowLeft, faCircleCheck } from "@fortawesome/free-solid-svg-icons";
import { faCircleCheck } from "@fortawesome/free-solid-svg-icons";
import { MealGroup, getMealGroups } from "../../../types";
import { normalizeSearch } from "../Utils";
import { STATISTIKY_URL } from "../routes";
import { MealGroupAction, SourceFilter } from "../enums";
import { getMealKey, normalizeSearch } from "../Utils";
import MealGroupRow from "../components/MealGroupRow";
import "./MealGroupsPage.scss";
/** Skupina se pozná podle názvu a zdroje, porovnávaných stejně jako na serveru. */
const getGroupKey = (group: MealGroup): string =>
`${normalizeSearch(group.name)}|${normalizeSearch(group.source ?? '')}`;
const isIncomplete = (group: MealGroup): boolean =>
group.missingCaloriesCount + group.missingWeightCount + group.missingNutrientsCount > 0;
/** Hodnota výběru pro konkrétní zdroj; předpona ji odliší od zvláštních voleb filtru. */
const toSourceOption = (source: string): string => `zdroj:${normalizeSearch(source)}`;
/**
* Hromadné doplnění údajů ke starším jídlům. Jídla se stejným názvem a zdrojem
* tvoří skupinu a chybějící kalorie, gramáž nebo živiny se jim doplní najednou,
* bez procházení jednotlivých dnů.
* Zdroje skupin pro filtr, každý jednou, od nejčastěji jedeného. Zápis je
* z nejčastější skupiny — seznam je seřazený od nejčastějších.
*/
const listSources = (groupList: MealGroup[]): { value: string, label: string }[] => {
const countMap = new Map<string, { label: string, count: number }>();
for (const group of groupList) {
if (!group.source) continue;
const value = toSourceOption(group.source);
const existing = countMap.get(value);
countMap.set(value, { label: existing?.label ?? group.source, count: (existing?.count ?? 0) + group.count });
}
return [...countMap.entries()]
.sort(([, a], [, b]) => b.count - a.count || a.label.localeCompare(b.label, 'cs'))
.map(([value, item]) => ({ value, label: item.label }));
};
const matchesSource = (group: MealGroup, sourceFilter: string): boolean => {
if (sourceFilter === SourceFilter.VSE) return true;
if (sourceFilter === SourceFilter.BEZ_ZDROJE) return !group.source;
return !!group.source && toSourceOption(group.source) === sourceFilter;
};
/**
* Seznam jídel — všechna jídla uživatele seskupená podle názvu a zdroje.
*
* Skupině jde hromadně doplnit chybějící kalorie, gramáž nebo živiny a změnit
* název a zdroj, a to ve všech dnech naráz, bez procházení jednotlivých dnů.
*/
const MealGroupsPage = () => {
const navigate = useNavigate();
const location = useLocation();
const [groupList, setGroupList] = useState<MealGroup[]>([]);
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState('');
const [onlyIncomplete, setOnlyIncomplete] = useState(true);
const [expandedKey, setExpandedKey] = useState<string | undefined>();
const [sourceFilter, setSourceFilter] = useState<string>(SourceFilter.VSE);
const [onlyIncomplete, setOnlyIncomplete] = useState(() =>
!!(location.state as { onlyIncomplete?: boolean } | null)?.onlyIncomplete);
const [opened, setOpened] = useState<{ key: string, action: MealGroupAction } | undefined>();
const load = useCallback(async () => {
const response = await getMealGroups();
@@ -37,35 +62,45 @@ const MealGroupsPage = () => {
useEffect(() => { load(); }, [load]);
const sourceList = useMemo(() => listSources(groupList), [groupList]);
const visibleList = useMemo(() => {
const search = normalizeSearch(query);
return groupList.filter(group =>
(!onlyIncomplete || isIncomplete(group))
&& (!search.length || normalizeSearch(`${group.name} ${group.source ?? ''}`).includes(search)));
}, [groupList, query, onlyIncomplete]);
&& matchesSource(group, sourceFilter)
&& (!search.length || normalizeSearch(group.name).includes(search)));
}, [groupList, query, sourceFilter, onlyIncomplete]);
const incompleteCount = groupList.filter(group => isIncomplete(group)).length;
const filtered = !!query.trim().length || sourceFilter !== SourceFilter.VSE;
return (
<>
<div className="page-header">
<div>
<h1>Doplnění údajů jídel</h1>
<h1>Jídla</h1>
<p className="page-subtitle">
Jídla se stejným názvem a zdrojem doplníte najednou — kalorie, gramáž i nutriční hodnoty
Všechna jídla podle názvu a zdroje — údaje doplníte nebo název změníte ve všech dnech najednou
</p>
</div>
<Button variant="outline-secondary" onClick={() => navigate(STATISTIKY_URL)}>
<FontAwesomeIcon icon={faArrowLeft} /> Statistiky
</Button>
</div>
<div className="card-panel meal-groups-filter">
<Form.Control
value={query}
placeholder="Hledat jídlo nebo zdroj…"
placeholder="Hledat jídlo…"
onChange={event => setQuery(event.target.value)}
/>
<Form.Select
className="meal-groups-source"
value={sourceFilter}
onChange={event => setSourceFilter(event.target.value)}
>
<option value={SourceFilter.VSE}>Všechny zdroje</option>
<option value={SourceFilter.BEZ_ZDROJE}>Bez zdroje</option>
{sourceList.map(source => <option key={source.value} value={source.value}>{source.label}</option>)}
</Form.Select>
<Form.Check
type="switch"
id="meal-groups-incomplete"
@@ -85,20 +120,21 @@ const MealGroupsPage = () => {
<div className="card-panel empty-state">
<div className="empty-icon"><FontAwesomeIcon icon={faCircleCheck} /></div>
<div className="empty-title">
{onlyIncomplete && !query.trim().length ? 'Všechna jídla mají údaje vyplněné' : 'Nic nenalezeno'}
{onlyIncomplete && !filtered ? 'Všechna jídla mají údaje vyplněné' : 'Nic nenalezeno'}
</div>
</div>
)}
{!loading && visibleList.map(group => {
const key = getGroupKey(group);
const key = getMealKey(group);
return (
<MealGroupRow
key={key}
group={group}
expanded={expandedKey === key}
onToggle={() => setExpandedKey(current => current === key ? undefined : key)}
onFilled={() => { setExpandedKey(undefined); load(); }}
groupList={groupList}
action={opened?.key === key ? opened.action : undefined}
onAction={action => setOpened(action ? { key, action } : undefined)}
onChanged={() => { setOpened(undefined); load(); }}
/>
);
})}
+14 -21
View File
@@ -5,8 +5,8 @@ import { faCalendarDay, faChartColumn, faCoins, faListCheck, faReceipt } from "@
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";
import { formatPrice } from "../Utils";
import { StatsBounds, TOOLTIP_STYLE, buildSourceSlices, formatPeriodLabel, pickPeriods } from "./statsShared";
type Props = {
range: StatsBounds;
@@ -50,6 +50,8 @@ const SpendingStatsPanel = ({ range, mode, reloadKey }: Readonly<Props>) => {
value: total.price / 100,
})), [stats]);
const sourceSlices = useMemo(() => buildSourceSlices(stats?.bySource ?? [], total => 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
@@ -130,25 +132,16 @@ const SpendingStatsPanel = ({ range, mode, reloadKey }: Readonly<Props>) => {
</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>
<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 => formatChartPrice(value)} contentStyle={TOOLTIP_STYLE} />
</PieChart>
</ResponsiveContainer>
</div>
</div>
</>
+1 -7
View File
@@ -1,13 +1,11 @@
import { useMemo, useState } from "react";
import { Button } from "react-bootstrap";
import { useNavigate } from "react-router";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCoins, faFileImport, faPenToSquare, faPersonRunning, faUtensils,
faCoins, faFileImport, faPersonRunning, faUtensils,
} from "@fortawesome/free-solid-svg-icons";
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";
@@ -35,7 +33,6 @@ export default function StatsPage() {
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. */
const range = useMemo(() => {
@@ -57,9 +54,6 @@ export default function StatsPage() {
</div>
{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
</Button>
<Button onClick={() => setImportOpen(true)}>
<FontAwesomeIcon icon={faFileImport} /> Import z Luncheru
</Button>
+1 -23
View File
@@ -1,4 +1,4 @@
import { IntakeGroupTotal, IntakeStats, MealType } from "../../../types";
import { IntakeStats, MealType } from "../../../types";
import { MEAL_TYPES_IN_ORDER, StatsRange } from "../enums";
import { StatsBounds, formatPeriodLabel, listPeriods, pickPeriods } from "./statsShared";
@@ -67,28 +67,6 @@ export const buildIntakeSeries = (stats: IntakeStats, range: StatsBounds, mode:
});
};
/** 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));
+26
View File
@@ -63,3 +63,29 @@ export const periodOf = (date: string, mode: StatsRange): string => {
if (mode === StatsRange.ROK) return date.substring(0, 7);
return date.substring(0, 4);
};
/** 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 dle zdroje — příjmu i útraty. 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 součty dle zdroje, sestupně dle zobrazované hodnoty
* @param getValue zobrazovaná hodnota součtu (kcal, Kč)
*/
export const buildSourceSlices = <T extends { key: string }>(
bySource: T[],
getValue: (total: T) => number,
): SourceSlice[] => {
const slices = bySource
.filter(total => getValue(total) > 0)
.slice(0, SOURCE_SLICE_COUNT)
.map((total, index) => ({ name: total.key, value: getValue(total), color: `var(--ft-series-${index + 1})` }));
const rest = bySource.slice(SOURCE_SLICE_COUNT).reduce((sum, total) => sum + getValue(total), 0);
if (rest > 0) slices.push({ name: 'Ostatní', value: rest, color: 'var(--ft-series-other)' });
return slices;
};
+1 -1
View File
@@ -8,7 +8,7 @@ export const DEN_URL = '/';
export const STATISTIKY_URL = '/statistiky';
export const SABLONY_URL = '/sablony';
export const PROFIL_URL = '/profil';
export const DOPLNENI_JIDEL_URL = '/statistiky/doplneni';
export const JIDLA_URL = '/jidla';
/** Parametr adresy přehledu dne s datem YYYY-MM-DD. */
export const DAY_DATE_PARAM = 'datum';