diff --git a/CLAUDE.md b/CLAUDE.md index 94bc40a..a62f26a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/client/src/App.scss b/client/src/App.scss index 270ff58..5408f04 100644 --- a/client/src/App.scss +++ b/client/src/App.scss @@ -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; +} diff --git a/client/src/components/MealGroupDays.tsx b/client/src/components/MealGroupDays.tsx new file mode 100644 index 0000000..0297789 --- /dev/null +++ b/client/src/components/MealGroupDays.tsx @@ -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) => ( +
+
+ Otevřít den v nové záložce{days.some(day => day.incomplete) && ' · zvýrazněné dny mají neúplné údaje'} +
+
+ {days.map(day => ( + + {getDayOfWeek(day.date).substring(0, 2)} {formatDateString(day.date)} + + + ))} +
+
+); + +export default MealGroupDays; diff --git a/client/src/components/MealGroupRow.tsx b/client/src/components/MealGroupRow.tsx index a14068c..fdbd1df 100644 --- a/client/src/components/MealGroupRow.tsx +++ b/client/src/components/MealGroupRow.tsx @@ -1,9 +1,11 @@ +import { useState } from "react"; import { Button } from "react-bootstrap"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faPenToSquare } from "@fortawesome/free-solid-svg-icons"; +import { faCalendarDays, faPenToSquare } from "@fortawesome/free-solid-svg-icons"; import { MealGroup } from "../../../types"; import { formatDateString } from "../Utils"; import MealGroupForm from "./MealGroupForm"; +import MealGroupDays from "./MealGroupDays"; type Props = { group: MealGroup, @@ -18,39 +20,56 @@ const describeMissing = (missing: number, count: number): string => /** * Řádek skupiny jídel (stejný název a zdroj) s přehledem chybějících údajů. - * Po rozbalení nabídne formulář, kterým se údaje doplní všem jídlům skupiny. + * Po rozbalení nabídne formulář, kterým se údaje doplní všem jídlům skupiny, + * a dny, kdy se jídlo jedlo, jako odkazy na přehled dne. */ -const MealGroupRow = ({ group, expanded, onToggle, onFilled }: Readonly) => ( -
-
-
-
{group.name}
-
- {group.source && {group.source}} - {group.count}× · naposledy {formatDateString(group.lastDate)} - {group.missingCaloriesCount > 0 && ( - - bez kalorií {describeMissing(group.missingCaloriesCount, group.count)} - - )} - {group.missingWeightCount > 0 && ( - - bez gramáže {describeMissing(group.missingWeightCount, group.count)} - - )} - {group.missingNutrientsCount > 0 && ( - - bez živin {describeMissing(group.missingNutrientsCount, group.count)} - +const MealGroupRow = ({ group, expanded, onToggle, onFilled }: Readonly) => { + const [daysOpen, setDaysOpen] = useState(false); + return ( +
+
+
+
{group.name}
+
+ {group.source && {group.source}} + {group.count}× · naposledy {formatDateString(group.lastDate)} + {group.missingCaloriesCount > 0 && ( + + bez kalorií {describeMissing(group.missingCaloriesCount, group.count)} + + )} + {group.missingWeightCount > 0 && ( + + bez gramáže {describeMissing(group.missingWeightCount, group.count)} + + )} + {group.missingNutrientsCount > 0 && ( + + bez živin {describeMissing(group.missingNutrientsCount, group.count)} + + )} +
+
+
+ {!expanded && ( + )} +
- + {(expanded || daysOpen) && } + {expanded && onToggle()} />}
- {expanded && onToggle()} />} -
-); + ); +}; export default MealGroupRow; diff --git a/client/src/components/MealRow.tsx b/client/src/components/MealRow.tsx index 0e91387..0c002a6 100644 --- a/client/src/components/MealRow.tsx +++ b/client/src/components/MealRow.tsx @@ -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) => { +const MealRow = ({ entry, onEdit, onDelete, onSplit, onMultiplierChange }: Readonly) => { 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
{formatPrice(entry.price)}
- +
); }; diff --git a/client/src/components/MealSplitRow.tsx b/client/src/components/MealSplitRow.tsx new file mode 100644 index 0000000..9253da9 --- /dev/null +++ b/client/src/components/MealSplitRow.tsx @@ -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) => ( + <> +
{label}
+
{original}
+
+ {onCopy && ( + + )} + {extra} +
+
{split}
+ +); + +export default MealSplitRow; diff --git a/client/src/components/RowActions.tsx b/client/src/components/RowActions.tsx index ecfec26..7cec17c 100644 --- a/client/src/components/RowActions.tsx +++ b/client/src/components/RowActions.tsx @@ -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) => ( +const RowActions = ({ onEdit, onDelete, onSaveTemplate, onSplit, label }: Readonly) => (
{onSaveTemplate && ( )} + {onSplit && ( + + )} diff --git a/client/src/components/modals/MealSplitModal.tsx b/client/src/components/modals/MealSplitModal.tsx new file mode 100644 index 0000000..b85af34 --- /dev/null +++ b/client/src/components/modals/MealSplitModal.tsx @@ -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 => { + 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)[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) => { + const [original, setOriginal] = useState(); + const [split, setSplit] = useState(); + const [recipes, setRecipes] = useState([]); + const [history, setHistory] = useState([]); + const [pickedSuggestion, setPickedSuggestion] = useState(); + 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) => setOriginal(current => current && { ...current, ...changes }); + const updateSplit = (changes: Partial) => 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) => void, key: keyof SplitForm, + options: { placeholder?: string, unit?: string, numeric?: boolean } = {}, + ) => { + const control = ( + update({ + [key]: options.numeric ? event.target.value.replace(/\D/g, '') : event.target.value, + })} + /> + ); + if (!options.unit) return control; + return ( +
+ {control} + {options.unit} +
+ ); + }; + + const mealTypeSelect = (form: SplitForm, update: (changes: Partial) => void) => ( + update({ mealType: event.target.value as MealType })}> + {MEAL_TYPES_IN_ORDER.map(mealType => ( + + ))} + + ); + + /** Kalorie porce — dopočtené z gramáže se jen ukážou, jinak jdou zadat ručně. */ + const caloriesCell = ( + form: SplitForm, update: (changes: Partial) => void, values: ReturnType, + ) => values.derived.calories != null + ?
{formatCalories(values.derived.calories)}
+ : textInput(form, update, 'calories', { numeric: true, unit: 'kcal', placeholder: 'volitelné' }); + + return ( + + + Rozdělit jídlo + + +

+ Z jednoho záznamu vzniknou dva ve stejném dni. Šipkou zkopírujete údaj z původního jídla do nového. +

+
{ event.preventDefault(); save(); }}> +
+
+
Původní jídlo
+
+
Nové jídlo
+ + + { updateSplit({ name: event.target.value }); setPickedSuggestion(undefined); }} + /> + {pickedSuggestion !== split.name && ( + + normalizeSearch(item.name) !== normalizeSearch(entry.name))} + query={split.name} + onPickRecipe={recipe => applyRecipe(recipe)} + onPickHistory={suggestion => applyHistory(suggestion)} + /> + )} + {pickedSuggestion === split.name && ( + doplněna prázdná pole z dřívějška, cena ne + )} + + } + onCopy={() => copy('name')} + /> + copy('mealType')} + /> + updateOriginal({ source })} />} + split={ updateSplit({ source })} />} + onCopy={() => copy('source')} + /> + copy('price')} + extra={ + + } + /> + copy('pricePer100g')} + /> + copy('weight')} + /> + copy('caloriesPer100g')} + /> + copy('calories')} + /> + copy('note')} + /> +
+ + {(entry.nutrientsPer100g || entry.multiplier != null) && ( +

+ {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ě. +

+ )} + {priceChanged && ( + + Součet cen {formatPrice(totalPrice)} nesedí na původní {formatPrice(entry.price ?? 0)}. + + )} + + +
+ {formatPrice(originalValues.price ?? 0)} + {formatPrice(splitValues.price ?? 0)} + {(originalValues.calories != null || splitValues.calories != null) && ( + + {formatCalories(originalValues.calories)} + {formatCalories(splitValues.calories)} + + )} + {splitValues.derived.weight != null && nové {formatWeight(splitValues.derived.weight)}} +
+ + +
+ + ); +}; + +export default MealSplitModal; diff --git a/client/src/enums.ts b/client/src/enums.ts index ef4ef58..48ea068 100644 --- a/client/src/enums.ts +++ b/client/src/enums.ts @@ -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 = { 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 = { + PRIJEM: 'Příjem', + VYDEJ: 'Výdej', + UTRATA: 'Útrata', +}; + /** Rozsahy, za které lze zobrazit statistiky. */ export enum StatsRange { MESIC = 'MESIC', diff --git a/client/src/pages/DayPage.tsx b/client/src/pages/DayPage.tsx index 840405f..324308a 100644 --- a/client/src/pages/DayPage.tsx +++ b/client/src/pages/DayPage.tsx @@ -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(() => formatDate(new Date())); + const [searchParams] = useSearchParams(); + const [date, setDate] = useState(() => getInitialDate(searchParams.get(DAY_DATE_PARAM))); const [day, setDay] = useState(() => emptyOverview(date)); const [tab, setTab] = useState(DayTab.PRIJEM); const [loading, setLoading] = useState(true); @@ -55,6 +63,7 @@ export default function DayPage() { const [savedNotice, setSavedNotice] = useState(); /** Jídlo, jehož kalorie jde nabídnout ostatním jídlům stejného názvu. */ const [fillOfferEntry, setFillOfferEntry] = useState(); + const [splitMealEntry, setSplitMealEntry] = useState(); 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() { )} + setSplitMealEntry(undefined)} + onSaved={() => { + setSplitMealEntry(undefined); + showNotice('Jídlo rozděleno.'); + loadDay(date); + }} + /> + ) => { + const navigate = useNavigate(); + const [stats, setStats] = useState(); + 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 ( +
+ +
+ ); + } + + if (!stats?.entryCount) { + return ( +
+
+
Za zvolené období nemáte zapsané žádné jídlo
+

+ Jídla přidáte v přehledu dne na záložce Příjem, nebo si je naimportujte z Luncheru. +

+
+ ); + } + + // 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 ( + <> +
+
+
Snědeno
+
{formatCalories(stats.totalCalories)}
+
+ za {stats.dayCount} dní se zápisem + {calorieDays < stats.dayCount && `, kalorie má ${calorieDays}`} +
+
+
+
Průměr na den
+
{formatCalories(averageIntake)}
+
+ {calorieDays ? `výdej v průměru ${formatCalories(averageBurn)}` : 'žádný den nemá kalorie'} +
+
+
+
Průměrná bilance
+
{averageBalance != null ? formatBalance(averageBalance) : '—'}
+
+ {!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ý')} +
+
+
+
Bílkoviny na den
+
{formatNutrient(averageProtein)}
+
+ {stats.proteinTargetGrams != null + ? `cíl ${formatNutrient(stats.proteinTargetGrams)}` + : `z ${stats.nutrientsDayCount} dní s živinami`} +
+
+
+ + {stats.missingCaloriesCount > 0 && ( +
+ + + {stats.missingCaloriesCount} z {stats.entryCount} jídel nemá kalorie, do součtů nevstupují + a příjem tak vychází nižší, než byl. + + +
+ )} + +
+

Příjem v čase

+ + + + + + } cursor={{ fill: 'var(--ft-bg-hover)' }} /> + + {mealTypes.map((mealType, index) => ( + + ))} + + {stats.targetDailyBalance != null && ( + + )} + + +

+ 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.'} +

+
+ +
+
+

Podle chodu

+ + + + {pieData.map(entry => ( + + ))} + + + formatCalories(Number(value))} contentStyle={TOOLTIP_STYLE} /> + + +
+ +
+

Podle zdroje

+ + + + {sourceSlices.map(slice => )} + + + formatCalories(Number(value))} contentStyle={TOOLTIP_STYLE} /> + + +
+
+ + {stats.totalNutrients && ( +
+

Živiny — průměr na den

+ + + {NUTRIENTS_IN_ORDER.map(key => ( + + + + + ))} + +
{NUTRIENT_NAMES[key]} + {formatNutrient(stats.totalNutrients?.[key] != null + ? stats.totalNutrients[key]! / stats.nutrientsDayCount + : undefined)} +
+

+ 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šší. +

+
+ )} + + ); +}; + +export default IntakeStatsPanel; diff --git a/client/src/pages/IntakeTooltip.tsx b/client/src/pages/IntakeTooltip.tsx new file mode 100644 index 0000000..c4f3d3c --- /dev/null +++ b/client/src/pages/IntakeTooltip.tsx @@ -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>>) => { + 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 ( +
+
{label}
+ {!row.dayCount &&
nic nezapsáno
} + {parts.map(item => ( +
+ + {item.name} + {formatCalories(Number(item.value))} +
+ ))} + {row.burn != null && ( + <> +
+ + Výdej + {formatCalories(row.burn)} +
+
+ Bilance + {formatBalance(row.calories - row.burn)} +
+ + )} + {row.targetBalance != null && ( +
+ Cílová bilance + {formatBalance(row.targetBalance)} +
+ )} + {row.dayCount > row.caloriesDayCount && ( +
+ {row.caloriesDayCount + ? `bilance jen za ${row.caloriesDayCount} z ${row.dayCount} dní, ostatní nemají kalorie` + : 'jídla bez kalorií'} +
+ )} +
+ ); +}; + +export default IntakeTooltip; diff --git a/client/src/pages/MealGroupsPage.scss b/client/src/pages/MealGroupsPage.scss index 985f8c7..5998af1 100644 --- a/client/src/pages/MealGroupsPage.scss +++ b/client/src/pages/MealGroupsPage.scss @@ -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); + } + } +} diff --git a/client/src/pages/SpendingStatsPanel.tsx b/client/src/pages/SpendingStatsPanel.tsx new file mode 100644 index 0000000..f374d85 --- /dev/null +++ b/client/src/pages/SpendingStatsPanel.tsx @@ -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) => { + const [stats, setStats] = useState(); + 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 ( + <> +
+
+
Celkem utraceno
+
{formatPrice(stats?.totalPrice ?? 0)}
+
+
+
Průměr na den
+
{formatPrice(averagePerDay)}
+
z {stats?.dayCount ?? 0} vykázaných dní
+
+
+
Průměr na jídlo
+
{formatPrice(averagePerMeal)}
+
jen jídla s cenou
+
+
+
Jídel
+
{stats?.entryCount ?? 0}
+
z toho placených {pricedCount}
+
+
+ + {loading && ( +
+ +
+ )} + + {!loading && !stats?.entryCount && ( +
+
+
Za zvolené období nemáte žádná data
+

+ Přidejte jídla v přehledu dne, nebo si sem naimportujte měsíční přehled z Luncheru. +

+
+ )} + + {!loading && !!stats?.entryCount && ( + <> +
+

Útrata v čase

+ + + + + + [formatChartPrice(value), 'Útrata']} + contentStyle={TOOLTIP_STYLE} + /> + + + +
+ +
+
+

Podle chodu

+ + + + {pieData.map((entry, index) => ( + + ))} + + + formatChartPrice(value)} contentStyle={TOOLTIP_STYLE} /> + + +
+ +
+

Kde nejvíc utrácíte

+ + + + + + + + + + {stats.bySource.slice(0, 8).map(total => ( + + + + + + ))} + +
ZdrojJídelÚtrata
{total.key}{total.count}{formatPriceShort(total.price)}
+
+
+ + )} + + ); +}; + +export default SpendingStatsPanel; diff --git a/client/src/pages/StatsPage.scss b/client/src/pages/StatsPage.scss index 9396744..080a94e 100644 --- a/client/src/pages/StatsPage.scss +++ b/client/src/pages/StatsPage.scss @@ -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; + } +} diff --git a/client/src/pages/StatsPage.tsx b/client/src/pages/StatsPage.tsx index abdfc9a..c131cff 100644 --- a/client/src/pages/StatsPage.tsx +++ b/client/src/pages/StatsPage.tsx @@ -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 = { + 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 = { + 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.PRIJEM); + const [tab, setTab] = useState(StatsTab.PRIJEM); const [mode, setMode] = useState(StatsRange.MESIC); const [month, setMonth] = useState(() => formatMonth(new Date())); const [year, setYear] = useState(() => new Date().getFullYear()); - const [stats, setStats] = useState(); - 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 ( <>

Statistiky

-

- {tab === DayTab.PRIJEM - ? 'Kolik jste utratili za jídlo a za co' - : 'Kolik jste spálili pohybem a jak se držíte šablon'} -

+

{TAB_SUBTITLES[tab]}

- {tab === DayTab.PRIJEM && ( + {tab !== StatsTab.VYDEJ && (
))}
@@ -128,114 +93,14 @@ export default function StatsPage() {
- {tab === DayTab.VYDEJ && } - - {tab === DayTab.PRIJEM && ( - <> -
-
-
Celkem utraceno
-
{formatPrice(stats?.totalPrice ?? 0)}
-
-
-
Průměr na den
-
{formatPrice(averagePerDay)}
-
z {stats?.dayCount ?? 0} vykázaných dní
-
-
-
Jídel
-
{stats?.entryCount ?? 0}
-
-
-
Kalorie
-
{formatCalories(stats?.totalCalories)}
-
-
- - {loading && ( -
- -
- )} - - {!loading && !stats?.entryCount && ( -
-
-
Za zvolené období nemáte žádná data
-

- Přidejte jídla v přehledu dne, nebo si sem naimportujte měsíční přehled z Luncheru. -

-
- )} - - {!loading && !!stats?.entryCount && ( - <> -
-

Útrata v čase

- - - - - - [formatChartPrice(value), 'Útrata']} - contentStyle={TOOLTIP_STYLE} - /> - - - -
- -
-
-

Podle chodu

- - - - {pieData.map((entry, index) => ( - - ))} - - - formatChartPrice(value)} - contentStyle={TOOLTIP_STYLE} - /> - - -
- -
-

Kde nejvíc utrácíte

- - - - - - - - - - {stats.bySource.slice(0, 8).map(total => ( - - - - - - ))} - -
ZdrojJídelÚtrata
{total.key}{total.count}{formatPriceShort(total.price)}
-
-
- - )} - - )} + {tab === StatsTab.PRIJEM && } + {tab === StatsTab.VYDEJ && } + {tab === StatsTab.UTRATA && } setImportOpen(false)} - onImported={load} + onImported={() => setReloadKey(key => key + 1)} /> ); diff --git a/client/src/pages/activityChartSeries.ts b/client/src/pages/activityChartSeries.ts index c9e716b..85e482f 100644 --- a/client/src/pages/activityChartSeries.ts +++ b/client/src/pages/activityChartSeries.ts @@ -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 `${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(listPeriods(range, mode, days).map(period => [period, { + const rows = new Map(listPeriods(range, mode, days[0]?.date).map(period => [period, { label: formatPeriodLabel(period, mode), extra: 0, planned: 0, notes: [], }])); diff --git a/client/src/pages/intakeChartSeries.ts b/client/src/pages/intakeChartSeries.ts new file mode 100644 index 0000000..4a6cdd5 --- /dev/null +++ b/client/src/pages/intakeChartSeries.ts @@ -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 = { + 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>; + +/** + * 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)); +}; diff --git a/client/src/pages/statsShared.ts b/client/src/pages/statsShared.ts index dd91c15..66dfe47 100644 --- a/client/src/pages/statsShared.ts +++ b/client/src/pages/statsShared.ts @@ -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(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); +}; diff --git a/client/src/routes.ts b/client/src/routes.ts index 984a38a..2114d1f 100644 --- a/client/src/routes.ts +++ b/client/src/routes.ts @@ -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}`; diff --git a/server/src/dayOverview.ts b/server/src/dayOverview.ts index f09af25..71fff6d 100644 --- a/server/src/dayOverview.ts +++ b/server/src/dayOverview.ts @@ -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 entry.date === date)?.weightGrams; const proteinTargetGrams = getProteinTarget(settings.goals, weightGrams); return { diff --git a/server/src/meals.ts b/server/src/meals.ts index eae902e..f518339 100644 --- a/server/src/meals.ts +++ b/server/src/meals.ts @@ -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( 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 => { + 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(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(); + 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 => { const entryList = await findAllMeals(login); const groupMap = new Map(); @@ -646,6 +717,7 @@ export const getMealGroups = async (login: string): Promise => { 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)) ?? []), }; }); diff --git a/server/src/routes/mealRoutes.ts b/server/src/routes/mealRoutes.ts index 818fde1..478b505 100644 --- a/server/src/routes/mealRoutes.ts +++ b/server/src/routes/mealRoutes.ts @@ -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 { diff --git a/server/src/routes/statsRoutes.ts b/server/src/routes/statsRoutes.ts index 89736ad..0531849 100644 --- a/server/src/routes/statsRoutes.ts +++ b/server/src/routes/statsRoutes.ts @@ -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 { diff --git a/server/src/statsService.ts b/server/src/statsService.ts index b637b6d..8718d39 100644 --- a/server/src/statsService.ts +++ b/server/src/statsService.ts @@ -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, 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[] => + 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, +}; + +/** + * 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, 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): 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 { + 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(); + const byMonth = new Map(); + const byYear = new Map(); + const byMealType = new Map(); + const bySource = new Map(); + 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'; diff --git a/server/src/tests/intakeStats.test.ts b/server/src/tests/intakeStats.test.ts new file mode 100644 index 0000000..8ece530 --- /dev/null +++ b/server/src/tests/intakeStats.test.ts @@ -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); + }); +}); diff --git a/server/src/tests/mealGroups.test.ts b/server/src/tests/mealGroups.test.ts index eba2fff..519f503 100644 --- a/server/src/tests/mealGroups.test.ts +++ b/server/src/tests/mealGroups.test.ts @@ -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, diff --git a/server/src/tests/mealSplit.test.ts b/server/src/tests/mealSplit.test.ts new file mode 100644 index 0000000..a0acbfd --- /dev/null +++ b/server/src/tests/mealSplit.test.ts @@ -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); + }); +}); diff --git a/types/api.yml b/types/api.yml index 4de0aba..882ea0f 100644 --- a/types/api.yml +++ b/types/api.yml @@ -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" diff --git a/types/paths/meals/split.yml b/types/paths/meals/split.yml new file mode 100644 index 0000000..ca66a2a --- /dev/null +++ b/types/paths/meals/split.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 diff --git a/types/paths/stats/intake.yml b/types/paths/stats/intake.yml new file mode 100644 index 0000000..1c101d2 --- /dev/null +++ b/types/paths/stats/intake.yml @@ -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 diff --git a/types/schemas/_index.yml b/types/schemas/_index.yml index ee03cef..dc454e8 100644 --- a/types/schemas/_index.yml +++ b/types/schemas/_index.yml @@ -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"