339 lines
13 KiB
Markdown
339 lines
13 KiB
Markdown
# 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.
|