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

13 KiB
Raw Blame History

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.