feat: split jídel
feat: graf příjmů
This commit is contained in:
1 parent
bc85a37fc0
commit
b880b36edd
32 files changed
+2022
-236
No files matched your search
@@ -75,16 +75,18 @@ cd client && yarn build # tsc --noEmit + vite build → client/dist
|
||||
- **Pages:** `pages/` — `DayPage` (přehled dne se záložkami Příjem/Výdej),
|
||||
`TemplatesPage` (šablony tréninků a recepty), `ProfilePage` (Profil — ukazatele,
|
||||
vývoj hmotnosti, tělesné údaje, cíle a klidový výdej),
|
||||
`StatsPage` (statistiky příjmu a výdeje + import; výdej v `ActivityStatsPanel`),
|
||||
`StatsPage` (statistiky se záložkami Příjem / Výdej / Útrata a import; panely
|
||||
`IntakeStatsPanel`, `ActivityStatsPanel`, `SpendingStatsPanel`),
|
||||
`MealGroupsPage` (`/statistiky/doplneni` — hromadné doplnění údajů jídel)
|
||||
- **Components:** `components/` (Header, CalorieLookup, MealSuggestions, NutrientsFieldset)
|
||||
a `components/modals/` (MealModal, ActivityModal, ApplyWorkoutModal, ApplyMealModal —
|
||||
výběr z historie a receptů, SettingsModal, ImportModal)
|
||||
- **Klientské enumy** patří do `enums.ts` jako TypeScript `enum` s mapou popisků,
|
||||
ne jako inline union u komponenty (`DayTab`, `StatsRange`)
|
||||
ne jako inline union u komponenty (`DayTab`, `StatsTab`, `StatsRange`)
|
||||
- **Context:** `context/auth.tsx` (JWT), `context/settings.tsx` (světlý/tmavý motiv)
|
||||
- **Routing:** konstanty adres jsou v `routes.ts`, ne v `AppRoutes.tsx` — hlavička
|
||||
je potřebuje a kruhový import by je nechal nedefinované
|
||||
je potřebuje a kruhový import by je nechal nedefinované. Přehled dne čte datum
|
||||
z parametru `?datum=YYYY-MM-DD` (`getDayUrl`), na konkrétní den se tak dá odkázat
|
||||
- **Styling:** Bootstrap 5 + React Bootstrap + SCSS; design systém a proměnné
|
||||
`--ft-*` jsou v `App.scss`, stránkové styly co-located vedle komponent
|
||||
- **API:** přes OpenAPI SDK z `types/gen/`
|
||||
@@ -250,6 +252,14 @@ stejný cvik dvakrát. Smazání všech cviků šablony ve dni `dropOrphanedPlan
|
||||
bere jako vrácené použití a plán zahodí. Aktivity ze šablony bez plánu (cvik
|
||||
přesunutý z jiného dne) se počítají jako splněné přesně dle plánu.
|
||||
|
||||
**Statistiky příjmu** (`getIntakeStats`, `GET /api/stats/intake`) jsou protějšek
|
||||
výdeje: příjem po chodech a zdrojích, živiny a porovnání s výdejem. Výdej
|
||||
a bilance se počítají **jen za dny, kdy má aspoň jedno jídlo kalorie**
|
||||
(`caloriesDayCount`) — den bez zápisu nebo s jídly z importu bez kalorií by jinak
|
||||
vypadal jako deficit celého výdeje. Ze stejného důvodu z nich vycházejí průměry.
|
||||
Klidový výdej je k hmotnosti platné v daný den (`getBasalOn` v `dayOverview.ts`).
|
||||
Peníze za jídlo jsou na samostatné záložce Útrata (`getFoodStats`, `/api/stats/summary`).
|
||||
|
||||
`GET /api/day?date=` vrací `DayOverview` se vším naráz (jídlo, pohyb, bilance),
|
||||
takže `DayPage` si vystačí s jedním voláním.
|
||||
|
||||
@@ -279,6 +289,8 @@ ostatních jídel stejného názvu, která je nemají (`fillSameNameMeals`,
|
||||
**Hromadné doplnění** (`MealGroupsPage`, odkaz ze Statistik → Příjem) seskupí
|
||||
jídla podle znormalizovaného názvu **a zdroje** (`getMealGroups`,
|
||||
`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
|
||||
údajů), a stránka je nabízí jako odkazy na přehled dne v nové záložce.
|
||||
`fillMealGroup` (`POST /api/meals/fillGroup`) doplní do skupiny **jen chybějící
|
||||
hodnoty**: gramáž ne tam, kde jde dopočítat z vlastní ceny a sazby, energii jen
|
||||
jídlům bez kalorií (jinak by dopočet přepsal ručně zadané kalorie), živiny po
|
||||
@@ -343,6 +355,13 @@ ve všech třech formátech, které Luncher exportuje (XLSX, CSV, JSON — viz
|
||||
dnech jsou (`existing`). Uživatel v něm řádky odškrtá a ostrý import dostane
|
||||
jen `selectedKeys`.
|
||||
|
||||
Jídlo, které Luncher zapsal jako jedno ("Chicken tikka massala+garlic nun"),
|
||||
jde v přehledu dne rozdělit (`MealSplitModal`, `splitMeal`, `POST /api/meals/split`)
|
||||
— import se kvůli tomu nekomplikuje. Původní záznam si nechá `importKey`, takže
|
||||
ho režim PRIDAT znovu nezaloží; odštěpená část dostane jen `importSource`, aby ji
|
||||
režim NAHRADIT odebral spolu s původním a nevzniklo jídlo dvakrát. Cena se mezi
|
||||
části nedopočítává, rozdělí se jen na vyžádání (napůl).
|
||||
|
||||
**Když se změní formát exportu v Luncheru, je potřeba upravit i tento parser**
|
||||
a testy v `server/src/tests/luncherImport.test.ts`, které si export sestavují
|
||||
přesně tak, jak ho Luncher generuje.
|
||||
|
||||
@@ -950,3 +950,73 @@ body {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ROZDĚLENÍ JÍDLA — původní a nové vedle sebe
|
||||
// ============================================
|
||||
|
||||
.meal-split-intro {
|
||||
font-size: 0.88rem;
|
||||
color: var(--ft-text-secondary);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.meal-split-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 120px minmax(0, 1fr) 34px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
// Nahoru, ne na střed — nabídka návrhů pod názvem jinak odsune popisek doprostřed
|
||||
align-items: start;
|
||||
|
||||
.meal-split-head {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--ft-text-muted);
|
||||
}
|
||||
|
||||
.meal-split-label {
|
||||
padding-top: 9px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--ft-text-secondary);
|
||||
}
|
||||
|
||||
.meal-split-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.meal-split-half {
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.derived-value {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// Na telefonu se popisek přesune nad řádek, ať na pole zbude šířka
|
||||
@media (max-width: 575.98px) {
|
||||
grid-template-columns: minmax(0, 1fr) 30px minmax(0, 1fr);
|
||||
|
||||
> div:empty:first-child {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.meal-split-label {
|
||||
grid-column: 1 / -1;
|
||||
margin-top: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.meal-split-alert {
|
||||
margin: 12px 0 0;
|
||||
padding: 8px 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
|
||||
import { MealGroupDay } from "../../../types";
|
||||
import { formatDateString, getDayOfWeek } from "../Utils";
|
||||
import { getDayUrl } from "../routes";
|
||||
|
||||
type Props = {
|
||||
days: MealGroupDay[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Dny, kdy se jídlo skupiny jedlo, jako odkazy na přehled dne v nové záložce —
|
||||
* aby šlo podívat se, co se ten den dělo, a stránku s doplňováním neopustit.
|
||||
*/
|
||||
const MealGroupDays = ({ days }: Readonly<Props>) => (
|
||||
<div className="meal-group-days">
|
||||
<div className="meal-group-days-title">
|
||||
Otevřít den v nové záložce{days.some(day => day.incomplete) && ' · zvýrazněné dny mají neúplné údaje'}
|
||||
</div>
|
||||
<div className="meal-meta">
|
||||
{days.map(day => (
|
||||
<a
|
||||
key={day.date}
|
||||
href={getDayUrl(day.date)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`tag meal-group-day ${day.incomplete ? 'tag-missing' : ''}`}
|
||||
title={day.incomplete ? 'Některé jídlo tohoto dne nemá kalorie, gramáž nebo živiny' : undefined}
|
||||
>
|
||||
{getDayOfWeek(day.date).substring(0, 2)} {formatDateString(day.date)}
|
||||
<FontAwesomeIcon icon={faArrowUpRightFromSquare} />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default MealGroupDays;
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "react-bootstrap";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faPenToSquare } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faCalendarDays, faPenToSquare } from "@fortawesome/free-solid-svg-icons";
|
||||
import { MealGroup } from "../../../types";
|
||||
import { formatDateString } from "../Utils";
|
||||
import MealGroupForm from "./MealGroupForm";
|
||||
import MealGroupDays from "./MealGroupDays";
|
||||
|
||||
type Props = {
|
||||
group: MealGroup,
|
||||
@@ -18,9 +20,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.
|
||||
* Po rozbalení nabídne formulář, kterým se údaje doplní všem jídlům skupiny,
|
||||
* a dny, kdy se jídlo jedlo, jako odkazy na přehled dne.
|
||||
*/
|
||||
const MealGroupRow = ({ group, expanded, onToggle, onFilled }: Readonly<Props>) => (
|
||||
const MealGroupRow = ({ group, expanded, onToggle, onFilled }: Readonly<Props>) => {
|
||||
const [daysOpen, setDaysOpen] = useState(false);
|
||||
return (
|
||||
<div className="card-panel meal-group">
|
||||
<div className="meal-group-head">
|
||||
<div className="meal-group-info">
|
||||
@@ -45,12 +50,26 @@ const MealGroupRow = ({ group, expanded, onToggle, onFilled }: Readonly<Props>)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="meal-group-actions">
|
||||
{!expanded && (
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
size="sm"
|
||||
active={daysOpen}
|
||||
onClick={() => setDaysOpen(open => !open)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCalendarDays} /> Dny
|
||||
</Button>
|
||||
)}
|
||||
<Button variant={expanded ? 'outline-secondary' : 'primary'} size="sm" onClick={() => onToggle()}>
|
||||
<FontAwesomeIcon icon={faPenToSquare} /> {expanded ? 'Zavřít' : 'Doplnit'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{(expanded || daysOpen) && <MealGroupDays days={group.days} />}
|
||||
{expanded && <MealGroupForm group={group} onFilled={onFilled} onCancel={() => onToggle()} />}
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export default MealGroupRow;
|
||||
@@ -8,11 +8,12 @@ type Props = {
|
||||
entry: MealEntry,
|
||||
onEdit: () => void,
|
||||
onDelete: () => void,
|
||||
onSplit: () => void,
|
||||
onMultiplierChange: (multiplier: number) => void,
|
||||
};
|
||||
|
||||
/** Jeden řádek seznamu jídel v přehledu dne. */
|
||||
const MealRow = ({ entry, onEdit, onDelete, onMultiplierChange }: Readonly<Props>) => {
|
||||
const MealRow = ({ entry, onEdit, onDelete, onSplit, onMultiplierChange }: Readonly<Props>) => {
|
||||
const calories = applyMultiplier(entry.calories, entry.multiplier);
|
||||
const nutrients = getPortionNutrients(entry.nutrientsPer100g, entry.weight, entry.multiplier);
|
||||
const nutrientKeys = NUTRIENTS_IN_ORDER.filter(key => nutrients?.[key] != null);
|
||||
@@ -53,7 +54,7 @@ const MealRow = ({ entry, onEdit, onDelete, onMultiplierChange }: Readonly<Props
|
||||
label="jídla"
|
||||
/>
|
||||
<div className="meal-price">{formatPrice(entry.price)}</div>
|
||||
<RowActions onEdit={onEdit} onDelete={onDelete} label="jídlo" />
|
||||
<RowActions onEdit={onEdit} onDelete={onDelete} onSplit={onSplit} label="jídlo" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ReactNode } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faArrowRight } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
type Props = {
|
||||
label: string,
|
||||
/** Pole původního jídla. */
|
||||
original: ReactNode,
|
||||
/** Pole odštěpené části. */
|
||||
split: ReactNode,
|
||||
/** Zkopíruje hodnotu původního jídla do odštěpené části; bez něj se šipka nezobrazí. */
|
||||
onCopy?: () => void,
|
||||
/** Doplňkové tlačítko pod šipkou, např. rozdělení ceny napůl. */
|
||||
extra?: ReactNode,
|
||||
};
|
||||
|
||||
/** Jeden řádek rozdělení jídla — stejný údaj vlevo u původního a vpravo u nového jídla. */
|
||||
const MealSplitRow = ({ label, original, split, onCopy, extra }: Readonly<Props>) => (
|
||||
<>
|
||||
<div className="meal-split-label">{label}</div>
|
||||
<div className="meal-split-cell">{original}</div>
|
||||
<div className="meal-split-copy">
|
||||
{onCopy && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
title="Zkopírovat doprava"
|
||||
aria-label={`Zkopírovat ${label.toLowerCase()} do nového jídla`}
|
||||
onClick={() => onCopy()}
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowRight} />
|
||||
</button>
|
||||
)}
|
||||
{extra}
|
||||
</div>
|
||||
<div className="meal-split-cell">{split}</div>
|
||||
</>
|
||||
);
|
||||
|
||||
export default MealSplitRow;
|
||||
@@ -1,17 +1,19 @@
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faPenToSquare, faTrash, faBookmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faPenToSquare, faTrash, faBookmark, faScissors } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
type Props = {
|
||||
onEdit: () => void,
|
||||
onDelete: () => void,
|
||||
/** Když je vyplněné, přibude tlačítko pro uložení záznamu jako šablony. */
|
||||
onSaveTemplate?: () => void,
|
||||
/** Když je vyplněné, přibude tlačítko pro rozdělení záznamu na dva. */
|
||||
onSplit?: () => void,
|
||||
/** Doplní se do popisku pro čtečky, např. "Upravit jídlo". */
|
||||
label: string,
|
||||
};
|
||||
|
||||
/** Tlačítka pro úpravu a smazání řádku seznamu. */
|
||||
const RowActions = ({ onEdit, onDelete, onSaveTemplate, label }: Readonly<Props>) => (
|
||||
const RowActions = ({ onEdit, onDelete, onSaveTemplate, onSplit, label }: Readonly<Props>) => (
|
||||
<div className="meal-actions">
|
||||
{onSaveTemplate && (
|
||||
<button
|
||||
@@ -23,6 +25,16 @@ const RowActions = ({ onEdit, onDelete, onSaveTemplate, label }: Readonly<Props>
|
||||
<FontAwesomeIcon icon={faBookmark} />
|
||||
</button>
|
||||
)}
|
||||
{onSplit && (
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => onSplit()}
|
||||
title="Rozdělit na dvě jídla"
|
||||
aria-label={`Rozdělit ${label}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faScissors} />
|
||||
</button>
|
||||
)}
|
||||
<button className="btn-icon" onClick={() => onEdit()} title="Upravit" aria-label={`Upravit ${label}`}>
|
||||
<FontAwesomeIcon icon={faPenToSquare} />
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, Button, Form, Modal } from "react-bootstrap";
|
||||
import {
|
||||
DayRecord, MealEntry, MealInput, MealSuggestion, MealTemplate, MealType, getMealHistory, getMealTemplates, splitMeal,
|
||||
} from "../../../../types";
|
||||
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums";
|
||||
import {
|
||||
deriveAmounts, formatCalories, formatMultiplier, formatPrice, formatPriceInput, formatWeight, normalizeSearch,
|
||||
parseIntInput, parsePriceInput,
|
||||
} from "../../Utils";
|
||||
import MealSplitRow from "../MealSplitRow";
|
||||
import MealSuggestions from "../MealSuggestions";
|
||||
import SourceInput from "../SourceInput";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean,
|
||||
/** Rozdělované jídlo; bez něj je dialog zavřený. */
|
||||
entry?: MealEntry,
|
||||
onClose: () => void,
|
||||
onSaved: (day: DayRecord) => void,
|
||||
};
|
||||
|
||||
/** Údaje jedné části rozdělovaného jídla tak, jak je uživatel píše. */
|
||||
type SplitForm = {
|
||||
name: string,
|
||||
mealType: MealType,
|
||||
source: string,
|
||||
price: string,
|
||||
pricePer100g: string,
|
||||
weight: string,
|
||||
caloriesPer100g: string,
|
||||
calories: string,
|
||||
note: string,
|
||||
};
|
||||
|
||||
/**
|
||||
* Rozdělí název podle prvního "+" — Luncher tak píše jídlo s přílohou
|
||||
* ("Chicken tikka massala+garlic nun"). Bez "+" zůstane celý název vlevo.
|
||||
*/
|
||||
const splitName = (name: string): [string, string] => {
|
||||
const index = name.indexOf('+');
|
||||
if (index < 0) return [name, ''];
|
||||
return [name.substring(0, index).trim(), name.substring(index + 1).trim()];
|
||||
};
|
||||
|
||||
const createOriginalForm = (source: MealEntry, name: string): SplitForm => ({
|
||||
name,
|
||||
mealType: source.mealType,
|
||||
source: source.source ?? '',
|
||||
price: formatPriceInput(source.price),
|
||||
pricePer100g: formatPriceInput(source.pricePer100g),
|
||||
weight: source.weight != null ? String(source.weight) : '',
|
||||
caloriesPer100g: source.caloriesPer100g != null ? String(source.caloriesPer100g) : '',
|
||||
calories: source.calories != null ? String(source.calories) : '',
|
||||
note: source.note ?? '',
|
||||
});
|
||||
|
||||
/** Nová část — ze stejného chodu a zdroje, ostatní je na uživateli. */
|
||||
const createSplitForm = (source: MealEntry, name: string): SplitForm => ({
|
||||
name,
|
||||
mealType: source.mealType,
|
||||
source: source.source ?? '',
|
||||
price: '',
|
||||
pricePer100g: '',
|
||||
weight: '',
|
||||
caloriesPer100g: '',
|
||||
calories: '',
|
||||
note: '',
|
||||
});
|
||||
|
||||
/** Dopočty části — stejně jako ve formuláři jídla. */
|
||||
const deriveForm = (form: SplitForm) => {
|
||||
const price = parsePriceInput(form.price);
|
||||
const pricePer100g = parsePriceInput(form.pricePer100g);
|
||||
const weight = parseIntInput(form.weight);
|
||||
const caloriesPer100g = parseIntInput(form.caloriesPer100g);
|
||||
const derived = deriveAmounts(price, pricePer100g, weight, caloriesPer100g);
|
||||
const manualCalories = parseIntInput(form.calories);
|
||||
return {
|
||||
price, pricePer100g, weight, caloriesPer100g, manualCalories, derived,
|
||||
calories: derived.calories ?? manualCalories,
|
||||
};
|
||||
};
|
||||
|
||||
/** Sestaví vstup serveru z části formuláře. Datum doplní server podle rozdělovaného jídla. */
|
||||
const createMealInput = (form: SplitForm, date: string): MealInput => {
|
||||
const values = deriveForm(form);
|
||||
return {
|
||||
date,
|
||||
mealType: form.mealType,
|
||||
name: form.name.trim(),
|
||||
source: form.source.trim() || undefined,
|
||||
price: values.price,
|
||||
pricePer100g: values.pricePer100g,
|
||||
weight: values.weight,
|
||||
caloriesPer100g: values.caloriesPer100g,
|
||||
// Server si kalorie dopočte sám, ručně poslané použije jen jako zálohu
|
||||
calories: values.derived.calories != null ? undefined : values.manualCalories,
|
||||
note: form.note.trim() || undefined,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Doplní do nové části hodnoty z návrhu, ale jen do prázdných polí. Cena se
|
||||
* nepřebírá — za jídlo se platilo jednou a cena z jiného dne by ji zdvojila.
|
||||
*/
|
||||
const fillEmpty = (current: SplitForm, values: Partial<SplitForm>): SplitForm => {
|
||||
const result = { ...current };
|
||||
for (const [key, value] of Object.entries(values) as [keyof SplitForm, string][]) {
|
||||
if (key === 'name' || !String(current[key]).trim().length) {
|
||||
(result as Record<string, string>)[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Rozdělení jídla, které je zapsané jako jedno, na dvě — typicky import
|
||||
* z Luncheru, kde je jídlo s přílohou v jednom řádku. Vlevo jsou hodnoty
|
||||
* původního jídla, vpravo nové části; šipka zkopíruje údaj zleva doprava.
|
||||
*
|
||||
* Nic se nedopočítává mezi sloupci, cena se rozdělí jen na vyžádání — někdy
|
||||
* zůstane celá u původního jídla, jindy se dělí.
|
||||
*/
|
||||
const MealSplitModal = ({ isOpen, entry, onClose, onSaved }: Readonly<Props>) => {
|
||||
const [original, setOriginal] = useState<SplitForm | undefined>();
|
||||
const [split, setSplit] = useState<SplitForm | undefined>();
|
||||
const [recipes, setRecipes] = useState<MealTemplate[]>([]);
|
||||
const [history, setHistory] = useState<MealSuggestion[]>([]);
|
||||
const [pickedSuggestion, setPickedSuggestion] = useState<string | undefined>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !entry) return;
|
||||
const [originalName, splitPartName] = splitName(entry.name);
|
||||
setOriginal(createOriginalForm(entry, originalName));
|
||||
setSplit(createSplitForm(entry, splitPartName));
|
||||
setPickedSuggestion(undefined);
|
||||
getMealTemplates().then(response => setRecipes(response.data ?? []));
|
||||
getMealHistory().then(response => setHistory(response.data ?? []));
|
||||
}, [isOpen, entry]);
|
||||
|
||||
if (!entry || !original || !split) return null;
|
||||
|
||||
const updateOriginal = (changes: Partial<SplitForm>) => setOriginal(current => current && { ...current, ...changes });
|
||||
const updateSplit = (changes: Partial<SplitForm>) => setSplit(current => current && { ...current, ...changes });
|
||||
const copy = (key: keyof SplitForm) => updateSplit({ [key]: original[key] });
|
||||
|
||||
/** Rozdělí současný součet ceny obou částí napůl; lichý haléř zůstane u původního jídla. */
|
||||
const halvePrice = () => {
|
||||
const total = (parsePriceInput(original.price) ?? 0) + (parsePriceInput(split.price) ?? 0);
|
||||
const half = Math.floor(total / 2);
|
||||
updateOriginal({ price: formatPriceInput(total - half) });
|
||||
updateSplit({ price: formatPriceInput(half) });
|
||||
};
|
||||
|
||||
const applyHistory = (suggestion: MealSuggestion) => {
|
||||
setPickedSuggestion(suggestion.name);
|
||||
setSplit(current => current && fillEmpty(current, {
|
||||
name: suggestion.name,
|
||||
source: suggestion.source ?? '',
|
||||
weight: suggestion.weight != null ? String(suggestion.weight) : '',
|
||||
caloriesPer100g: suggestion.caloriesPer100g != null ? String(suggestion.caloriesPer100g) : '',
|
||||
calories: suggestion.calories != null ? String(suggestion.calories) : '',
|
||||
}));
|
||||
};
|
||||
|
||||
const applyRecipe = (recipe: MealTemplate) => {
|
||||
setPickedSuggestion(recipe.name);
|
||||
setSplit(current => current && fillEmpty(current, {
|
||||
name: recipe.name,
|
||||
weight: recipe.weight != null ? String(recipe.weight) : '',
|
||||
caloriesPer100g: recipe.caloriesPer100g != null ? String(recipe.caloriesPer100g) : '',
|
||||
calories: recipe.calories != null ? String(recipe.calories) : '',
|
||||
}));
|
||||
};
|
||||
|
||||
const originalValues = deriveForm(original);
|
||||
const splitValues = deriveForm(split);
|
||||
const totalPrice = (originalValues.price ?? 0) + (splitValues.price ?? 0);
|
||||
const priceChanged = totalPrice !== (entry.price ?? 0);
|
||||
const canSave = !!original.name.trim().length && !!split.name.trim().length && !saving;
|
||||
|
||||
const save = async () => {
|
||||
if (!canSave) return;
|
||||
setSaving(true);
|
||||
const response = await splitMeal({
|
||||
body: {
|
||||
id: entry.id,
|
||||
date: entry.date,
|
||||
original: {
|
||||
...createMealInput(original, entry.date),
|
||||
// Živiny a násobek se v dialogu nemění a zůstávají původnímu jídlu
|
||||
nutrientsPer100g: entry.nutrientsPer100g,
|
||||
multiplier: entry.multiplier,
|
||||
},
|
||||
split: createMealInput(split, entry.date),
|
||||
},
|
||||
});
|
||||
setSaving(false);
|
||||
if (response.data) onSaved(response.data);
|
||||
};
|
||||
|
||||
/** Textové pole části; u čísel bez desetinné čárky propustí jen číslice. */
|
||||
const textInput = (
|
||||
form: SplitForm, update: (changes: Partial<SplitForm>) => void, key: keyof SplitForm,
|
||||
options: { placeholder?: string, unit?: string, numeric?: boolean } = {},
|
||||
) => {
|
||||
const control = (
|
||||
<Form.Control
|
||||
inputMode={options.numeric ? 'numeric' : (options.unit ? 'decimal' : undefined)}
|
||||
value={form[key]}
|
||||
placeholder={options.placeholder}
|
||||
onChange={event => update({
|
||||
[key]: options.numeric ? event.target.value.replace(/\D/g, '') : event.target.value,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
if (!options.unit) return control;
|
||||
return (
|
||||
<div className="input-group">
|
||||
{control}
|
||||
<span className="input-group-text">{options.unit}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const mealTypeSelect = (form: SplitForm, update: (changes: Partial<SplitForm>) => void) => (
|
||||
<Form.Select value={form.mealType} onChange={event => update({ mealType: event.target.value as MealType })}>
|
||||
{MEAL_TYPES_IN_ORDER.map(mealType => (
|
||||
<option key={mealType} value={mealType}>
|
||||
{MEAL_TYPE_ICONS[mealType]} {MEAL_TYPE_NAMES[mealType]}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
);
|
||||
|
||||
/** Kalorie porce — dopočtené z gramáže se jen ukážou, jinak jdou zadat ručně. */
|
||||
const caloriesCell = (
|
||||
form: SplitForm, update: (changes: Partial<SplitForm>) => void, values: ReturnType<typeof deriveForm>,
|
||||
) => values.derived.calories != null
|
||||
? <div className="derived-value">{formatCalories(values.derived.calories)}</div>
|
||||
: textInput(form, update, 'calories', { numeric: true, unit: 'kcal', placeholder: 'volitelné' });
|
||||
|
||||
return (
|
||||
<Modal show={isOpen} onHide={onClose} centered size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>Rozdělit jídlo</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<p className="meal-split-intro">
|
||||
Z jednoho záznamu vzniknou dva ve stejném dni. Šipkou zkopírujete údaj z původního jídla do nového.
|
||||
</p>
|
||||
<Form onSubmit={event => { event.preventDefault(); save(); }}>
|
||||
<div className="meal-split-grid">
|
||||
<div />
|
||||
<div className="meal-split-head">Původní jídlo</div>
|
||||
<div />
|
||||
<div className="meal-split-head">Nové jídlo</div>
|
||||
|
||||
<MealSplitRow
|
||||
label="Jídlo"
|
||||
original={textInput(original, updateOriginal, 'name')}
|
||||
split={
|
||||
<>
|
||||
<Form.Control
|
||||
autoFocus
|
||||
value={split.name}
|
||||
placeholder="např. Garlic naan"
|
||||
onChange={event => { updateSplit({ name: event.target.value }); setPickedSuggestion(undefined); }}
|
||||
/>
|
||||
{pickedSuggestion !== split.name && (
|
||||
<MealSuggestions
|
||||
recipes={recipes}
|
||||
// Rozdělované jídlo samo sebe nenabízí
|
||||
history={history.filter(item =>
|
||||
normalizeSearch(item.name) !== normalizeSearch(entry.name))}
|
||||
query={split.name}
|
||||
onPickRecipe={recipe => applyRecipe(recipe)}
|
||||
onPickHistory={suggestion => applyHistory(suggestion)}
|
||||
/>
|
||||
)}
|
||||
{pickedSuggestion === split.name && (
|
||||
<Form.Text>doplněna prázdná pole z dřívějška, cena ne</Form.Text>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
onCopy={() => copy('name')}
|
||||
/>
|
||||
<MealSplitRow
|
||||
label="Chod"
|
||||
original={mealTypeSelect(original, updateOriginal)}
|
||||
split={mealTypeSelect(split, updateSplit)}
|
||||
onCopy={() => copy('mealType')}
|
||||
/>
|
||||
<MealSplitRow
|
||||
label="Zdroj"
|
||||
original={<SourceInput value={original.source} onChange={source => updateOriginal({ source })} />}
|
||||
split={<SourceInput value={split.source} onChange={source => updateSplit({ source })} />}
|
||||
onCopy={() => copy('source')}
|
||||
/>
|
||||
<MealSplitRow
|
||||
label="Cena"
|
||||
original={textInput(original, updateOriginal, 'price', { unit: 'Kč', placeholder: '0,00' })}
|
||||
split={textInput(split, updateSplit, 'price', { unit: 'Kč', placeholder: '0,00' })}
|
||||
onCopy={() => copy('price')}
|
||||
extra={
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon meal-split-half"
|
||||
title="Rozdělit cenu napůl"
|
||||
aria-label="Rozdělit cenu napůl"
|
||||
onClick={() => halvePrice()}
|
||||
>
|
||||
½
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<MealSplitRow
|
||||
label="Cena za 100 g"
|
||||
original={textInput(original, updateOriginal, 'pricePer100g', { unit: 'Kč' })}
|
||||
split={textInput(split, updateSplit, 'pricePer100g', { unit: 'Kč' })}
|
||||
onCopy={() => copy('pricePer100g')}
|
||||
/>
|
||||
<MealSplitRow
|
||||
label="Gramáž"
|
||||
original={textInput(original, updateOriginal, 'weight', {
|
||||
numeric: true, unit: 'g', placeholder: originalValues.derived.weight?.toString() ?? 'volitelné',
|
||||
})}
|
||||
split={textInput(split, updateSplit, 'weight', {
|
||||
numeric: true, unit: 'g', placeholder: splitValues.derived.weight?.toString() ?? 'volitelné',
|
||||
})}
|
||||
onCopy={() => copy('weight')}
|
||||
/>
|
||||
<MealSplitRow
|
||||
label="Energie na 100 g"
|
||||
original={textInput(original, updateOriginal, 'caloriesPer100g', { numeric: true, unit: 'kcal' })}
|
||||
split={textInput(split, updateSplit, 'caloriesPer100g', { numeric: true, unit: 'kcal' })}
|
||||
onCopy={() => copy('caloriesPer100g')}
|
||||
/>
|
||||
<MealSplitRow
|
||||
label="Kalorie porce"
|
||||
original={caloriesCell(original, updateOriginal, originalValues)}
|
||||
split={caloriesCell(split, updateSplit, splitValues)}
|
||||
onCopy={() => copy('calories')}
|
||||
/>
|
||||
<MealSplitRow
|
||||
label="Poznámka"
|
||||
original={textInput(original, updateOriginal, 'note')}
|
||||
split={textInput(split, updateSplit, 'note')}
|
||||
onCopy={() => copy('note')}
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
{(entry.nutrientsPer100g || entry.multiplier != null) && (
|
||||
<p className="usage-note">
|
||||
{entry.multiplier != null && `Násobek ${formatMultiplier(entry.multiplier)}`}
|
||||
{entry.multiplier != null && entry.nutrientsPer100g && ' a '}
|
||||
{entry.nutrientsPer100g && (entry.multiplier != null ? 'živiny' : 'Živiny')}
|
||||
{' '}zůstávají u původního jídla, novému je doplníte v úpravě.
|
||||
</p>
|
||||
)}
|
||||
{priceChanged && (
|
||||
<Alert variant="warning" className="meal-split-alert">
|
||||
Součet cen {formatPrice(totalPrice)} nesedí na původní {formatPrice(entry.price ?? 0)}.
|
||||
</Alert>
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<div className="meal-modal-summary">
|
||||
<span>{formatPrice(originalValues.price ?? 0)} + {formatPrice(splitValues.price ?? 0)}</span>
|
||||
{(originalValues.calories != null || splitValues.calories != null) && (
|
||||
<span>
|
||||
{formatCalories(originalValues.calories)} + {formatCalories(splitValues.calories)}
|
||||
</span>
|
||||
)}
|
||||
{splitValues.derived.weight != null && <span>nové {formatWeight(splitValues.derived.weight)}</span>}
|
||||
</div>
|
||||
<Button variant="outline-secondary" onClick={onClose}>Zrušit</Button>
|
||||
<Button onClick={() => save()} disabled={!canSave}>
|
||||
{saving ? 'Ukládám…' : 'Rozdělit'}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default MealSplitModal;
|
||||
+14
-1
@@ -78,7 +78,7 @@ export const ACTIVITY_PRESETS: { name: string, unit: ActivityUnit, caloriesPer10
|
||||
{ name: 'Shyby', unit: ActivityUnit.OPAKOVANI, caloriesPer100Units: 100 },
|
||||
];
|
||||
|
||||
/** Záložky příjem/výdej — v přehledu dne i ve statistikách. */
|
||||
/** Záložky příjem/výdej v přehledu dne. */
|
||||
export enum DayTab {
|
||||
PRIJEM = 'PRIJEM',
|
||||
VYDEJ = 'VYDEJ',
|
||||
@@ -89,6 +89,19 @@ export const DAY_TAB_NAMES: Record<DayTab, string> = {
|
||||
VYDEJ: 'Výdej',
|
||||
};
|
||||
|
||||
/** Záložky statistik — energie jídla, pohyb a peníze za jídlo. */
|
||||
export enum StatsTab {
|
||||
PRIJEM = 'PRIJEM',
|
||||
VYDEJ = 'VYDEJ',
|
||||
UTRATA = 'UTRATA',
|
||||
}
|
||||
|
||||
export const STATS_TAB_NAMES: Record<StatsTab, string> = {
|
||||
PRIJEM: 'Příjem',
|
||||
VYDEJ: 'Výdej',
|
||||
UTRATA: 'Útrata',
|
||||
};
|
||||
|
||||
/** Rozsahy, za které lze zobrazit statistiky. */
|
||||
export enum StatsRange {
|
||||
MESIC = 'MESIC',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Alert, Button, Spinner } from "react-bootstrap";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faChevronLeft, faChevronRight, faPlus, faUtensils, faPersonRunning,
|
||||
@@ -19,12 +20,14 @@ import {
|
||||
shiftDate,
|
||||
} from "../Utils";
|
||||
import MealModal from "../components/modals/MealModal";
|
||||
import MealSplitModal from "../components/modals/MealSplitModal";
|
||||
import ActivityModal from "../components/modals/ActivityModal";
|
||||
import ApplyWorkoutModal from "../components/modals/ApplyWorkoutModal";
|
||||
import ApplyMealModal from "../components/modals/ApplyMealModal";
|
||||
import SaveWorkoutTemplateModal from "../components/modals/SaveWorkoutTemplateModal";
|
||||
import MealRow from "../components/MealRow";
|
||||
import ActivityRow from "../components/ActivityRow";
|
||||
import { DAY_DATE_PARAM } from "../routes";
|
||||
import "./DayPage.scss";
|
||||
|
||||
/** Prázdný přehled dne — použije se, než dorazí data ze serveru. */
|
||||
@@ -37,8 +40,13 @@ function emptyOverview(date: string): DayOverview {
|
||||
};
|
||||
}
|
||||
|
||||
/** Den z adresy (odkaz ze statistik nebo z doplnění údajů), jinak dnešek. */
|
||||
const getInitialDate = (param: string | null): string =>
|
||||
param && /^\d{4}-\d{2}-\d{2}$/.test(param) ? param : formatDate(new Date());
|
||||
|
||||
export default function DayPage() {
|
||||
const [date, setDate] = useState<string>(() => formatDate(new Date()));
|
||||
const [searchParams] = useSearchParams();
|
||||
const [date, setDate] = useState<string>(() => getInitialDate(searchParams.get(DAY_DATE_PARAM)));
|
||||
const [day, setDay] = useState<DayOverview>(() => emptyOverview(date));
|
||||
const [tab, setTab] = useState<DayTab>(DayTab.PRIJEM);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -55,6 +63,7 @@ export default function DayPage() {
|
||||
const [savedNotice, setSavedNotice] = useState<string | undefined>();
|
||||
/** Jídlo, jehož kalorie jde nabídnout ostatním jídlům stejného názvu. */
|
||||
const [fillOfferEntry, setFillOfferEntry] = useState<MealEntry | undefined>();
|
||||
const [splitMealEntry, setSplitMealEntry] = useState<MealEntry | undefined>();
|
||||
|
||||
const loadDay = useCallback(async (forDate: string) => {
|
||||
setLoading(true);
|
||||
@@ -337,6 +346,7 @@ export default function DayPage() {
|
||||
entry={entry}
|
||||
onEdit={() => { setEditedMeal(entry); setMealModalOpen(true); }}
|
||||
onDelete={() => removeMeal(entry)}
|
||||
onSplit={() => setSplitMealEntry(entry)}
|
||||
onMultiplierChange={multiplier => changeMealMultiplier(entry, multiplier)}
|
||||
/>
|
||||
))}
|
||||
@@ -374,6 +384,17 @@ export default function DayPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<MealSplitModal
|
||||
isOpen={!!splitMealEntry}
|
||||
entry={splitMealEntry}
|
||||
onClose={() => setSplitMealEntry(undefined)}
|
||||
onSaved={() => {
|
||||
setSplitMealEntry(undefined);
|
||||
showNotice('Jídlo rozděleno.');
|
||||
loadDay(date);
|
||||
}}
|
||||
/>
|
||||
|
||||
<MealModal
|
||||
isOpen={mealModalOpen}
|
||||
date={date}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button, Spinner } from "react-bootstrap";
|
||||
import { useNavigate } from "react-router";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faCalendarDay, faChartColumn, faDrumstickBite, faFire, faPenToSquare, faScaleBalanced, faTriangleExclamation,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
Bar, CartesianGrid, Cell, ComposedChart, Legend, Line, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
|
||||
} from "recharts";
|
||||
import { IntakeStats, MealType, getIntakeStats } from "../../../types";
|
||||
import { MEAL_TYPE_NAMES, NUTRIENT_NAMES, NUTRIENTS_IN_ORDER, StatsRange } from "../enums";
|
||||
import { formatBalance, formatCalories, formatNutrient } from "../Utils";
|
||||
import { DOPLNENI_JIDEL_URL } from "../routes";
|
||||
import { StatsBounds, TOOLTIP_STYLE } from "./statsShared";
|
||||
import { MEAL_TYPE_COLORS, buildIntakeSeries, buildSourceSlices, getUsedMealTypes } from "./intakeChartSeries";
|
||||
import IntakeTooltip from "./IntakeTooltip";
|
||||
|
||||
type Props = {
|
||||
range: StatsBounds;
|
||||
mode: StatsRange;
|
||||
/** Změna vynutí nové načtení — po importu. */
|
||||
reloadKey: number;
|
||||
};
|
||||
|
||||
/** Živiny, které jsou součástí jiné ("z toho cukry") — v tabulce odsazené. */
|
||||
const SUB_NUTRIENTS = new Set(['sugars', 'saturatedFat']);
|
||||
|
||||
/**
|
||||
* Statistiky energetického příjmu: kolik se snědlo, v jakých chodech a jídlech,
|
||||
* jaké živiny a jak to vychází proti výdeji. Protějšek statistik výdeje.
|
||||
*/
|
||||
const IntakeStatsPanel = ({ range, mode, reloadKey }: Readonly<Props>) => {
|
||||
const navigate = useNavigate();
|
||||
const [stats, setStats] = useState<IntakeStats | undefined>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
getIntakeStats({ query: range }).then(response => {
|
||||
if (cancelled) return;
|
||||
setStats(response.data);
|
||||
setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [range, reloadKey]);
|
||||
|
||||
const series = useMemo(() => stats ? buildIntakeSeries(stats, range, mode) : [], [stats, range, mode]);
|
||||
const mealTypes = useMemo(() => stats ? getUsedMealTypes(stats) : [], [stats]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<Spinner animation="border" variant="success" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats?.entryCount) {
|
||||
return (
|
||||
<div className="card-panel empty-state">
|
||||
<div className="empty-icon"><FontAwesomeIcon icon={faChartColumn} /></div>
|
||||
<div className="empty-title">Za zvolené období nemáte zapsané žádné jídlo</div>
|
||||
<p className="empty-description">
|
||||
Jídla přidáte v přehledu dne na záložce Příjem, nebo si je naimportujte z Luncheru.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Průměry jen ze dnů s kaloriemi — den, kde je žádné jídlo nemá, by průměr stáhl k nule
|
||||
const calorieDays = stats.caloriesDayCount;
|
||||
const averageIntake = calorieDays ? Math.round(stats.totalCalories / calorieDays) : undefined;
|
||||
const averageBurn = calorieDays ? Math.round(stats.burnCalories / calorieDays) : undefined;
|
||||
const averageBalance = averageIntake != null && averageBurn != null ? averageIntake - averageBurn : undefined;
|
||||
const averageProtein = stats.totalNutrients?.protein != null && stats.nutrientsDayCount
|
||||
? stats.totalNutrients.protein / stats.nutrientsDayCount
|
||||
: undefined;
|
||||
const pieData = stats.byMealType.map(group => ({
|
||||
key: group.key as MealType,
|
||||
name: MEAL_TYPE_NAMES[group.key as MealType] ?? group.key,
|
||||
value: group.calories,
|
||||
}));
|
||||
const sourceSlices = buildSourceSlices(stats.bySource);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="summary-grid">
|
||||
<div className="summary-tile accent">
|
||||
<div className="summary-label"><FontAwesomeIcon icon={faFire} /> Snědeno</div>
|
||||
<div className="summary-value">{formatCalories(stats.totalCalories)}</div>
|
||||
<div className="summary-hint">
|
||||
za {stats.dayCount} dní se zápisem
|
||||
{calorieDays < stats.dayCount && `, kalorie má ${calorieDays}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="summary-tile">
|
||||
<div className="summary-label"><FontAwesomeIcon icon={faCalendarDay} /> Průměr na den</div>
|
||||
<div className="summary-value">{formatCalories(averageIntake)}</div>
|
||||
<div className="summary-hint">
|
||||
{calorieDays ? `výdej v průměru ${formatCalories(averageBurn)}` : 'žádný den nemá kalorie'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="summary-tile">
|
||||
<div className="summary-label"><FontAwesomeIcon icon={faScaleBalanced} /> Průměrná bilance</div>
|
||||
<div className="summary-value">{averageBalance != null ? formatBalance(averageBalance) : '—'}</div>
|
||||
<div className="summary-hint">
|
||||
{!stats.hasBasal && 'jen jídlo proti pohybu, bez klidového výdeje'}
|
||||
{stats.hasBasal && (stats.targetDailyBalance != null
|
||||
? `cíl ${formatBalance(stats.targetDailyBalance)} denně`
|
||||
: 'cíl není nastavený')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="summary-tile">
|
||||
<div className="summary-label"><FontAwesomeIcon icon={faDrumstickBite} /> Bílkoviny na den</div>
|
||||
<div className="summary-value">{formatNutrient(averageProtein)}</div>
|
||||
<div className="summary-hint">
|
||||
{stats.proteinTargetGrams != null
|
||||
? `cíl ${formatNutrient(stats.proteinTargetGrams)}`
|
||||
: `z ${stats.nutrientsDayCount} dní s živinami`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stats.missingCaloriesCount > 0 && (
|
||||
<div className="card-panel intake-missing">
|
||||
<FontAwesomeIcon icon={faTriangleExclamation} className="plan-note-icon" />
|
||||
<span>
|
||||
{stats.missingCaloriesCount} z {stats.entryCount} jídel nemá kalorie, do součtů nevstupují
|
||||
a příjem tak vychází nižší, než byl.
|
||||
</span>
|
||||
<Button variant="outline-secondary" size="sm" onClick={() => navigate(DOPLNENI_JIDEL_URL)}>
|
||||
<FontAwesomeIcon icon={faPenToSquare} /> Doplnit
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card-panel chart-panel">
|
||||
<h2 className="chart-title">Příjem v čase</h2>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<ComposedChart data={series} margin={{ top: 12, right: 8, bottom: 0, left: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--ft-border)" vertical={false} />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 12 }} stroke="var(--ft-text-muted)" />
|
||||
<YAxis tick={{ fontSize: 12 }} stroke="var(--ft-text-muted)" width={56} />
|
||||
<Tooltip content={<IntakeTooltip />} cursor={{ fill: 'var(--ft-bg-hover)' }} />
|
||||
<Legend />
|
||||
{mealTypes.map((mealType, index) => (
|
||||
<Bar
|
||||
key={mealType}
|
||||
dataKey={mealType}
|
||||
name={MEAL_TYPE_NAMES[mealType]}
|
||||
stackId="prijem"
|
||||
fill={MEAL_TYPE_COLORS[mealType]}
|
||||
stroke="var(--ft-bg-card)"
|
||||
strokeWidth={1}
|
||||
radius={index === mealTypes.length - 1 ? [4, 4, 0, 0] : undefined}
|
||||
/>
|
||||
))}
|
||||
<Line
|
||||
dataKey="burn"
|
||||
name="Výdej"
|
||||
stroke="var(--ft-text-secondary)"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 4"
|
||||
dot={{ r: 2, fill: 'var(--ft-text-secondary)' }}
|
||||
activeDot={false}
|
||||
type="linear"
|
||||
/>
|
||||
{stats.targetDailyBalance != null && (
|
||||
<Line
|
||||
dataKey="target"
|
||||
name="Cílový příjem"
|
||||
stroke="var(--ft-primary)"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="2 3"
|
||||
dot={false}
|
||||
activeDot={false}
|
||||
type="linear"
|
||||
/>
|
||||
)}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
<p className="usage-note">
|
||||
Výdej je klidový metabolismus a pohyb, jen za dny, kdy má aspoň jedno jídlo kalorie —
|
||||
den bez nich by jinak vypadal jako velký deficit.
|
||||
{!stats.hasBasal && ' Klidový výdej nemáte nastavený, čára ukazuje jen pohyb; doplníte ho v Profilu.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="chart-row">
|
||||
<div className="card-panel chart-panel">
|
||||
<h2 className="chart-title">Podle chodu</h2>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<PieChart>
|
||||
<Pie data={pieData} dataKey="value" nameKey="name" innerRadius={50} outerRadius={85}>
|
||||
{pieData.map(entry => (
|
||||
<Cell key={entry.key} fill={MEAL_TYPE_COLORS[entry.key] ?? 'var(--ft-series-other)'} />
|
||||
))}
|
||||
</Pie>
|
||||
<Legend />
|
||||
<Tooltip formatter={value => formatCalories(Number(value))} contentStyle={TOOLTIP_STYLE} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="card-panel chart-panel">
|
||||
<h2 className="chart-title">Podle zdroje</h2>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<PieChart>
|
||||
<Pie data={sourceSlices} dataKey="value" nameKey="name" innerRadius={50} outerRadius={85}>
|
||||
{sourceSlices.map(slice => <Cell key={slice.name} fill={slice.color} />)}
|
||||
</Pie>
|
||||
<Legend />
|
||||
<Tooltip formatter={value => formatCalories(Number(value))} contentStyle={TOOLTIP_STYLE} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stats.totalNutrients && (
|
||||
<div className="card-panel chart-panel">
|
||||
<h2 className="chart-title">Živiny — průměr na den</h2>
|
||||
<table className="source-table nutrient-table">
|
||||
<tbody>
|
||||
{NUTRIENTS_IN_ORDER.map(key => (
|
||||
<tr key={key} className={SUB_NUTRIENTS.has(key) ? 'nutrient-sub' : undefined}>
|
||||
<td>{NUTRIENT_NAMES[key]}</td>
|
||||
<td className="numeric">
|
||||
{formatNutrient(stats.totalNutrients?.[key] != null
|
||||
? stats.totalNutrients[key]! / stats.nutrientsDayCount
|
||||
: undefined)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="usage-note">
|
||||
Průměr z {stats.nutrientsDayCount} dní, kdy aspoň jedno jídlo mělo gramáž a nutriční
|
||||
hodnoty. Jídla bez nich se nezapočítávají, skutečný příjem bývá vyšší.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default IntakeStatsPanel;
|
||||
@@ -0,0 +1,55 @@
|
||||
import { TooltipContentProps } from "recharts";
|
||||
import { formatBalance, formatCalories } from "../Utils";
|
||||
import { TOOLTIP_STYLE } from "./statsShared";
|
||||
import { IntakeSeriesRow } from "./intakeChartSeries";
|
||||
|
||||
/** Klíče řad, které nejsou chody — v bublině mají vlastní řádky. */
|
||||
const LINE_KEYS = new Set(['burn', 'target']);
|
||||
|
||||
/** Bublina sloupce příjmu — díly po chodech, výdej a bilance za dny s kaloriemi. */
|
||||
const IntakeTooltip = ({ active, payload, label }: Readonly<Partial<TooltipContentProps<number, string>>>) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const row = payload[0].payload as IntakeSeriesRow;
|
||||
const parts = payload.filter(item => !LINE_KEYS.has(String(item.dataKey)) && Number(item.value) > 0);
|
||||
return (
|
||||
<div className="activity-tooltip" style={TOOLTIP_STYLE}>
|
||||
<div className="activity-tooltip-title">{label}</div>
|
||||
{!row.dayCount && <div className="activity-tooltip-empty">nic nezapsáno</div>}
|
||||
{parts.map(item => (
|
||||
<div key={String(item.dataKey)} className="activity-tooltip-row">
|
||||
<span className="activity-tooltip-swatch" style={{ background: item.color }} />
|
||||
<span>{item.name}</span>
|
||||
<span className="activity-tooltip-value">{formatCalories(Number(item.value))}</span>
|
||||
</div>
|
||||
))}
|
||||
{row.burn != null && (
|
||||
<>
|
||||
<div className="activity-tooltip-row activity-tooltip-plan">
|
||||
<span className="activity-tooltip-swatch plan" />
|
||||
<span>Výdej</span>
|
||||
<span className="activity-tooltip-value">{formatCalories(row.burn)}</span>
|
||||
</div>
|
||||
<div className="activity-tooltip-row intake-tooltip-balance">
|
||||
<span>Bilance</span>
|
||||
<span className="activity-tooltip-value">{formatBalance(row.calories - row.burn)}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{row.targetBalance != null && (
|
||||
<div className="activity-tooltip-row activity-tooltip-plan">
|
||||
<span>Cílová bilance</span>
|
||||
<span className="activity-tooltip-value">{formatBalance(row.targetBalance)}</span>
|
||||
</div>
|
||||
)}
|
||||
{row.dayCount > row.caloriesDayCount && (
|
||||
<div className="activity-tooltip-empty">
|
||||
{row.caloriesDayCount
|
||||
? `bilance jen za ${row.caloriesDayCount} z ${row.dayCount} dní, ostatní nemají kalorie`
|
||||
: 'jídla bez kalorií'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IntakeTooltip;
|
||||
@@ -54,3 +54,38 @@
|
||||
border-top: 1px solid var(--ft-border-light);
|
||||
}
|
||||
}
|
||||
|
||||
.meal-group {
|
||||
.meal-group-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.meal-group-days {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--ft-border-light);
|
||||
}
|
||||
|
||||
.meal-group-days-title {
|
||||
font-size: 0.8rem;
|
||||
color: var(--ft-text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.meal-group-day {
|
||||
text-decoration: none;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
svg {
|
||||
font-size: 0.65rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--ft-primary-light);
|
||||
color: var(--ft-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Spinner } from "react-bootstrap";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCalendarDay, faChartColumn, faCoins, faListCheck, faReceipt } from "@fortawesome/free-solid-svg-icons";
|
||||
import { Bar, BarChart, CartesianGrid, Cell, Legend, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { FoodStats, MealType, getFoodStats } from "../../../types";
|
||||
import { MEAL_TYPE_NAMES, StatsRange } from "../enums";
|
||||
import { formatPrice, formatPriceShort } from "../Utils";
|
||||
import { StatsBounds, TOOLTIP_STYLE, formatPeriodLabel, pickPeriods } from "./statsShared";
|
||||
|
||||
type Props = {
|
||||
range: StatsBounds;
|
||||
mode: StatsRange;
|
||||
/** Změna vynutí nové načtení — po importu. */
|
||||
reloadKey: number;
|
||||
};
|
||||
|
||||
/** Barvy výsečí grafu dle typu jídla — laděné do zelené palety aplikace. */
|
||||
const PIE_COLORS = ['#16a34a', '#22c55e', '#4ade80', '#86efac', '#0ea5e9', '#6366f1', '#a3a3a3'];
|
||||
|
||||
/** Naformátuje hodnotu v bublině grafu na koruny. Hodnoty chodí z rechartsu jako `unknown`. */
|
||||
const formatChartPrice = (value: unknown): string => `${Number(value).toLocaleString('cs-CZ')} Kč`;
|
||||
|
||||
/** Statistiky peněz za jídlo: kolik, kdy, za jaké chody a kde. */
|
||||
const SpendingStatsPanel = ({ range, mode, reloadKey }: Readonly<Props>) => {
|
||||
const [stats, setStats] = useState<FoodStats | undefined>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
getFoodStats({ query: range }).then(response => {
|
||||
if (cancelled) return;
|
||||
setStats(response.data);
|
||||
setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [range, reloadKey]);
|
||||
|
||||
/** Řada pro sloupcový graf — dny u měsíce, měsíce u roku, roky u celého přehledu. */
|
||||
const series = useMemo(() => stats ? pickPeriods(stats, mode).map(total => ({
|
||||
label: formatPeriodLabel(total.period, mode),
|
||||
price: total.price / 100,
|
||||
})) : [], [stats, mode]);
|
||||
|
||||
const pieData = useMemo(() => (stats?.byMealType ?? [])
|
||||
.filter(total => total.price > 0)
|
||||
.map(total => ({
|
||||
name: MEAL_TYPE_NAMES[total.key as MealType] ?? total.key,
|
||||
value: total.price / 100,
|
||||
})), [stats]);
|
||||
|
||||
const averagePerDay = stats?.dayCount ? Math.round(stats.totalPrice / stats.dayCount) : 0;
|
||||
const pricedCount = (stats?.byMealType ?? []).reduce((sum, total) => sum + (total.price > 0 ? total.count : 0), 0);
|
||||
// Jídla bez ceny (vlastní, doma) by průměr na jídlo stahovala k nule
|
||||
const averagePerMeal = pricedCount ? Math.round(stats!.totalPrice / pricedCount) : 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="summary-grid">
|
||||
<div className="summary-tile accent">
|
||||
<div className="summary-label"><FontAwesomeIcon icon={faCoins} /> Celkem utraceno</div>
|
||||
<div className="summary-value">{formatPrice(stats?.totalPrice ?? 0)}</div>
|
||||
</div>
|
||||
<div className="summary-tile">
|
||||
<div className="summary-label"><FontAwesomeIcon icon={faCalendarDay} /> Průměr na den</div>
|
||||
<div className="summary-value">{formatPrice(averagePerDay)}</div>
|
||||
<div className="summary-hint">z {stats?.dayCount ?? 0} vykázaných dní</div>
|
||||
</div>
|
||||
<div className="summary-tile">
|
||||
<div className="summary-label"><FontAwesomeIcon icon={faReceipt} /> Průměr na jídlo</div>
|
||||
<div className="summary-value">{formatPrice(averagePerMeal)}</div>
|
||||
<div className="summary-hint">jen jídla s cenou</div>
|
||||
</div>
|
||||
<div className="summary-tile">
|
||||
<div className="summary-label"><FontAwesomeIcon icon={faListCheck} /> Jídel</div>
|
||||
<div className="summary-value">{stats?.entryCount ?? 0}</div>
|
||||
<div className="summary-hint">z toho placených {pricedCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="empty-state">
|
||||
<Spinner animation="border" variant="success" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !stats?.entryCount && (
|
||||
<div className="card-panel empty-state">
|
||||
<div className="empty-icon"><FontAwesomeIcon icon={faChartColumn} /></div>
|
||||
<div className="empty-title">Za zvolené období nemáte žádná data</div>
|
||||
<p className="empty-description">
|
||||
Přidejte jídla v přehledu dne, nebo si sem naimportujte měsíční přehled z Luncheru.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !!stats?.entryCount && (
|
||||
<>
|
||||
<div className="card-panel chart-panel">
|
||||
<h2 className="chart-title">Útrata v čase</h2>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={series} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--ft-border)" vertical={false} />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 12 }} stroke="var(--ft-text-muted)" />
|
||||
<YAxis tick={{ fontSize: 12 }} stroke="var(--ft-text-muted)" width={56} />
|
||||
<Tooltip
|
||||
formatter={value => [formatChartPrice(value), 'Útrata']}
|
||||
contentStyle={TOOLTIP_STYLE}
|
||||
/>
|
||||
<Bar dataKey="price" fill="var(--ft-primary)" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="chart-row">
|
||||
<div className="card-panel chart-panel">
|
||||
<h2 className="chart-title">Podle chodu</h2>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<PieChart>
|
||||
<Pie data={pieData} dataKey="value" nameKey="name" innerRadius={50} outerRadius={85}>
|
||||
{pieData.map((entry, index) => (
|
||||
<Cell key={entry.name} fill={PIE_COLORS[index % PIE_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Legend />
|
||||
<Tooltip formatter={value => formatChartPrice(value)} contentStyle={TOOLTIP_STYLE} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="card-panel chart-panel">
|
||||
<h2 className="chart-title">Kde nejvíc utrácíte</h2>
|
||||
<table className="source-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zdroj</th>
|
||||
<th className="numeric">Jídel</th>
|
||||
<th className="numeric">Útrata</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats.bySource.slice(0, 8).map(total => (
|
||||
<tr key={total.key}>
|
||||
<td>{total.key}</td>
|
||||
<td className="numeric">{total.count}</td>
|
||||
<td className="numeric">{formatPriceShort(total.price)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpendingStatsPanel;
|
||||
@@ -314,3 +314,31 @@
|
||||
font-size: 0.8rem;
|
||||
color: var(--ft-text-muted);
|
||||
}
|
||||
|
||||
// Upozornění, že část jídel nemá kalorie a příjem vychází nižší
|
||||
.intake-missing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 16px;
|
||||
font-size: 0.9rem;
|
||||
|
||||
span {
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
}
|
||||
|
||||
.activity-tooltip .intake-tooltip-balance {
|
||||
font-weight: 700;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.nutrient-table .nutrient-sub td {
|
||||
color: var(--ft-text-secondary);
|
||||
|
||||
&:first-child {
|
||||
padding-left: 16px;
|
||||
}
|
||||
}
|
||||
+28
-163
@@ -1,45 +1,40 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Spinner } from "react-bootstrap";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button } from "react-bootstrap";
|
||||
import { useNavigate } from "react-router";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faCoins, faFire, faListCheck, faCalendarDay, faFileImport, faChartColumn, faUtensils, faPersonRunning,
|
||||
faPenToSquare,
|
||||
faCoins, faFileImport, faPenToSquare, faPersonRunning, faUtensils,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis, Legend,
|
||||
} from "recharts";
|
||||
import { FoodStats, MealType, getFoodStats } from "../../../types";
|
||||
import { DAY_TAB_NAMES, DayTab, MEAL_TYPE_NAMES, StatsRange, STATS_RANGE_NAMES } from "../enums";
|
||||
import {
|
||||
formatCalories, formatMonth, formatPrice, formatPriceShort, getMonthEnd, getMonthStart,
|
||||
} from "../Utils";
|
||||
import { StatsRange, STATS_RANGE_NAMES, STATS_TAB_NAMES, StatsTab } from "../enums";
|
||||
import { formatMonth, getMonthEnd, getMonthStart } from "../Utils";
|
||||
import { DOPLNENI_JIDEL_URL } from "../routes";
|
||||
import ImportModal from "../components/modals/ImportModal";
|
||||
import PeriodPicker, { EARLIEST_YEAR } from "../components/PeriodPicker";
|
||||
import ActivityStatsPanel from "./ActivityStatsPanel";
|
||||
import { TOOLTIP_STYLE, formatPeriodLabel, pickPeriods } from "./statsShared";
|
||||
import IntakeStatsPanel from "./IntakeStatsPanel";
|
||||
import SpendingStatsPanel from "./SpendingStatsPanel";
|
||||
import "./StatsPage.scss";
|
||||
|
||||
/** Barvy výsečí grafu dle typu jídla — laděné do zelené palety aplikace. */
|
||||
const PIE_COLORS = ['#16a34a', '#22c55e', '#4ade80', '#86efac', '#0ea5e9', '#6366f1', '#a3a3a3'];
|
||||
const TAB_ICONS: Record<StatsTab, typeof faCoins> = {
|
||||
PRIJEM: faUtensils,
|
||||
VYDEJ: faPersonRunning,
|
||||
UTRATA: faCoins,
|
||||
};
|
||||
|
||||
/** Naformátuje hodnotu v bublině grafu na koruny. Hodnoty chodí z rechartsu jako `unknown`. */
|
||||
function formatChartPrice(value: unknown): string {
|
||||
return `${Number(value).toLocaleString('cs-CZ')} Kč`;
|
||||
}
|
||||
|
||||
/** Ikony záložek statistik — stejné dělení jako přehled dne. */
|
||||
const TAB_ICONS = { [DayTab.PRIJEM]: faUtensils, [DayTab.VYDEJ]: faPersonRunning };
|
||||
const TAB_SUBTITLES: Record<StatsTab, string> = {
|
||||
PRIJEM: 'Kolik jste snědli, v čem a jak to vychází proti výdeji',
|
||||
VYDEJ: 'Kolik jste spálili pohybem a jak se držíte šablon',
|
||||
UTRATA: 'Kolik jste utratili za jídlo a za co',
|
||||
};
|
||||
|
||||
export default function StatsPage() {
|
||||
const [tab, setTab] = useState<DayTab>(DayTab.PRIJEM);
|
||||
const [tab, setTab] = useState<StatsTab>(StatsTab.PRIJEM);
|
||||
const [mode, setMode] = useState<StatsRange>(StatsRange.MESIC);
|
||||
const [month, setMonth] = useState<string>(() => formatMonth(new Date()));
|
||||
const [year, setYear] = useState<number>(() => new Date().getFullYear());
|
||||
const [stats, setStats] = useState<FoodStats | undefined>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
/** Zvyšuje se po importu, aby si panely jídel načetly data znovu. */
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const navigate = useNavigate();
|
||||
|
||||
/** Přepočte hranice období z aktuálně zvoleného rozsahu. */
|
||||
@@ -53,44 +48,14 @@ export default function StatsPage() {
|
||||
return { from: `${EARLIEST_YEAR}-01-01`, to: `${new Date().getFullYear()}-12-31` };
|
||||
}, [mode, month, year]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const response = await getFoodStats({ query: range });
|
||||
setStats(response.data);
|
||||
setLoading(false);
|
||||
}, [range]);
|
||||
|
||||
// Výdej si data načítá sám ve svém panelu
|
||||
useEffect(() => { if (tab === DayTab.PRIJEM) load(); }, [load, tab]);
|
||||
|
||||
/** Řada pro sloupcový graf — dny u měsíce, měsíce u roku, roky u celého přehledu. */
|
||||
const series = useMemo(() => stats ? pickPeriods(stats, mode).map(total => ({
|
||||
label: formatPeriodLabel(total.period, mode),
|
||||
price: total.price / 100,
|
||||
calories: total.calories,
|
||||
})) : [], [stats, mode]);
|
||||
|
||||
const pieData = useMemo(() => (stats?.byMealType ?? [])
|
||||
.filter(total => total.price > 0)
|
||||
.map(total => ({
|
||||
name: MEAL_TYPE_NAMES[total.key as MealType] ?? total.key,
|
||||
value: total.price / 100,
|
||||
})), [stats]);
|
||||
|
||||
const averagePerDay = stats?.dayCount ? Math.round(stats.totalPrice / stats.dayCount) : 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>Statistiky</h1>
|
||||
<p className="page-subtitle">
|
||||
{tab === DayTab.PRIJEM
|
||||
? 'Kolik jste utratili za jídlo a za co'
|
||||
: 'Kolik jste spálili pohybem a jak se držíte šablon'}
|
||||
</p>
|
||||
<p className="page-subtitle">{TAB_SUBTITLES[tab]}</p>
|
||||
</div>
|
||||
{tab === DayTab.PRIJEM && (
|
||||
{tab !== StatsTab.VYDEJ && (
|
||||
<div className="d-flex flex-wrap gap-2">
|
||||
<Button variant="outline-secondary" onClick={() => navigate(DOPLNENI_JIDEL_URL)}>
|
||||
<FontAwesomeIcon icon={faPenToSquare} /> Doplnit údaje jídel
|
||||
@@ -104,13 +69,13 @@ export default function StatsPage() {
|
||||
|
||||
<div className="range-picker card-panel">
|
||||
<div className="range-modes">
|
||||
{Object.values(DayTab).map(value => (
|
||||
{Object.values(StatsTab).map(value => (
|
||||
<button
|
||||
key={value}
|
||||
className={`range-mode ${tab === value ? 'active' : ''}`}
|
||||
onClick={() => setTab(value)}
|
||||
>
|
||||
<FontAwesomeIcon icon={TAB_ICONS[value]} /> {DAY_TAB_NAMES[value]}
|
||||
<FontAwesomeIcon icon={TAB_ICONS[value]} /> {STATS_TAB_NAMES[value]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -128,114 +93,14 @@ export default function StatsPage() {
|
||||
<PeriodPicker mode={mode} month={month} year={year} onMonthChange={setMonth} onYearChange={setYear} />
|
||||
</div>
|
||||
|
||||
{tab === DayTab.VYDEJ && <ActivityStatsPanel range={range} mode={mode} />}
|
||||
|
||||
{tab === DayTab.PRIJEM && (
|
||||
<>
|
||||
<div className="summary-grid">
|
||||
<div className="summary-tile accent">
|
||||
<div className="summary-label"><FontAwesomeIcon icon={faCoins} /> Celkem utraceno</div>
|
||||
<div className="summary-value">{formatPrice(stats?.totalPrice ?? 0)}</div>
|
||||
</div>
|
||||
<div className="summary-tile">
|
||||
<div className="summary-label"><FontAwesomeIcon icon={faCalendarDay} /> Průměr na den</div>
|
||||
<div className="summary-value">{formatPrice(averagePerDay)}</div>
|
||||
<div className="summary-hint">z {stats?.dayCount ?? 0} vykázaných dní</div>
|
||||
</div>
|
||||
<div className="summary-tile">
|
||||
<div className="summary-label"><FontAwesomeIcon icon={faListCheck} /> Jídel</div>
|
||||
<div className="summary-value">{stats?.entryCount ?? 0}</div>
|
||||
</div>
|
||||
<div className="summary-tile">
|
||||
<div className="summary-label"><FontAwesomeIcon icon={faFire} /> Kalorie</div>
|
||||
<div className="summary-value">{formatCalories(stats?.totalCalories)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="empty-state">
|
||||
<Spinner animation="border" variant="success" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !stats?.entryCount && (
|
||||
<div className="card-panel empty-state">
|
||||
<div className="empty-icon"><FontAwesomeIcon icon={faChartColumn} /></div>
|
||||
<div className="empty-title">Za zvolené období nemáte žádná data</div>
|
||||
<p className="empty-description">
|
||||
Přidejte jídla v přehledu dne, nebo si sem naimportujte měsíční přehled z Luncheru.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !!stats?.entryCount && (
|
||||
<>
|
||||
<div className="card-panel chart-panel">
|
||||
<h2 className="chart-title">Útrata v čase</h2>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={series} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--ft-border)" vertical={false} />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 12 }} stroke="var(--ft-text-muted)" />
|
||||
<YAxis tick={{ fontSize: 12 }} stroke="var(--ft-text-muted)" width={56} />
|
||||
<Tooltip
|
||||
formatter={value => [formatChartPrice(value), 'Útrata']}
|
||||
contentStyle={TOOLTIP_STYLE}
|
||||
/>
|
||||
<Bar dataKey="price" fill="var(--ft-primary)" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="chart-row">
|
||||
<div className="card-panel chart-panel">
|
||||
<h2 className="chart-title">Podle chodu</h2>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<PieChart>
|
||||
<Pie data={pieData} dataKey="value" nameKey="name" innerRadius={50} outerRadius={85}>
|
||||
{pieData.map((entry, index) => (
|
||||
<Cell key={entry.name} fill={PIE_COLORS[index % PIE_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Legend />
|
||||
<Tooltip
|
||||
formatter={value => formatChartPrice(value)}
|
||||
contentStyle={TOOLTIP_STYLE}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="card-panel chart-panel">
|
||||
<h2 className="chart-title">Kde nejvíc utrácíte</h2>
|
||||
<table className="source-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zdroj</th>
|
||||
<th className="numeric">Jídel</th>
|
||||
<th className="numeric">Útrata</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats.bySource.slice(0, 8).map(total => (
|
||||
<tr key={total.key}>
|
||||
<td>{total.key}</td>
|
||||
<td className="numeric">{total.count}</td>
|
||||
<td className="numeric">{formatPriceShort(total.price)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{tab === StatsTab.PRIJEM && <IntakeStatsPanel range={range} mode={mode} reloadKey={reloadKey} />}
|
||||
{tab === StatsTab.VYDEJ && <ActivityStatsPanel range={range} mode={mode} />}
|
||||
{tab === StatsTab.UTRATA && <SpendingStatsPanel range={range} mode={mode} reloadKey={reloadKey} />}
|
||||
|
||||
<ImportModal
|
||||
isOpen={importOpen}
|
||||
onClose={() => setImportOpen(false)}
|
||||
onImported={load}
|
||||
onImported={() => setReloadKey(key => key + 1)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ActivityDayStats, PlanDeviationKind } from "../../../types";
|
||||
import { StatsRange } from "../enums";
|
||||
import { formatDateString, shiftDate } from "../Utils";
|
||||
import { StatsBounds, formatPeriodLabel } from "./statsShared";
|
||||
import { formatDateString } from "../Utils";
|
||||
import { StatsBounds, formatPeriodLabel, listPeriods, periodOf } from "./statsShared";
|
||||
|
||||
/** Počet barev kategoriální palety (`--ft-series-1` až `-8`). Další šablony se slijí do "Ostatní šablony". */
|
||||
export const SERIES_COLOR_COUNT = 8;
|
||||
@@ -65,35 +65,6 @@ export function buildTemplateSeries(allTemplateIds: string[], used: Map<string,
|
||||
return { series, keyOf };
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrátí označení všech období rozsahu, i těch bez pohybu — prázdný den v grafu
|
||||
* je informace sama o sobě.
|
||||
*
|
||||
* U "Vše" se začíná prvním rokem s pohybem, jinak by graf otevíralo deset let nul.
|
||||
*/
|
||||
function listPeriods(range: StatsBounds, mode: StatsRange, days: ActivityDayStats[]): string[] {
|
||||
if (mode === StatsRange.MESIC) {
|
||||
const periods: string[] = [];
|
||||
for (let date = range.from; date <= range.to; date = shiftDate(date, 1)) periods.push(date);
|
||||
return periods;
|
||||
}
|
||||
if (mode === StatsRange.ROK) {
|
||||
const year = range.from.substring(0, 4);
|
||||
return Array.from({ length: 12 }, (_, index) => `${year}-${String(index + 1).padStart(2, '0')}`);
|
||||
}
|
||||
if (!days.length) return [];
|
||||
const first = Number(days[0].date.substring(0, 4));
|
||||
const last = Number(range.to.substring(0, 4));
|
||||
return Array.from({ length: last - first + 1 }, (_, index) => String(first + index));
|
||||
}
|
||||
|
||||
/** Zkrátí datum dne na označení období grafu. */
|
||||
const periodOf = (date: string, mode: StatsRange): string => {
|
||||
if (mode === StatsRange.MESIC) return date;
|
||||
if (mode === StatsRange.ROK) return date.substring(0, 7);
|
||||
return date.substring(0, 4);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sestaví sloupce grafu výdeje: každá šablona vlastní díl sloupce, nad ní
|
||||
* aktivity mimo šablony, čára plánu a poznámky o nesplněných cvicích.
|
||||
@@ -106,7 +77,7 @@ const periodOf = (date: string, mode: StatsRange): string => {
|
||||
export function buildActivitySeries(
|
||||
days: ActivityDayStats[], range: StatsBounds, mode: StatsRange, keyOf: (templateId: string) => string,
|
||||
): ActivitySeriesRow[] {
|
||||
const rows = new Map<string, ActivitySeriesRow>(listPeriods(range, mode, days).map(period => [period, {
|
||||
const rows = new Map<string, ActivitySeriesRow>(listPeriods(range, mode, days[0]?.date).map(period => [period, {
|
||||
label: formatPeriodLabel(period, mode), extra: 0, planned: 0, notes: [],
|
||||
}]));
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { IntakeGroupTotal, IntakeStats, MealType } from "../../../types";
|
||||
import { MEAL_TYPES_IN_ORDER, StatsRange } from "../enums";
|
||||
import { StatsBounds, formatPeriodLabel, listPeriods, pickPeriods } from "./statsShared";
|
||||
|
||||
/**
|
||||
* Barva chodu v grafech příjmu. Pořadí sleduje průběh dne, takže sousední
|
||||
* chody mají od sebe dobře odlišitelné barvy palety.
|
||||
*/
|
||||
export const MEAL_TYPE_COLORS: Record<MealType, string> = {
|
||||
SNIDANE: 'var(--ft-series-4)',
|
||||
DOPOLEDNI_SVACINA: 'var(--ft-series-5)',
|
||||
OBED: 'var(--ft-series-1)',
|
||||
ODPOLEDNI_SVACINA: 'var(--ft-series-3)',
|
||||
VECERE: 'var(--ft-series-7)',
|
||||
PULNOCNI_SVACINA: 'var(--ft-series-2)',
|
||||
JINE: 'var(--ft-series-other)',
|
||||
};
|
||||
|
||||
/** Jeden sloupec grafu příjmu — kcal po chodech pod klíčem chodu (`OBED`, ...). */
|
||||
export type IntakeSeriesRow = {
|
||||
label: string;
|
||||
calories: number;
|
||||
/** Výdej dní s kaloriemi; v období bez nich chybí, čára se tam přeruší místo pádu na nulu. */
|
||||
burn: number | null;
|
||||
/** Výdej posunutý o cílovou bilanci — kolik se mělo sníst; jen s nastaveným cílem. */
|
||||
target: number | null;
|
||||
/** Cílová bilance za dny s kaloriemi. */
|
||||
targetBalance: number | null;
|
||||
dayCount: number;
|
||||
caloriesDayCount: number;
|
||||
} & Partial<Record<MealType, number>>;
|
||||
|
||||
/**
|
||||
* Sestaví sloupce grafu příjmu: každý chod vlastní díl sloupce, nad nimi čára
|
||||
* výdeje a případně cílového příjmu.
|
||||
*
|
||||
* Období bez zapsaného jídla zůstávají v grafu jako prázdná, ale výdej v nich
|
||||
* chybí — nezapsaný den (nebo den s jídly bez kalorií) není den, kdy se nejedlo.
|
||||
*
|
||||
* @param stats statistiky příjmu ze serveru
|
||||
* @param range zobrazené období
|
||||
* @param mode rozsah statistik
|
||||
*/
|
||||
export const buildIntakeSeries = (stats: IntakeStats, range: StatsBounds, mode: StatsRange): IntakeSeriesRow[] => {
|
||||
const totalMap = new Map(pickPeriods(stats, mode).map(total => [total.period, total]));
|
||||
return listPeriods(range, mode, stats.byDay[0]?.period).map(period => {
|
||||
const total = totalMap.get(period);
|
||||
const row: IntakeSeriesRow = {
|
||||
label: formatPeriodLabel(period, mode),
|
||||
calories: total?.calories ?? 0,
|
||||
burn: null,
|
||||
target: null,
|
||||
targetBalance: null,
|
||||
dayCount: total?.dayCount ?? 0,
|
||||
caloriesDayCount: total?.caloriesDayCount ?? 0,
|
||||
};
|
||||
if (!total?.caloriesDayCount) return row;
|
||||
row.burn = total.burnCalories;
|
||||
if (stats.targetDailyBalance != null) {
|
||||
row.targetBalance = stats.targetDailyBalance * total.caloriesDayCount;
|
||||
row.target = total.burnCalories + row.targetBalance;
|
||||
}
|
||||
for (const group of total.byMealType) {
|
||||
row[group.key as MealType] = group.calories;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
};
|
||||
|
||||
/** Kolik zdrojů má v koláči vlastní výseč; zbytek se slije do "Ostatní". */
|
||||
const SOURCE_SLICE_COUNT = 7;
|
||||
|
||||
/** Výseč koláče zdrojů. */
|
||||
export type SourceSlice = { name: string, value: number, color: string };
|
||||
|
||||
/**
|
||||
* Sestaví výseče koláče příjmu dle zdroje. Barva jde podle pořadí ve výběru,
|
||||
* zdrojů bývá hodně a pevná barva pro každý by paletu nestačila.
|
||||
*
|
||||
* @param bySource příjem dle zdroje, sestupně dle kcal
|
||||
*/
|
||||
export const buildSourceSlices = (bySource: IntakeGroupTotal[]): SourceSlice[] => {
|
||||
const slices = bySource
|
||||
.filter(group => group.calories > 0)
|
||||
.slice(0, SOURCE_SLICE_COUNT)
|
||||
.map((group, index) => ({ name: group.key, value: group.calories, color: `var(--ft-series-${index + 1})` }));
|
||||
const rest = bySource.slice(SOURCE_SLICE_COUNT).reduce((sum, group) => sum + group.calories, 0);
|
||||
if (rest > 0) slices.push({ name: 'Ostatní', value: rest, color: 'var(--ft-series-other)' });
|
||||
return slices;
|
||||
};
|
||||
|
||||
/** Chody, které v období něco dodaly, v pořadí průběhu dne — pořadí dílů sloupce i legendy. */
|
||||
export const getUsedMealTypes = (stats: IntakeStats): MealType[] => {
|
||||
const used = new Set(stats.byMealType.map(group => group.key));
|
||||
return MEAL_TYPES_IN_ORDER.filter(mealType => used.has(mealType));
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { StatsRange } from "../enums";
|
||||
import { formatDateString, getHumanMonth } from "../Utils";
|
||||
import { formatDateString, getHumanMonth, shiftDate } from "../Utils";
|
||||
|
||||
/** Vzhled bubliny s hodnotou — recharts ji kreslí inline stylem, ne přes CSS třídu. */
|
||||
export const TOOLTIP_STYLE = {
|
||||
@@ -30,3 +30,36 @@ export function pickPeriods<T>(stats: { byDay: T[], byMonth: T[], byYear: T[] },
|
||||
if (mode === StatsRange.ROK) return stats.byMonth;
|
||||
return stats.byYear;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrátí označení všech období rozsahu, i těch bez dat — prázdný den v grafu
|
||||
* je informace sama o sobě.
|
||||
*
|
||||
* U "Vše" se začíná rokem prvního dne s daty, jinak by graf otevíralo deset let nul.
|
||||
*
|
||||
* @param range zobrazené období
|
||||
* @param mode rozsah statistik
|
||||
* @param firstDate první den s daty, YYYY-MM-DD; bez něj je u "Vše" výsledek prázdný
|
||||
*/
|
||||
export const listPeriods = (range: StatsBounds, mode: StatsRange, firstDate?: string): string[] => {
|
||||
if (mode === StatsRange.MESIC) {
|
||||
const periods: string[] = [];
|
||||
for (let date = range.from; date <= range.to; date = shiftDate(date, 1)) periods.push(date);
|
||||
return periods;
|
||||
}
|
||||
if (mode === StatsRange.ROK) {
|
||||
const year = range.from.substring(0, 4);
|
||||
return Array.from({ length: 12 }, (_, index) => `${year}-${String(index + 1).padStart(2, '0')}`);
|
||||
}
|
||||
if (!firstDate) return [];
|
||||
const first = Number(firstDate.substring(0, 4));
|
||||
const last = Number(range.to.substring(0, 4));
|
||||
return Array.from({ length: last - first + 1 }, (_, index) => String(first + index));
|
||||
};
|
||||
|
||||
/** Zkrátí datum dne na označení období grafu. */
|
||||
export const periodOf = (date: string, mode: StatsRange): string => {
|
||||
if (mode === StatsRange.MESIC) return date;
|
||||
if (mode === StatsRange.ROK) return date.substring(0, 7);
|
||||
return date.substring(0, 4);
|
||||
};
|
||||
@@ -9,3 +9,9 @@ export const STATISTIKY_URL = '/statistiky';
|
||||
export const SABLONY_URL = '/sablony';
|
||||
export const PROFIL_URL = '/profil';
|
||||
export const DOPLNENI_JIDEL_URL = '/statistiky/doplneni';
|
||||
|
||||
/** Parametr adresy přehledu dne s datem YYYY-MM-DD. */
|
||||
export const DAY_DATE_PARAM = 'datum';
|
||||
|
||||
/** Adresa přehledu konkrétního dne — pro odkazy, které se otevírají v nové záložce. */
|
||||
export const getDayUrl = (date: string): string => `${DEN_URL}?${DAY_DATE_PARAM}=${date}`;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DayOverview, EnergyBalance } from "../../types/gen/types.gen";
|
||||
import { DayOverview, EnergyBalance, UserSettings, WeightEntry } from "../../types/gen/types.gen";
|
||||
import { getDay } from "./meals";
|
||||
import { getActivityDay } from "./activities";
|
||||
import { getSettings } from "./settings";
|
||||
@@ -42,6 +42,19 @@ export const buildEnergyBalance = (
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Vrátí hmotnost platnou k danému dni — poslední vážení v ten den nebo dřív,
|
||||
* bez vážení hmotnost z profilu, nakonec nejstarší vážení.
|
||||
*/
|
||||
export const getWeightOn = (settings: UserSettings, weightList: WeightEntry[], date: string): number | undefined =>
|
||||
findWeightOn(weightList, date)?.weightGrams
|
||||
?? settings.profile?.weightGrams
|
||||
?? weightList[0]?.weightGrams;
|
||||
|
||||
/** Klidový výdej dne. Ručně zadaný vyhrává nad spočítaným z tělesných údajů a hmotnosti k tomu dni. */
|
||||
export const getBasalOn = (settings: UserSettings, weightList: WeightEntry[], date: string): number | undefined =>
|
||||
settings.basalCalories ?? computeBasalCalories(settings.profile, getWeightOn(settings, weightList, date));
|
||||
|
||||
/**
|
||||
* Sestaví kompletní přehled dne — jídlo, pohyb, spánek, vážení a energetickou bilanci.
|
||||
*
|
||||
@@ -60,11 +73,8 @@ export const getDayOverview = async (login: string, date: string): Promise<DayOv
|
||||
getSleep(login, date),
|
||||
getWeights(login),
|
||||
]);
|
||||
const weightGrams = findWeightOn(weightList, date)?.weightGrams
|
||||
?? settings.profile?.weightGrams
|
||||
?? weightList[0]?.weightGrams;
|
||||
// Ručně zadaný klidový výdej vyhrává nad spočítaným z tělesných údajů
|
||||
const basal = settings.basalCalories ?? computeBasalCalories(settings.profile, weightGrams);
|
||||
const weightGrams = getWeightOn(settings, weightList, date);
|
||||
const basal = getBasalOn(settings, weightList, date);
|
||||
const dayWeight = weightList.find(entry => entry.date === date)?.weightGrams;
|
||||
const proteinTargetGrams = getProteinTarget(settings.goals, weightGrams);
|
||||
return {
|
||||
|
||||
+89
-17
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import {
|
||||
DayRecord, FillSameNameResult, MealEntry, MealGroup, MealGroupValues, MealSuggestion, MealType, Nutrients,
|
||||
DayRecord, FillSameNameResult, MealEntry, MealGroup, MealGroupDay, MealGroupValues, MealSuggestion, MealType, Nutrients,
|
||||
} from "../../types/gen/types.gen";
|
||||
import getStorage from "./storage";
|
||||
import {
|
||||
@@ -237,6 +237,28 @@ export async function addEntries(login: string, entries: MealEntry[], replaceImp
|
||||
return Array.from(byDate.keys()).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Přepíše záznam o jídle novými hodnotami. Identita záznamu (id, vznik, vazba
|
||||
* na import a recept) zůstává.
|
||||
*
|
||||
* Cena, kalorie, zdroj i poznámka se přepisují včetně vymazání na prázdno,
|
||||
* proto se berou z normalizovaného vstupu a ne z původního záznamu.
|
||||
*/
|
||||
const applyMealInput = (original: MealEntry, normalized: NormalizedMealInput): MealEntry => ({
|
||||
...original,
|
||||
...normalized,
|
||||
price: normalized.price,
|
||||
calories: normalized.calories,
|
||||
source: normalized.source,
|
||||
note: normalized.note,
|
||||
weight: normalized.weight,
|
||||
pricePer100g: normalized.pricePer100g,
|
||||
caloriesPer100g: normalized.caloriesPer100g,
|
||||
nutrientsPer100g: normalized.nutrientsPer100g,
|
||||
multiplier: normalized.multiplier,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Upraví existující záznam o jídle a vrátí aktualizovaný přehled dne.
|
||||
*
|
||||
@@ -266,22 +288,7 @@ export async function updateMeal(login: string, id: unknown, input: unknown): Pr
|
||||
throw new NotFoundError("Záznam o jídle nebyl nalezen");
|
||||
}
|
||||
|
||||
// Cena, kalorie, zdroj i poznámka se přepisují včetně vymazání na prázdno,
|
||||
// proto se berou z normalizovaného vstupu a ne z původního záznamu.
|
||||
const updated: MealEntry = {
|
||||
...original,
|
||||
...normalized,
|
||||
price: normalized.price,
|
||||
calories: normalized.calories,
|
||||
source: normalized.source,
|
||||
note: normalized.note,
|
||||
weight: normalized.weight,
|
||||
pricePer100g: normalized.pricePer100g,
|
||||
caloriesPer100g: normalized.caloriesPer100g,
|
||||
nutrientsPer100g: normalized.nutrientsPer100g,
|
||||
multiplier: normalized.multiplier,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const updated = applyMealInput(original, normalized);
|
||||
const entries = await storage.updateData<MealEntry[]>(
|
||||
getDayKey(login, normalized.date),
|
||||
current => [...(current ?? []), updated]
|
||||
@@ -292,6 +299,55 @@ export async function updateMeal(login: string, id: unknown, input: unknown): Pr
|
||||
return buildDayRecord(normalized.date, entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rozdělí jídlo na dvě: původní záznam se přepíše a hned za něj se vloží nový.
|
||||
* Obojí proběhne jedním zápisem dne, takže nemůže zůstat jen půlka.
|
||||
*
|
||||
* Odštěpená část převezme příznak importu, ale ne jeho klíč. Opakovaný import
|
||||
* v režimu přidání tak řádek pozná podle původního záznamu a znovu ho nezaloží;
|
||||
* režim nahrazení odebere obě části a založí jídlo znovu celé, místo aby vedle
|
||||
* rozděleného vzniklo i nerozdělené.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param id identifikátor rozdělovaného jídla
|
||||
* @param date den rozdělovaného jídla; obě části v něm zůstanou
|
||||
* @param originalInput nové hodnoty původního jídla
|
||||
* @param splitInput hodnoty odštěpené části
|
||||
*/
|
||||
export const splitMeal = async (
|
||||
login: string, id: unknown, date: unknown, originalInput: unknown, splitInput: unknown,
|
||||
): Promise<DayRecord> => {
|
||||
if (typeof id !== 'string' || !id.length) {
|
||||
throw new BadRequestError("Nebyl předán identifikátor záznamu");
|
||||
}
|
||||
if (!isValidIsoDate(date)) {
|
||||
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
|
||||
}
|
||||
const original = normalizeInput({ ...(originalInput as object ?? {}), date });
|
||||
const split = normalizeInput({ ...(splitInput as object ?? {}), date });
|
||||
|
||||
let found = false;
|
||||
const entries = await storage.updateData<MealEntry[]>(getDayKey(login, date), current =>
|
||||
(current ?? []).flatMap(entry => {
|
||||
if (entry.id !== id) return [entry];
|
||||
found = true;
|
||||
const created: MealEntry = {
|
||||
id: randomUUID(),
|
||||
createdAt: new Date().toISOString(),
|
||||
...split,
|
||||
...(entry.importSource ? { importSource: entry.importSource } : {}),
|
||||
};
|
||||
return [applyMealInput(entry, original), created];
|
||||
}));
|
||||
if (!found) {
|
||||
throw new NotFoundError("Záznam o jídle nebyl nalezen");
|
||||
}
|
||||
for (const part of [original, split]) {
|
||||
if (part.caloriesPer100g) await rememberCalories(login, part.name, part.caloriesPer100g);
|
||||
}
|
||||
return buildDayRecord(date, entries);
|
||||
};
|
||||
|
||||
/**
|
||||
* Smaže záznam o jídle a vrátí aktualizovaný přehled dne.
|
||||
*
|
||||
@@ -625,6 +681,21 @@ const createMealGroupValues = (groupList: MealEntry[], sameNameList: MealEntry[]
|
||||
*
|
||||
* @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ší. */
|
||||
const isIncomplete = (entry: MealEntry): boolean =>
|
||||
entry.calories == null || entry.weight == null || !entry.nutrientsPer100g;
|
||||
|
||||
/** Dny, kdy se jídla skupiny jedla, ve stejném pořadí jako skupina (od nejnovějšího). */
|
||||
const createMealGroupDays = (groupList: MealEntry[]): MealGroupDay[] => {
|
||||
const dayMap = new Map<string, MealGroupDay>();
|
||||
for (const entry of groupList) {
|
||||
const day = dayMap.get(entry.date) ?? { date: entry.date, incomplete: false };
|
||||
day.incomplete ||= isIncomplete(entry);
|
||||
dayMap.set(entry.date, day);
|
||||
}
|
||||
return [...dayMap.values()];
|
||||
};
|
||||
|
||||
export const getMealGroups = async (login: string): Promise<MealGroup[]> => {
|
||||
const entryList = await findAllMeals(login);
|
||||
const groupMap = new Map<string, MealEntry[]>();
|
||||
@@ -646,6 +717,7 @@ export const getMealGroups = async (login: string): Promise<MealGroup[]> => {
|
||||
missingCaloriesCount: groupList.filter(entry => entry.calories == null).length,
|
||||
missingWeightCount: groupList.filter(entry => entry.weight == null).length,
|
||||
missingNutrientsCount: groupList.filter(entry => !entry.nutrientsPer100g).length,
|
||||
days: createMealGroupDays(groupList),
|
||||
values: createMealGroupValues(groupList, nameMap.get(normalizeName(latest.name)) ?? []),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import express, { Request, Response, NextFunction } from "express";
|
||||
import {
|
||||
addMeal, deleteMeal, fillMealGroup, fillSameNameMeals, getDay, getMealGroups, getMealHistory, getMealSources,
|
||||
getRange, setMealMultiplier, updateMeal,
|
||||
getRange, setMealMultiplier, splitMeal, updateMeal,
|
||||
} from "../meals";
|
||||
import { getDayOverview } from "../dayOverview";
|
||||
import { getLogin } from "../auth";
|
||||
@@ -93,6 +93,15 @@ router.post("/update", async (req: Request, res: Response, next: NextFunction) =
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Rozdělí jídlo na dvě ve stejném dni. */
|
||||
router.post("/split", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const { id, date, original, split } = req.body ?? {};
|
||||
res.status(200).json(await splitMeal(login, id, date, original, split));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Smaže záznam o jídle. */
|
||||
router.post("/delete", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import express, { Request, Response, NextFunction } from "express";
|
||||
import { getActivityStats, getFoodStats } from "../statsService";
|
||||
import { getActivityStats, getFoodStats, getIntakeStats } from "../statsService";
|
||||
import { getLogin } from "../auth";
|
||||
import { parseToken, requireIsoDate } from "../utils";
|
||||
|
||||
@@ -15,6 +15,16 @@ router.get("/summary", async (req: Request, res: Response, next: NextFunction) =
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Vrátí statistiky energetického příjmu přihlášeného uživatele za zvolené období. */
|
||||
router.get("/intake", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const from = requireIsoDate(req.query.from, 'from');
|
||||
const to = requireIsoDate(req.query.to, 'to');
|
||||
res.status(200).json(await getIntakeStats(login, from, to));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Vrátí statistiky výdeje pohybem přihlášeného uživatele za zvolené období. */
|
||||
router.get("/activity", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
|
||||
+139
-2
@@ -1,10 +1,15 @@
|
||||
import {
|
||||
ActivityDayStats, ActivityEntry, ActivityGroupTotal, ActivityPeriodTotal, ActivityStats, ActivitySummaryItem,
|
||||
ActivityUnit, FoodStats, GroupTotal, MealEntry, PeriodTotal, PlanDeviationKind, TemplateDayUsage,
|
||||
TemplateUsageTotal,
|
||||
ActivityUnit, FoodStats, GroupTotal, IntakeGroupTotal, IntakePeriodTotal, IntakeStats, MealEntry, Nutrients,
|
||||
PeriodTotal, PlanDeviationKind, TemplateDayUsage, TemplateUsageTotal,
|
||||
} from "../../types/gen/types.gen";
|
||||
import { getRange } from "./meals";
|
||||
import { getActivityDay } from "./activities";
|
||||
import { getSettings } from "./settings";
|
||||
import { getWeights } from "./weights";
|
||||
import { getBasalOn, getWeightOn } from "./dayOverview";
|
||||
import { getProteinTarget, getTargetDailyBalance } from "./bodyMetrics";
|
||||
import { sumNutrients } from "./nutrients";
|
||||
import { getWorkoutTemplates } from "./workouts";
|
||||
import { WorkoutPlan, WorkoutPlanItem, getWorkoutPlans } from "./workoutPlans";
|
||||
import { BadRequestError, applyMultiplier, listDatesInRange } from "./utils";
|
||||
@@ -89,6 +94,138 @@ export async function getFoodStats(login: string, from: string, to: string): Pro
|
||||
};
|
||||
}
|
||||
|
||||
/** Přičte kcal do skupiny příjmu pod daným klíčem. */
|
||||
const accumulateIntakeGroup = (groups: Map<string, IntakeGroupTotal>, key: string, calories: number): void => {
|
||||
const group = groups.get(key) ?? { key, calories: 0, count: 0 };
|
||||
group.calories += calories;
|
||||
group.count++;
|
||||
groups.set(key, group);
|
||||
};
|
||||
|
||||
/** Seřadí skupiny příjmu sestupně dle kcal. */
|
||||
const sortIntakeGroups = (groups: Iterable<IntakeGroupTotal>): IntakeGroupTotal[] =>
|
||||
Array.from(groups).sort((a, b) => b.calories - a.calories || b.count - a.count || a.key.localeCompare(b.key, 'cs'));
|
||||
|
||||
/** Průběžný součet příjmu jednoho období; chody se sčítají v mapě a do kontraktu jdou až na konci. */
|
||||
type IntakeBucket = {
|
||||
period: string, calories: number, burnCalories: number, dayCount: number, caloriesDayCount: number,
|
||||
mealTypes: Map<string, IntakeGroupTotal>,
|
||||
};
|
||||
|
||||
/**
|
||||
* Přičte den do období pod daným klíčem. Jídla bez kalorií se přeskočí;
|
||||
* výdej má jen den, kde nějaké kalorie jsou.
|
||||
*/
|
||||
const accumulateIntakeDay = (
|
||||
buckets: Map<string, IntakeBucket>, period: string, burn: number | undefined, entryList: MealEntry[],
|
||||
): void => {
|
||||
const bucket = buckets.get(period)
|
||||
?? { period, calories: 0, burnCalories: 0, dayCount: 0, caloriesDayCount: 0, mealTypes: new Map() };
|
||||
bucket.dayCount++;
|
||||
if (burn != null) {
|
||||
bucket.burnCalories += burn;
|
||||
bucket.caloriesDayCount++;
|
||||
}
|
||||
for (const entry of entryList) {
|
||||
const calories = applyMultiplier(entry.calories, entry.multiplier);
|
||||
if (calories == null) continue;
|
||||
bucket.calories += calories;
|
||||
accumulateIntakeGroup(bucket.mealTypes, entry.mealType, calories);
|
||||
}
|
||||
buckets.set(period, bucket);
|
||||
};
|
||||
|
||||
/** Převede období příjmu na kontrakt, vzestupně dle označení období. */
|
||||
const toIntakePeriods = (buckets: Map<string, IntakeBucket>): IntakePeriodTotal[] =>
|
||||
Array.from(buckets.values())
|
||||
.sort((a, b) => a.period.localeCompare(b.period))
|
||||
.map(({ mealTypes, ...bucket }) => ({ ...bucket, byMealType: sortIntakeGroups(mealTypes.values()) }));
|
||||
|
||||
/**
|
||||
* Sestaví statistiky energetického příjmu za zvolené období.
|
||||
*
|
||||
* Výdej (klidový + pohyb) se počítá jen za dny, kdy má aspoň jedno jídlo
|
||||
* kalorie — den, kdy uživatel nic nezapsal nebo kalorie nezná (typicky import
|
||||
* z Luncheru), by jinak vypadal jako obrovský deficit. Klidový výdej je
|
||||
* s hmotností platnou k danému dni, stejně jako v přehledu dne.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param from první den období (včetně), YYYY-MM-DD
|
||||
* @param to poslední den období (včetně), YYYY-MM-DD
|
||||
*/
|
||||
export async function getIntakeStats(login: string, from: string, to: string): Promise<IntakeStats> {
|
||||
const days = await getRange(login, from, to);
|
||||
const [activityDays, settings, weightList] = await Promise.all([
|
||||
Promise.all(days.map(day => getActivityDay(login, day.date))),
|
||||
getSettings(login),
|
||||
getWeights(login),
|
||||
]);
|
||||
|
||||
const byDay = new Map<string, IntakeBucket>();
|
||||
const byMonth = new Map<string, IntakeBucket>();
|
||||
const byYear = new Map<string, IntakeBucket>();
|
||||
const byMealType = new Map<string, IntakeGroupTotal>();
|
||||
const bySource = new Map<string, IntakeGroupTotal>();
|
||||
const nutrientsList: Nutrients[] = [];
|
||||
|
||||
let burnCalories = 0;
|
||||
let hasBasal = false;
|
||||
let entryCount = 0;
|
||||
let missingCaloriesCount = 0;
|
||||
let caloriesDayCount = 0;
|
||||
|
||||
days.forEach((day, index) => {
|
||||
let burn: number | undefined;
|
||||
if (day.entries.some(entry => entry.calories != null)) {
|
||||
const basal = getBasalOn(settings, weightList, day.date);
|
||||
hasBasal ||= !!basal;
|
||||
burn = (basal ?? 0) + activityDays[index].totalCalories;
|
||||
burnCalories += burn;
|
||||
caloriesDayCount++;
|
||||
}
|
||||
entryCount += day.entries.length;
|
||||
if (day.totalNutrients) nutrientsList.push(day.totalNutrients);
|
||||
|
||||
accumulateIntakeDay(byDay, day.date, burn, day.entries);
|
||||
accumulateIntakeDay(byMonth, day.date.substring(0, 7), burn, day.entries);
|
||||
accumulateIntakeDay(byYear, day.date.substring(0, 4), burn, day.entries);
|
||||
|
||||
for (const entry of day.entries) {
|
||||
const calories = applyMultiplier(entry.calories, entry.multiplier);
|
||||
if (calories == null) {
|
||||
missingCaloriesCount++;
|
||||
continue;
|
||||
}
|
||||
accumulateIntakeGroup(byMealType, entry.mealType, calories);
|
||||
accumulateIntakeGroup(bySource, entry.source ?? UNKNOWN_SOURCE, calories);
|
||||
}
|
||||
});
|
||||
|
||||
const targetDailyBalance = getTargetDailyBalance(settings.goals);
|
||||
const proteinTargetGrams = getProteinTarget(settings.goals, getWeightOn(settings, weightList, to));
|
||||
const totalNutrients = sumNutrients(nutrientsList);
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
totalCalories: days.reduce((sum, day) => sum + day.totalCalories, 0),
|
||||
burnCalories,
|
||||
hasBasal,
|
||||
...(targetDailyBalance != null ? { targetDailyBalance } : {}),
|
||||
...(proteinTargetGrams != null ? { proteinTargetGrams } : {}),
|
||||
entryCount,
|
||||
missingCaloriesCount,
|
||||
dayCount: days.length,
|
||||
caloriesDayCount,
|
||||
...(totalNutrients ? { totalNutrients } : {}),
|
||||
nutrientsDayCount: nutrientsList.length,
|
||||
byDay: toIntakePeriods(byDay),
|
||||
byMonth: toIntakePeriods(byMonth),
|
||||
byYear: toIntakePeriods(byYear),
|
||||
byMealType: sortIntakeGroups(byMealType.values()),
|
||||
bySource: sortIntakeGroups(bySource.values()),
|
||||
};
|
||||
}
|
||||
|
||||
/** Název šablony, která už neexistuje a nemá ani uložený plán s názvem. */
|
||||
const DELETED_TEMPLATE = 'Smazaná šablona';
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
process.env.CALORIE_PROVIDER = 'none';
|
||||
|
||||
import { addMeal } from "../meals";
|
||||
import { addActivity } from "../activities";
|
||||
import { saveBasalCalories, saveGoals } from "../settings";
|
||||
import { getIntakeStats } from "../statsService";
|
||||
import { resetMemoryStorage } from "../storage/memory";
|
||||
import { ActivityUnit, MealType } from "../../../types/gen/types.gen";
|
||||
|
||||
const LOGIN = 'tester';
|
||||
|
||||
const stats = () => getIntakeStats(LOGIN, '2025-03-01', '2025-03-31');
|
||||
|
||||
beforeEach(() => resetMemoryStorage());
|
||||
|
||||
describe('statistiky příjmu', () => {
|
||||
|
||||
it('sečte příjem po dnech a chodech včetně násobku, jídla bez kalorií jen spočítá', async () => {
|
||||
await addMeal(LOGIN, { date: '2025-03-03', mealType: MealType.SNIDANE, name: 'Kaše', calories: 300 });
|
||||
await addMeal(LOGIN, {
|
||||
date: '2025-03-03', mealType: MealType.OBED, name: 'Čína', source: 'Chefie', calories: 600, multiplier: 0.5,
|
||||
});
|
||||
await addMeal(LOGIN, { date: '2025-03-04', mealType: MealType.OBED, name: 'Čína', source: 'Chefie', calories: 600 });
|
||||
await addMeal(LOGIN, { date: '2025-03-04', mealType: MealType.VECERE, name: 'Neznámo', price: 9900 });
|
||||
|
||||
const result = await stats();
|
||||
|
||||
expect(result).toMatchObject({
|
||||
totalCalories: 1200, entryCount: 4, missingCaloriesCount: 1, dayCount: 2, caloriesDayCount: 2,
|
||||
});
|
||||
expect(result.byDay.map(day => [day.period, day.calories])).toEqual([['2025-03-03', 600], ['2025-03-04', 600]]);
|
||||
expect(result.byMonth).toEqual([expect.objectContaining({ period: '2025-03', calories: 1200, dayCount: 2 })]);
|
||||
expect(result.byMealType).toEqual([
|
||||
{ key: MealType.OBED, calories: 900, count: 2 },
|
||||
{ key: MealType.SNIDANE, calories: 300, count: 1 },
|
||||
]);
|
||||
expect(result.bySource).toEqual([
|
||||
{ key: 'Chefie', calories: 900, count: 2 },
|
||||
{ key: 'Neuvedeno', calories: 300, count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('výdej počítá jen za dny, kdy má nějaké jídlo kalorie', async () => {
|
||||
await saveBasalCalories(LOGIN, 1800);
|
||||
await addMeal(LOGIN, { date: '2025-03-03', mealType: MealType.OBED, name: 'Čína', calories: 700 });
|
||||
await addActivity(LOGIN, {
|
||||
date: '2025-03-03', name: 'Chůze', unit: ActivityUnit.KROKY, quantity: 5000, caloriesPer100Units: 4,
|
||||
});
|
||||
// Pohyb v den bez jídla do výdeje nevstoupí
|
||||
await addActivity(LOGIN, {
|
||||
date: '2025-03-05', name: 'Chůze', unit: ActivityUnit.KROKY, quantity: 5000, caloriesPer100Units: 4,
|
||||
});
|
||||
// Den, kde žádné jídlo kalorie nemá (import), by jinak vyšel jako deficit celého výdeje
|
||||
await addMeal(LOGIN, { date: '2025-03-06', mealType: MealType.OBED, name: 'Neznámo', price: 9900 });
|
||||
|
||||
const result = await stats();
|
||||
|
||||
expect(result.hasBasal).toBe(true);
|
||||
expect(result.burnCalories).toBe(2000);
|
||||
expect(result).toMatchObject({ dayCount: 2, caloriesDayCount: 1 });
|
||||
expect(result.byDay).toEqual([
|
||||
expect.objectContaining({ period: '2025-03-03', burnCalories: 2000, caloriesDayCount: 1 }),
|
||||
expect.objectContaining({ period: '2025-03-06', burnCalories: 0, caloriesDayCount: 0 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('bez klidového výdeje to pozná a cíl vrátí, jen když je nastavený', async () => {
|
||||
await addMeal(LOGIN, { date: '2025-03-03', mealType: MealType.OBED, name: 'Čína', calories: 700 });
|
||||
|
||||
const before = await stats();
|
||||
expect(before.hasBasal).toBe(false);
|
||||
expect(before.targetDailyBalance).toBeUndefined();
|
||||
|
||||
await saveGoals(LOGIN, { weeklyChangeGrams: -500 });
|
||||
expect((await stats()).targetDailyBalance).toBe(-550);
|
||||
});
|
||||
|
||||
it('živiny sečte jen ze dnů, kde je jídlo má', async () => {
|
||||
await addMeal(LOGIN, {
|
||||
date: '2025-03-03', mealType: MealType.OBED, name: 'Kuře', weight: 200, caloriesPer100g: 150,
|
||||
nutrientsPer100g: { protein: 25 },
|
||||
});
|
||||
await addMeal(LOGIN, { date: '2025-03-04', mealType: MealType.OBED, name: 'Čína', calories: 700 });
|
||||
|
||||
const result = await stats();
|
||||
|
||||
expect(result.totalNutrients).toEqual({ protein: 50 });
|
||||
expect(result.nutrientsDayCount).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -25,10 +25,29 @@ describe('skupiny jídel podle názvu a zdroje', () => {
|
||||
name: 'Čína', source: 'Chefie', count: 3, lastDate: '2025-03-03',
|
||||
missingCaloriesCount: 2, missingWeightCount: 2, missingNutrientsCount: 3,
|
||||
values: { weight: 400, caloriesPer100g: 150 },
|
||||
days: [
|
||||
{ date: '2025-03-03', incomplete: true },
|
||||
{ date: '2025-03-02', incomplete: true },
|
||||
{ date: '2025-03-01', incomplete: true },
|
||||
],
|
||||
});
|
||||
expect(groups[1]).toMatchObject({ source: 'doma', count: 1 });
|
||||
});
|
||||
|
||||
it('u dnů skupiny označí ty, kde jídlu něco chybí', async () => {
|
||||
await addMeal(LOGIN, {
|
||||
...cina, date: '2025-03-01', weight: 400, caloriesPer100g: 150, nutrientsPer100g: { protein: 8 },
|
||||
});
|
||||
await addMeal(LOGIN, { ...cina, date: '2025-03-02', price: 13900 });
|
||||
|
||||
const [group] = await getMealGroups(LOGIN);
|
||||
|
||||
expect(group.days).toEqual([
|
||||
{ date: '2025-03-02', incomplete: true },
|
||||
{ date: '2025-03-01', incomplete: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('energii a živiny předvyplní i z jídla stejného názvu odjinud, sazbu ne', async () => {
|
||||
await addMeal(LOGIN, {
|
||||
...cina, date: '2025-03-01', source: 'doma', pricePer100g: 2000, caloriesPer100g: 140,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
process.env.CALORIE_PROVIDER = 'none';
|
||||
|
||||
import { addEntries, addMeal, getDay, splitMeal } from "../meals";
|
||||
import { IMPORT_SOURCE } from "../luncherImport";
|
||||
import { resetMemoryStorage } from "../storage/memory";
|
||||
import { MealType } from "../../../types/gen/types.gen";
|
||||
|
||||
const LOGIN = 'tester';
|
||||
const DATE = '2025-03-03';
|
||||
|
||||
beforeEach(() => resetMemoryStorage());
|
||||
|
||||
describe('rozdělení jídla', () => {
|
||||
|
||||
it('upraví původní jídlo a hned za něj založí nové ve stejném dni', async () => {
|
||||
await addMeal(LOGIN, { date: DATE, mealType: MealType.SNIDANE, name: 'Kaše', calories: 300 });
|
||||
const day = await addMeal(LOGIN, {
|
||||
date: DATE, mealType: MealType.OBED, name: 'Tikka masala+garlic naan', source: 'Everest', price: 19900,
|
||||
nutrientsPer100g: { protein: 9 }, multiplier: 0.5,
|
||||
});
|
||||
const original = day.entries.find(entry => entry.mealType === MealType.OBED)!;
|
||||
|
||||
const result = await splitMeal(LOGIN, original.id, DATE,
|
||||
{
|
||||
mealType: MealType.OBED, name: 'Tikka masala', source: 'Everest', price: 19900,
|
||||
weight: 400, caloriesPer100g: 150, nutrientsPer100g: { protein: 9 }, multiplier: 0.5,
|
||||
},
|
||||
// Datum části se ignoruje, obě zůstávají v rozdělovaném dni
|
||||
{ date: '2025-03-10', mealType: MealType.OBED, name: 'Garlic naan', source: 'Everest', calories: 280 });
|
||||
|
||||
expect(result.entries.map(entry => entry.name)).toEqual(['Kaše', 'Tikka masala', 'Garlic naan']);
|
||||
const [, updated, created] = result.entries;
|
||||
expect(updated).toMatchObject({
|
||||
id: original.id, createdAt: original.createdAt, price: 19900, calories: 600, multiplier: 0.5,
|
||||
nutrientsPer100g: { protein: 9 },
|
||||
});
|
||||
expect(created.id).not.toBe(original.id);
|
||||
expect(created).toMatchObject({ date: DATE, price: undefined, calories: 280 });
|
||||
expect(result.totalCalories).toBe(300 + 300 + 280);
|
||||
expect((await getDay(LOGIN, '2025-03-10')).entries).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('odštěpená část převezme příznak importu, ale ne jeho klíč', async () => {
|
||||
await addEntries(LOGIN, [{
|
||||
id: 'imp-1', createdAt: '2025-03-03T10:00:00.000Z', date: DATE, mealType: MealType.OBED,
|
||||
name: 'Tikka masala+garlic naan', price: 19900, importSource: IMPORT_SOURCE, importKey: 'klic-1',
|
||||
}]);
|
||||
|
||||
const result = await splitMeal(LOGIN, 'imp-1', DATE,
|
||||
{ mealType: MealType.OBED, name: 'Tikka masala', price: 19900 },
|
||||
{ mealType: MealType.OBED, name: 'Garlic naan' });
|
||||
|
||||
const [updated, created] = result.entries;
|
||||
expect(updated).toMatchObject({ importSource: IMPORT_SOURCE, importKey: 'klic-1' });
|
||||
expect(created.importSource).toBe(IMPORT_SOURCE);
|
||||
expect(created.importKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it('neexistující jídlo ani chybějící název nerozdělí', async () => {
|
||||
const day = await addMeal(LOGIN, { date: DATE, mealType: MealType.OBED, name: 'Čína' });
|
||||
const { id } = day.entries[0];
|
||||
|
||||
await expect(splitMeal(LOGIN, 'neni', DATE, { mealType: MealType.OBED, name: 'A' }, { mealType: MealType.OBED, name: 'B' }))
|
||||
.rejects.toThrow('nebyl nalezen');
|
||||
await expect(splitMeal(LOGIN, id, DATE, { mealType: MealType.OBED, name: 'A' }, { mealType: MealType.OBED, name: ' ' }))
|
||||
.rejects.toThrow('název');
|
||||
expect((await getDay(LOGIN, DATE)).entries).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,8 @@ paths:
|
||||
$ref: "./paths/meals/add.yml"
|
||||
/meals/update:
|
||||
$ref: "./paths/meals/update.yml"
|
||||
/meals/split:
|
||||
$ref: "./paths/meals/split.yml"
|
||||
/meals/delete:
|
||||
$ref: "./paths/meals/delete.yml"
|
||||
/meals/multiplier:
|
||||
@@ -38,6 +40,8 @@ paths:
|
||||
# Statistiky (/api/stats)
|
||||
/stats/summary:
|
||||
$ref: "./paths/stats/summary.yml"
|
||||
/stats/intake:
|
||||
$ref: "./paths/stats/intake.yml"
|
||||
/stats/activity:
|
||||
$ref: "./paths/stats/activity.yml"
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
post:
|
||||
operationId: splitMeal
|
||||
summary: Rozdělí jedno jídlo na dvě — původní se upraví a vedle něj vznikne nové ve stejném dni.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "../../schemas/_index.yml#/MealSplitRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Aktualizovaný přehled dne
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "../../schemas/_index.yml#/DayRecord"
|
||||
"400":
|
||||
description: Neplatná data
|
||||
"401":
|
||||
description: Neautentizovaný uživatel
|
||||
"404":
|
||||
description: Záznam neexistuje
|
||||
@@ -0,0 +1,29 @@
|
||||
get:
|
||||
operationId: getIntakeStats
|
||||
summary: Vrátí statistiky energetického příjmu (jídla) za zvolené období včetně porovnání s výdejem.
|
||||
parameters:
|
||||
- in: query
|
||||
name: from
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
description: První den období (včetně), YYYY-MM-DD
|
||||
- in: query
|
||||
name: to
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
description: Poslední den období (včetně), YYYY-MM-DD
|
||||
responses:
|
||||
"200":
|
||||
description: Statistiky příjmu za období
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "../../schemas/_index.yml#/IntakeStats"
|
||||
"400":
|
||||
description: Neplatný rozsah
|
||||
"401":
|
||||
description: Neautentizovaný uživatel
|
||||
@@ -228,6 +228,7 @@ MealGroup:
|
||||
- missingCaloriesCount
|
||||
- missingWeightCount
|
||||
- missingNutrientsCount
|
||||
- days
|
||||
- values
|
||||
properties:
|
||||
name:
|
||||
@@ -252,6 +253,11 @@ MealGroup:
|
||||
missingNutrientsCount:
|
||||
description: Počet jídel bez nutričních hodnot
|
||||
type: integer
|
||||
days:
|
||||
description: Dny, kdy se jídlo jedlo, od nejnovějšího
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/MealGroupDay"
|
||||
values:
|
||||
description: |
|
||||
Hodnoty k předvyplnění — z nejnovějšího jídla skupiny, které je má.
|
||||
@@ -1508,3 +1514,194 @@ ActivitySummaryItem:
|
||||
calories:
|
||||
description: Spálené kcal včetně násobku
|
||||
type: integer
|
||||
|
||||
IntakeGroupTotal:
|
||||
description: Součet příjmu jedné skupiny jídel (chod, nebo zdroj)
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- key
|
||||
- calories
|
||||
- count
|
||||
properties:
|
||||
key:
|
||||
description: Klíč skupiny — hodnota MealType, nebo název zdroje
|
||||
type: string
|
||||
calories:
|
||||
description: Přijaté kcal včetně násobku
|
||||
type: integer
|
||||
count:
|
||||
description: Počet záznamů o jídle
|
||||
type: integer
|
||||
|
||||
IntakePeriodTotal:
|
||||
description: Příjem za jedno období (den, měsíc nebo rok) s výdejem stejných dní
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- period
|
||||
- calories
|
||||
- burnCalories
|
||||
- dayCount
|
||||
- caloriesDayCount
|
||||
- byMealType
|
||||
properties:
|
||||
period:
|
||||
description: Označení období — YYYY-MM-DD (den), YYYY-MM (měsíc) nebo YYYY (rok)
|
||||
type: string
|
||||
calories:
|
||||
description: Přijaté kcal včetně násobku
|
||||
type: integer
|
||||
burnCalories:
|
||||
description: |
|
||||
Výdej (klidový + pohyb) za dny období, ve kterých má aspoň jedno jídlo
|
||||
kalorie. Ostatní dny se nezapočítávají, jinak by vycházel deficit tam,
|
||||
kde uživatel jen nic nezapsal nebo kalorie nezná.
|
||||
type: integer
|
||||
dayCount:
|
||||
description: Počet dní období se zapsaným jídlem
|
||||
type: integer
|
||||
caloriesDayCount:
|
||||
description: Počet dní období, kdy má aspoň jedno jídlo kalorie — základ průměru a bilance
|
||||
type: integer
|
||||
byMealType:
|
||||
description: Příjem po chodech; chody bez kalorií chybí
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/IntakeGroupTotal"
|
||||
|
||||
IntakeStats:
|
||||
description: |
|
||||
Statistiky energetického příjmu za zvolené období — kolik se snědlo, v jakých
|
||||
chodech a jídlech, jaké živiny a jak to vychází proti výdeji.
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- from
|
||||
- to
|
||||
- totalCalories
|
||||
- burnCalories
|
||||
- hasBasal
|
||||
- entryCount
|
||||
- missingCaloriesCount
|
||||
- dayCount
|
||||
- caloriesDayCount
|
||||
- nutrientsDayCount
|
||||
- byDay
|
||||
- byMonth
|
||||
- byYear
|
||||
- byMealType
|
||||
- bySource
|
||||
properties:
|
||||
from:
|
||||
description: Začátek období (YYYY-MM-DD, včetně)
|
||||
type: string
|
||||
format: date
|
||||
to:
|
||||
description: Konec období (YYYY-MM-DD, včetně)
|
||||
type: string
|
||||
format: date
|
||||
totalCalories:
|
||||
description: Přijaté kcal za období včetně násobku
|
||||
type: integer
|
||||
burnCalories:
|
||||
description: Výdej (klidový + pohyb) za dny, kdy má aspoň jedno jídlo kalorie
|
||||
type: integer
|
||||
hasBasal:
|
||||
description: |
|
||||
Zda výdej obsahuje klidový metabolismus. Bez něj porovnává bilance jen
|
||||
jídlo proti pohybu a nejde o skutečný deficit.
|
||||
type: boolean
|
||||
targetDailyBalance:
|
||||
description: Cílová denní bilance podle tempa změny hmotnosti, pokud je cíl nastavený
|
||||
type: integer
|
||||
proteinTargetGrams:
|
||||
description: Denní cíl bílkovin v gramech k hmotnosti na konci období, pokud je cíl nastavený
|
||||
type: integer
|
||||
entryCount:
|
||||
description: Počet záznamů o jídle v období
|
||||
type: integer
|
||||
missingCaloriesCount:
|
||||
description: Počet jídel bez kalorií — do součtů nevstupují
|
||||
type: integer
|
||||
dayCount:
|
||||
description: Počet dní, ve kterých je alespoň jedno jídlo
|
||||
type: integer
|
||||
caloriesDayCount:
|
||||
description: Počet dní, kdy má aspoň jedno jídlo kalorie — základ průměru a bilance
|
||||
type: integer
|
||||
totalNutrients:
|
||||
description: Součet živin za období; jen jídla s gramáží a nutričními hodnotami
|
||||
allOf:
|
||||
- $ref: "#/Nutrients"
|
||||
nutrientsDayCount:
|
||||
description: Počet dní, ve kterých má aspoň jedno jídlo živiny — základ pro průměr na den
|
||||
type: integer
|
||||
byDay:
|
||||
description: Součty po dnech se zapsaným jídlem, vzestupně
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/IntakePeriodTotal"
|
||||
byMonth:
|
||||
description: Součty po měsících, vzestupně
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/IntakePeriodTotal"
|
||||
byYear:
|
||||
description: Součty po letech, vzestupně
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/IntakePeriodTotal"
|
||||
byMealType:
|
||||
description: Příjem po chodech, sestupně dle kcal
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/IntakeGroupTotal"
|
||||
bySource:
|
||||
description: Příjem dle zdroje jídla, sestupně dle kcal; jídla bez zdroje pod "Neuvedeno"
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/IntakeGroupTotal"
|
||||
|
||||
MealGroupDay:
|
||||
description: Den, ve kterém se jídlo skupiny jedlo
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- date
|
||||
- incomplete
|
||||
properties:
|
||||
date:
|
||||
type: string
|
||||
format: date
|
||||
incomplete:
|
||||
description: Zda některému jídlu skupiny v tento den chybí kalorie, gramáž nebo živiny
|
||||
type: boolean
|
||||
|
||||
MealSplitRequest:
|
||||
description: |
|
||||
Rozdělení jídla, které import (nebo uživatel) zapsal jako jedno, na dvě.
|
||||
Obě části zůstávají ve dni původního jídla; datum v částech se ignoruje.
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- id
|
||||
- date
|
||||
- original
|
||||
- split
|
||||
properties:
|
||||
id:
|
||||
description: Identifikátor rozdělovaného jídla
|
||||
type: string
|
||||
date:
|
||||
description: Den rozdělovaného jídla (YYYY-MM-DD)
|
||||
type: string
|
||||
format: date
|
||||
original:
|
||||
description: Nové hodnoty původního jídla — přepíší ho stejně jako úprava
|
||||
allOf:
|
||||
- $ref: "#/MealInput"
|
||||
split:
|
||||
description: Odštěpená část, vznikne z ní nové jídlo
|
||||
allOf:
|
||||
- $ref: "#/MealInput"
|
||||
Reference in new issue
Block a user