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 — 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í
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
./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:
cd server && NODE_ENV=development yarn startReload # port 3001
cd client && yarn start # port 3000, proxy /api → 3001
Testy a build
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í
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í.
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.