Files
food-tracer/README.md
T
Ondřej Anděl 7641e26c9f chore: graf db
feat: workout plans (volný, fixní, plovoucí)
2026-10-22 12:11:05 +02:00

339 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Food Tracer
Aplikace pro evidenci snědených jídel a útraty za jídlo. Ke každému jídlu se dá
vedle názvu a zdroje uložit i cena a kalorie, takže jde zpětně zjistit, kolik
člověk za jídlo utratil za den, měsíc nebo rok.
Umí naimportovat měsíční přehled vyexportovaný ze stránky statistik
[Luncheru](../Luncher) — ve formátu XLSX, CSV i JSON.
## Struktura
```
types/ → Sdílené TypeScript typy generované z OpenAPI (zdroj pravdy: types/api.yml)
server/ → Express 5 backend (Node.js 22, ts-node)
client/ → React 19 frontend (Vite 7, React Bootstrap)
```
Package manager: **Yarn Classic**. Každá složka má vlastní `package.json`.
## Rozjetí
```bash
cd types && yarn install && yarn openapi-ts # typy je nutné vygenerovat první
cd ../server && yarn install
cd ../client && yarn install
```
Do `server/.env.development` patří `JWT_SECRET` o délce alespoň 32 znaků
(šablona je v `server/.env.template`).
## Vývoj
```bash
./run_dev.sh # vše naráz v tmuxu (Linux/macOS)
.\run_dev.ps1 # dvě PowerShell okna (Windows)
```
Nebo ručně ve dvou terminálech:
```bash
cd server && NODE_ENV=development yarn startReload # port 3001
cd client && yarn start # port 3000, proxy /api → 3001
```
## Testy a build
```bash
cd server && yarn test # Jest, in-memory úložiště
cd server && yarn build # tsc → server/dist
cd client && yarn build # tsc --noEmit + vite build → client/dist
```
## Nasazení
```bash
JWT_SECRET=... docker compose up --build
```
Produkčně běží aplikace nad Redisem, ve vývoji nad JSON souborem
(`server/data/db.json`). Viz `server/.env.template`.
## Datový model
Úložiště je klíč–hodnota (JSON soubor ve vývoji, Redis v produkci). „Tabulka" je
tedy typ klíče a hodnotou je pod ním buď celé pole záznamů, nebo jeden objekt.
Klíče začínají typem a přihlašovacím jménem uživatele, data jednotlivých
uživatelů se nemíchají.
| Klíč | Hodnota |
|---|---|
| `meals:<login>:<datum>` | pole jídel dne (`MealEntry`) |
| `mealTemplates:<login>` | pole receptů (`MealTemplate`) |
| `mealPhotos:<login>` | rejstřík fotek: klíč jídla (název + zdroj) → pole náhledů |
| `mealPhoto:<login>:<id>` | plný obrázek jedné fotky (data URL) |
| `calorieLibrary:<login>` | vlastní knihovna kalorií: znormalizovaný název → záznam |
| `settings:<login>` | nastavení, tělesné údaje a cíle |
| `weights:<login>` | vážení: datum → hmotnost v gramech |
| `sleep:<login>:<datum>` | délka spánku v minutách |
| `activities:<login>:<datum>` | pole aktivit dne (`ActivityEntry`) |
| `workouts:<login>` | pole šablon tréninků — volné šablony i plány |
| `workoutPlans:<login>:<datum>` | pole záznamů o použití šablon a dnů plánu v daném dni |
Výchozí hodnoty se do úložiště nezapisují (jedna série, násobek 1, volný typ
šablony, slaná chuť receptu) a dopočítávaná pole (součty, odhady kalorií,
ukazatele) se počítají při čtení.
```mermaid
erDiagram
MEAL_ENTRY {
string id PK
date date
enum mealType "SNIDANE, OBED, ..."
string name
string source "podnik / obchod"
int price "haléře"
int weight "g"
int pricePer100g "haléře"
int calories "kcal za porci"
int caloriesPer100g
object nutrientsPer100g "g na 100 g, na desetiny"
decimal multiplier
string note
string templateId FK "recept"
string importSource
string importKey "duplicity importu"
datetime createdAt
datetime updatedAt
}
MEAL_TEMPLATE {
string id PK
string name
string source
string category "typ: polévka, omáčka..."
enum flavor "SLANE, SLADKE"
int servings
int cookedWeight "g"
int prepMinutes
int weight "porce, dopočteno"
int calories "porce, dopočteno"
int price "porce, dopočteno"
string note "postup v Markdownu"
array sideDishIds FK "přílohy"
array similarIds FK "podobné"
}
RECIPE_INGREDIENT {
string name
decimal amount "v jiné jednotce než g"
enum unit "KS, ML, L, LZICE..."
int weight "g"
int caloriesPer100g
int calories
int price
}
MEAL_PHOTO_INDEX {
string mealKey PK "název + zdroj"
string id FK
string thumbnail "data URL ~240 px"
datetime createdAt
}
MEAL_PHOTO {
string id PK
string image "data URL ~1600 px"
}
CALORIE_LIBRARY {
string normalizedName PK
string name
int caloriesPer100g
int uses
datetime updatedAt
}
SETTINGS {
array sourceRates "podnik a sazba za 100 g"
int basalCalories "ručně zadaný klidový výdej"
object profile "pohlaví, rok narození, výška, obvody"
object goals "tempo, cílová hmotnost, bílkoviny/kg"
}
WEIGHTS {
date date PK
int weightGrams
}
SLEEP {
date date PK
int minutes
}
ACTIVITY_ENTRY {
string id PK
date date
string name
enum unit "KROKY, MINUTY, SEKUNDY, KM, OPAKOVANI"
int quantity "na sérii"
int sets
int caloriesPer100Units
int calories "za jedno provedení"
int extraWeightGrams
decimal multiplier
string note
string templateId FK "šablona / plán"
string planDayId FK "den plánu"
date replacesDate "nahrazuje vynechaný cvik z tohoto dne"
datetime createdAt
datetime updatedAt
}
WORKOUT_TEMPLATE {
string id PK
string name
enum type "VOLNY (výchozí), FIXNI, PLOVOUCI"
int replaceWithinDays "jen plán"
date startDate "jen plán"
}
WORKOUT_PLAN_DAY {
string id PK
string name
int weekday "1-7, jen fixní plán"
}
WORKOUT_TEMPLATE_ITEM {
enum kind "CVIK, SABLONA"
string name
enum unit
int quantity
int sets
int caloriesPer100Units
int extraWeightGrams
string templateRefId FK "volná šablona"
}
WORKOUT_PLAN {
string id PK
string templateId FK
string templateName "v době použití"
string planDayId FK
string planDayName "v době použití"
int replaceWithinDays "v době použití"
datetime appliedAt
}
WORKOUT_PLAN_ITEM {
string entryId FK "chybí = vynecháno"
string name
enum unit
int quantity
int sets
int caloriesPer100Units
int calories
object replacement "date + entryId náhrady"
}
MEAL_ENTRY }o--o| MEAL_TEMPLATE : "templateId"
MEAL_TEMPLATE ||--|{ RECIPE_INGREDIENT : "ingredients"
MEAL_TEMPLATE }o--o{ MEAL_TEMPLATE : "přílohy, podobné"
MEAL_PHOTO_INDEX ||--|| MEAL_PHOTO : "id"
MEAL_PHOTO_INDEX }o..o{ MEAL_ENTRY : "název + zdroj"
CALORIE_LIBRARY }o..o{ MEAL_ENTRY : "znormalizovaný název"
WORKOUT_TEMPLATE ||--o{ WORKOUT_TEMPLATE_ITEM : "items (volná)"
WORKOUT_TEMPLATE ||--o{ WORKOUT_PLAN_DAY : "days (plán)"
WORKOUT_PLAN_DAY ||--|{ WORKOUT_TEMPLATE_ITEM : "items"
WORKOUT_TEMPLATE_ITEM }o--o| WORKOUT_TEMPLATE : "templateRefId"
ACTIVITY_ENTRY }o--o| WORKOUT_TEMPLATE : "templateId"
ACTIVITY_ENTRY }o--o| WORKOUT_PLAN_DAY : "planDayId"
WORKOUT_PLAN }o--|| WORKOUT_TEMPLATE : "templateId"
WORKOUT_PLAN }o--o| WORKOUT_PLAN_DAY : "planDayId"
WORKOUT_PLAN ||--|{ WORKOUT_PLAN_ITEM : "items"
WORKOUT_PLAN_ITEM |o--o| ACTIVITY_ENTRY : "entryId (odcvičeno)"
WORKOUT_PLAN_ITEM |o--o| ACTIVITY_ENTRY : "replacement (náhrada)"
```
Vazby na šablony a plány jsou volné — smazaná šablona v historii zůstane pod
názvem uloženým v `WORKOUT_PLAN`. Záznam použití (`workoutPlans`) je u plánu zároveň
evidence, který den plánu se kdy odcvičil; podle ní se počítá, co je na řadě.
## Den: příjem a výdej
Přehled dne má dvě záložky:
- **Příjem** — co jste snědli, s cenou, gramáží a kaloriemi.
- **Výdej** — pohyb. Kroky, minuty, sekundy, kilometry nebo opakování; z množství a
spotřeby na 100 jednotek se dopočtou spálené kalorie (10 000 kroků ≈ 400 kcal).
U silových cviků jde zadat i **počet sérií** — 3 × 40 kliků se tak neplete
se 120 v kuse, i když kalorie vyjdou stejně.
Volitelně se dá k dni zapsat i **délka spánku**. Slouží jako kontext — do
energetické bilance nevstupuje, protože klidový výdej už pokrývá celých 24 hodin
včetně spánku a žádná standardní rovnice s délkou spánku nepočítá.
Nahoře je **bilance dne**: `příjem − (klidový výdej + pohyb)`.
## Tréninky a plány
Na stránce **Tréninky** jsou tři typy:
- **Plovoucí plán** — sekvence tréninkových dnů (Den 1, Den 2, Den 3…), po které
následuje den volna a cyklus se opakuje. Na kalendář navázaný není: další den
přijde na řadu, až se splní předchozí. Den volna jde v rámci cyklu jednou
**posunout dopředu** — když uprostřed cyklu den necvičíte, bere se to jako volno
a po posledním dni se pokračuje rovnou od začátku (Den 1, Den 2, volno, Den 3,
Den 1…). Další necvičený den trénink jen odsune.
- **Fixní plán** — pevné dny v týdnu (pondělí nohy, středa běh…), ostatní dny jsou volno.
- **Volný trénink** — seznam cviků na kdykoli (rozcvička, protažení). Jde ho vložit
do dne plánu i do jiného volného tréninku; vkládání je jen na jednu úroveň.
Plán se založí jednou a **přehled dne** pak sám navrhne, co je ten den na řadě.
Po odcvičení stačí návrh potvrdit; co se nestihlo, se odškrtne a zapíše jako
vynechané. Nad rámec plánu se ručně přidávají jen „unikátnosti" — chůze, kroky,
jednorázový běh. Jiný den plánu, než který je na řadě, nebo volný trénink jde
založit tlačítkem *Z tréninků*.
U plánu jde nastavit **Nahraditelné během N dní**: vynechaný cvik (odškrtnutý při
potvrzení nebo smazaný ze dne) se pak v následujících N dnech nabízí v přehledu
dne k náhradě. Ve statistikách výdeje se u takového dne místo vykřičníku ukáže
šipka na den náhrady. Dny volna ani necvičené dny se ve statistikách nijak
nezapisují — zobrazuje se jen to, co proběhlo.
Plán i trénink jde kdykoli **vylepšit** — přidat cviky, dny, zvýšit zátěž nebo
počet opakování. Historie zůstává, jak byla: každé použití si pamatuje, co plán
v ten den sliboval.
Založené položky jsou samostatné aktivity, takže je jde v dni doupravit, aniž by
se změnil plán. Volný trénink jde vytvořit i **opačným směrem** — z toho, co
už máte v dni zapsané, tlačítkem *Uložit jako trénink* v záložce Výdej.
Recepty (vařená jídla se surovinami a postupem) jsou rozšířením jídla v seznamu
jídel na stránce **Jídla**.
## Osobní údaje
Na stránce **Osobní údaje** se z pohlaví, roku narození, výšky a hmotnosti spočítá
klidový výdej (bazální metabolismus) rovnicí Mifflin–St Jeor. Kdo má hodnotu
změřenou přesněji, může ji zadat ručně — ta pak má přednost.
Výpočet se záměrně nenásobí koeficientem fyzické aktivity, jak to dělají kalkulačky
TDEE: pohyb si aplikace eviduje zvlášť a přičítá ho, takže vynásobení by ho
započítalo dvakrát.
## Kalorie a gramáž
Ke každému jídlu jde vyplnit **cenu za 100 g** a z ní se dopočítá **gramáž**
(u TechToweru typicky 44 Kč/100 g). Gramáž jde kdykoli přepsat ručně a každá
položka má vlastní sazbu — hlavní jídlo a salát s jiným cenováním se nepletou.
Sazby podniků se nastavují v *Nastavení* a při výběru zdroje se předvyplní.
Z gramáže a **energie na 100 g** se pak dopočítají kalorie. Hodnotu nabídne
tlačítko *Najít kalorie*:
- z **vlastní knihovny** — co jste jednou zadali, aplikace si pamatuje pod názvem
jídla a příště nabídne sama,
- z **Open Food Facts** (otevřená databáze, bez klíče),
- odkazem na **KalorickéTabulky.cz**, které veřejné API nemají — otevřou se
v novém panelu a název jídla se zkopíruje do schránky, hodnotu opíšete ručně.
Když je externí databáze nedostupná, hledání nespadne — zůstane vlastní knihovna.
## Import z Luncheru
Na stránce **Statistiky** je tlačítko *Import z Luncheru*. Stačí nahrát soubor
stažený ze statistik Luncheru:
- Záznamy se rozdělí do dnů, ke kterým patří (export je typicky za celý měsíc).
- Zakládají se jako **oběd**, protože Luncher řeší výběr obědů. Chod jde při
importu změnit a jednotlivá jídla pak upravit v přehledu dne.
- Před uložením se ukáže náhled toho, co se založí.
- Opakovaný import stejného měsíce data nezduplikuje.