feat: založení základní stránky příjem/výdej + import a statistiky

This commit is contained in:
Ondřej Anděl
2026-09-07 14:58:49 +02:00
commit a5711f3e1e
98 changed files with 15505 additions and 0 deletions
+190
View File
@@ -0,0 +1,190 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Food Tracer je aplikace pro evidenci snědených jídel a útraty za jídlo. Czech-language UI.
Full-stack TypeScript monorepo. Technologicky i vzhledově navazuje na sesterský projekt
**Luncher** (`../Luncher`) — sdílí s ním stack, konvence i design systém.
## Monorepo Structure
```
types/ → Shared OpenAPI-generated TypeScript types (source of truth: types/api.yml)
server/ → Express 5 backend (Node.js 22, ts-node)
client/ → React 19 frontend (Vite 7, React Bootstrap)
```
Package manager: **Yarn Classic**. Deployment: `Dockerfile`, `compose.yml`.
## Development Commands
### Initial setup
```bash
cd types && yarn install && yarn openapi-ts # Generate API types first
cd ../server && yarn install
cd ../client && yarn install
```
### Running dev environment
```bash
./run_dev.sh # tmux (Linux/macOS)
.\run_dev.ps1 # dvě PowerShell okna (Windows)
# Nebo ručně:
cd server && NODE_ENV=development yarn startReload # Port 3001
cd client && yarn start # Port 3000, proxies /api → 3001
```
### Building & tests
```bash
cd types && yarn openapi-ts # Regenerate types from api.yml
cd server && yarn build # tsc → server/dist
cd server && yarn test # Jest (in-memory storage)
cd server && yarn test meals # Run one file by name
cd client && yarn build # tsc --noEmit + vite build → client/dist
```
## Architecture
### API Types (types/)
- OpenAPI 3.0 spec in `types/api.yml` — thin aggregator, endpoint specs in
`types/paths/<domain>/*.yml`, shared schemas in `types/schemas/_index.yml`
- `yarn openapi-ts` generates `types/gen/` (client.gen.ts, sdk.gen.ts, types.gen.ts)
- Both server and client import from these generated types
- **When changing API contracts: update api.yml first, then regenerate**
### Server (server/src/)
- **Entry:** `index.ts` — Express app, auth middleware, error middleware
- **Routes:** `routes/``dayRoutes`, `mealRoutes`, `activityRoutes`, `workoutRoutes`,
`statsRoutes`, `calorieRoutes`, `settingsRoutes`, `importRoutes`
- **Domain:** `meals.ts` (jídlo), `activities.ts` (pohyb), `workouts.ts` (šablony
tréninků), `dayOverview.ts` (spojení dne a bilance), `statsService.ts` (agregace),
`luncherImport.ts` (parsování exportů z Luncheru), `settings.ts`, `calories.ts`
- **Auth:** `auth.ts` — JWT + volitelná autentizace z hlavičky reverzní proxy
- **Storage:** `storage/index.ts` factory dle proměnné `STORAGE`; backendy:
`json.ts` (soubor, vývoj), `redis.ts` (produkce), `memory.ts` (testy)
- **Config:** `.env.development` / `.env.production` (viz `.env.template`)
### Client (client/src/)
- **Entry:** `index.tsx``AppRoutes.tsx`; `Login.tsx` je přihlašovací obrazovka
- **Pages:** `pages/``DayPage` (přehled dne se záložkami Příjem/Výdej),
`StatsPage` (statistiky + import)
- **Components:** `components/` (Header, CalorieLookup) a `components/modals/`
(MealModal, ActivityModal, WorkoutModal, SettingsModal, ImportModal)
- **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é
- **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/`
## Data model
- Jídlo (`MealEntry`) patří jednomu dni a jednomu uživateli; klíč úložiště je
`meals:<login>:<YYYY-MM-DD>` a hodnotou je pole záznamů dne.
- **Ceny jsou všude celá čísla v haléřích** — v úložišti, v API i mezi klientem
a serverem. Na koruny se převádí až při zobrazení (`formatPrice` v `Utils.tsx`).
Stejnou konvenci má Luncher, díky tomu se částky z importu přenesou beze ztráty.
- Kalorie jsou volitelné celé číslo v kcal. Jídla bez kalorií se do součtů
nezapočítávají.
- Gramáž (`weight`, g) a jednotkové hodnoty (`pricePer100g` v haléřích za 100 g,
`caloriesPer100g` v kcal) umožňují dopočet — viz níže.
## Gramáž a kalorie
`deriveAmounts` v `server/src/meals.ts` je jediný zdroj pravdy pro dopočty:
1. **gramáž** = `price / pricePer100g * 100`, pokud není zadaná ručně
2. **kalorie** = `weight / 100 * caloriesPer100g`, jinak ručně zadaná hodnota
Ručně zadaná gramáž má vždy přednost — porci lze zvážit přesněji, než kolik řekne
cena. Sazba je na každém záznamu zvlášť, takže hlavní jídlo za 44 Kč/100 g a salát
s jiným cenováním můžou být ve stejném dni vedle sebe. Výchozí sazby podniků drží
`server/src/settings.ts` (`sourceRates`) a klient jimi předvyplňuje pole, když
uživatel vybere zdroj.
`client/src/Utils.tsx` má stejnojmennou funkci pro živý náhled ve formuláři.
**Když se změní jedna, musí se změnit i druhá** — jinak klient ukazuje něco jiného,
než server uloží.
## Zdroje energetických hodnot
`server/src/calorieProvider.ts` definuje rozhraní `CalorieProvider` a jeho
implementaci nad Open Food Facts. Poskytovatel je záměrně vyměnitelný za jeden
soubor a **nikdy nevyhazuje výjimku** — při nedostupnosti vrací `unavailable`,
takže hledání jen přijde o návrhy a nespadne. `CALORIE_PROVIDER=none` externí
dotazy vypne (offline provoz, testy).
Poznámky ke zdrojům, ověřené v září 2026:
- **KalorickéTabulky.cz nemají veřejné API.** Jejich robots.txt zakazuje
`/*query.page` (jejich vyhledávání) a smluvní podmínky omezují užití nad rámec
zamýšleného účelu, takže se odtud **nesmí scrapovat**. Aplikace jen nabídne
odkaz na jejich tabulku potravin a zkopíruje název jídla do schránky.
Předvyplnit jejich hledání z URL nejde — běží v JavaScriptu a parametry v query
stringu ignoruje (ověřeno porovnáním odpovědí).
- **Open Food Facts:** používá se `search.openfoodfacts.org`, ne hlavní
`world.openfoodfacts.org` — tamní `/cgi/search.pl` i `/api/v2/search` vrací 503.
Produkty bez `energy-kcal_100g` se odfiltrují.
- **Vlastní knihovna** (`server/src/calories.ts`) si pamatuje kcal/100 g pod
znormalizovaným názvem jídla a řadí se před externí návrhy — na kantýnová jídla
sedí líp než databáze balených potravin. Plní se sama při uložení jídla.
## Pohyb a energetická bilance
Den má dvě strany a `DayPage` je dělí do záložek:
- **Příjem** — jídla (`MealEntry`), klíč `meals:<login>:<datum>`
- **Výdej** — pohyb (`ActivityEntry`), klíč `activities:<login>:<datum>`
Aktivita se měří v jednotce (`ActivityUnit`: KROKY, MINUTY, KM, OPAKOVANI) a
kalorie se dopočtou jako `quantity / 100 * caloriesPer100Units`. **Sto jednotek,
ne jedna** — u kroků by sazba na jeden krok byla zlomek (~0,04 kcal) a aplikace
všude pracuje s celými čísly. Stejná konvence jako u energie jídla na 100 g.
`WorkoutTemplate` (`workouts.ts`) je pojmenovaný seznam cviků. Použitím vzniknou
běžné aktivity s vazbou `templateId` — jsou samostatné, takže úprava založené
položky šablonu nemění.
`buildEnergyBalance` v `dayOverview.ts` počítá:
`bilance = příjem (klidový výdej + pohyb)`. Záporná hodnota je deficit.
Bez nastaveného klidového výdeje (`basalCalories` v nastavení) porovnává bilance
jen jídlo proti pohybu — **to není skutečný deficit**, proto se to přes
`hasBasal: false` propisuje do UI, aby to číslo nikoho nemátlo.
`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.
## Import z Luncheru
`server/src/luncherImport.ts` čte měsíční přehled ze stránky statistik Luncheru
ve všech třech formátech, které Luncher exportuje (XLSX, CSV, JSON — viz
`../Luncher/server/src/userExport.ts`).
- Sloupce se hledají **podle názvu hlavičky**, ne podle pozice.
- CSV má BOM, oddělovač `;`, datum `DD.MM.YYYY` a desetinnou čárku (český Excel).
- XLSX má dva listy — čte se `Přehled`, list `Souhrn` se ignoruje.
- Řádky se zakládají jako **oběd**, pokud se neurčí jinak (Luncher řeší obědy).
- `name` = jídlo → poznámka → typ záznamu. U voleb "Budu objednávat" a
"Rozhoduji se" bývá sloupec s jídlem prázdný a co se reálně jedlo stojí
v poznámce ("Chefie - Těstovinový salát"), proto ten mezikrok. Poznámka, ze
které se stal název, se už nekopíruje do `note`.
- `source` = obchod objednávky, jinak podnik. Stavy volby z `NON_PLACE_TYPES`
("Budu objednávat", "Rozhoduji se", "Objednávka", "Mám vlastní/neobědvám")
nejsou místa, takže se jako zdroj nepoužijí a záznam zůstane bez zdroje.
- Duplicity řeší `importKey` složený z data, pořadí řádku v rámci dne, typu,
jídla a částky — opakovaný import stejného měsíce nic nezduplikuje.
- Endpoint podporuje `dryRun` pro náhled před uložením; klient ho vždy použije.
**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.
## Conventions
- Czech naming for domain variables and UI strings; English for infrastructure code
- TypeScript strict mode in both client and server
- Server module resolution: Node16; Client: ESNext/bundler
- Komentáře v češtině, u netriviálních míst vysvětlují **proč**, ne co