Files
food-tracer/CLAUDE.md
T
Ondřej Anděl e13f7ce8e5 fix: recepty přesnuty k jídlům
feat: přidána podpora fotek
2026-10-12 10:20:12 +02:00

24 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ů), 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 — š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) a components/modals/ (MealModal, ActivityModal, ApplyWorkoutModal, ApplyMealModal — výběr z historie a receptů, SettingsModal, ImportModal)
  • 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 š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) je pojmenovaný seznam položek. Položka je buď cvik (kind: CVIK), nebo odkaz na jinou šablonu (kind: SABLONA), jejíž cviky se při použití rozbalí. Použitím vzniknou běžné aktivity s vazbou templateId — jsou samostatné, takže úprava založené položky šablonu nemění.

Zanoření je povolené jen na jednu úroveň a checkNesting 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ž někdo používá. Bez druhé kontroly by druhá úroveň vznikla oklikou přes úpravu. Smazat nelze šablonu, kterou skládá jiná — 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.

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í. Aktivity ze šablony bez plánu (cvik přesunutý z jiného dne) se počítají jako splněné přesně dle plánu.

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, toMealTemplate 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. 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é.

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