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

30 KiB
Raw Blame History

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

cd types && yarn install && yarn openapi-ts   # Generate API types first
cd ../server && yarn install
cd ../client && yarn install

Running dev environment

./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

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 a historie jídel), mealGroups.ts (seznam jídel — skupiny podle názvu a zdroje, doplnění a přejmenování), mealPhotos.ts (fotky jídel), nutrients.ts (živiny), mealTemplates.ts (recepty), activities.ts (pohyb), workouts.ts (šablony tréninků a plány), workoutSchedule.ts (co plán navrhuje na den, náhrady vynechaných cviků), workoutPlans.ts (záznamy použití šablon), sleep.ts (spánek), weights.ts (vážení po dnech), profile.ts (tělesné údaje a výpočet BMR), bodyMetrics.ts (BMI, podíl tuku, cíle), 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), MealGroupsPage (/jidla — seznam jídel podle názvu a zdroje s filtrem zdroje, hromadným doplněním údajů a přejmenováním), WorkoutsPage (/treninky — plány a šablony tréninků), ProfilePage (Profil — ukazatele, vývoj hmotnosti, tělesné údaje, cíle a klidový výdej), StatsPage (statistiky se záložkami Příjem / Výdej / Útrata a import; panely IntakeStatsPanel, ActivityStatsPanel, SpendingStatsPanel)
  • Components: components/ (Header, CalorieLookup, MealSuggestions, NutrientsFieldset, PlannedWorkoutCard a PendingReplacementsPanel — návrh tréninku a náhrady v přehledu dne, WorkoutTemplateForm/WorkoutTemplateCard — šablony a plány) a components/modals/ (MealModal, ActivityModal, ApplyWorkoutModal, ApplyMealModal — výběr z historie a receptů, SettingsModal, ImportModal, SpinWheelModal — kolo štěstí)
  • Klientské enumy patří do enums.ts jako TypeScript enum s mapou popisků, ne jako inline union u komponenty (DayTab, StatsTab, StatsRange)
  • 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é. Přehled dne čte datum z parametru ?datum=YYYY-MM-DD (getDayUrl), na konkrétní den se tak dá odkázat
  • 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.
  • Nutriční hodnoty (nutrientsPer100g: bílkoviny, sacharidy, z toho cukry, tuky, z toho nasycené, vláknina, sůl) jsou volitelné, v gramech na 100 g jako na etiketě. Jako jediné nejsou celočíselné — ukládají se na desetiny gramu, u soli a vlákniny by celé gramy smazaly celou hodnotu. Hodnoty porce se dopočtou z gramáže a násobku (getPortionNutrients v nutrients.ts), takže jídlo bez gramáže do součtu dne (DayRecord.totalNutrients) nevstupuje.

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ží. Totéž platí pro applyMultiplier (server/src/utils.ts), deriveRecipe (server/src/mealTemplates.ts) a getPortionNutrients (server/src/nutrients.ts).

Násobek položky dne

Jídlo i aktivita mají volitelný multiplier (i necelý, 0,01–100): dvě sušenky jsou ×2, nedojedená polovina ×0,5. Mění se přímo z přehledu dne (/meals/multiplier, /activities/multiplier), bez otevření formuláře.

  • Uložené calories jsou vždy za jednu porci a výsledek je applyMultiplier(calories, multiplier). Kdyby se ukládal už vynásobený výsledek, opakovaná změna násobku by kumulovala zaokrouhlení.
  • Všechny součty (buildDayRecord, buildActivityDay, statsService) proto musí jít přes applyMultiplier, ne přes holé calories.
  • Cena se nenásobí — za nedojedené jídlo se platí celé. Dvě kusy se stejnou cenou jsou dva záznamy, nebo jedna cena zadaná za oba.
  • Násobek 1 je výchozí stav a neukládá se (stejně jako jedna série).

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, SEKUNDY, KM, OPAKOVANI) a kalorie se dopočtou jako getTotalQuantity(quantity, sets) / 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.

quantity je množství na jednu sérii, sets je počet sérií; celkové množství je jejich součin. Drží se odděleně schválně: 3 × 40 opakování s pauzami je jiný trénink než 120 v kuse, i když spálené kalorie vyjdou stejně. Jedna série je výchozí stav a neukládá se.

Opačný směr k použití šablony je createWorkoutTemplateFromActivities — z aktivit odcvičeného dne (všech, nebo vybraných) vznikne volná šablona. Záznamy ve dni zůstanou beze změny. U jídla nic takového není, opakovaně jedená jídla se nabízejí z historie (viz Historie jídel a recepty).

Dodatečná zátěž (extraWeightGrams u aktivity i cviku šablony, v gramech) je jen informace o tréninku, kalorie nemění — žádný jednoduchý vzorec spotřebu podle zátěže nedává, takže by šlo o vymyšlené číslo. Kdo chce, promítne ji do sazby na 100 jednotek.

WorkoutTemplate (workouts.ts) má typ (WorkoutTemplateType):

  • VOLNY — pojmenovaný seznam položek, do dne se zakládá ručně. Výchozí typ, nezapisuje se; šablony uložené dřív, než typy přibyly, jsou volné.
  • FIXNI — plán s dny (days) navázanými na den v týdnu (weekday 1–7).
  • PLOVOUCI — plán jako sekvence dnů zakončená dnem volna (volno se jako den nezapisuje, plyne z typu).

Položka je buď cvik (kind: CVIK), nebo odkaz na volnou šablonu (kind: SABLONA), jejíž cviky se při použití rozbalí (expandExercises). Použitím vzniknou běžné aktivity s vazbou templateId (u plánu i planDayId) — jsou samostatné, takže úprava založené položky šablonu nemění. Plán má navíc replaceWithinDays (lhůta pro náhradu vynechaných cviků) a startDate. Dny plánu mají stálé id, podle kterého se v historii pozná, který den se kdy cvičil, i po úpravě plánu.

Zanoření volných šablon je povolené jen na jednu úroveň a checkReferences to hlídá z obou stran: šablona nesmí odkazovat na takovou, která sama něco skládá, a zároveň nesmí začít skládat, pokud ji už jiná volná šablona používá. Bez druhé kontroly by druhá úroveň vznikla oklikou přes úpravu. Den plánu úroveň nepřidává, smí odkázat na libovolnou volnou šablonu; plán sám do ničeho vnořit nejde a volná šablona, kterou něco používá, se nemůže stát plánem. Smazat nelze šablonu, kterou skládá jiná nebo kterou má plán v některém dni — zůstal by odkaz do prázdna. Šablony uložené dřív, než skládání přibylo, nemají kind; withKind jim ho při načtení doplní na CVIK.

Návrh tréninku (getPlannedWorkouts ve workoutSchedule.ts, v přehledu dne DayOverview.plannedWorkouts): fixní plán navrhne den podle dne v týdnu. Plovoucí prochází záznamy použití od startDate jako stavový automat (advanceDone, advanceGap): odcvičený den posune sekvenci, den bez tréninku je řádné volno (když je na řadě), posunuté volno (uprostřed rozběhnutého cyklu, jednou za cyklus — po posledním dni se pak pokračuje rovnou od začátku), jinak trénink jen odsune. Budoucí dny se počítají, jako by se navržený trénink splnil. Návrh se potvrdí přes applyWorkoutTemplate s dayId a doneIndexes (pořadí v rozbaleném seznamu cviků); odškrtnuté cviky se zapíšou do záznamu bez entryId, jako vynechané. Den jde potvrdit i celý jako vynechaný.

Náhrady (getPendingReplacements, replaceSkippedExercise, POST /api/workouts/replace): vynechaný cvik (bez aktivity a bez náhrady) se nabízí v přehledu dne po dobu replaceWithinDays ze záznamu (lhůta, jaká platila v den vynechání, nejvýš 14). Náhrada založí aktivitu s replacesDate a k cviku v záznamu původního dne zapíše replacement (datum a entryId). Smazání náhradní aktivity vazbu zruší, přesun jí změní datum (updateReplacementLink). Náhrada ani den volna plovoucí plán neposouvají — posouvá ho jen záznam dne plánu.

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 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.

Klidový výdej: ručně zadaný basalCalories má přednost před hodnotou spočítanou z tělesných údajů (computeBasalCalories v profile.ts, rovnice Mifflin–St Jeor). getDayOverview ho počítá s hmotností platnou k danému dni (poslední vážení v ten den nebo dřív), takže starší dny se se zhubnutím zpětně nepřepočítají; settings.computedBasalCalories je hodnota z posledního vážení. Výpočet záměrně nenásobí koeficientem fyzické aktivity (PAL), jak to dělají kalkulačky TDEE — pohyb se eviduje zvlášť a přičítá se, takže vynásobení by ho započítalo dvakrát.

Vážení (weights.ts, klíč weights:<login> — mapa den → gramy) se zapisuje v přehledu dne nebo na stránce Profil. Hmotnost v profilu (profile.weightGrams) je pozůstatek z doby před vážením po dnech a použije se, jen dokud uživatel žádné vážení nemá; klient ji při uložení profilu posílá zpět beze změny.

Ukazatele (bodyMetrics.ts, UserSettings.bodyMetrics) počítá server, nic z nich se neukládá: BMI s pásmem podle WHO, zdravé rozmezí hmotnosti (BMI 18,5–25), podíl tuku metodou US Navy z obvodů (muž pas a krk, žena navíc boky) a z cílů (Goals) cílová denní bilance a bílkoviny. Tempo počítá s 7 700 kcal na kilogram — −0,5 kg týdně je −550 kcal denně. Cílová bilance (EnergyBalance.targetBalance) i cíl bílkovin (DayOverview.proteinTargetGrams) jsou jen informace pro uživatele, do bilance nevstupují. Popisky tempa ve výběru na stránce Profil počítají kcal/den se stejnou konstantou.

Spánek (sleep.ts, klíč sleep:<login>:<datum>) se eviduje jako kontext dne a do bilance nevstupuje. Klidový výdej je hodnota za celých 24 hodin včetně spánku a žádná ze standardních rovnic délku spánku jako proměnnou nemá — přičítat nebo odečítat za něj kalorie by bylo vymýšlení čísla. Test to hlídá: bilance se po zadání spánku nesmí změnit.

Statistiky výdeje (getActivityStats ve statsService.ts, GET /api/stats/activity) dělí pohyb na aktivity ze šablon a mimo ně a u šablon porovnává skutečnost s plánem. Plán si applyWorkoutTemplate ukládá při použití (workoutPlans.ts, klíč workoutPlans:<login>:<datum>) — nesrovnává se se současnou podobou šablony, ta se mohla mezitím změnit, a smazaná aktivita by beze stopy zmizela. Plán nese jednotlivé cviky s entryId aktivity, která z nich vznikla; podle něj analyzeDay u každého dne pozná cvik odebraný (aktivita chybí) a upravený (jiné množství, série nebo násobek) — podle názvu by to nešlo, šablona může mít stejný cvik dvakrát. Smazání všech cviků šablony ve dni dropOrphanedPlans bere jako vrácené použití a plán zahodí — kromě záznamu vynechaného celého dne plánu a záznamu, jehož cvik už má náhradu. Aktivity ze šablony bez plánu (cvik přesunutý z jiného dne) se počítají jako splněné přesně dle plánu. Plán se ve statistikách dělí po dnech (klíč templateId:planDayId). Nahrazený cvik je odchylka NAHRAZENO s replacementDate a počítá se jako provedený; náhrada sama je v ActivityDayStats.replacements dne, kdy se odcvičila, a její kalorie jdou do skutečnosti dne plánu, ze kterého pochází. Graf u takového dne místo „!" ukáže šipku na den náhrady. Dny volna ani necvičené dny plánu se do statistik nijak nepromítají.

Statistiky příjmu (getIntakeStats, GET /api/stats/intake) jsou protějšek výdeje: příjem po chodech a zdrojích, živiny a porovnání s výdejem. Výdej a bilance se počítají jen za dny, kdy má aspoň jedno jídlo kalorie (caloriesDayCount) — den bez zápisu nebo s jídly z importu bez kalorií by jinak vypadal jako deficit celého výdeje. Ze stejného důvodu z nich vycházejí průměry. Klidový výdej je k hmotnosti platné v daný den (getBasalOn v dayOverview.ts). Peníze za jídlo jsou na samostatné záložce Útrata (getFoodStats, /api/stats/summary).

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.

Historie jídel a recepty

Hotová jídla (objednaná, koupená) nemají šablony. Při zakládání jídla se k rozepsanému názvu nabízí historie: getMealHistory v meals.ts (GET /api/meals/history) projde všechna jídla uživatele a každou dvojici název a zdroj vrátí jednou (MealSuggestion). Stejnojmenné jídlo z jiného zdroje (tikka masala z Everestu a z Namaste) je jiné jídlo a nikde se s ním neslučuje — ani v návrzích, ani v seznamu jídel, ani při fillSameNameMeals; na klientovi to hlídá getMealKey v Utils.tsx. Porovnává se znormalizovaně přes normalizeName, hodnoty jsou z posledního záznamu (cena i gramáž se časem mění) a řadí se od nejčastěji jedených.

Návrh nese jen to, co popisuje jídlo samo — chod, datum, násobek ani poznámku ne, ty patří ke konkrétnímu dni. Gramáž dopočtená z ceny se vynechá, aby se v novém záznamu dopočetla z jeho ceny; ruční kalorie jen tam, kde nejdou dopočítat z gramáže. Jinak by se z dopočtené hodnoty stala ručně zadaná. Když poslední záznam kalorie nemá (typicky import), doplní se do návrhu z dřívějšího přes createNutritionFiller, stejně jako při importu.

Návrhy se nabízejí i při úpravě jídla, ale až po změně názvu, a doplní jen prázdná pole (mergeSuggestion v MealModal) — slouží k přejmenování naimportovaného jídla, jehož cena a zdroj jsou skutečné. Po uložení jídla s kaloriemi DayPage nabídne (FillSameNameOffer) doplnit stejné hodnoty do ostatních jídel stejného názvu a zdroje, která je nemají (fillSameNameMeals, POST /api/meals/fillSameName); jídla s kaloriemi se nepřepisují.

Seznam jídel (MealGroupsPage, položka Jídla v hlavičce) seskupí jídla podle znormalizovaného názvu a zdroje (getMealGroups, GET /api/meals/groups) a ukáže, kolika chybí kalorie, gramáž a živiny. Skupina nese i dny, kdy se jídlo jedlo (MealGroup.days, s příznakem neúplných údajů), a stránka je nabízí jako odkazy na přehled dne v nové záložce. fillMealGroup (POST /api/meals/fillGroup) doplní do skupiny jen chybějící hodnoty: gramáž ne tam, kde jde dopočítat z vlastní ceny a sazby, energii jen jídlům bez kalorií (jinak by dopočet přepsal ručně zadané kalorie), živiny po jednotlivých položkách. Předvyplněné hodnoty jsou z nejnovějšího jídla skupiny; energie a živiny bez nich i z jídla stejného názvu odjinud, sazba a gramáž ne.

Druhou akcí skupiny je přejmenování (renameMealGroup, POST /api/meals/renameGroup) — změní název i zdroj všem jídlům skupiny ve všech dnech. Je oddělené od doplnění schválně: doplnění jen připisuje, co chybí, přejmenování přepisuje. Když nový název a zdroj patří jiné skupině, jídla se do ní sloučí (formulář na to předem upozorní). Recept a fotky jdou s jídlem; dvě jídla s recepty sloučit nejde.

Recept a fotky jsou rozšířením jídla v seznamu, ne samostatnou evidencí — patří dvojici název a zdroj, ne dni. V přehledu dne se nezobrazují.

  • Recept se k jídlu váže shodou názvu a zdroje (getRecipeKey), jedno jídlo má nejvýš jeden (saveMealTemplate druhý odmítne). Recept jídla, které se ještě nejedlo, je v seznamu jako skupina s count: 0 a bez lastDate. Má-li jídlo recept, doplnění údajů předvyplní gramáž porce a energii z něj.
  • Fotky (mealPhotos.ts): klient je před nahráním zmenší (imageResize.ts, ~1600 px JPEG) a pošle i náhled (240 px). Rejstřík s náhledy je jeden pro uživatele (mealPhotos:<login>, klíč jídla → fotky), aby ho seznam jídel načetl jedním čtením; plné obrázky leží každý zvlášť (mealPhoto:<login>:<id>) a načtou se, až když se fotky jídla otevřou.

Pole Zdroj (SourceInput) nabízí zdroje z historie (getMealSources, GET /api/meals/sources, nejčastější první) a podniky se sazbou z nastavení; psát jde i nový.

MealTemplate (mealTemplates.ts, klíč mealTemplates:<login>) je recept — vařené jídlo se surovinami (ingredients), počtem porcí (servings), hmotností hotového jídla (cookedWeight) a postupem v note. Chod nemá — patří ke dni, ne k jídlu; použije se zvolený při použití, jinak oběd (starší recepty ho v úložišti mít můžou, toOwnRecipe ho zahodí). deriveRecipe z nich při uložení spočítá hodnoty jedné porce a uloží je do plochých polí (weight, caloriesPer100g, calories, price); použitím vznikne běžný MealEntry přes addMeal. Energie na 100 g se bere z hmotnosti hotového jídla, ne ze součtu surovin — vařením se voda odpaří nebo nasákne. Postup se do záznamu dne nekopíruje. Recept má volitelný typ (category — polévka, omáčka, pečení…) jako volný text; formulář nabízí typy z ostatních receptů (RecipeCategoryInput), pevný číselník to záměrně není. Chuť (flavor, RecipeFlavor: SLANE/SLADKE) je v API vždy vyplněná; slaná je výchozí a do úložiště se nezapisuje, toOwnRecipe ji při čtení doplní. Volitelný je i čas přípravy (prepMinutes, v minutách).

Jednotky surovin: surovina má množství v gramech (weight), nebo v jiné jednotce (amount + unit, IngredientUnit: ks, ml, l, lžíce…) — vejce na kusy, mléko v litrech. U jiné jednotky je weight jen nepovinný přepočet na gramy pro výživu; energie i hmotnost jídla se počítají vždy z gramů a surovina bez přepočtu do nich nevstupuje. Hustotu ani hmotnost kusu aplikace neodhaduje, bylo by to vymyšlené číslo. Bez jednotky se amount neukládá.

Vazby mezi recepty: přílohy (sideDishIds, směrové — ukládají se u hlavního jídla, druhá strana je sideDishOfIds) a podobné (similarIds, oboustranné, uložené jen u jednoho z dvojice). Druhou stranu dopočte při čtení withLinks, do úložiště se nezapisuje. Uložení receptu je pro všechny vazby, které se ho týkají, autoritativní (relinkOther je odebere ostatním a znovu zapíše jen podle vstupu), takže vazbu jde přidat i odebrat z kteréhokoli z obou receptů. Smazání receptu vazby na něj odklidí. Karta receptu vazby ukazuje jako odkazy (v seznamu jídel i na kole štěstí), PDF jako odkazy na stránku receptu, pokud je v tomtéž exportu.

Recepty se nabízejí v našeptávání i ve výběru z historie (ApplyMealModal) a jídlo stejného jména z historie se vedle receptu nenabízí podruhé.

Postup receptu je Markdown. V aplikaci ho vykresluje RecipeNote (react-markdown, syrové HTML se nevykreslí), do PDF ho převádí markdownPdf.ts nad stejným parserem (mdast). Obě cesty používají markdownBreaks.ts, který z jednoduchého zalomení řádku dělá tvrdé — starší postupy jsou prostý text po řádcích a jinak by se slily do jednoho odstavce. Když se změní zobrazení Markdownu v jedné cestě, je potřeba upravit i druhou.

Export do PDF (recipePdf.ts, pdfmake v prohlížeči, načte se líně až při exportu) jde u jednoho receptu (ikona na kartě receptu) i hromadně tlačítkem na stránce Jídla — exportují se recepty, které seznam po filtrech ukazuje, bez filtru všechny. Víc receptů dostane titulní stranu s klikacím obsahem, každý recept začíná na nové stránce. PDF nese suroviny, hodnoty porce, postup a fotky jídla (první přes šířku, ostatní menší); pdfmake umí jen JPEG a PNG, jiné formáty se vynechají. Server se exportu neúčastní.

Kolo štěstí (SpinWheelModal, tlačítko v hlavičce) je převzaté z Luncheru. Losuje ze všech jídel (skupin ze seznamu jídel — i objednávaných), nebo jen z receptů, volitelně zúžených chutí a typem. U všech jídel se nejdřív vybere nebo vylosuje zdroj a točí se až z jeho jídel — všech jídel jsou stovky a kolo by s nimi bylo nepoužitelné. Padlý recept jde rovnou otevřít (WheelRecipe, jen ke čtení) a stáhnout jako PDF. Nic se neukládá — kolo jen rozhoduje. Konfety (WheelConfetti) mají vzhled jednoduchého stylu a Hell Girl.

Dřív pod stejným klíčem ležely i šablony hotových jídel (kind: HOTOVE, nebo bez kind). Ty zanikly: loadRecipes je při čtení vynechá (poznají se podle toho, že nemají suroviny) a první zápis receptu je z úložiště odklidí. Jídla z nich založená ve dnech zůstávají, dál nesou templateId zaniklé šablony.

Jídlo založené z receptu nese templateId. Nastavuje ho jen server (addMeal s třetím parametrem) — MealInput ho nemá, takže ho klient nepodstrčí a úprava jídla ho zachová.

Kontrakt má vlastní MealTemplateInput, kde je id volitelné a hodnoty porce chybí — dopočítá je server.

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.
  • Luncher kalorie nezná, takže je createNutritionFiller (meals.ts) doplní z historie: vzorem je nejnovější jídlo stejného názvu, které energii nebo živiny má, přednostně ze stejného zdroje. Cena zůstává z importu. Sazba za 100 g se přebírá jen ze stejného zdroje (gramáž se pak dopočte z nové ceny), jinak se vezme gramáž vzoru.
  • 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.
  • Režim (ImportMode): PRIDAT (výchozí) přeskočí už naimportované řádky; NAHRADIT v dnech, do kterých se něco zakládá, odebere dřívější záznamy z importu a založí je znovu. Ručně zadaná jídla zůstávají v obou režimech.
  • Endpoint podporuje dryRun pro náhled před uložením; klient ho vždy použije. Náhled vrací všechny řádky, které založit jde, i jídla, která už v dotčených dnech jsou (existing). Uživatel v něm řádky odškrtá a ostrý import dostane jen selectedKeys.

Jídlo, které Luncher zapsal jako jedno ("Chicken tikka massala+garlic nun"), jde v přehledu dne rozdělit (MealSplitModal, splitMeal, POST /api/meals/split) — import se kvůli tomu nekomplikuje. Původní záznam si nechá importKey, takže ho režim PRIDAT znovu nezaloží; odštěpená část dostane jen importSource, aby ji režim NAHRADIT odebral spolu s původním a nevzniklo jídlo dvakrát. Cena se mezi části nedopočítává, rozdělí se jen na vyžádání (napůl).

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