Files
food-tracer/CLAUDE.md
T
Ondřej Anděl 865c2c3b22 fix: rozdílné jednotky u receptů
feat: vazby a trvání receptů
2026-10-19 18:40:13 +02:00

27 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, 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 š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, 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