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:
1 parent
b880b36edd
commit
efed5e6e86
30 files changed
+514
-153
No files matched your search
@@ -73,11 +73,12 @@ cd client && yarn build # tsc --noEmit + vite build → client/dist
|
|||||||
### Client (client/src/)
|
### Client (client/src/)
|
||||||
- **Entry:** `index.tsx` → `AppRoutes.tsx`; `Login.tsx` je přihlašovací obrazovka
|
- **Entry:** `index.tsx` → `AppRoutes.tsx`; `Login.tsx` je přihlašovací obrazovka
|
||||||
- **Pages:** `pages/` — `DayPage` (přehled dne se záložkami Příjem/Výdej),
|
- **Pages:** `pages/` — `DayPage` (přehled dne se záložkami Příjem/Výdej),
|
||||||
|
`MealGroupsPage` (`/jidla` — seznam jídel podle názvu a zdroje s filtrem zdroje,
|
||||||
|
hromadným doplněním údajů a přejmenováním),
|
||||||
`TemplatesPage` (šablony tréninků a recepty), `ProfilePage` (Profil — ukazatele,
|
`TemplatesPage` (šablony tréninků a recepty), `ProfilePage` (Profil — ukazatele,
|
||||||
vývoj hmotnosti, tělesné údaje, cíle a klidový výdej),
|
vývoj hmotnosti, tělesné údaje, cíle a klidový výdej),
|
||||||
`StatsPage` (statistiky se záložkami Příjem / Výdej / Útrata a import; panely
|
`StatsPage` (statistiky se záložkami Příjem / Výdej / Útrata a import; panely
|
||||||
`IntakeStatsPanel`, `ActivityStatsPanel`, `SpendingStatsPanel`),
|
`IntakeStatsPanel`, `ActivityStatsPanel`, `SpendingStatsPanel`)
|
||||||
`MealGroupsPage` (`/statistiky/doplneni` — hromadné doplnění údajů jídel)
|
|
||||||
- **Components:** `components/` (Header, CalorieLookup, MealSuggestions, NutrientsFieldset)
|
- **Components:** `components/` (Header, CalorieLookup, MealSuggestions, NutrientsFieldset)
|
||||||
a `components/modals/` (MealModal, ActivityModal, ApplyWorkoutModal, ApplyMealModal —
|
a `components/modals/` (MealModal, ActivityModal, ApplyWorkoutModal, ApplyMealModal —
|
||||||
výběr z historie a receptů, SettingsModal, ImportModal)
|
výběr z historie a receptů, SettingsModal, ImportModal)
|
||||||
@@ -267,9 +268,12 @@ takže `DayPage` si vystačí s jedním voláním.
|
|||||||
|
|
||||||
Hotová jídla (objednaná, koupená) **nemají šablony**. Při zakládání jídla se
|
Hotová jídla (objednaná, koupená) **nemají šablony**. Při zakládání jídla se
|
||||||
k rozepsanému názvu nabízí historie: `getMealHistory` v `meals.ts`
|
k rozepsanému názvu nabízí historie: `getMealHistory` v `meals.ts`
|
||||||
(`GET /api/meals/history`) projde všechna jídla uživatele a každý název vrátí
|
(`GET /api/meals/history`) projde všechna jídla uživatele a každou dvojici
|
||||||
jednou (`MealSuggestion`). Názvy se porovnávají znormalizované přes
|
**název a zdroj** vrátí jednou (`MealSuggestion`). Stejnojmenné jídlo z jiného
|
||||||
`normalizeName`, hodnoty jsou z **posledního** záznamu toho jména (cena i gramáž
|
zdroje (tikka masala z Everestu a z Namaste) je jiné jídlo a nikde se s ním
|
||||||
|
neslučuje — ani v návrzích, ani v seznamu jídel, ani při `fillSameNameMeals`;
|
||||||
|
na klientovi to hlídá `getMealKey` v `Utils.tsx`. Porovnává se znormalizovaně
|
||||||
|
přes `normalizeName`, hodnoty jsou z **posledního** záznamu (cena i gramáž
|
||||||
se časem mění) a řadí se od nejčastěji jedených.
|
se časem mění) a řadí se od nejčastěji jedených.
|
||||||
|
|
||||||
Návrh nese jen to, co popisuje jídlo samo — **chod, datum, násobek ani poznámku
|
Návrh nese jen to, co popisuje jídlo samo — **chod, datum, násobek ani poznámku
|
||||||
@@ -283,10 +287,10 @@ Návrhy se nabízejí i při **úpravě** jídla, ale až po změně názvu, a d
|
|||||||
prázdná pole (`mergeSuggestion` v `MealModal`) — slouží k přejmenování
|
prázdná pole (`mergeSuggestion` v `MealModal`) — slouží k přejmenování
|
||||||
naimportovaného jídla, jehož cena a zdroj jsou skutečné. Po uložení jídla
|
naimportovaného jídla, jehož cena a zdroj jsou skutečné. Po uložení jídla
|
||||||
s kaloriemi `DayPage` nabídne (`FillSameNameOffer`) doplnit stejné hodnoty do
|
s kaloriemi `DayPage` nabídne (`FillSameNameOffer`) doplnit stejné hodnoty do
|
||||||
ostatních jídel stejného názvu, která je nemají (`fillSameNameMeals`,
|
ostatních jídel stejného názvu a zdroje, která je nemají (`fillSameNameMeals`,
|
||||||
`POST /api/meals/fillSameName`); jídla s kaloriemi se nepřepisují.
|
`POST /api/meals/fillSameName`); jídla s kaloriemi se nepřepisují.
|
||||||
|
|
||||||
**Hromadné doplnění** (`MealGroupsPage`, odkaz ze Statistik → Příjem) seskupí
|
**Seznam jídel** (`MealGroupsPage`, položka Jídla v hlavičce) seskupí
|
||||||
jídla podle znormalizovaného názvu **a zdroje** (`getMealGroups`,
|
jídla podle znormalizovaného názvu **a zdroje** (`getMealGroups`,
|
||||||
`GET /api/meals/groups`) a ukáže, kolika chybí kalorie, gramáž a živiny.
|
`GET /api/meals/groups`) a ukáže, kolika chybí kalorie, gramáž a živiny.
|
||||||
Skupina nese i dny, kdy se jídlo jedlo (`MealGroup.days`, s příznakem neúplných
|
Skupina nese i dny, kdy se jídlo jedlo (`MealGroup.days`, s příznakem neúplných
|
||||||
@@ -297,6 +301,12 @@ jídlům bez kalorií (jinak by dopočet přepsal ručně zadané kalorie), živ
|
|||||||
jednotlivých položkách. Předvyplněné hodnoty jsou z nejnovějšího jídla skupiny;
|
jednotlivých položkách. Předvyplněné hodnoty jsou z nejnovějšího jídla skupiny;
|
||||||
energie a živiny bez nich i z jídla stejného názvu odjinud, sazba a gramáž ne.
|
energie a živiny bez nich i z jídla stejného názvu odjinud, sazba a gramáž ne.
|
||||||
|
|
||||||
|
Druhou akcí skupiny je **přejmenování** (`renameMealGroup`,
|
||||||
|
`POST /api/meals/renameGroup`) — změní název i zdroj všem jídlům skupiny ve
|
||||||
|
všech dnech. Je oddělené od doplnění schválně: doplnění jen připisuje, co chybí,
|
||||||
|
přejmenování přepisuje. Když nový název a zdroj patří jiné skupině, jídla se do
|
||||||
|
ní sloučí (formulář na to předem upozorní).
|
||||||
|
|
||||||
Pole Zdroj (`SourceInput`) nabízí zdroje z historie (`getMealSources`,
|
Pole Zdroj (`SourceInput`) nabízí zdroje z historie (`getMealSources`,
|
||||||
`GET /api/meals/sources`, nejčastější první) a podniky se sazbou z nastavení;
|
`GET /api/meals/sources`, nejčastější první) a podniky se sazbou z nastavení;
|
||||||
psát jde i nový.
|
psát jde i nový.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import StatsPage from "./pages/StatsPage";
|
|||||||
import TemplatesPage from "./pages/TemplatesPage";
|
import TemplatesPage from "./pages/TemplatesPage";
|
||||||
import ProfilePage from "./pages/ProfilePage";
|
import ProfilePage from "./pages/ProfilePage";
|
||||||
import MealGroupsPage from "./pages/MealGroupsPage";
|
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() {
|
export default function AppRoutes() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
@@ -23,10 +23,10 @@ export default function AppRoutes() {
|
|||||||
<div className="wrapper">
|
<div className="wrapper">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path={DEN_URL} element={<DayPage />} />
|
<Route path={DEN_URL} element={<DayPage />} />
|
||||||
|
<Route path={JIDLA_URL} element={<MealGroupsPage />} />
|
||||||
<Route path={STATISTIKY_URL} element={<StatsPage />} />
|
<Route path={STATISTIKY_URL} element={<StatsPage />} />
|
||||||
<Route path={SABLONY_URL} element={<TemplatesPage />} />
|
<Route path={SABLONY_URL} element={<TemplatesPage />} />
|
||||||
<Route path={PROFIL_URL} element={<ProfilePage />} />
|
<Route path={PROFIL_URL} element={<ProfilePage />} />
|
||||||
<Route path={DOPLNENI_JIDEL_URL} element={<MealGroupsPage />} />
|
|
||||||
<Route path="*" element={<Navigate to={DEN_URL} replace />} />
|
<Route path="*" element={<Navigate to={DEN_URL} replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -342,6 +342,13 @@ export const deriveRecipe = (
|
|||||||
export const normalizeSearch = (value: string): string =>
|
export const normalizeSearch = (value: string): string =>
|
||||||
value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim();
|
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é. */
|
/** Popíše recept jednou řádkou — zdroj a hodnoty jedné porce, co je vyplněné. */
|
||||||
export const describeMealTemplate = (template: MealTemplate): string => [
|
export const describeMealTemplate = (template: MealTemplate): string => [
|
||||||
template.source,
|
template.source,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ type Props = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Nabídka doplnit kalorie a živiny právě uloženého jídla do ostatních jídel
|
* 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í.
|
* Když takové jídlo není, nic se nezobrazí.
|
||||||
*/
|
*/
|
||||||
const FillSameNameOffer = ({ entry, onFilled, onDismiss }: Readonly<Props>) => {
|
const FillSameNameOffer = ({ entry, onFilled, onDismiss }: Readonly<Props>) => {
|
||||||
@@ -27,6 +27,8 @@ const FillSameNameOffer = ({ entry, onFilled, onDismiss }: Readonly<Props>) => {
|
|||||||
|
|
||||||
if (!preview?.count) return null;
|
if (!preview?.count) return null;
|
||||||
|
|
||||||
|
const label = entry.source ? `„${entry.name}“ z ${entry.source}` : `„${entry.name}“`;
|
||||||
|
|
||||||
const fill = async () => {
|
const fill = async () => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
const response = await fillSameNameMeals({ body: { id: entry.id, date: entry.date } });
|
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">
|
<span className="me-auto">
|
||||||
{preview.count === 1
|
{preview.count === 1
|
||||||
? `Další jídlo „${entry.name}“ nemá kalorie.`
|
? `Další jídlo ${label} nemá kalorie.`
|
||||||
: `Další jídla „${entry.name}“ (${preview.count}) nemají kalorie.`}
|
: `Další jídla ${label} (${preview.count}) nemají kalorie.`}
|
||||||
{' '}Doplnit stejné hodnoty?
|
{' '}Doplnit stejné hodnoty?
|
||||||
</span>
|
</span>
|
||||||
<Button size="sm" onClick={() => fill()} disabled={busy}>Doplnit</Button>
|
<Button size="sm" onClick={() => fill()} disabled={busy}>Doplnit</Button>
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|||||||
import { faSun, faMoon, faGear } from "@fortawesome/free-solid-svg-icons";
|
import { faSun, faMoon, faGear } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { useAuth } from "../context/auth";
|
import { useAuth } from "../context/auth";
|
||||||
import { useSettings, ThemePreference } from "../context/settings";
|
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";
|
import SettingsModal from "./modals/SettingsModal";
|
||||||
|
|
||||||
/** Položky hlavní navigace. */
|
/** Položky hlavní navigace. */
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ url: DEN_URL, label: 'Můj den' },
|
{ url: DEN_URL, label: 'Můj den' },
|
||||||
|
{ url: JIDLA_URL, label: 'Jídla' },
|
||||||
{ url: SABLONY_URL, label: 'Šablony' },
|
{ url: SABLONY_URL, label: 'Šablony' },
|
||||||
{ url: STATISTIKY_URL, label: 'Statistiky' },
|
{ url: STATISTIKY_URL, label: 'Statistiky' },
|
||||||
{ url: PROFIL_URL, label: 'Profil' },
|
{ url: PROFIL_URL, label: 'Profil' },
|
||||||
@@ -25,7 +26,7 @@ export default function Header() {
|
|||||||
|
|
||||||
const effectiveDark = settings?.effectiveDark ?? false;
|
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) =>
|
const isActive = (url: string) =>
|
||||||
location.pathname === url || (url !== DEN_URL && location.pathname.startsWith(`${url}/`));
|
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;
|
||||||
@@ -1,17 +1,23 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "react-bootstrap";
|
import { Button } from "react-bootstrap";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faCalendarDays, faPenToSquare } from "@fortawesome/free-solid-svg-icons";
|
import { faCalendarDays, faPen, faPenToSquare } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { MealGroup } from "../../../types";
|
import { MealGroup } from "../../../types";
|
||||||
|
import { MealGroupAction } from "../enums";
|
||||||
import { formatDateString } from "../Utils";
|
import { formatDateString } from "../Utils";
|
||||||
import MealGroupForm from "./MealGroupForm";
|
import MealGroupForm from "./MealGroupForm";
|
||||||
|
import MealGroupRenameForm from "./MealGroupRenameForm";
|
||||||
import MealGroupDays from "./MealGroupDays";
|
import MealGroupDays from "./MealGroupDays";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
group: MealGroup,
|
group: MealGroup,
|
||||||
expanded: boolean,
|
/** Všechny skupiny — přejmenování podle nich pozná sloučení s jinou. */
|
||||||
onToggle: () => void,
|
groupList: MealGroup[],
|
||||||
onFilled: () => void,
|
/** 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". */
|
/** 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ů.
|
* Řá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,
|
* Nabízí doplnění údajů a změnu názvu a zdroje všem jídlům skupiny a dny,
|
||||||
* a dny, kdy se jídlo jedlo, jako odkazy na přehled dne.
|
* 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 [daysOpen, setDaysOpen] = useState(false);
|
||||||
|
const toggle = (value: MealGroupAction) => onAction(action === value ? undefined : value);
|
||||||
return (
|
return (
|
||||||
<div className="card-panel meal-group">
|
<div className="card-panel meal-group">
|
||||||
<div className="meal-group-head">
|
<div className="meal-group-head">
|
||||||
@@ -51,7 +58,7 @@ const MealGroupRow = ({ group, expanded, onToggle, onFilled }: Readonly<Props>)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="meal-group-actions">
|
<div className="meal-group-actions">
|
||||||
{!expanded && (
|
{!action && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline-secondary"
|
variant="outline-secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -61,13 +68,36 @@ const MealGroupRow = ({ group, expanded, onToggle, onFilled }: Readonly<Props>)
|
|||||||
<FontAwesomeIcon icon={faCalendarDays} /> Dny
|
<FontAwesomeIcon icon={faCalendarDays} /> Dny
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button variant={expanded ? 'outline-secondary' : 'primary'} size="sm" onClick={() => onToggle()}>
|
<Button
|
||||||
<FontAwesomeIcon icon={faPenToSquare} /> {expanded ? 'Zavřít' : 'Doplnit'}
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(expanded || daysOpen) && <MealGroupDays days={group.days} />}
|
{(action === MealGroupAction.DOPLNIT || (!action && daysOpen)) && <MealGroupDays days={group.days} />}
|
||||||
{expanded && <MealGroupForm group={group} onFilled={onFilled} onCancel={() => onToggle()} />}
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faBookOpen, faClockRotateLeft } from "@fortawesome/free-solid-svg-icons";
|
import { faBookOpen, faClockRotateLeft } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { MealSuggestion, MealTemplate } from "../../../types";
|
import { MealSuggestion, MealTemplate } from "../../../types";
|
||||||
import { describeMealSuggestion, describeMealTemplate, normalizeSearch } from "../Utils";
|
import { describeMealSuggestion, describeMealTemplate, getMealKey, normalizeSearch } from "../Utils";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
recipes: MealTemplate[],
|
recipes: MealTemplate[],
|
||||||
@@ -54,7 +54,7 @@ const MealSuggestions = ({ recipes, history, query, onPickRecipe, onPickHistory
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="meal-suggestion"
|
className="meal-suggestion"
|
||||||
key={item.name}
|
key={getMealKey(item)}
|
||||||
onClick={() => onPickHistory(item)}
|
onClick={() => onPickHistory(item)}
|
||||||
title="Vyplnit hodnoty z posledního záznamu"
|
title="Vyplnit hodnoty z posledního záznamu"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
MealSuggestion, MealTemplate, MealType, addMeal, applyMealTemplate, getMealHistory, getMealTemplates,
|
MealSuggestion, MealTemplate, MealType, addMeal, applyMealTemplate, getMealHistory, getMealTemplates,
|
||||||
} from "../../../../types";
|
} from "../../../../types";
|
||||||
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES, TemplateTab } from "../../enums";
|
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";
|
import { SABLONY_URL } from "../../routes";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -188,7 +188,7 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) =
|
|||||||
<div className="apply-template-group">
|
<div className="apply-template-group">
|
||||||
<h3 className="apply-template-group-title">Dříve snědené</h3>
|
<h3 className="apply-template-group-title">Dříve snědené</h3>
|
||||||
{historyList.map(item => (
|
{historyList.map(item => (
|
||||||
<div className="workout-row" key={item.name}>
|
<div className="workout-row" key={getMealKey(item)}>
|
||||||
<div className="workout-info">
|
<div className="workout-info">
|
||||||
<div className="workout-name">{item.name}</div>
|
<div className="workout-name">{item.name}</div>
|
||||||
<div className="workout-items">
|
<div className="workout-items">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES, NUTRIENTS_IN_ORDER } from "../../enums";
|
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES, NUTRIENTS_IN_ORDER } from "../../enums";
|
||||||
import {
|
import {
|
||||||
applyMultiplier, deriveAmounts, formatCalories, formatMultiplier, formatMultiplierInput, formatPriceInput,
|
applyMultiplier, deriveAmounts, formatCalories, formatMultiplier, formatMultiplierInput, formatPriceInput,
|
||||||
formatWeight, normalizeSearch, parseIntInput, parseMultiplierInput, parseNutrientInput,
|
formatWeight, getMealKey, parseIntInput, parseMultiplierInput, parseNutrientInput,
|
||||||
parsePriceInput,
|
parsePriceInput,
|
||||||
} from "../../Utils";
|
} from "../../Utils";
|
||||||
import CalorieLookup from "../CalorieLookup";
|
import CalorieLookup from "../CalorieLookup";
|
||||||
@@ -253,7 +253,7 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
|
|||||||
recipes={recipes}
|
recipes={recipes}
|
||||||
// Upravované jídlo samo sebe nenabízí
|
// Upravované jídlo samo sebe nenabízí
|
||||||
history={entry
|
history={entry
|
||||||
? history.filter(item => normalizeSearch(item.name) !== normalizeSearch(entry.name))
|
? history.filter(item => getMealKey(item) !== getMealKey(entry))
|
||||||
: history}
|
: history}
|
||||||
query={form.name}
|
query={form.name}
|
||||||
onPickRecipe={recipe => applyRecipe(recipe)}
|
onPickRecipe={recipe => applyRecipe(recipe)}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
} from "../../../../types";
|
} from "../../../../types";
|
||||||
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums";
|
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums";
|
||||||
import {
|
import {
|
||||||
deriveAmounts, formatCalories, formatMultiplier, formatPrice, formatPriceInput, formatWeight, normalizeSearch,
|
deriveAmounts, formatCalories, formatMultiplier, formatPrice, formatPriceInput, formatWeight, getMealKey,
|
||||||
parseIntInput, parsePriceInput,
|
parseIntInput, parsePriceInput,
|
||||||
} from "../../Utils";
|
} from "../../Utils";
|
||||||
import MealSplitRow from "../MealSplitRow";
|
import MealSplitRow from "../MealSplitRow";
|
||||||
@@ -273,8 +273,7 @@ const MealSplitModal = ({ isOpen, entry, onClose, onSaved }: Readonly<Props>) =>
|
|||||||
<MealSuggestions
|
<MealSuggestions
|
||||||
recipes={recipes}
|
recipes={recipes}
|
||||||
// Rozdělované jídlo samo sebe nenabízí
|
// Rozdělované jídlo samo sebe nenabízí
|
||||||
history={history.filter(item =>
|
history={history.filter(item => getMealKey(item) !== getMealKey(entry))}
|
||||||
normalizeSearch(item.name) !== normalizeSearch(entry.name))}
|
|
||||||
query={split.name}
|
query={split.name}
|
||||||
onPickRecipe={recipe => applyRecipe(recipe)}
|
onPickRecipe={recipe => applyRecipe(recipe)}
|
||||||
onPickHistory={suggestion => applyHistory(suggestion)}
|
onPickHistory={suggestion => applyHistory(suggestion)}
|
||||||
|
|||||||
@@ -176,3 +176,15 @@ export const IMPORT_MODE_NAMES: Record<ImportMode, string> = {
|
|||||||
|
|
||||||
/** Předvolby násobku porce nabízené v přehledu dne. */
|
/** Předvolby násobku porce nabízené v přehledu dne. */
|
||||||
export const MULTIPLIER_PRESETS = [0.5, 1, 1.5, 2, 3];
|
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',
|
||||||
|
}
|
||||||
@@ -11,9 +11,9 @@ import {
|
|||||||
import { IntakeStats, MealType, getIntakeStats } from "../../../types";
|
import { IntakeStats, MealType, getIntakeStats } from "../../../types";
|
||||||
import { MEAL_TYPE_NAMES, NUTRIENT_NAMES, NUTRIENTS_IN_ORDER, StatsRange } from "../enums";
|
import { MEAL_TYPE_NAMES, NUTRIENT_NAMES, NUTRIENTS_IN_ORDER, StatsRange } from "../enums";
|
||||||
import { formatBalance, formatCalories, formatNutrient } from "../Utils";
|
import { formatBalance, formatCalories, formatNutrient } from "../Utils";
|
||||||
import { DOPLNENI_JIDEL_URL } from "../routes";
|
import { JIDLA_URL } from "../routes";
|
||||||
import { StatsBounds, TOOLTIP_STYLE } from "./statsShared";
|
import { StatsBounds, TOOLTIP_STYLE, buildSourceSlices } from "./statsShared";
|
||||||
import { MEAL_TYPE_COLORS, buildIntakeSeries, buildSourceSlices, getUsedMealTypes } from "./intakeChartSeries";
|
import { MEAL_TYPE_COLORS, buildIntakeSeries, getUsedMealTypes } from "./intakeChartSeries";
|
||||||
import IntakeTooltip from "./IntakeTooltip";
|
import IntakeTooltip from "./IntakeTooltip";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -82,7 +82,7 @@ const IntakeStatsPanel = ({ range, mode, reloadKey }: Readonly<Props>) => {
|
|||||||
name: MEAL_TYPE_NAMES[group.key as MealType] ?? group.key,
|
name: MEAL_TYPE_NAMES[group.key as MealType] ?? group.key,
|
||||||
value: group.calories,
|
value: group.calories,
|
||||||
}));
|
}));
|
||||||
const sourceSlices = buildSourceSlices(stats.bySource);
|
const sourceSlices = buildSourceSlices(stats.bySource, group => group.calories);
|
||||||
|
|
||||||
return (
|
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í
|
{stats.missingCaloriesCount} z {stats.entryCount} jídel nemá kalorie, do součtů nevstupují
|
||||||
a příjem tak vychází nižší, než byl.
|
a příjem tak vychází nižší, než byl.
|
||||||
</span>
|
</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
|
<FontAwesomeIcon icon={faPenToSquare} /> Doplnit
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
flex: 1 1 240px;
|
flex: 1 1 240px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.meal-groups-source {
|
||||||
|
flex: 0 1 220px;
|
||||||
|
}
|
||||||
|
|
||||||
.form-check {
|
.form-check {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
@@ -1,33 +1,58 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Button, Form, Spinner } from "react-bootstrap";
|
import { Form, Spinner } from "react-bootstrap";
|
||||||
import { useNavigate } from "react-router";
|
import { useLocation } from "react-router";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
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 { MealGroup, getMealGroups } from "../../../types";
|
||||||
import { normalizeSearch } from "../Utils";
|
import { MealGroupAction, SourceFilter } from "../enums";
|
||||||
import { STATISTIKY_URL } from "../routes";
|
import { getMealKey, normalizeSearch } from "../Utils";
|
||||||
import MealGroupRow from "../components/MealGroupRow";
|
import MealGroupRow from "../components/MealGroupRow";
|
||||||
import "./MealGroupsPage.scss";
|
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 =>
|
const isIncomplete = (group: MealGroup): boolean =>
|
||||||
group.missingCaloriesCount + group.missingWeightCount + group.missingNutrientsCount > 0;
|
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
|
* Zdroje skupin pro filtr, každý jednou, od nejčastěji jedeného. Zápis je
|
||||||
* tvoří skupinu a chybějící kalorie, gramáž nebo živiny se jim doplní najednou,
|
* z nejčastější skupiny — seznam je seřazený od nejčastějších.
|
||||||
* bez procházení jednotlivých dnů.
|
*/
|
||||||
|
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 MealGroupsPage = () => {
|
||||||
const navigate = useNavigate();
|
const location = useLocation();
|
||||||
const [groupList, setGroupList] = useState<MealGroup[]>([]);
|
const [groupList, setGroupList] = useState<MealGroup[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [onlyIncomplete, setOnlyIncomplete] = useState(true);
|
const [sourceFilter, setSourceFilter] = useState<string>(SourceFilter.VSE);
|
||||||
const [expandedKey, setExpandedKey] = useState<string | undefined>();
|
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 load = useCallback(async () => {
|
||||||
const response = await getMealGroups();
|
const response = await getMealGroups();
|
||||||
@@ -37,35 +62,45 @@ const MealGroupsPage = () => {
|
|||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const sourceList = useMemo(() => listSources(groupList), [groupList]);
|
||||||
|
|
||||||
const visibleList = useMemo(() => {
|
const visibleList = useMemo(() => {
|
||||||
const search = normalizeSearch(query);
|
const search = normalizeSearch(query);
|
||||||
return groupList.filter(group =>
|
return groupList.filter(group =>
|
||||||
(!onlyIncomplete || isIncomplete(group))
|
(!onlyIncomplete || isIncomplete(group))
|
||||||
&& (!search.length || normalizeSearch(`${group.name} ${group.source ?? ''}`).includes(search)));
|
&& matchesSource(group, sourceFilter)
|
||||||
}, [groupList, query, onlyIncomplete]);
|
&& (!search.length || normalizeSearch(group.name).includes(search)));
|
||||||
|
}, [groupList, query, sourceFilter, onlyIncomplete]);
|
||||||
|
|
||||||
const incompleteCount = groupList.filter(group => isIncomplete(group)).length;
|
const incompleteCount = groupList.filter(group => isIncomplete(group)).length;
|
||||||
|
const filtered = !!query.trim().length || sourceFilter !== SourceFilter.VSE;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>Doplnění údajů jídel</h1>
|
<h1>Jídla</h1>
|
||||||
<p className="page-subtitle">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline-secondary" onClick={() => navigate(STATISTIKY_URL)}>
|
|
||||||
<FontAwesomeIcon icon={faArrowLeft} /> Statistiky
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card-panel meal-groups-filter">
|
<div className="card-panel meal-groups-filter">
|
||||||
<Form.Control
|
<Form.Control
|
||||||
value={query}
|
value={query}
|
||||||
placeholder="Hledat jídlo nebo zdroj…"
|
placeholder="Hledat jídlo…"
|
||||||
onChange={event => setQuery(event.target.value)}
|
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
|
<Form.Check
|
||||||
type="switch"
|
type="switch"
|
||||||
id="meal-groups-incomplete"
|
id="meal-groups-incomplete"
|
||||||
@@ -85,20 +120,21 @@ const MealGroupsPage = () => {
|
|||||||
<div className="card-panel empty-state">
|
<div className="card-panel empty-state">
|
||||||
<div className="empty-icon"><FontAwesomeIcon icon={faCircleCheck} /></div>
|
<div className="empty-icon"><FontAwesomeIcon icon={faCircleCheck} /></div>
|
||||||
<div className="empty-title">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && visibleList.map(group => {
|
{!loading && visibleList.map(group => {
|
||||||
const key = getGroupKey(group);
|
const key = getMealKey(group);
|
||||||
return (
|
return (
|
||||||
<MealGroupRow
|
<MealGroupRow
|
||||||
key={key}
|
key={key}
|
||||||
group={group}
|
group={group}
|
||||||
expanded={expandedKey === key}
|
groupList={groupList}
|
||||||
onToggle={() => setExpandedKey(current => current === key ? undefined : key)}
|
action={opened?.key === key ? opened.action : undefined}
|
||||||
onFilled={() => { setExpandedKey(undefined); load(); }}
|
onAction={action => setOpened(action ? { key, action } : undefined)}
|
||||||
|
onChanged={() => { setOpened(undefined); load(); }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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 { Bar, BarChart, CartesianGrid, Cell, Legend, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||||
import { FoodStats, MealType, getFoodStats } from "../../../types";
|
import { FoodStats, MealType, getFoodStats } from "../../../types";
|
||||||
import { MEAL_TYPE_NAMES, StatsRange } from "../enums";
|
import { MEAL_TYPE_NAMES, StatsRange } from "../enums";
|
||||||
import { formatPrice, formatPriceShort } from "../Utils";
|
import { formatPrice } from "../Utils";
|
||||||
import { StatsBounds, TOOLTIP_STYLE, formatPeriodLabel, pickPeriods } from "./statsShared";
|
import { StatsBounds, TOOLTIP_STYLE, buildSourceSlices, formatPeriodLabel, pickPeriods } from "./statsShared";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
range: StatsBounds;
|
range: StatsBounds;
|
||||||
@@ -50,6 +50,8 @@ const SpendingStatsPanel = ({ range, mode, reloadKey }: Readonly<Props>) => {
|
|||||||
value: total.price / 100,
|
value: total.price / 100,
|
||||||
})), [stats]);
|
})), [stats]);
|
||||||
|
|
||||||
|
const sourceSlices = useMemo(() => buildSourceSlices(stats?.bySource ?? [], total => total.price / 100), [stats]);
|
||||||
|
|
||||||
const averagePerDay = stats?.dayCount ? Math.round(stats.totalPrice / stats.dayCount) : 0;
|
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);
|
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
|
// 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>
|
||||||
|
|
||||||
<div className="card-panel chart-panel">
|
<div className="card-panel chart-panel">
|
||||||
<h2 className="chart-title">Kde nejvíc utrácíte</h2>
|
<h2 className="chart-title">Podle zdroje</h2>
|
||||||
<table className="source-table">
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
<thead>
|
<PieChart>
|
||||||
<tr>
|
<Pie data={sourceSlices} dataKey="value" nameKey="name" innerRadius={50} outerRadius={85}>
|
||||||
<th>Zdroj</th>
|
{sourceSlices.map(slice => <Cell key={slice.name} fill={slice.color} />)}
|
||||||
<th className="numeric">Jídel</th>
|
</Pie>
|
||||||
<th className="numeric">Útrata</th>
|
<Legend />
|
||||||
</tr>
|
<Tooltip formatter={value => formatChartPrice(value)} contentStyle={TOOLTIP_STYLE} />
|
||||||
</thead>
|
</PieChart>
|
||||||
<tbody>
|
</ResponsiveContainer>
|
||||||
{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>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Button } from "react-bootstrap";
|
import { Button } from "react-bootstrap";
|
||||||
import { useNavigate } from "react-router";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import {
|
import {
|
||||||
faCoins, faFileImport, faPenToSquare, faPersonRunning, faUtensils,
|
faCoins, faFileImport, faPersonRunning, faUtensils,
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { StatsRange, STATS_RANGE_NAMES, STATS_TAB_NAMES, StatsTab } from "../enums";
|
import { StatsRange, STATS_RANGE_NAMES, STATS_TAB_NAMES, StatsTab } from "../enums";
|
||||||
import { formatMonth, getMonthEnd, getMonthStart } from "../Utils";
|
import { formatMonth, getMonthEnd, getMonthStart } from "../Utils";
|
||||||
import { DOPLNENI_JIDEL_URL } from "../routes";
|
|
||||||
import ImportModal from "../components/modals/ImportModal";
|
import ImportModal from "../components/modals/ImportModal";
|
||||||
import PeriodPicker, { EARLIEST_YEAR } from "../components/PeriodPicker";
|
import PeriodPicker, { EARLIEST_YEAR } from "../components/PeriodPicker";
|
||||||
import ActivityStatsPanel from "./ActivityStatsPanel";
|
import ActivityStatsPanel from "./ActivityStatsPanel";
|
||||||
@@ -35,7 +33,6 @@ export default function StatsPage() {
|
|||||||
const [importOpen, setImportOpen] = useState(false);
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
/** Zvyšuje se po importu, aby si panely jídel načetly data znovu. */
|
/** Zvyšuje se po importu, aby si panely jídel načetly data znovu. */
|
||||||
const [reloadKey, setReloadKey] = useState(0);
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
/** Přepočte hranice období z aktuálně zvoleného rozsahu. */
|
/** Přepočte hranice období z aktuálně zvoleného rozsahu. */
|
||||||
const range = useMemo(() => {
|
const range = useMemo(() => {
|
||||||
@@ -57,9 +54,6 @@ export default function StatsPage() {
|
|||||||
</div>
|
</div>
|
||||||
{tab !== StatsTab.VYDEJ && (
|
{tab !== StatsTab.VYDEJ && (
|
||||||
<div className="d-flex flex-wrap gap-2">
|
<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)}>
|
<Button onClick={() => setImportOpen(true)}>
|
||||||
<FontAwesomeIcon icon={faFileImport} /> Import z Luncheru
|
<FontAwesomeIcon icon={faFileImport} /> Import z Luncheru
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IntakeGroupTotal, IntakeStats, MealType } from "../../../types";
|
import { IntakeStats, MealType } from "../../../types";
|
||||||
import { MEAL_TYPES_IN_ORDER, StatsRange } from "../enums";
|
import { MEAL_TYPES_IN_ORDER, StatsRange } from "../enums";
|
||||||
import { StatsBounds, formatPeriodLabel, listPeriods, pickPeriods } from "./statsShared";
|
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. */
|
/** 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[] => {
|
export const getUsedMealTypes = (stats: IntakeStats): MealType[] => {
|
||||||
const used = new Set(stats.byMealType.map(group => group.key));
|
const used = new Set(stats.byMealType.map(group => group.key));
|
||||||
|
|||||||
@@ -63,3 +63,29 @@ export const periodOf = (date: string, mode: StatsRange): string => {
|
|||||||
if (mode === StatsRange.ROK) return date.substring(0, 7);
|
if (mode === StatsRange.ROK) return date.substring(0, 7);
|
||||||
return date.substring(0, 4);
|
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;
|
||||||
|
};
|
||||||
@@ -8,7 +8,7 @@ export const DEN_URL = '/';
|
|||||||
export const STATISTIKY_URL = '/statistiky';
|
export const STATISTIKY_URL = '/statistiky';
|
||||||
export const SABLONY_URL = '/sablony';
|
export const SABLONY_URL = '/sablony';
|
||||||
export const PROFIL_URL = '/profil';
|
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. */
|
/** Parametr adresy přehledu dne s datem YYYY-MM-DD. */
|
||||||
export const DAY_DATE_PARAM = 'datum';
|
export const DAY_DATE_PARAM = 'datum';
|
||||||
|
|||||||
+78
-21
@@ -475,12 +475,17 @@ function createMealSuggestion(source: MealEntry): MealSuggestion {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Klíč jídla podle názvu a zdroje — jídlo stejného jména jinde bývá jinak velké i jinak účtované. */
|
||||||
|
const getNameSourceKey = (name: string, source?: string): string =>
|
||||||
|
`${normalizeName(name)}|${normalizeName(source ?? '')}`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vrátí jídla z celé historie uživatele, každý název jednou — slouží
|
* Vrátí jídla z celé historie uživatele, každou dvojici název a zdroj jednou —
|
||||||
* k našeptávání při zakládání jídla místo ručně ukládaných šablon.
|
* slouží k našeptávání při zakládání jídla místo ručně ukládaných šablon.
|
||||||
*
|
*
|
||||||
* Názvy se porovnávají znormalizované (bez diakritiky a velikosti písmen), takže
|
* Názvy i zdroje se porovnávají znormalizované (bez diakritiky a velikosti písmen),
|
||||||
* "Kuřecí gyros" a "kureci gyros" jsou jedno jídlo. Hodnoty návrhu jsou
|
* takže "Kuřecí gyros" a "kureci gyros" jsou jedno jídlo. Stejnojmenné jídlo
|
||||||
|
* z jiného zdroje je jiné jídlo — jinde se vaří, nandává i účtuje jinak. Hodnoty návrhu jsou
|
||||||
* z posledního záznamu, protože cena i gramáž se časem mění. Když poslední
|
* z posledního záznamu, protože cena i gramáž se časem mění. Když poslední
|
||||||
* záznam nemá kalorie (typicky import z Luncheru), doplní se z dřívějšího stejně
|
* záznam nemá kalorie (typicky import z Luncheru), doplní se z dřívějšího stejně
|
||||||
* jako při importu. Řadí se od nejčastěji jedených, při shodě od naposledy jedených.
|
* jako při importu. Řadí se od nejčastěji jedených, při shodě od naposledy jedených.
|
||||||
@@ -492,7 +497,7 @@ export async function getMealHistory(login: string): Promise<MealSuggestion[]> {
|
|||||||
const fillNutrition = createNutritionFiller(entryList);
|
const fillNutrition = createNutritionFiller(entryList);
|
||||||
const resultMap = new Map<string, MealSuggestion>();
|
const resultMap = new Map<string, MealSuggestion>();
|
||||||
for (const entry of entryList) {
|
for (const entry of entryList) {
|
||||||
const key = normalizeName(entry.name);
|
const key = getNameSourceKey(entry.name, entry.source);
|
||||||
const existing = resultMap.get(key);
|
const existing = resultMap.get(key);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.count++;
|
existing.count++;
|
||||||
@@ -517,10 +522,6 @@ export async function findAllMeals(login: string): Promise<MealEntry[]> {
|
|||||||
.sort((a, b) => b.date.localeCompare(a.date) || b.createdAt.localeCompare(a.createdAt));
|
.sort((a, b) => b.date.localeCompare(a.date) || b.createdAt.localeCompare(a.createdAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Klíč jídla podle názvu a zdroje — jídlo stejného jména jinde bývá jinak velké i jinak účtované. */
|
|
||||||
const getNameSourceKey = (name: string, source?: string): string =>
|
|
||||||
`${normalizeName(name)}|${normalizeName(source ?? '')}`;
|
|
||||||
|
|
||||||
/** Nese záznam něco o energii nebo živinách — jen takový má smysl přebírat. */
|
/** Nese záznam něco o energii nebo živinách — jen takový má smysl přebírat. */
|
||||||
const hasNutrition = (entry: MealEntry): boolean =>
|
const hasNutrition = (entry: MealEntry): boolean =>
|
||||||
entry.calories != null || !!entry.caloriesPer100g || !!entry.nutrientsPer100g;
|
entry.calories != null || !!entry.caloriesPer100g || !!entry.nutrientsPer100g;
|
||||||
@@ -595,8 +596,9 @@ export async function getMealSources(login: string): Promise<string[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Doplní energii a živiny jídla do ostatních jídel stejného názvu, která je
|
* Doplní energii a živiny jídla do ostatních jídel stejného názvu a zdroje,
|
||||||
* nemají — typicky po ručním doplnění kalorií jednomu naimportovanému jídlu.
|
* která je nemají — typicky po ručním doplnění kalorií jednomu naimportovanému
|
||||||
|
* jídlu. Stejnojmenné jídlo z jiného zdroje je jiné jídlo a nechá se být.
|
||||||
*
|
*
|
||||||
* Přebírá se stejně jako při importu (\`createNutritionFiller\`), takže u jídel
|
* Přebírá se stejně jako při importu (\`createNutritionFiller\`), takže u jídel
|
||||||
* ze stejného zdroje se gramáž dopočte z jejich vlastní ceny. Jídla, která
|
* ze stejného zdroje se gramáž dopočte z jejich vlastní ceny. Jídla, která
|
||||||
@@ -627,9 +629,9 @@ export async function fillSameNameMeals(
|
|||||||
throw new BadRequestError("Jídlo nemá kalorie ani živiny, není co doplnit");
|
throw new BadRequestError("Jídlo nemá kalorie ani živiny, není co doplnit");
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = normalizeName(source.name);
|
const key = getNameSourceKey(source.name, source.source);
|
||||||
const targetList = (await findAllMeals(login))
|
const targetList = (await findAllMeals(login)).filter(entry =>
|
||||||
.filter(entry => entry.id !== source.id && normalizeName(entry.name) === key && !hasNutrition(entry));
|
entry.id !== source.id && getNameSourceKey(entry.name, entry.source) === key && !hasNutrition(entry));
|
||||||
const dates = [...new Set(targetList.map(entry => entry.date))].sort();
|
const dates = [...new Set(targetList.map(entry => entry.date))].sort();
|
||||||
|
|
||||||
if (!dryRun) {
|
if (!dryRun) {
|
||||||
@@ -674,13 +676,6 @@ const createMealGroupValues = (groupList: MealEntry[], sameNameList: MealEntry[]
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Vrátí jídla uživatele seskupená podle názvu a zdroje, i s počty jídel,
|
|
||||||
* kterým chybí kalorie, gramáž nebo živiny — podklad pro hromadné doplnění
|
|
||||||
* údajů ke starším záznamům.
|
|
||||||
*
|
|
||||||
* @param login přihlašovací jméno uživatele
|
|
||||||
*/
|
|
||||||
/** Vrátí true, pokud jídlu chybí kalorie, gramáž nebo živiny — tedy to, co doplnění řeší. */
|
/** Vrátí true, pokud jídlu chybí kalorie, gramáž nebo živiny — tedy to, co doplnění řeší. */
|
||||||
const isIncomplete = (entry: MealEntry): boolean =>
|
const isIncomplete = (entry: MealEntry): boolean =>
|
||||||
entry.calories == null || entry.weight == null || !entry.nutrientsPer100g;
|
entry.calories == null || entry.weight == null || !entry.nutrientsPer100g;
|
||||||
@@ -696,6 +691,13 @@ const createMealGroupDays = (groupList: MealEntry[]): MealGroupDay[] => {
|
|||||||
return [...dayMap.values()];
|
return [...dayMap.values()];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrátí jídla uživatele seskupená podle názvu a zdroje, i s počty jídel,
|
||||||
|
* kterým chybí kalorie, gramáž nebo živiny — seznam jídel s hromadným
|
||||||
|
* doplněním údajů a přejmenováním.
|
||||||
|
*
|
||||||
|
* @param login přihlašovací jméno uživatele
|
||||||
|
*/
|
||||||
export const getMealGroups = async (login: string): Promise<MealGroup[]> => {
|
export const getMealGroups = async (login: string): Promise<MealGroup[]> => {
|
||||||
const entryList = await findAllMeals(login);
|
const entryList = await findAllMeals(login);
|
||||||
const groupMap = new Map<string, MealEntry[]>();
|
const groupMap = new Map<string, MealEntry[]>();
|
||||||
@@ -814,3 +816,58 @@ export const fillMealGroup = async (
|
|||||||
}
|
}
|
||||||
return { count, dates };
|
return { count, dates };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Přejmenuje všechna jídla se stejným názvem a zdrojem a případně jim změní
|
||||||
|
* zdroj — oprava překlepu nebo sjednocení názvu napříč všemi dny, kdy se jídlo
|
||||||
|
* jedlo. Na rozdíl od doplnění údajů přepisuje, co jídla mají.
|
||||||
|
*
|
||||||
|
* Když nový název a zdroj patří jiné skupině, jídla se do ní tím sloučí.
|
||||||
|
*
|
||||||
|
* @param login přihlašovací jméno uživatele
|
||||||
|
* @param name název jídla skupiny
|
||||||
|
* @param source zdroj skupiny; prázdný znamená jídla bez zdroje
|
||||||
|
* @param newName nový název
|
||||||
|
* @param newSource nový zdroj; prázdný zdroj odebere
|
||||||
|
*/
|
||||||
|
export const renameMealGroup = async (
|
||||||
|
login: string,
|
||||||
|
name: unknown,
|
||||||
|
source: unknown,
|
||||||
|
newName: unknown,
|
||||||
|
newSource: unknown,
|
||||||
|
): Promise<FillSameNameResult> => {
|
||||||
|
const groupName = normalizeText(name, 'name');
|
||||||
|
if (!groupName) {
|
||||||
|
throw new BadRequestError("Nebyl předán název jídla");
|
||||||
|
}
|
||||||
|
const targetName = normalizeText(newName, 'newName');
|
||||||
|
if (!targetName) {
|
||||||
|
throw new BadRequestError("Nebyl předán nový název jídla");
|
||||||
|
}
|
||||||
|
const targetSource = normalizeText(newSource, 'newSource');
|
||||||
|
const key = getNameSourceKey(groupName, normalizeText(source, 'source'));
|
||||||
|
const isInGroup = (entry: MealEntry) => getNameSourceKey(entry.name, entry.source) === key;
|
||||||
|
|
||||||
|
const groupList = (await findAllMeals(login)).filter(isInGroup);
|
||||||
|
const groupDates = [...new Set(groupList.map(entry => entry.date))].sort();
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
let count = 0;
|
||||||
|
const dates: string[] = [];
|
||||||
|
for (const date of groupDates) {
|
||||||
|
await storage.updateData<MealEntry[]>(getDayKey(login, date), current => (current ?? []).map(entry => {
|
||||||
|
if (!isInGroup(entry) || (entry.name === targetName && entry.source === targetSource)) return entry;
|
||||||
|
count++;
|
||||||
|
if (!dates.includes(date)) dates.push(date);
|
||||||
|
const renamed: MealEntry = { ...entry, name: targetName, source: targetSource, updatedAt };
|
||||||
|
if (!targetSource) delete renamed.source;
|
||||||
|
return renamed;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// Knihovna kalorií je podle názvu, pod novým se má jídlo najít také
|
||||||
|
const caloriesPer100g = findLatestValue(groupList, entry => entry.caloriesPer100g);
|
||||||
|
if (count && caloriesPer100g) {
|
||||||
|
await rememberCalories(login, targetName, caloriesPer100g);
|
||||||
|
}
|
||||||
|
return { count, dates };
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import express, { Request, Response, NextFunction } from "express";
|
import express, { Request, Response, NextFunction } from "express";
|
||||||
import {
|
import {
|
||||||
addMeal, deleteMeal, fillMealGroup, fillSameNameMeals, getDay, getMealGroups, getMealHistory, getMealSources,
|
addMeal, deleteMeal, fillMealGroup, fillSameNameMeals, getDay, getMealGroups, getMealHistory, getMealSources,
|
||||||
getRange, setMealMultiplier, splitMeal, updateMeal,
|
getRange, renameMealGroup, setMealMultiplier, splitMeal, updateMeal,
|
||||||
} from "../meals";
|
} from "../meals";
|
||||||
import { getDayOverview } from "../dayOverview";
|
import { getDayOverview } from "../dayOverview";
|
||||||
import { getLogin } from "../auth";
|
import { getLogin } from "../auth";
|
||||||
@@ -59,7 +59,7 @@ router.post("/fillSameName", async (req: Request, res: Response, next: NextFunct
|
|||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Vrátí jídla seskupená podle názvu a zdroje — podklad pro hromadné doplnění údajů. */
|
/** Vrátí jídla seskupená podle názvu a zdroje — seznam jídel. */
|
||||||
router.get("/groups", async (req: Request, res: Response, next: NextFunction) => {
|
router.get("/groups", async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const login = getLogin(parseToken(req));
|
const login = getLogin(parseToken(req));
|
||||||
@@ -76,6 +76,15 @@ router.post("/fillGroup", async (req: Request, res: Response, next: NextFunction
|
|||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Přejmenuje všechna jídla se stejným názvem a zdrojem, případně jim změní zdroj. */
|
||||||
|
router.post("/renameGroup", async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
const { name, source, newName, newSource } = req.body ?? {};
|
||||||
|
res.status(200).json(await renameMealGroup(login, name, source, newName, newSource));
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
/** Přidá jídlo do zvoleného dne. */
|
/** Přidá jídlo do zvoleného dne. */
|
||||||
router.post("/add", async (req: Request, res: Response, next: NextFunction) => {
|
router.post("/add", async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
process.env.CALORIE_PROVIDER = 'none';
|
process.env.CALORIE_PROVIDER = 'none';
|
||||||
|
|
||||||
import { addMeal, fillMealGroup, getDay, getMealGroups } from "../meals";
|
import { addMeal, fillMealGroup, getDay, getMealGroups, renameMealGroup } from "../meals";
|
||||||
|
import { searchCalories } from "../calories";
|
||||||
import { resetMemoryStorage } from "../storage/memory";
|
import { resetMemoryStorage } from "../storage/memory";
|
||||||
import { MealType } from "../../../types/gen/types.gen";
|
import { MealType } from "../../../types/gen/types.gen";
|
||||||
|
|
||||||
@@ -118,3 +119,55 @@ describe('hromadné doplnění údajů do skupiny', () => {
|
|||||||
expect((await getDay(LOGIN, '2025-03-02')).entries[0].calories).toBeUndefined();
|
expect((await getDay(LOGIN, '2025-03-02')).entries[0].calories).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('přejmenování skupiny jídel', () => {
|
||||||
|
|
||||||
|
it('přejmenuje a přesune do jiného zdroje všechna jídla skupiny', async () => {
|
||||||
|
await addMeal(LOGIN, { ...cina, date: '2025-03-01', price: 13900 });
|
||||||
|
await addMeal(LOGIN, { ...cina, date: '2025-03-02', name: 'cina' });
|
||||||
|
await addMeal(LOGIN, { ...cina, date: '2025-03-02', source: 'doma' });
|
||||||
|
|
||||||
|
const result = await renameMealGroup(LOGIN, 'Čína', 'Chefie', 'Kuře kung-pao', 'Everest');
|
||||||
|
|
||||||
|
expect(result).toEqual({ count: 2, dates: ['2025-03-01', '2025-03-02'] });
|
||||||
|
expect((await getDay(LOGIN, '2025-03-01')).entries[0])
|
||||||
|
.toMatchObject({ name: 'Kuře kung-pao', source: 'Everest', price: 13900 });
|
||||||
|
const second = (await getDay(LOGIN, '2025-03-02')).entries;
|
||||||
|
expect(second.map(entry => `${entry.name}|${entry.source}`).sort())
|
||||||
|
.toEqual(['Kuře kung-pao|Everest', 'Čína|doma']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prázdný zdroj zdroj odebere a jídla se sloučí se skupinou bez zdroje', async () => {
|
||||||
|
await addMeal(LOGIN, { ...cina, date: '2025-03-01' });
|
||||||
|
await addMeal(LOGIN, { ...cina, date: '2025-03-02', source: undefined });
|
||||||
|
|
||||||
|
await renameMealGroup(LOGIN, 'Čína', 'Chefie', 'Čína', undefined);
|
||||||
|
|
||||||
|
const groups = await getMealGroups(LOGIN);
|
||||||
|
expect(groups).toHaveLength(1);
|
||||||
|
expect(groups[0].count).toBe(2);
|
||||||
|
expect((await getDay(LOGIN, '2025-03-01')).entries[0]).not.toHaveProperty('source');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jídla se shodným zápisem nepočítá', async () => {
|
||||||
|
await addMeal(LOGIN, { ...cina, date: '2025-03-01' });
|
||||||
|
await addMeal(LOGIN, { ...cina, date: '2025-03-02', name: 'čína' });
|
||||||
|
|
||||||
|
const result = await renameMealGroup(LOGIN, 'Čína', 'Chefie', 'Čína', 'Chefie');
|
||||||
|
|
||||||
|
expect(result).toEqual({ count: 1, dates: ['2025-03-02'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('energii na 100 g si knihovna zapamatuje i pod novým názvem', async () => {
|
||||||
|
await addMeal(LOGIN, { ...cina, date: '2025-03-01', weight: 400, caloriesPer100g: 150 });
|
||||||
|
|
||||||
|
await renameMealGroup(LOGIN, 'Čína', 'Chefie', 'Kuře kung-pao', 'Chefie');
|
||||||
|
|
||||||
|
const { suggestions } = await searchCalories(LOGIN, 'Kuře kung-pao');
|
||||||
|
expect(suggestions[0]).toMatchObject({ name: 'Kuře kung-pao', caloriesPer100g: 150 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('odmítne prázdný nový název', async () => {
|
||||||
|
await expect(renameMealGroup(LOGIN, 'Čína', 'Chefie', ' ', 'Chefie')).rejects.toThrow(/nový název/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -81,6 +81,16 @@ describe('návrhy jídel z historie', () => {
|
|||||||
expect(history.map(item => item.name)).toEqual(['Častější', 'Novější', 'Starší']);
|
expect(history.map(item => item.name)).toEqual(['Častější', 'Novější', 'Starší']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('stejnojmenná jídla z různých zdrojů nabídne zvlášť', async () => {
|
||||||
|
await addMeal(LOGIN, { ...gyros, source: 'Everest', price: 15900 });
|
||||||
|
await addMeal(LOGIN, { ...gyros, source: 'Namaste', price: 16900 });
|
||||||
|
|
||||||
|
const history = await getMealHistory(LOGIN);
|
||||||
|
|
||||||
|
expect(history.map(item => `${item.source}: ${item.price}`).sort())
|
||||||
|
.toEqual(['Everest: 15900', 'Namaste: 16900']);
|
||||||
|
});
|
||||||
|
|
||||||
it('historie je oddělená mezi uživateli', async () => {
|
it('historie je oddělená mezi uživateli', async () => {
|
||||||
await addMeal(LOGIN, gyros);
|
await addMeal(LOGIN, gyros);
|
||||||
|
|
||||||
@@ -181,6 +191,7 @@ describe('doplnění do jídel stejného názvu', () => {
|
|||||||
await addMeal(LOGIN, { ...salat, date: '2025-03-01' });
|
await addMeal(LOGIN, { ...salat, date: '2025-03-01' });
|
||||||
await addMeal(LOGIN, { ...salat, date: '2025-03-02', name: 'těstovinový SALÁT' });
|
await addMeal(LOGIN, { ...salat, date: '2025-03-02', name: 'těstovinový SALÁT' });
|
||||||
await addMeal(LOGIN, { ...salat, date: '2025-03-02', name: 'Jiné jídlo' });
|
await addMeal(LOGIN, { ...salat, date: '2025-03-02', name: 'Jiné jídlo' });
|
||||||
|
await addMeal(LOGIN, { ...salat, date: '2025-03-05', source: 'Everest' });
|
||||||
await addMeal(LOGIN, { ...salat, date: '2025-03-04', calories: 300, caloriesPer100g: 120 });
|
await addMeal(LOGIN, { ...salat, date: '2025-03-04', calories: 300, caloriesPer100g: 120 });
|
||||||
const day = await addMeal(LOGIN, {
|
const day = await addMeal(LOGIN, {
|
||||||
...salat, date: DATE, weight: 350, caloriesPer100g: 160, nutrientsPer100g: { protein: 6 },
|
...salat, date: DATE, weight: 350, caloriesPer100g: 160, nutrientsPer100g: { protein: 6 },
|
||||||
@@ -207,6 +218,7 @@ describe('doplnění do jídel stejného názvu', () => {
|
|||||||
const second = (await getDay(LOGIN, '2025-03-02')).entries;
|
const second = (await getDay(LOGIN, '2025-03-02')).entries;
|
||||||
expect(second.find(entry => entry.name === 'těstovinový SALÁT')?.calories).toBe(560);
|
expect(second.find(entry => entry.name === 'těstovinový SALÁT')?.calories).toBe(560);
|
||||||
expect(second.find(entry => entry.name === 'Jiné jídlo')?.calories).toBeUndefined();
|
expect(second.find(entry => entry.name === 'Jiné jídlo')?.calories).toBeUndefined();
|
||||||
|
expect((await getDay(LOGIN, '2025-03-05')).entries[0].calories).toBeUndefined();
|
||||||
expect((await getDay(LOGIN, '2025-03-04')).entries[0].calories).toBe(300);
|
expect((await getDay(LOGIN, '2025-03-04')).entries[0].calories).toBe(300);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ paths:
|
|||||||
$ref: "./paths/meals/groups.yml"
|
$ref: "./paths/meals/groups.yml"
|
||||||
/meals/fillGroup:
|
/meals/fillGroup:
|
||||||
$ref: "./paths/meals/fillGroup.yml"
|
$ref: "./paths/meals/fillGroup.yml"
|
||||||
|
/meals/renameGroup:
|
||||||
|
$ref: "./paths/meals/renameGroup.yml"
|
||||||
|
|
||||||
# Statistiky (/api/stats)
|
# Statistiky (/api/stats)
|
||||||
/stats/summary:
|
/stats/summary:
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
post:
|
post:
|
||||||
operationId: fillSameNameMeals
|
operationId: fillSameNameMeals
|
||||||
summary: |
|
summary: |
|
||||||
Doplní energii a živiny jídla do ostatních jídel stejného názvu, která je nemají.
|
Doplní energii a živiny jídla do ostatních jídel stejného názvu a zdroje, která je nemají.
|
||||||
description: |
|
description: |
|
||||||
Typicky po doplnění kalorií jednomu naimportovanému jídlu — ostatní jídla
|
Typicky po doplnění kalorií jednomu naimportovanému jídlu — ostatní jídla
|
||||||
téhož jména mají stejné složení. Jídla, která energii nebo živiny už mají,
|
téhož jména a zdroje mají stejné složení. Stejnojmenné jídlo z jiného zdroje
|
||||||
se nemění. S `dryRun` jen spočítá, kterých jídel by se to týkalo.
|
ani jídla, která energii nebo živiny už mají, se nemění. S `dryRun` jen spočítá, kterých jídel by se to týkalo.
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ get:
|
|||||||
operationId: getMealGroups
|
operationId: getMealGroups
|
||||||
summary: Vrátí jídla přihlášeného uživatele seskupená podle názvu a zdroje.
|
summary: Vrátí jídla přihlášeného uživatele seskupená podle názvu a zdroje.
|
||||||
description: |
|
description: |
|
||||||
Slouží k hromadnému doplnění chybějících údajů ke starším jídlům. Názvy
|
Seznam jídel s hromadným doplněním chybějících údajů a přejmenováním. Názvy
|
||||||
i zdroje se porovnávají bez diakritiky a velikosti písmen. Seřazeno od
|
i zdroje se porovnávají bez diakritiky a velikosti písmen. Seřazeno od
|
||||||
nejčastěji jedených, při shodě od naposledy jedených.
|
nejčastěji jedených, při shodě od naposledy jedených.
|
||||||
responses:
|
responses:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
get:
|
get:
|
||||||
operationId: getMealHistory
|
operationId: getMealHistory
|
||||||
summary: Vrátí jídla z historie přihlášeného uživatele, každý název jednou.
|
summary: Vrátí jídla z historie přihlášeného uživatele, každou dvojici název a zdroj jednou.
|
||||||
description: |
|
description: |
|
||||||
Slouží k našeptávání při zakládání jídla. Seřazeno od nejčastěji jedených,
|
Slouží k našeptávání při zakládání jídla. Seřazeno od nejčastěji jedených,
|
||||||
při shodě od naposledy jedených.
|
při shodě od naposledy jedených.
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
post:
|
||||||
|
operationId: renameMealGroup
|
||||||
|
summary: Přejmenuje všechna jídla se stejným názvem a zdrojem a případně jim změní zdroj.
|
||||||
|
description: |
|
||||||
|
Na rozdíl od doplnění údajů přepisuje — nový název a zdroj dostanou
|
||||||
|
všechna jídla skupiny ve všech dnech. Když nový název a zdroj patří
|
||||||
|
jiné skupině, jídla se do ní tím sloučí.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [name, newName]
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
description: Název jídla skupiny
|
||||||
|
type: string
|
||||||
|
source:
|
||||||
|
description: Zdroj skupiny; bez něj jde o jídla bez zdroje
|
||||||
|
type: string
|
||||||
|
newName:
|
||||||
|
description: Nový název jídla
|
||||||
|
type: string
|
||||||
|
newSource:
|
||||||
|
description: Nový zdroj; bez něj se zdroj jídlům odebere
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Jídla, která se změnila
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "../../schemas/_index.yml#/FillSameNameResult"
|
||||||
|
"400":
|
||||||
|
description: Neplatná data
|
||||||
|
"401":
|
||||||
|
description: Neautentizovaný uživatel
|
||||||
@@ -217,8 +217,8 @@ MealGroupValues:
|
|||||||
|
|
||||||
MealGroup:
|
MealGroup:
|
||||||
description: |
|
description: |
|
||||||
Všechna jídla uživatele se stejným názvem a zdrojem. Slouží k hromadnému
|
Všechna jídla uživatele se stejným názvem a zdrojem. Položka seznamu jídel,
|
||||||
doplnění údajů, které u starších záznamů chybí.
|
ze kterého se doplňují chybějící údaje a hromadně přejmenovává.
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
required:
|
required:
|
||||||
@@ -267,8 +267,8 @@ MealGroup:
|
|||||||
|
|
||||||
MealSuggestion:
|
MealSuggestion:
|
||||||
description: |
|
description: |
|
||||||
Jídlo z historie uživatele nabízené při zakládání nového. Každý název je
|
Jídlo z historie uživatele nabízené při zakládání nového. Každá dvojice název
|
||||||
v návrzích jen jednou; hodnoty jsou z posledního záznamu toho jména.
|
a zdroj je v návrzích jen jednou; hodnoty jsou z jejího posledního záznamu.
|
||||||
Chod, datum, násobek ani poznámka se nepřenášejí — patří ke konkrétnímu dni.
|
Chod, datum, násobek ani poznámka se nepřenášejí — patří ke konkrétnímu dni.
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
|
|||||||
Reference in new issue
Block a user