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
+11
View File
@@ -0,0 +1,11 @@
node_modules
types/gen
**.DS_Store
.mcp.json
.claude/settings.local.json
server/public/
server/data/
.claude/*.lock
.claude/worktrees
.playwright-mcp
.idea/
+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
+52
View File
@@ -0,0 +1,52 @@
# syntax=docker/dockerfile:1
# ─── Generování typů z OpenAPI ────────────────────────────────────────────────
FROM node:22-alpine AS types
WORKDIR /app/types
COPY types/package.json types/yarn.lock* ./
RUN yarn install --frozen-lockfile
COPY types/ ./
RUN yarn openapi-ts
# ─── Build serveru ────────────────────────────────────────────────────────────
FROM node:22-alpine AS server-build
WORKDIR /app
COPY --from=types /app/types ./types
WORKDIR /app/server
COPY server/package.json server/yarn.lock* ./
RUN yarn install --frozen-lockfile
COPY server/ ./
RUN yarn build
# ─── Build klienta ────────────────────────────────────────────────────────────
FROM node:22-alpine AS client-build
WORKDIR /app
COPY --from=types /app/types ./types
WORKDIR /app/client
COPY client/package.json client/yarn.lock* ./
RUN yarn install --frozen-lockfile
COPY client/ ./
RUN yarn build
# ─── Runtime ──────────────────────────────────────────────────────────────────
FROM node:22-alpine AS runner
ENV NODE_ENV=production
ENV TZ=Europe/Prague
RUN apk add --no-cache tzdata
WORKDIR /app
# Server běží ze zkompilovaného dist/, kde si drží strukturu server/ + types/
COPY --from=server-build /app/server/dist ./
COPY server/package.json ./server/package.json
COPY server/yarn.lock* ./server/
WORKDIR /app/server
RUN yarn install --frozen-lockfile --production
# Klient se servíruje jako statické soubory ze složky public
COPY --from=client-build /app/client/dist ./public
EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
CMD wget -qO- http://127.0.0.1:3001/api/health || exit 1
CMD ["node", "src/index.js"]
+105
View File
@@ -0,0 +1,105 @@
# 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](../Luncher) — 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í
```bash
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
```bash
./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:
```bash
cd server && NODE_ENV=development yarn startReload # port 3001
cd client && yarn start # port 3000, proxy /api → 3001
```
## Testy a build
```bash
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í
```bash
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`.
## 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, kilometry nebo opakování; z množství a
spotřeby na 100 jednotek se dopočtou spálené kalorie (10 000 kroků ≈ 400 kcal).
Opakované tréninky se dají uložit jako **šablonu***Workout day 1* pak stačí
jedním kliknutím založit do dne celý, místo zadávání cviku po cviku. Založené
položky jsou samostatné, takže je jde doupravit, aniž by se změnila šablona.
Nahoře je **bilance dne**: `příjem (klidový výdej + pohyb)`. Klidový výdej
(bazální metabolismus) se nastavuje v *Nastavení* — bez něj se porovnává jen
jídlo proti pohybu a nejde o skutečný deficit, na což aplikace upozorní.
## 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.
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#111827" />
<meta name="description" content="Food Tracer — evidence snědených jídel a útraty za jídlo" />
<title>Food Tracer</title>
</head>
<body>
<noscript>Pro běh aplikace je potřeba zapnutý JavaScript.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@food-tracer/client",
"version": "0.1.0",
"license": "MIT",
"private": true,
"type": "module",
"homepage": ".",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.1.0",
"@types/node": "^24.10.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
"bootstrap": "^5.3.8",
"react": "^19.2.0",
"react-bootstrap": "^2.10.10",
"react-dom": "^19.2.0",
"react-jwt": "^1.3.0",
"react-router": "^7.9.5",
"react-router-dom": "^7.9.5",
"react-toastify": "^11.0.5",
"recharts": "^3.4.1",
"sass": "^1.93.3",
"typescript": "^5.9.3",
"vite": "^7.2.2",
"vite-tsconfig-paths": "^5.1.4"
},
"scripts": {
"start": "vite",
"build": "tsc --noEmit && vite build"
},
"devDependencies": {
"prettier": "^3.6.2"
}
}
+723
View File
@@ -0,0 +1,723 @@
// ============================================
// DESIGN SYSTEM - FOOD TRACER
// Vychází ze systému Luncheru, aby obě aplikace působily jako jedna rodina.
// ============================================
:root, [data-bs-theme="light"] {
// Primary colors
--ft-primary: #16a34a;
--ft-primary-hover: #15803d;
--ft-primary-light: #dcfce7;
// Background colors
--ft-bg: #f9fafb;
--ft-bg-card: #ffffff;
--ft-bg-hover: #f3f4f6;
// Text colors
--ft-text: #111827;
--ft-text-secondary: #4b5563;
--ft-text-muted: #9ca3af;
// Border colors
--ft-border: #e5e7eb;
--ft-border-light: #f3f4f6;
// Navbar - Dark
--ft-navbar-bg: #111827;
--ft-navbar-text: #f9fafb;
// Action colors
--ft-success: #16a34a;
--ft-warning: #d97706;
--ft-danger: #dc2626;
--ft-info: #2563eb;
// Shadows
--ft-shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--ft-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--ft-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
// Border radius
--ft-radius-sm: 6px;
--ft-radius: 10px;
--ft-radius-lg: 12px;
--ft-radius-xl: 16px;
// Transitions
--ft-transition: all 0.2s ease;
--ft-transition-slow: all 0.3s ease;
}
[data-bs-theme="dark"] {
--ft-primary: #1db954;
--ft-primary-hover: #1ed760;
--ft-primary-light: #052e16;
--ft-bg: #121212;
--ft-bg-card: #181818;
--ft-bg-hover: #282828;
--ft-text: #ffffff;
--ft-text-secondary: #b3b3b3;
--ft-text-muted: #6b7280;
--ft-border: #282828;
--ft-border-light: #1f1f1f;
--ft-navbar-bg: #000000;
--ft-navbar-text: #ffffff;
--ft-success: #1db954;
--ft-warning: #fbbf24;
--ft-danger: #f87171;
--ft-info: #60a5fa;
--ft-shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.5);
--ft-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.5), 0 2px 4px -2px rgb(0 0 0 / 0.4);
--ft-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5), 0 4px 6px -4px rgb(0 0 0 / 0.4);
}
// ============================================
// BASE
// ============================================
html, body, #root {
height: 100%;
margin: 0;
}
html {
overflow-x: hidden;
}
body {
background-color: var(--ft-bg);
color: var(--ft-text);
transition: var(--ft-transition-slow);
}
.app-container {
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: var(--ft-bg);
}
.wrapper {
padding: 24px;
flex: 1;
max-width: 1200px;
margin: 0 auto;
width: 100%;
@media (max-width: 768px) {
padding: 16px;
}
}
// ============================================
// NAVBAR
// ============================================
.navbar {
background: var(--ft-navbar-bg) !important;
padding: 12px 24px;
box-shadow: none;
border: none;
.navbar-brand {
color: var(--ft-navbar-text) !important;
font-weight: 700;
font-size: 1.25rem;
letter-spacing: -0.02em;
&::before {
content: '🍽';
margin-right: 8px;
}
}
.nav {
margin-left: auto;
align-items: center;
gap: 4px;
}
.nav-link,
.dropdown-toggle {
color: var(--ft-navbar-text) !important;
font-weight: 500;
}
.nav-pill {
color: var(--ft-navbar-text);
background: transparent;
border: none;
border-radius: var(--ft-radius-sm);
padding: 6px 14px;
font-weight: 500;
font-size: 0.9rem;
transition: var(--ft-transition);
&:hover {
background: rgb(255 255 255 / 0.1);
}
&.active {
background: var(--ft-primary);
color: #fff;
}
}
.theme-toggle {
background: transparent;
border: none;
color: var(--ft-navbar-text);
font-size: 1rem;
padding: 6px 10px;
border-radius: var(--ft-radius-sm);
transition: var(--ft-transition);
&:hover {
background: rgb(255 255 255 / 0.1);
}
}
.dropdown-menu {
background: var(--ft-bg-card);
border: 1px solid var(--ft-border);
border-radius: var(--ft-radius);
box-shadow: var(--ft-shadow-lg);
padding: 6px;
.dropdown-item {
color: var(--ft-text);
border-radius: var(--ft-radius-sm);
padding: 8px 12px;
font-size: 0.9rem;
&:hover, &:focus {
background: var(--ft-bg-hover);
color: var(--ft-text);
}
}
.dropdown-divider {
border-color: var(--ft-border);
}
}
}
// ============================================
// CARDS & PANELS
// ============================================
.card-panel {
background: var(--ft-bg-card);
border: 1px solid var(--ft-border);
border-radius: var(--ft-radius-lg);
box-shadow: var(--ft-shadow-sm);
padding: 20px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 20px;
h1 {
font-size: 1.6rem;
font-weight: 700;
letter-spacing: -0.02em;
margin: 0;
color: var(--ft-text);
}
.page-subtitle {
margin: 4px 0 0;
color: var(--ft-text-secondary);
font-size: 0.9rem;
}
}
// ============================================
// SUMMARY TILES
// ============================================
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.summary-tile {
background: var(--ft-bg-card);
border: 1px solid var(--ft-border);
border-radius: var(--ft-radius-lg);
box-shadow: var(--ft-shadow-sm);
padding: 16px 18px;
.summary-label {
display: flex;
align-items: center;
gap: 8px;
color: var(--ft-text-secondary);
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 6px;
}
.summary-value {
font-size: 1.6rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--ft-text);
line-height: 1.2;
}
.summary-hint {
margin-top: 2px;
color: var(--ft-text-muted);
font-size: 0.8rem;
}
&.accent .summary-value {
color: var(--ft-primary);
}
}
// ============================================
// FORMS
// ============================================
.form-control,
.form-select {
background: var(--ft-bg-card);
border: 1px solid var(--ft-border);
border-radius: var(--ft-radius-sm);
color: var(--ft-text);
transition: var(--ft-transition);
&:focus {
background: var(--ft-bg-card);
color: var(--ft-text);
border-color: var(--ft-primary);
box-shadow: 0 0 0 3px var(--ft-primary-light);
}
&::placeholder {
color: var(--ft-text-muted);
}
}
.form-label {
color: var(--ft-text-secondary);
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 4px;
}
.form-text {
color: var(--ft-text-muted);
font-size: 0.8rem;
}
// ============================================
// BUTTONS
// ============================================
.btn {
border-radius: var(--ft-radius-sm);
font-weight: 600;
transition: var(--ft-transition);
}
.btn-primary {
background: var(--ft-primary);
border-color: var(--ft-primary);
&:hover, &:focus, &:active {
background: var(--ft-primary-hover) !important;
border-color: var(--ft-primary-hover) !important;
}
&:disabled {
background: var(--ft-primary);
border-color: var(--ft-primary);
opacity: 0.55;
}
}
.btn-outline-secondary {
color: var(--ft-text-secondary);
border-color: var(--ft-border);
&:hover, &:focus, &:active {
background: var(--ft-bg-hover) !important;
border-color: var(--ft-border) !important;
color: var(--ft-text) !important;
}
}
.btn-icon {
background: transparent;
border: none;
color: var(--ft-text-muted);
padding: 6px 8px;
border-radius: var(--ft-radius-sm);
transition: var(--ft-transition);
&:hover {
background: var(--ft-bg-hover);
color: var(--ft-text);
}
&.danger:hover {
color: var(--ft-danger);
}
}
// ============================================
// MODALS
// ============================================
.modal-content {
background: var(--ft-bg-card);
color: var(--ft-text);
border: 1px solid var(--ft-border);
border-radius: var(--ft-radius-lg);
box-shadow: var(--ft-shadow-lg);
}
.modal-header,
.modal-footer {
border-color: var(--ft-border);
}
.modal-title {
font-weight: 700;
font-size: 1.15rem;
}
.btn-close {
filter: var(--ft-close-filter, none);
}
[data-bs-theme="dark"] .btn-close {
filter: invert(1) grayscale(100%) brightness(200%);
}
// ============================================
// EMPTY STATE
// ============================================
.empty-state {
text-align: center;
padding: 40px 20px;
color: var(--ft-text-secondary);
.empty-icon {
font-size: 2.4rem;
color: var(--ft-text-muted);
margin-bottom: 12px;
}
.empty-title {
font-size: 1.05rem;
font-weight: 600;
color: var(--ft-text);
margin-bottom: 4px;
}
.empty-description {
font-size: 0.9rem;
margin: 0;
}
}
// ============================================
// BADGES
// ============================================
.tag {
display: inline-flex;
align-items: center;
gap: 5px;
background: var(--ft-bg-hover);
color: var(--ft-text-secondary);
border-radius: 999px;
padding: 2px 10px;
font-size: 0.78rem;
font-weight: 600;
white-space: nowrap;
}
.tag-import {
background: var(--ft-primary-light);
color: var(--ft-primary);
}
// ============================================
// FORMULÁŘ JÍDLA — gramáž a kalorie
// ============================================
.meal-fieldset {
border: 1px solid var(--ft-border);
border-radius: var(--ft-radius);
padding: 12px 16px 16px;
legend {
float: none;
width: auto;
padding: 0 6px;
margin: 0 0 0 -6px;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ft-text-muted);
}
}
// Dopočtená hodnota se needituje, tak vypadá jako text, ne jako pole
.derived-value {
display: flex;
align-items: center;
min-height: 38px;
padding: 0 2px;
font-size: 1.1rem;
font-weight: 700;
color: var(--ft-primary);
}
.meal-modal-summary {
display: flex;
gap: 14px;
margin-right: auto;
color: var(--ft-text-secondary);
font-size: 0.88rem;
font-weight: 600;
}
// ============================================
// HLEDÁNÍ KALORIÍ
// ============================================
.calorie-lookup {
width: 100%;
position: relative;
.calorie-lookup-hint {
display: block;
margin-top: 6px;
font-size: 0.78rem;
color: var(--ft-text-muted);
}
.calorie-lookup-results {
position: absolute;
z-index: 5;
top: calc(100% + 6px);
right: 0;
width: min(380px, 90vw);
background: var(--ft-bg-card);
border: 1px solid var(--ft-border);
border-radius: var(--ft-radius);
box-shadow: var(--ft-shadow-lg);
padding: 6px;
max-height: 300px;
overflow-y: auto;
}
.calorie-suggestion {
display: flex;
align-items: baseline;
gap: 8px;
width: 100%;
text-align: left;
background: transparent;
border: none;
border-radius: var(--ft-radius-sm);
padding: 7px 10px;
font-size: 0.85rem;
color: var(--ft-text);
transition: var(--ft-transition);
&:hover {
background: var(--ft-bg-hover);
}
}
.calorie-suggestion-icon {
color: var(--ft-primary);
font-size: 0.75rem;
flex: 0 0 auto;
}
.calorie-suggestion-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.calorie-suggestion-brand {
color: var(--ft-text-muted);
font-size: 0.76rem;
flex: 0 0 auto;
}
.calorie-suggestion-value {
font-weight: 700;
white-space: nowrap;
flex: 0 0 auto;
}
.calorie-lookup-external {
display: block;
padding: 8px 10px;
margin-top: 4px;
border-top: 1px solid var(--ft-border-light);
font-size: 0.8rem;
color: var(--ft-primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
// ============================================
// NASTAVENÍ — sazby za 100 g
// ============================================
.settings-section-title {
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ft-text-secondary);
margin: 0 0 6px;
}
.rate-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid var(--ft-border-light);
.rate-source {
flex: 1;
font-weight: 600;
}
.rate-value {
color: var(--ft-text-secondary);
font-size: 0.87rem;
white-space: nowrap;
}
}
.rate-form {
display: flex;
gap: 8px;
margin-top: 14px;
.rate-form-price {
width: 130px;
flex: 0 0 auto;
}
}
// ============================================
// ŠABLONY TRÉNINKŮ
// ============================================
.workout-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid var(--ft-border-light);
.workout-info {
flex: 1;
min-width: 0;
}
.workout-name {
font-weight: 600;
color: var(--ft-text);
}
.workout-items {
font-size: 0.8rem;
color: var(--ft-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workout-calories {
color: var(--ft-warning);
font-weight: 700;
font-size: 0.85rem;
white-space: nowrap;
}
}
.workout-item-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
.workout-item-quantity {
width: 90px;
flex: 0 0 auto;
}
.workout-item-unit {
width: 130px;
flex: 0 0 auto;
}
.workout-item-rate {
width: 110px;
flex: 0 0 auto;
}
@media (max-width: 640px) {
flex-wrap: wrap;
.workout-item-quantity,
.workout-item-unit,
.workout-item-rate {
width: auto;
flex: 1;
}
}
}
.workout-form-footer {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
.workout-draft-calories {
margin-left: auto;
color: var(--ft-warning);
font-weight: 600;
font-size: 0.85rem;
}
}
+30
View File
@@ -0,0 +1,30 @@
import { Routes, Route, Navigate } from "react-router-dom";
import { ToastContainer } from "react-toastify";
import { useAuth } from "./context/auth";
import Header from "./components/Header";
import Login from "./Login";
import DayPage from "./pages/DayPage";
import StatsPage from "./pages/StatsPage";
import { DEN_URL, STATISTIKY_URL } from "./routes";
export default function AppRoutes() {
const auth = useAuth();
if (!auth?.login) {
return <Login />;
}
return (
<div className="app-container">
<Header />
<div className="wrapper">
<Routes>
<Route path={DEN_URL} element={<DayPage />} />
<Route path={STATISTIKY_URL} element={<StatsPage />} />
<Route path="*" element={<Navigate to={DEN_URL} replace />} />
</Routes>
</div>
<ToastContainer position="bottom-right" />
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: var(--ft-bg);
}
.login-card {
width: 100%;
max-width: 420px;
background: var(--ft-bg-card);
border: 1px solid var(--ft-border);
border-radius: var(--ft-radius-xl);
box-shadow: var(--ft-shadow-lg);
padding: 32px;
}
.login-logo {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
letter-spacing: -0.03em;
color: var(--ft-text);
}
.login-subtitle {
margin: 6px 0 24px;
color: var(--ft-text-secondary);
font-size: 0.92rem;
}
.login-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.login-form label {
display: block;
margin-bottom: 6px;
font-size: 0.85rem;
font-weight: 600;
color: var(--ft-text-secondary);
}
.login-form input {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--ft-border);
border-radius: var(--ft-radius-sm);
background: var(--ft-bg);
color: var(--ft-text);
font-size: 0.95rem;
transition: var(--ft-transition);
}
.login-form input:focus {
outline: none;
border-color: var(--ft-primary);
box-shadow: 0 0 0 3px var(--ft-primary-light);
}
.login-form .hint {
margin: 8px 0 0;
font-size: 0.8rem;
color: var(--ft-text-muted);
}
+63
View File
@@ -0,0 +1,63 @@
import { useCallback, useEffect, useRef } from 'react';
import { Button } from 'react-bootstrap';
import { useAuth } from './context/auth';
import { login } from '../../types';
import './Login.css';
/**
* Formulář pro prvotní zadání přihlašovacího jména.
*/
export default function Login() {
const auth = useAuth();
const loginRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (auth && !auth.login) {
// Vyzkoušíme přihlášení "naprázdno" — pokud projde, přihlásily nás trusted headers
login().then(response => {
if (response.data) {
auth.setToken(response.data as unknown as string);
}
}).catch(() => { /* nepřihlášeno, uživatel zadá jméno ručně */ });
}
}, [auth]);
const doLogin = useCallback(async () => {
const value = loginRef.current?.value ?? '';
if (!value.replaceAll(/\s/g, '').length) return;
const response = await login({ body: { login: value } });
if (response.data) {
auth?.setToken(response.data as unknown as string);
}
}, [auth]);
return (
<div className='login-page'>
<div className='login-card'>
<h1 className='login-logo'>🍽 Food Tracer</h1>
<p className='login-subtitle'>Přehled o tom, co jste snědli a kolik vás to stálo</p>
<div className='login-form'>
<div>
<label htmlFor="login-input">Zobrazované jméno</label>
<input
id="login-input"
ref={loginRef}
type='text'
placeholder="Např. Jan Novák"
onKeyDown={event => {
if (event.key === 'Enter') {
doLogin();
}
}}
/>
<p className='hint'>
Zadejte jméno nebo přezdívku, pod kterou se vaše záznamy uloží.
Pokud používáte Luncher, hodí se stejné jméno jako tam.
</p>
</div>
<Button onClick={doLogin}>Pokračovat</Button>
</div>
</div>
</div>
);
}
+195
View File
@@ -0,0 +1,195 @@
const TOKEN_KEY = "token";
/** Uloží token do local storage prohlížeče. */
export const storeToken = (token: string) => {
localStorage.setItem(TOKEN_KEY, token);
}
/** Vrátí token z local storage, pokud tam je. */
export const getToken = (): string | undefined => {
return localStorage.getItem(TOKEN_KEY) ?? undefined;
}
/** Odstraní token z local storage, pokud tam je. */
export const deleteToken = () => {
localStorage.removeItem(TOKEN_KEY);
}
/** Vrátí datum v ISO formátu YYYY-MM-DD v lokální časové zóně. */
export function formatDate(date: Date): string {
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = String(date.getFullYear());
return `${year}-${month}-${day}`;
}
/** Vrátí měsíc data ve formátu YYYY-MM (hodnota pro <input type="month">). */
export function formatMonth(date: Date): string {
return formatDate(date).substring(0, 7);
}
/** Převede datum ve formátu YYYY-MM-DD na DD.MM.YYYY */
export function formatDateString(date: string): string {
const [year, month, day] = date.split('-');
return `${day}.${month}.${year}`;
}
/** Převede řetězec YYYY-MM-DD na Date v lokální časové zóně. */
export function parseIsoDate(date: string): Date {
const [year, month, day] = date.split('-').map(Number);
return new Date(year, month - 1, day);
}
/** Posune datum ve formátu YYYY-MM-DD o zadaný počet dní. */
export function shiftDate(date: string, days: number): string {
const shifted = parseIsoDate(date);
shifted.setDate(shifted.getDate() + days);
return formatDate(shifted);
}
/** Vrátí první den měsíce (YYYY-MM) ve formátu YYYY-MM-DD. */
export function getMonthStart(month: string): string {
return `${month}-01`;
}
/** Vrátí poslední den měsíce (YYYY-MM) ve formátu YYYY-MM-DD. */
export function getMonthEnd(month: string): string {
const [year, monthNumber] = month.split('-').map(Number);
// Nultý den následujícího měsíce = poslední den tohoto měsíce
return formatDate(new Date(year, monthNumber, 0));
}
const DAY_OF_WEEK_FORMAT = new Intl.DateTimeFormat('cs-CZ', { weekday: 'long' });
const HUMAN_DATE_FORMAT = new Intl.DateTimeFormat('cs-CZ', { day: 'numeric', month: 'long', year: 'numeric' });
const MONTH_FORMAT = new Intl.DateTimeFormat('cs-CZ', { month: 'long', year: 'numeric' });
/** Vrátí název dne v týdnu pro datum ve formátu YYYY-MM-DD. */
export function getDayOfWeek(date: string): string {
return DAY_OF_WEEK_FORMAT.format(parseIsoDate(date));
}
/** Vrátí čitelný zápis data, např. "3. března 2025". */
export function getHumanDate(date: string): string {
return HUMAN_DATE_FORMAT.format(parseIsoDate(date));
}
/** Vrátí čitelný zápis měsíce (YYYY-MM), např. "březen 2025". */
export function getHumanMonth(month: string): string {
return MONTH_FORMAT.format(parseIsoDate(`${month}-01`));
}
const PRICE_FORMAT = new Intl.NumberFormat('cs-CZ', {
style: 'currency',
currency: 'CZK',
maximumFractionDigits: 2,
});
/**
* Naformátuje cenu z haléřů na koruny, např. 15900 → "159,00 Kč".
* Nevyplněnou cenu zobrazí jako pomlčku.
*/
export function formatPrice(halere?: number): string {
if (halere == null) return '—';
return PRICE_FORMAT.format(halere / 100);
}
/** Naformátuje cenu z haléřů bez desetinných míst — pro souhrny a osy grafů. */
export function formatPriceShort(halere: number): string {
return `${Math.round(halere / 100).toLocaleString('cs-CZ')}`;
}
/**
* Převede uživatelem zadanou cenu v korunách na haléře.
* Přijímá desetinnou čárku i tečku. Prázdný vstup vrací undefined.
*/
export function parsePriceInput(value: string): number | undefined {
const trimmed = value.trim().replace(',', '.');
if (!trimmed.length) return undefined;
const parsed = Number(trimmed);
return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed * 100) : undefined;
}
/** Převede haléře na text pro editační pole (koruny s desetinnou čárkou). */
export function formatPriceInput(halere?: number): string {
if (halere == null) return '';
return (halere / 100).toFixed(2).replace('.', ',');
}
/** Naformátuje počet kalorií, např. 850 → "850 kcal". */
export function formatCalories(kcal?: number): string {
if (!kcal) return '—';
return `${kcal.toLocaleString('cs-CZ')} kcal`;
}
/** Načte soubor jako Base64 řetězec (bez data: prefixu). */
export function readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = String(reader.result);
// FileReader vrací "data:<mime>;base64,<data>", server chce jen data
resolve(result.substring(result.indexOf(',') + 1));
};
reader.onerror = () => reject(new Error('Soubor se nepodařilo načíst'));
reader.readAsDataURL(file);
});
}
/**
* Dopočte gramáž a kalorie z jednotkových hodnot.
*
* Musí dávat stejné výsledky jako `deriveAmounts` na serveru — klient jen ukazuje
* živý náhled toho, co server uloží.
*
* @param price cena v haléřích
* @param pricePer100g cena za 100 g v haléřích
* @param weight ručně zadaná gramáž v gramech, má přednost před dopočtem
* @param caloriesPer100g energie na 100 g v kcal
*/
export function deriveAmounts(
price?: number,
pricePer100g?: number,
weight?: number,
caloriesPer100g?: number,
): { weight?: number, calories?: number } {
const resolvedWeight = weight ?? (
price != null && pricePer100g ? Math.round(price / pricePer100g * 100) : undefined
);
const calories = resolvedWeight != null && caloriesPer100g
? Math.round(resolvedWeight / 100 * caloriesPer100g)
: undefined;
return { weight: resolvedWeight, calories };
}
/** Naformátuje gramáž, např. 400 → "400 g". */
export function formatWeight(grams?: number): string {
if (grams == null) return '—';
return `${grams.toLocaleString('cs-CZ')} g`;
}
/** Převede celé číslo z pole na number, prázdné pole na undefined. */
export function parseIntInput(value: string): number | undefined {
const trimmed = value.trim();
if (!trimmed.length) return undefined;
const parsed = Number(trimmed);
return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed) : undefined;
}
/** Naformátuje množství aktivity i s jednotkou, např. "10 000 kroků". */
export function formatQuantity(quantity: number, unitShort: string): string {
return `${quantity.toLocaleString('cs-CZ')} ${unitShort}`;
}
/** Dopočte spálené kalorie z množství a sazby na 100 jednotek. */
export function deriveActivityCalories(quantity?: number, caloriesPer100Units?: number): number | undefined {
if (quantity == null || !caloriesPer100Units) return undefined;
return Math.round(quantity / 100 * caloriesPer100Units);
}
/** Naformátuje bilanci se znaménkem, např. "320 kcal" nebo "+150 kcal". */
export function formatBalance(kcal: number): string {
const sign = kcal < 0 ? '' : '+';
return `${sign}${Math.abs(kcal).toLocaleString('cs-CZ')} kcal`;
}
+111
View File
@@ -0,0 +1,111 @@
import { useState } from "react";
import { Button, Spinner } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faMagnifyingGlass, faArrowUpRightFromSquare, faBookmark } from "@fortawesome/free-solid-svg-icons";
import { CalorieSearchResult, searchCalories } from "../../../types";
type Props = {
/** Název jídla, ke kterému se energie hledá. */
name: string,
/** Zavolá se po výběru návrhu s energií na 100 g v kcal. */
onPick: (caloriesPer100g: number) => void,
};
/** Návrhy pocházející z vlastní knihovny se odlišují ikonou a popiskem. */
const LIBRARY_ORIGIN = 'library';
/**
* Vyhledání energetické hodnoty k názvu jídla.
*
* Návrhy chodí z vlastní knihovny dřívějších zadání a od externího poskytovatele.
* Když je poskytovatel nedostupný (což se stává), zůstane aspoň knihovna a odkaz
* na KalorickéTabulky.cz, odkud si hodnotu uživatel opíše ručně — automaticky se
* odtud nic nestahuje, veřejné API nemají.
*/
export default function CalorieLookup({ name, onPick }: Readonly<Props>) {
const [result, setResult] = useState<CalorieSearchResult | undefined>();
const [searching, setSearching] = useState(false);
const [copied, setCopied] = useState(false);
const canSearch = name.trim().length > 0 && !searching;
/**
* Zkopíruje název jídla do schránky při otevření Kalorických tabulek.
* Jejich vyhledávání nejde předvyplnit z URL, takže aspoň stačí vložit.
*/
const copyName = () => {
navigator.clipboard?.writeText(name.trim())
.then(() => setCopied(true))
.catch(() => { /* schránka není dostupná, odkaz se stejně otevře */ });
};
const search = async () => {
if (!canSearch) return;
setSearching(true);
const response = await searchCalories({ query: { q: name.trim() } });
setResult(response.data);
setCopied(false);
setSearching(false);
};
return (
<div className="calorie-lookup">
<Button variant="outline-secondary" size="sm" onClick={search} disabled={!canSearch}>
{searching
? <Spinner animation="border" size="sm" />
: <FontAwesomeIcon icon={faMagnifyingGlass} />}
{' '}Najít kalorie
</Button>
{!canSearch && !name.trim().length && (
<span className="calorie-lookup-hint">nejdřív vyplňte název jídla</span>
)}
{result && !searching && (
<div className="calorie-lookup-results">
{result.suggestions.map(suggestion => (
<button
type="button"
className="calorie-suggestion"
key={`${suggestion.origin}-${suggestion.name}-${suggestion.caloriesPer100g}`}
onClick={() => onPick(suggestion.caloriesPer100g)}
title="Použít tuto hodnotu"
>
{suggestion.origin === LIBRARY_ORIGIN && (
<FontAwesomeIcon icon={faBookmark} className="calorie-suggestion-icon" />
)}
<span className="calorie-suggestion-name">{suggestion.name}</span>
{suggestion.brand && (
<span className="calorie-suggestion-brand">{suggestion.brand}</span>
)}
<span className="calorie-suggestion-value">{suggestion.caloriesPer100g} kcal/100 g</span>
</button>
))}
{!result.suggestions.length && (
<div className="calorie-lookup-hint">
Nic se nenašlo{!result.providerAvailable && ' a externí databáze je nedostupná'}.
</div>
)}
{result.providerAvailable === false && !!result.suggestions.length && (
<div className="calorie-lookup-hint">
Externí databáze je nedostupná, návrhy jsou jen z vašich dřívějších zadání.
</div>
)}
<a
className="calorie-lookup-external"
href={result.externalSearchUrl}
target="_blank"
rel="noreferrer noopener"
onClick={copyName}
>
{copied ? 'Název zkopírován, vložte ho do jejich hledání' : 'Otevřít KalorickéTabulky.cz'}
{' '}<FontAwesomeIcon icon={faArrowUpRightFromSquare} />
</a>
</div>
)}
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { useState } from "react";
import { Navbar, Nav, NavDropdown } from "react-bootstrap";
import { useLocation, useNavigate } from "react-router";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSun, faMoon, faGear } from "@fortawesome/free-solid-svg-icons";
import { useAuth } from "../context/auth";
import { useSettings, ThemePreference } from "../context/settings";
import { DEN_URL, STATISTIKY_URL } from "../routes";
import SettingsModal from "./modals/SettingsModal";
/** Položky hlavní navigace. */
const NAV_ITEMS = [
{ url: DEN_URL, label: 'Můj den' },
{ url: STATISTIKY_URL, label: 'Statistiky' },
];
export default function Header() {
const [settingsOpen, setSettingsOpen] = useState(false);
const auth = useAuth();
const settings = useSettings();
const navigate = useNavigate();
const location = useLocation();
const effectiveDark = settings?.effectiveDark ?? false;
const toggleTheme = () => {
const next: ThemePreference = effectiveDark ? 'light' : 'dark';
settings?.setThemePreference(next);
};
return (
<Navbar variant="dark" expand="lg">
<Navbar.Brand href={DEN_URL}>Food Tracer</Navbar.Brand>
<Navbar.Toggle aria-controls="main-navbar-nav" />
<Navbar.Collapse id="main-navbar-nav">
<Nav className="nav">
{NAV_ITEMS.map(item => (
<button
key={item.url}
className={`nav-pill ${location.pathname === item.url ? 'active' : ''}`}
onClick={() => navigate(item.url)}
>
{item.label}
</button>
))}
<button
className="theme-toggle"
onClick={toggleTheme}
title={effectiveDark ? 'Přepnout na světlý režim' : 'Přepnout na tmavý režim'}
aria-label="Přepnout světlý/tmavý režim"
>
<FontAwesomeIcon icon={effectiveDark ? faSun : faMoon} />
</button>
<NavDropdown align="end" title={auth?.login} id="user-nav-dropdown">
<NavDropdown.Item onClick={() => navigate(DEN_URL)}>Můj den</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(STATISTIKY_URL)}>Statistiky</NavDropdown.Item>
<NavDropdown.Item onClick={() => setSettingsOpen(true)}>
<FontAwesomeIcon icon={faGear} /> Nastavení
</NavDropdown.Item>
<NavDropdown.Divider />
<NavDropdown.Item onClick={auth?.logout}>Odhlásit se</NavDropdown.Item>
</NavDropdown>
</Nav>
</Navbar.Collapse>
<SettingsModal isOpen={settingsOpen} onClose={() => setSettingsOpen(false)} />
</Navbar>
);
}
@@ -0,0 +1,208 @@
import { useEffect, useState } from "react";
import { Button, Form, Modal } from "react-bootstrap";
import { ActivityEntry, ActivityUnit, DayOverview, addActivity, updateActivity } from "../../../../types";
import { ACTIVITY_PRESETS, ACTIVITY_UNITS_IN_ORDER, ACTIVITY_UNIT_NAMES } from "../../enums";
import { deriveActivityCalories, formatCalories, parseIntInput } from "../../Utils";
type Props = {
isOpen: boolean,
/** Den, do kterého se zakládá nová aktivita. */
date: string,
/** Upravovaný záznam. Pokud chybí, jde o založení nového. */
entry?: ActivityEntry,
onClose: () => void,
onSaved: (day: DayOverview) => void,
};
/** Prázdný formulář pro novou aktivitu v daném dni. */
function emptyForm(date: string) {
return {
date,
name: '',
unit: ActivityUnit.KROKY as ActivityUnit,
quantity: '',
caloriesPer100Units: '',
calories: '',
note: '',
};
}
/**
* Dialog pro přidání nebo úpravu pohybové aktivity.
*
* Kalorie se dopočítají z množství a spotřeby na 100 jednotek — sto proto, že
* u kroků by sazba na jeden krok byla zlomek. Výběr známé aktivity předvyplní
* jednotku i orientační sazbu.
*/
export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }: Readonly<Props>) {
const [form, setForm] = useState(() => emptyForm(date));
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!isOpen) return;
setForm(entry
? {
date: entry.date,
name: entry.name,
unit: entry.unit,
quantity: String(entry.quantity),
caloriesPer100Units: entry.caloriesPer100Units != null ? String(entry.caloriesPer100Units) : '',
calories: entry.calories != null ? String(entry.calories) : '',
note: entry.note ?? '',
}
: emptyForm(date));
}, [isOpen, entry, date]);
const update = (changes: Partial<typeof form>) => setForm(current => ({ ...current, ...changes }));
/** Výběr z nabídky předvyplní jednotku i orientační sazbu. */
const applyPreset = (name: string) => {
const preset = ACTIVITY_PRESETS.find(item => item.name === name);
if (!preset) {
update({ name });
return;
}
update({
name: preset.name,
unit: preset.unit,
caloriesPer100Units: String(preset.caloriesPer100Units),
});
};
const quantity = parseIntInput(form.quantity);
const caloriesPer100Units = parseIntInput(form.caloriesPer100Units);
const derivedCalories = deriveActivityCalories(quantity, caloriesPer100Units);
const manualCalories = parseIntInput(form.calories);
const effectiveCalories = derivedCalories ?? manualCalories;
const canSave = form.name.trim().length > 0 && quantity != null && !saving;
const save = async () => {
if (!canSave) return;
setSaving(true);
const body = {
date: form.date,
name: form.name.trim(),
unit: form.unit,
quantity,
caloriesPer100Units,
// Server si kalorie dopočte sám, ručně zadané použije jen jako zálohu
calories: derivedCalories != null ? undefined : manualCalories,
note: form.note.trim() || undefined,
};
const response = entry
? await updateActivity({ body: { id: entry.id, ...body } })
: await addActivity({ body });
setSaving(false);
if (response.data) {
onSaved(response.data);
}
};
return (
<Modal show={isOpen} onHide={onClose} centered size="lg">
<Modal.Header closeButton>
<Modal.Title>{entry ? 'Úprava aktivity' : 'Nová aktivita'}</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form onSubmit={event => { event.preventDefault(); save(); }}>
<div className="row g-3 mb-3">
<div className="col-sm-8">
<Form.Label>Aktivita</Form.Label>
<Form.Control
autoFocus
list="activity-presets"
value={form.name}
placeholder="Např. Chůze"
onChange={event => applyPreset(event.target.value)}
/>
<datalist id="activity-presets">
{ACTIVITY_PRESETS.map(preset => (
<option key={preset.name} value={preset.name} />
))}
</datalist>
</div>
<div className="col-sm-4">
<Form.Label>Datum</Form.Label>
<Form.Control
type="date"
value={form.date}
onChange={event => event.target.value && update({ date: event.target.value })}
/>
</div>
</div>
<fieldset className="meal-fieldset mb-3">
<legend>Množství a spotřeba</legend>
<div className="row g-3">
<div className="col-sm-3">
<Form.Label>Množství</Form.Label>
<Form.Control
inputMode="numeric"
value={form.quantity}
placeholder="10000"
onChange={event => update({ quantity: event.target.value.replace(/\D/g, '') })}
/>
</div>
<div className="col-sm-3">
<Form.Label>Jednotka</Form.Label>
<Form.Select
value={form.unit}
onChange={event => update({ unit: event.target.value as ActivityUnit })}
>
{ACTIVITY_UNITS_IN_ORDER.map(unit => (
<option key={unit} value={unit}>{ACTIVITY_UNIT_NAMES[unit]}</option>
))}
</Form.Select>
</div>
<div className="col-sm-3">
<Form.Label>Kcal na 100 jedn.</Form.Label>
<Form.Control
inputMode="numeric"
value={form.caloriesPer100Units}
placeholder="např. 4"
onChange={event => update({ caloriesPer100Units: event.target.value.replace(/\D/g, '') })}
/>
</div>
<div className="col-sm-3">
<Form.Label>Spáleno</Form.Label>
{derivedCalories != null ? (
<div className="derived-value">{formatCalories(derivedCalories)}</div>
) : (
<div className="input-group">
<Form.Control
inputMode="numeric"
value={form.calories}
placeholder="volitelné"
onChange={event => update({ calories: event.target.value.replace(/\D/g, '') })}
/>
<span className="input-group-text">kcal</span>
</div>
)}
</div>
</div>
</fieldset>
<Form.Group>
<Form.Label>Poznámka</Form.Label>
<Form.Control
as="textarea"
rows={2}
value={form.note}
onChange={event => update({ note: event.target.value })}
/>
</Form.Group>
</Form>
</Modal.Body>
<Modal.Footer>
<div className="meal-modal-summary">
{effectiveCalories != null && <span>{formatCalories(effectiveCalories)}</span>}
</div>
<Button variant="outline-secondary" onClick={onClose}>Zrušit</Button>
<Button onClick={save} disabled={!canSave}>
{saving ? 'Ukládám…' : 'Uložit'}
</Button>
</Modal.Footer>
</Modal>
);
}
@@ -0,0 +1,183 @@
import { useEffect, useState } from "react";
import { Alert, Button, Form, Modal, Spinner } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faFileImport, faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
import { ImportResult, MealType, importLuncher } from "../../../../types";
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums";
import { formatDateString, formatPrice, readFileAsBase64 } from "../../Utils";
type Props = {
isOpen: boolean,
onClose: () => void,
/** Zavolá se po dokončení importu, aby se přepočítaly statistiky. */
onImported: () => void,
};
/** Kolik záznamů náhledu se vypíše, než se zbytek shrne do jednoho řádku. */
const PREVIEW_LIMIT = 8;
/**
* Dialog pro import měsíčního přehledu ze stránky statistik Luncheru.
*
* Import běží dvoufázově — nejdřív náhled (dryRun), aby uživatel viděl, co se
* založí a kolik řádků už má, a teprve po potvrzení se data uloží.
*/
export default function ImportModal({ isOpen, onClose, onImported }: Readonly<Props>) {
const [file, setFile] = useState<File | undefined>();
const [mealType, setMealType] = useState<MealType>(MealType.OBED);
const [preview, setPreview] = useState<ImportResult | undefined>();
const [result, setResult] = useState<ImportResult | undefined>();
const [busy, setBusy] = useState(false);
// Každé otevření začíná načisto, aby nezůstal viset náhled z minula
useEffect(() => {
if (!isOpen) return;
setFile(undefined);
setPreview(undefined);
setResult(undefined);
setBusy(false);
}, [isOpen]);
/** Odešle soubor na server — buď jako náhled, nebo jako ostrý import. */
const send = async (dryRun: boolean) => {
if (!file) return;
setBusy(true);
try {
const content = await readFileAsBase64(file);
const response = await importLuncher({
body: { fileName: file.name, content, defaultMealType: mealType, dryRun },
});
if (!response.data) return;
if (dryRun) {
setPreview(response.data);
} else {
setResult(response.data);
setPreview(undefined);
onImported();
}
} finally {
setBusy(false);
}
};
const chooseFile = (chosen?: File) => {
setFile(chosen);
setPreview(undefined);
setResult(undefined);
};
return (
<Modal show={isOpen} onHide={onClose} centered size="lg">
<Modal.Header closeButton>
<Modal.Title>
<FontAwesomeIcon icon={faFileImport} /> Import z Luncheru
</Modal.Title>
</Modal.Header>
<Modal.Body>
<p className="text-body-secondary small">
Na stránce statistik v Luncheru si stáhněte přehled za měsíc (XLSX, CSV nebo JSON)
a nahrajte ho sem. Záznamy se založí do dnů, ke kterým patří. Opakovaný import
stejného měsíce data nezduplikuje.
</p>
<Form.Group className="mb-3">
<Form.Label>Soubor s přehledem</Form.Label>
<Form.Control
type="file"
accept=".xlsx,.csv,.json"
onChange={event => chooseFile((event.target as HTMLInputElement).files?.[0])}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Založit jako</Form.Label>
<Form.Select
value={mealType}
onChange={event => { setMealType(event.target.value as MealType); setPreview(undefined); }}
>
{MEAL_TYPES_IN_ORDER.map(type => (
<option key={type} value={type}>
{MEAL_TYPE_ICONS[type]} {MEAL_TYPE_NAMES[type]}
</option>
))}
</Form.Select>
<Form.Text>
Luncher řeší výběr obědů, proto je předvolený oběd. Jednotlivá jídla si pak
můžete v přehledu dne upravit nebo doplnit o další.
</Form.Text>
</Form.Group>
{busy && (
<div className="text-center py-3">
<Spinner animation="border" variant="success" />
</div>
)}
{preview && !busy && (
<Alert variant={preview.imported ? 'success' : 'secondary'}>
<strong>Náhled importu</strong>
<div className="small mt-1">
V souboru je {preview.rowCount} řádků. Založí se {preview.imported} záznamů
{preview.skipped > 0 && <> a {preview.skipped} se přeskočí, protože je máte</>}.
{preview.days.length > 0 && (
<> Dotčené dny: {formatDateString(preview.days[0])}
{preview.days.length > 1 && <> {formatDateString(preview.days[preview.days.length - 1])}</>}
{' '}({preview.days.length}).</>
)}
{' '}Celkem za {formatPrice(preview.totalPrice)}.
</div>
{preview.entries.length > 0 && (
<ul className="import-preview-list">
{preview.entries.slice(0, PREVIEW_LIMIT).map(entry => (
<li key={entry.id}>
<span className="import-preview-date">{formatDateString(entry.date)}</span>
{entry.name}
{entry.source && <span className="text-body-secondary"> · {entry.source}</span>}
<span className="import-preview-price">{formatPrice(entry.price)}</span>
</li>
))}
{preview.entries.length > PREVIEW_LIMIT && (
<li className="text-body-secondary">
a dalších {preview.entries.length - PREVIEW_LIMIT}
</li>
)}
</ul>
)}
</Alert>
)}
{result && !busy && (
<Alert variant="success">
<strong>Import dokončen</strong>
<div className="small mt-1">
Založeno {result.imported} záznamů ve {result.days.length} dnech
{result.skipped > 0 && <>, {result.skipped} přeskočeno jako již existující</>}.
Celkem za {formatPrice(result.totalPrice)}.
</div>
</Alert>
)}
{(preview ?? result)?.warnings.map(warning => (
<div className="import-warning" key={warning}>
<FontAwesomeIcon icon={faTriangleExclamation} /> {warning}
</div>
))}
</Modal.Body>
<Modal.Footer>
<Button variant="outline-secondary" onClick={onClose}>
{result ? 'Zavřít' : 'Zrušit'}
</Button>
{!result && !preview && (
<Button onClick={() => send(true)} disabled={!file || busy}>
Zobrazit náhled
</Button>
)}
{!result && preview && (
<Button onClick={() => send(false)} disabled={busy || preview.imported === 0}>
Naimportovat {preview.imported} záznamů
</Button>
)}
</Modal.Footer>
</Modal>
);
}
+309
View File
@@ -0,0 +1,309 @@
import { useEffect, useMemo, useState } from "react";
import { Button, Form, Modal } from "react-bootstrap";
import { DayRecord, MealEntry, MealType, UserSettings, addMeal, getSettings, updateMeal } from "../../../../types";
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums";
import {
deriveAmounts, formatCalories, formatPriceInput, formatWeight, parseIntInput, parsePriceInput,
} from "../../Utils";
import CalorieLookup from "../CalorieLookup";
type Props = {
isOpen: boolean,
/** Den, do kterého se zakládá nové jídlo. */
date: string,
/** Upravovaný záznam. Pokud chybí, jde o založení nového. */
entry?: MealEntry,
onClose: () => void,
onSaved: (day: DayRecord) => void,
};
/** Prázdný formulář pro nové jídlo v daném dni. */
function emptyForm(date: string) {
return {
date,
mealType: MealType.OBED as MealType,
name: '',
source: '',
price: '',
pricePer100g: '',
weight: '',
caloriesPer100g: '',
calories: '',
note: '',
};
}
/** Znormalizuje název zdroje pro porovnání se sazbami z nastavení. */
function normalizeSource(source: string): string {
return source.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim();
}
/**
* Dialog pro přidání nebo úpravu jídla.
*
* Kromě ceny umí i gramáž — u podniků účtujících podle váhy (TechTower, 44 Kč/100 g)
* se dopočte z ceny a sazby, ale dá se přepsat. Z gramáže a energie na 100 g se
* pak dopočtou kalorie. Každá položka má vlastní sazbu, takže hlavní jídlo a salát
* s jiným cenováním můžou být ve stejném dni vedle sebe.
*/
export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Readonly<Props>) {
const [form, setForm] = useState(() => emptyForm(date));
const [settings, setSettings] = useState<UserSettings | undefined>();
const [saving, setSaving] = useState(false);
// Při každém otevření se formulář naplní dle toho, co se upravuje
useEffect(() => {
if (!isOpen) return;
setForm(entry
? {
date: entry.date,
mealType: entry.mealType,
name: entry.name,
source: entry.source ?? '',
price: formatPriceInput(entry.price),
pricePer100g: formatPriceInput(entry.pricePer100g),
weight: entry.weight != null ? String(entry.weight) : '',
caloriesPer100g: entry.caloriesPer100g != null ? String(entry.caloriesPer100g) : '',
calories: entry.calories != null ? String(entry.calories) : '',
note: entry.note ?? '',
}
: emptyForm(date));
}, [isOpen, entry, date]);
// Sazby podniků se načtou jednou při otevření a slouží k předvyplnění Kč/100 g
useEffect(() => {
if (!isOpen || settings) return;
getSettings().then(response => setSettings(response.data));
}, [isOpen, settings]);
const update = (changes: Partial<typeof form>) => setForm(current => ({ ...current, ...changes }));
/** Sazba nastavená pro právě zadaný zdroj, pokud nějaká je. */
const rateForSource = useMemo(() => {
const key = normalizeSource(form.source);
if (!key.length) return undefined;
return settings?.sourceRates.find(rate => normalizeSource(rate.source) === key)?.pricePer100g;
}, [settings, form.source]);
/**
* Doplní sazbu podle zdroje, jakmile ji uživatel vybere a pole je prázdné.
* Ručně zadanou sazbu nepřepisujeme — u salátu bývá jiná než u hlavního jídla.
*/
useEffect(() => {
if (rateForSource && !form.pricePer100g.trim().length) {
setForm(current => ({ ...current, pricePer100g: formatPriceInput(rateForSource) }));
}
}, [rateForSource, form.pricePer100g]);
const price = parsePriceInput(form.price);
const pricePer100g = parsePriceInput(form.pricePer100g);
const manualWeight = parseIntInput(form.weight);
const caloriesPer100g = parseIntInput(form.caloriesPer100g);
const derived = deriveAmounts(price, pricePer100g, manualWeight, caloriesPer100g);
/** Kalorie zadané ručně se použijí jen tehdy, když nejdou dopočítat. */
const manualCalories = parseIntInput(form.calories);
const effectiveCalories = derived.calories ?? manualCalories;
const canSave = form.name.trim().length > 0 && !saving;
const save = async () => {
if (!canSave) return;
setSaving(true);
const body = {
date: form.date,
mealType: form.mealType,
name: form.name.trim(),
source: form.source.trim() || undefined,
price,
pricePer100g,
weight: manualWeight,
caloriesPer100g,
// Server si kalorie dopočte sám, ručně poslané použije jen jako zálohu
calories: derived.calories != null ? undefined : manualCalories,
note: form.note.trim() || undefined,
};
const response = entry
? await updateMeal({ body: { id: entry.id, ...body } })
: await addMeal({ body });
setSaving(false);
if (response.data) {
onSaved(response.data);
}
};
return (
<Modal show={isOpen} onHide={onClose} centered size="lg">
<Modal.Header closeButton>
<Modal.Title>{entry ? 'Úprava jídla' : 'Nové jídlo'}</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form onSubmit={event => { event.preventDefault(); save(); }}>
<Form.Group className="mb-3">
<Form.Label>Jídlo</Form.Label>
<Form.Control
autoFocus
value={form.name}
placeholder="Např. Kuřecí gyros s hranolkami"
onChange={event => update({ name: event.target.value })}
/>
</Form.Group>
<div className="row g-3 mb-3">
<div className="col-sm-4">
<Form.Label>Chod</Form.Label>
<Form.Select
value={form.mealType}
onChange={event => update({ mealType: event.target.value as MealType })}
>
{MEAL_TYPES_IN_ORDER.map(mealType => (
<option key={mealType} value={mealType}>
{MEAL_TYPE_ICONS[mealType]} {MEAL_TYPE_NAMES[mealType]}
</option>
))}
</Form.Select>
</div>
<div className="col-sm-4">
<Form.Label>Datum</Form.Label>
<Form.Control
type="date"
value={form.date}
onChange={event => event.target.value && update({ date: event.target.value })}
/>
</div>
<div className="col-sm-4">
<Form.Label>Zdroj</Form.Label>
<Form.Control
list="known-sources"
value={form.source}
placeholder="TechTower, doma…"
onChange={event => update({ source: event.target.value })}
/>
<datalist id="known-sources">
{settings?.sourceRates.map(rate => (
<option key={rate.source} value={rate.source} />
))}
</datalist>
</div>
</div>
<fieldset className="meal-fieldset mb-3">
<legend>Cena a gramáž</legend>
<div className="row g-3">
<div className="col-sm-4">
<Form.Label>Cena</Form.Label>
<div className="input-group">
<Form.Control
inputMode="decimal"
value={form.price}
placeholder="0,00"
onChange={event => update({ price: event.target.value })}
/>
<span className="input-group-text"></span>
</div>
</div>
<div className="col-sm-4">
<Form.Label>Cena za 100 g</Form.Label>
<div className="input-group">
<Form.Control
inputMode="decimal"
value={form.pricePer100g}
placeholder="např. 44,00"
onChange={event => update({ pricePer100g: event.target.value })}
/>
<span className="input-group-text"></span>
</div>
{rateForSource != null && (
<Form.Text>sazba zdroje {form.source.trim()}</Form.Text>
)}
</div>
<div className="col-sm-4">
<Form.Label>Gramáž</Form.Label>
<div className="input-group">
<Form.Control
inputMode="numeric"
value={form.weight}
placeholder={derived.weight != null ? String(derived.weight) : 'volitelné'}
onChange={event => update({ weight: event.target.value.replace(/\D/g, '') })}
/>
<span className="input-group-text">g</span>
</div>
{manualWeight == null && derived.weight != null && (
<Form.Text>dopočteno z ceny</Form.Text>
)}
{manualWeight != null && pricePer100g != null && (
<Form.Text>zadáno ručně, cena se nemění</Form.Text>
)}
</div>
</div>
</fieldset>
<fieldset className="meal-fieldset mb-3">
<legend>Kalorie</legend>
<div className="row g-3">
<div className="col-sm-4">
<Form.Label>Energie na 100 g</Form.Label>
<div className="input-group">
<Form.Control
inputMode="numeric"
value={form.caloriesPer100g}
placeholder="např. 172"
onChange={event => update({ caloriesPer100g: event.target.value.replace(/\D/g, '') })}
/>
<span className="input-group-text">kcal</span>
</div>
</div>
<div className="col-sm-4">
<Form.Label>Kalorie celkem</Form.Label>
{derived.calories != null ? (
<div className="derived-value">{formatCalories(derived.calories)}</div>
) : (
<div className="input-group">
<Form.Control
inputMode="numeric"
value={form.calories}
placeholder="volitelné"
onChange={event => update({ calories: event.target.value.replace(/\D/g, '') })}
/>
<span className="input-group-text">kcal</span>
</div>
)}
{derived.calories != null && (
<Form.Text>
{formatWeight(derived.weight)} × {caloriesPer100g} kcal/100 g
</Form.Text>
)}
</div>
<div className="col-sm-4 d-flex align-items-end">
<CalorieLookup
name={form.name}
onPick={value => update({ caloriesPer100g: String(value) })}
/>
</div>
</div>
</fieldset>
<Form.Group>
<Form.Label>Poznámka</Form.Label>
<Form.Control
as="textarea"
rows={2}
value={form.note}
onChange={event => update({ note: event.target.value })}
/>
</Form.Group>
</Form>
</Modal.Body>
<Modal.Footer>
<div className="meal-modal-summary">
{derived.weight != null && <span>{formatWeight(derived.weight)}</span>}
{effectiveCalories != null && <span>{formatCalories(effectiveCalories)}</span>}
</div>
<Button variant="outline-secondary" onClick={onClose}>Zrušit</Button>
<Button onClick={save} disabled={!canSave}>
{saving ? 'Ukládám…' : 'Uložit'}
</Button>
</Modal.Footer>
</Modal>
);
}
@@ -0,0 +1,144 @@
import { useEffect, useState } from "react";
import { Button, Form, Modal } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrash, faPlus } from "@fortawesome/free-solid-svg-icons";
import { UserSettings, getSettings, saveBasalCalories, saveSourceRate } from "../../../../types";
import { formatPriceInput, parseIntInput, parsePriceInput } from "../../Utils";
type Props = {
isOpen: boolean,
onClose: () => void,
};
/**
* Nastavení sazeb za 100 g u podniků, které účtují podle váhy.
*
* Sazba slouží jako výchozí hodnota při zadávání jídla — u konkrétní položky
* ji jde přepsat, protože salát bývá účtovaný jinak než hlavní jídlo.
*/
export default function SettingsModal({ isOpen, onClose }: Readonly<Props>) {
const [settings, setSettings] = useState<UserSettings | undefined>();
const [newSource, setNewSource] = useState('');
const [newRate, setNewRate] = useState('');
const [basal, setBasal] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!isOpen) return;
setNewSource('');
setNewRate('');
getSettings().then(response => {
setSettings(response.data);
setBasal(response.data?.basalCalories != null ? String(response.data.basalCalories) : '');
});
}, [isOpen]);
const save = async (source: string, pricePer100g: number | null) => {
setSaving(true);
const response = await saveSourceRate({ body: { source, pricePer100g } });
setSaving(false);
if (response.data) {
setSettings(response.data);
}
};
/** Uloží klidový výdej. Prázdné pole ho odstraní. */
const saveBasal = async () => {
setSaving(true);
const response = await saveBasalCalories({ body: { basalCalories: parseIntInput(basal) ?? null } });
setSaving(false);
if (response.data) {
setSettings(response.data);
}
};
const addRate = async () => {
const rate = parsePriceInput(newRate);
if (!newSource.trim().length || !rate) return;
await save(newSource.trim(), rate);
setNewSource('');
setNewRate('');
};
return (
<Modal show={isOpen} onHide={onClose} centered>
<Modal.Header closeButton>
<Modal.Title>Nastavení</Modal.Title>
</Modal.Header>
<Modal.Body>
<h3 className="settings-section-title">Klidový výdej</h3>
<p className="text-body-secondary small">
Kolik kcal spálíte za den bez pohybu (bazální metabolismus). Bez něj
porovnává bilance dne jen jídlo proti pohybu a nejde o skutečný deficit.
</p>
<div className="rate-form mb-4">
<div className="input-group">
<Form.Control
inputMode="numeric"
value={basal}
placeholder="např. 1600"
onChange={event => setBasal(event.target.value.replace(/\D/g, ''))}
/>
<span className="input-group-text">kcal / den</span>
</div>
<Button onClick={saveBasal} disabled={saving}>Uložit</Button>
</div>
<h3 className="settings-section-title">Cena za 100 g</h3>
<p className="text-body-secondary small">
U podniků, které účtují podle váhy, se z ceny jídla a této sazby dopočítá
gramáž a z kalorie. U konkrétního jídla jde sazbu vždy přepsat.
</p>
{settings?.sourceRates.length === 0 && (
<p className="text-body-secondary small fst-italic">Zatím nemáte žádnou sazbu.</p>
)}
{settings?.sourceRates.map(rate => (
<div className="rate-row" key={rate.source}>
<span className="rate-source">{rate.source}</span>
<span className="rate-value">{formatPriceInput(rate.pricePer100g)} / 100 g</span>
<button
className="btn-icon danger"
onClick={() => save(rate.source, null)}
disabled={saving}
title="Odebrat sazbu"
aria-label={`Odebrat sazbu ${rate.source}`}
>
<FontAwesomeIcon icon={faTrash} />
</button>
</div>
))}
<Form
className="rate-form"
onSubmit={event => { event.preventDefault(); addRate(); }}
>
<Form.Control
value={newSource}
placeholder="Podnik, např. TechTower"
onChange={event => setNewSource(event.target.value)}
/>
<div className="input-group rate-form-price">
<Form.Control
inputMode="decimal"
value={newRate}
placeholder="44,00"
onChange={event => setNewRate(event.target.value)}
/>
<span className="input-group-text"></span>
</div>
<Button
onClick={addRate}
disabled={saving || !newSource.trim().length || !parsePriceInput(newRate)}
>
<FontAwesomeIcon icon={faPlus} />
</Button>
</Form>
</Modal.Body>
<Modal.Footer>
<Button variant="outline-secondary" onClick={onClose}>Zavřít</Button>
</Modal.Footer>
</Modal>
);
}
@@ -0,0 +1,257 @@
import { useEffect, useState } from "react";
import { Button, Form, Modal } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrash, faPlus, faPlay, faPenToSquare } from "@fortawesome/free-solid-svg-icons";
import {
ActivityUnit, DayOverview, WorkoutTemplate, WorkoutTemplateItem,
applyWorkoutTemplate, deleteWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate,
} from "../../../../types";
import { ACTIVITY_PRESETS, ACTIVITY_UNITS_IN_ORDER, ACTIVITY_UNIT_NAMES, ACTIVITY_UNIT_SHORT } from "../../enums";
import { deriveActivityCalories, formatCalories, formatQuantity, parseIntInput } from "../../Utils";
type Props = {
isOpen: boolean,
/** Den, do kterého se šablona použije. */
date: string,
onClose: () => void,
/** Zavolá se po použití šablony s aktualizovaným přehledem dne. */
onApplied: (day: DayOverview) => void,
};
/** Rozpracovaná položka šablony — hodnoty jsou texty, protože se editují v polích. */
type DraftItem = {
name: string,
unit: ActivityUnit,
quantity: string,
caloriesPer100Units: string,
};
function emptyItem(): DraftItem {
return { name: '', unit: ActivityUnit.OPAKOVANI, quantity: '', caloriesPer100Units: '' };
}
/**
* Správa šablon tréninků a jejich použití na den.
*
* Šablona je seznam cviků, který se jedním kliknutím založí do dne — opakovaný
* trénink tak není potřeba zadávat cvik po cviku znovu. Založené položky jsou
* pak samostatné aktivity, takže úprava jedné z nich šablonu nemění.
*/
export default function WorkoutModal({ isOpen, date, onClose, onApplied }: Readonly<Props>) {
const [templates, setTemplates] = useState<WorkoutTemplate[]>([]);
const [editedId, setEditedId] = useState<string | undefined>();
const [name, setName] = useState('');
const [items, setItems] = useState<DraftItem[]>([emptyItem()]);
const [busy, setBusy] = useState(false);
const resetForm = () => {
setEditedId(undefined);
setName('');
setItems([emptyItem()]);
};
useEffect(() => {
if (!isOpen) return;
resetForm();
getWorkoutTemplates().then(response => setTemplates(response.data ?? []));
}, [isOpen]);
const updateItem = (index: number, changes: Partial<DraftItem>) =>
setItems(current => current.map((item, i) => (i === index ? { ...item, ...changes } : item)));
/** Výběr známého cviku předvyplní jednotku i orientační sazbu. */
const applyPreset = (index: number, value: string) => {
const preset = ACTIVITY_PRESETS.find(item => item.name === value);
updateItem(index, preset
? { name: preset.name, unit: preset.unit, caloriesPer100Units: String(preset.caloriesPer100Units) }
: { name: value });
};
/** Převede rozpracované položky na ty, které jdou uložit. */
const toItems = (): WorkoutTemplateItem[] => items
.filter(item => item.name.trim().length && parseIntInput(item.quantity) != null)
.map(item => ({
name: item.name.trim(),
unit: item.unit,
quantity: parseIntInput(item.quantity)!,
...(parseIntInput(item.caloriesPer100Units) != null
? { caloriesPer100Units: parseIntInput(item.caloriesPer100Units) }
: {}),
}));
const canSave = name.trim().length > 0 && toItems().length > 0 && !busy;
const save = async () => {
if (!canSave) return;
setBusy(true);
const response = await saveWorkoutTemplate({ body: { id: editedId, name: name.trim(), items: toItems() } });
setBusy(false);
if (response.data) {
setTemplates(response.data);
resetForm();
}
};
const remove = async (id: string) => {
setBusy(true);
const response = await deleteWorkoutTemplate({ body: { id } });
setBusy(false);
if (response.data) {
setTemplates(response.data);
if (editedId === id) resetForm();
}
};
const apply = async (id: string) => {
setBusy(true);
const response = await applyWorkoutTemplate({ body: { id, date } });
setBusy(false);
if (response.data) {
onApplied(response.data);
}
};
/** Načte šablonu do formuláře k úpravě. */
const edit = (template: WorkoutTemplate) => {
setEditedId(template.id);
setName(template.name);
setItems(template.items.map(item => ({
name: item.name,
unit: item.unit,
quantity: String(item.quantity),
caloriesPer100Units: item.caloriesPer100Units != null ? String(item.caloriesPer100Units) : '',
})));
};
/** Průběžný odhad kalorií rozpracované šablony. */
const draftCalories = items.reduce((sum, item) =>
sum + (deriveActivityCalories(parseIntInput(item.quantity), parseIntInput(item.caloriesPer100Units)) ?? 0), 0);
return (
<Modal show={isOpen} onHide={onClose} centered size="lg">
<Modal.Header closeButton>
<Modal.Title>Šablony tréninků</Modal.Title>
</Modal.Header>
<Modal.Body>
{templates.length === 0 && (
<p className="text-body-secondary small fst-italic">
Zatím nemáte žádnou šablonu. Sestavte si ji níže příště pak stačí jedno kliknutí.
</p>
)}
{templates.map(template => (
<div className="workout-row" key={template.id}>
<div className="workout-info">
<div className="workout-name">{template.name}</div>
<div className="workout-items">
{template.items.map(item =>
`${item.name} ${formatQuantity(item.quantity, ACTIVITY_UNIT_SHORT[item.unit])}`
).join(' · ')}
</div>
</div>
<span className="workout-calories">{formatCalories(template.estimatedCalories)}</span>
<Button size="sm" onClick={() => apply(template.id)} disabled={busy} title="Použít na tento den">
<FontAwesomeIcon icon={faPlay} /> Použít
</Button>
<button className="btn-icon" onClick={() => edit(template)} title="Upravit" aria-label="Upravit šablonu">
<FontAwesomeIcon icon={faPenToSquare} />
</button>
<button
className="btn-icon danger"
onClick={() => remove(template.id)}
disabled={busy}
title="Smazat"
aria-label="Smazat šablonu"
>
<FontAwesomeIcon icon={faTrash} />
</button>
</div>
))}
<fieldset className="meal-fieldset mt-3">
<legend>{editedId ? 'Úprava šablony' : 'Nová šablona'}</legend>
<Form.Group className="mb-3">
<Form.Label>Název</Form.Label>
<Form.Control
value={name}
placeholder="Např. Workout day 1"
onChange={event => setName(event.target.value)}
/>
</Form.Group>
{items.map((item, index) => (
// Položky nemají stabilní id, dokud se šablona neuloží — index je tu jediný klíč
<div className="workout-item-row" key={index}>
<Form.Control
list="activity-presets-workout"
value={item.name}
placeholder="Cvik"
onChange={event => applyPreset(index, event.target.value)}
/>
<Form.Control
className="workout-item-quantity"
inputMode="numeric"
value={item.quantity}
placeholder="60"
onChange={event => updateItem(index, { quantity: event.target.value.replace(/\D/g, '') })}
/>
<Form.Select
className="workout-item-unit"
value={item.unit}
onChange={event => updateItem(index, { unit: event.target.value as ActivityUnit })}
>
{ACTIVITY_UNITS_IN_ORDER.map(unit => (
<option key={unit} value={unit}>{ACTIVITY_UNIT_NAMES[unit]}</option>
))}
</Form.Select>
<Form.Control
className="workout-item-rate"
inputMode="numeric"
value={item.caloriesPer100Units}
placeholder="kcal/100"
onChange={event => updateItem(index, { caloriesPer100Units: event.target.value.replace(/\D/g, '') })}
/>
<button
className="btn-icon danger"
onClick={() => setItems(current => current.filter((_, i) => i !== index))}
disabled={items.length === 1}
title="Odebrat položku"
aria-label="Odebrat položku"
>
<FontAwesomeIcon icon={faTrash} />
</button>
</div>
))}
<datalist id="activity-presets-workout">
{ACTIVITY_PRESETS.map(preset => (
<option key={preset.name} value={preset.name} />
))}
</datalist>
<div className="workout-form-footer">
<Button
variant="outline-secondary"
size="sm"
onClick={() => setItems(current => [...current, emptyItem()])}
>
<FontAwesomeIcon icon={faPlus} /> Další cvik
</Button>
{draftCalories > 0 && (
<span className="workout-draft-calories">celkem {formatCalories(draftCalories)}</span>
)}
{editedId && (
<Button variant="outline-secondary" size="sm" onClick={resetForm}>Zrušit úpravu</Button>
)}
<Button size="sm" onClick={save} disabled={!canSave}>
{editedId ? 'Uložit změny' : 'Vytvořit šablonu'}
</Button>
</div>
</fieldset>
</Modal.Body>
<Modal.Footer>
<Button variant="outline-secondary" onClick={onClose}>Zavřít</Button>
</Modal.Footer>
</Modal>
);
}
+68
View File
@@ -0,0 +1,68 @@
import React, { ReactNode, useContext, useEffect, useState } from "react";
import { useJwt } from "react-jwt";
import { deleteToken, getToken, storeToken } from "../Utils";
export type AuthContextProps = {
login?: string,
trusted?: boolean,
setToken: (token: string) => void,
logout: () => void,
}
type ContextProps = {
children: ReactNode
}
const authContext = React.createContext<AuthContextProps | null>(null);
export function ProvideAuth(props: Readonly<ContextProps>) {
const auth = useProvideAuth();
return <authContext.Provider value={auth}>{props.children}</authContext.Provider>
}
export const useAuth = () => {
return useContext(authContext);
}
function useProvideAuth(): AuthContextProps {
const [loginName, setLoginName] = useState<string | undefined>();
const [trusted, setTrusted] = useState<boolean | undefined>();
const [token, setToken] = useState<string | undefined>(getToken());
const { decodedToken } = useJwt(token ?? '');
useEffect(() => {
if (token && token.length > 0) {
storeToken(token);
} else {
deleteToken();
}
}, [token]);
useEffect(() => {
if (decodedToken) {
setLoginName((decodedToken as any).login);
setTrusted((decodedToken as any).trusted);
} else {
setLoginName(undefined);
setTrusted(undefined);
}
}, [decodedToken]);
function logout() {
const isTrusted = (decodedToken as any)?.trusted;
const logoutUrl = (decodedToken as any)?.logoutUrl;
setToken(undefined);
setLoginName(undefined);
setTrusted(undefined);
if (isTrusted && logoutUrl?.length) {
globalThis.location.replace(logoutUrl);
}
}
return {
login: loginName,
trusted,
setToken,
logout,
}
}
+60
View File
@@ -0,0 +1,60 @@
import React, { ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
/** Volba motivu — buď pevná, nebo řízená nastavením operačního systému. */
export type ThemePreference = 'light' | 'dark' | 'system';
const THEME_KEY = 'themePreference';
export type SettingsContextProps = {
themePreference: ThemePreference,
/** Skutečně použitý motiv po vyhodnocení volby 'system'. */
effectiveDark: boolean,
setThemePreference: (preference: ThemePreference) => void,
};
const settingsContext = React.createContext<SettingsContextProps | null>(null);
export const useSettings = () => useContext(settingsContext);
/** Vrátí uloženou volbu motivu, nebo 'system', pokud uživatel nic nezvolil. */
function getStoredPreference(): ThemePreference {
const stored = localStorage.getItem(THEME_KEY);
return stored === 'light' || stored === 'dark' ? stored : 'system';
}
export function ProvideSettings(props: Readonly<{ children: ReactNode }>) {
const [themePreference, setStoredPreference] = useState<ThemePreference>(getStoredPreference);
const [systemDark, setSystemDark] = useState(() =>
globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false);
// Při volbě 'system' musí přepnutí motivu v OS překreslit i běžící aplikaci
useEffect(() => {
const query = globalThis.matchMedia?.('(prefers-color-scheme: dark)');
if (!query) return;
const listener = (event: MediaQueryListEvent) => setSystemDark(event.matches);
query.addEventListener('change', listener);
return () => query.removeEventListener('change', listener);
}, []);
const effectiveDark = themePreference === 'system' ? systemDark : themePreference === 'dark';
// Bootstrap i vlastní proměnné se řídí atributem na <html>
useEffect(() => {
document.documentElement.setAttribute('data-bs-theme', effectiveDark ? 'dark' : 'light');
}, [effectiveDark]);
const setThemePreference = useCallback((preference: ThemePreference) => {
setStoredPreference(preference);
if (preference === 'system') {
localStorage.removeItem(THEME_KEY);
} else {
localStorage.setItem(THEME_KEY, preference);
}
}, []);
const value = useMemo(
() => ({ themePreference, effectiveDark, setThemePreference }),
[themePreference, effectiveDark, setThemePreference]);
return <settingsContext.Provider value={value}>{props.children}</settingsContext.Provider>;
}
+73
View File
@@ -0,0 +1,73 @@
import { ActivityUnit, MealType } from "../../types";
/** Lidsky čitelné názvy typů jídel. */
export const MEAL_TYPE_NAMES: Record<MealType, string> = {
SNIDANE: 'Snídaně',
DOPOLEDNI_SVACINA: 'Dopolední svačina',
OBED: 'Oběd',
ODPOLEDNI_SVACINA: 'Odpolední svačina',
VECERE: 'Večeře',
JINE: 'Jiné',
};
/** Emoji doprovázející typ jídla — odlišuje chody v seznamu na první pohled. */
export const MEAL_TYPE_ICONS: Record<MealType, string> = {
SNIDANE: '🥐',
DOPOLEDNI_SVACINA: '🍎',
OBED: '🍲',
ODPOLEDNI_SVACINA: '🥨',
VECERE: '🍽',
JINE: '🍫',
};
/** Typy jídel v pořadí, v jakém probíhá den. Určuje i pořadí v seznamech a výběrech. */
export const MEAL_TYPES_IN_ORDER: MealType[] = [
MealType.SNIDANE,
MealType.DOPOLEDNI_SVACINA,
MealType.OBED,
MealType.ODPOLEDNI_SVACINA,
MealType.VECERE,
MealType.JINE,
];
/** Lidsky čitelné názvy jednotek aktivit. */
export const ACTIVITY_UNIT_NAMES: Record<ActivityUnit, string> = {
KROKY: 'kroky',
MINUTY: 'minuty',
KM: 'km',
OPAKOVANI: 'opakování',
};
/** Zkratka jednotky za číslo, např. "10 000 kroků". */
export const ACTIVITY_UNIT_SHORT: Record<ActivityUnit, string> = {
KROKY: 'kroků',
MINUTY: 'min',
KM: 'km',
OPAKOVANI: '×',
};
/** Jednotky v pořadí, v jakém se nabízejí. */
export const ACTIVITY_UNITS_IN_ORDER: ActivityUnit[] = [
ActivityUnit.KROKY,
ActivityUnit.MINUTY,
ActivityUnit.KM,
ActivityUnit.OPAKOVANI,
];
/**
* Orientační spotřeba běžných aktivit v kcal na 100 jednotek.
*
* Hodnoty jsou hrubý odhad pro člověka kolem 75 kg a slouží jen jako
* předvyplnění — uživatel si je může kdykoli přepsat.
*/
export const ACTIVITY_PRESETS: { name: string, unit: ActivityUnit, caloriesPer100Units: number }[] = [
{ name: 'Chůze', unit: ActivityUnit.KROKY, caloriesPer100Units: 4 },
{ name: 'Svižná chůze', unit: ActivityUnit.MINUTY, caloriesPer100Units: 500 },
{ name: 'Běh', unit: ActivityUnit.MINUTY, caloriesPer100Units: 1000 },
{ name: 'Kolo', unit: ActivityUnit.MINUTY, caloriesPer100Units: 700 },
{ name: 'Plavání', unit: ActivityUnit.MINUTY, caloriesPer100Units: 800 },
{ name: 'Posilovna', unit: ActivityUnit.MINUTY, caloriesPer100Units: 600 },
{ name: 'Kliky', unit: ActivityUnit.OPAKOVANI, caloriesPer100Units: 50 },
{ name: 'Dřepy', unit: ActivityUnit.OPAKOVANI, caloriesPer100Units: 40 },
{ name: 'Shyby', unit: ActivityUnit.OPAKOVANI, caloriesPer100Units: 100 },
];
+35
View File
@@ -0,0 +1,35 @@
html,
body,
#root {
width: 100%;
height: 100%;
}
body {
margin: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
line-height: 1.5;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
html {
scroll-behavior: smooth;
}
:focus-visible {
outline: 2px solid var(--ft-primary);
outline-offset: 2px;
}
::selection {
background: var(--ft-primary-light);
color: var(--ft-primary);
}
+40
View File
@@ -0,0 +1,40 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import { toast } from 'react-toastify';
import { ProvideAuth } from './context/auth';
import { ProvideSettings } from './context/settings';
import AppRoutes from './AppRoutes';
import { client } from '../../types/gen/client.gen';
import { getToken } from './Utils';
import 'bootstrap/dist/css/bootstrap.min.css';
import 'react-toastify/dist/ReactToastify.css';
import './index.css';
import './App.scss';
client.setConfig({
auth: () => getToken(),
baseUrl: '/api', // openapi-ts si to neumí převzít z api.yml
});
// Interceptor na vyhození toasteru při chybě
client.interceptors.response.use(async response => {
// Login je výjimka — voláme ho "naprázdno", abychom zjistili, zda nás nepřihlásily trusted headers
if (!response.ok && !response.url.includes('/login')) {
const json = await response.clone().json().catch(() => ({}));
toast.error(json.error ?? 'Něco se nepovedlo', { theme: 'colored' });
}
return response;
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ProvideAuth>
<ProvideSettings>
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
</ProvideSettings>
</ProvideAuth>
</React.StrictMode>
);
+200
View File
@@ -0,0 +1,200 @@
.day-picker {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
padding: 12px 16px;
.day-picker-current {
flex: 1;
min-width: 0;
}
.day-picker-date {
font-weight: 700;
font-size: 1.05rem;
color: var(--ft-text);
letter-spacing: -0.01em;
}
.day-picker-weekday {
font-size: 0.82rem;
color: var(--ft-text-secondary);
text-transform: lowercase;
}
.day-picker-input {
width: auto;
flex: 0 0 auto;
}
@media (max-width: 576px) {
flex-wrap: wrap;
.day-picker-input {
order: 10;
width: 100%;
}
}
}
.meal-group {
margin-bottom: 20px;
.meal-group-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ft-text-secondary);
margin: 0 4px 8px;
}
.meal-group-icon {
font-size: 1rem;
}
.meal-group-total {
margin-left: auto;
color: var(--ft-text-muted);
font-weight: 600;
letter-spacing: 0;
text-transform: none;
}
}
.meal-list {
padding: 0;
overflow: hidden;
}
.meal-row {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid var(--ft-border-light);
transition: var(--ft-transition);
&:last-child {
border-bottom: none;
}
&:hover {
background: var(--ft-bg-hover);
}
.meal-main {
flex: 1;
min-width: 0;
}
.meal-name {
font-weight: 600;
color: var(--ft-text);
word-break: break-word;
}
.meal-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 5px;
}
.meal-note {
margin-top: 5px;
font-size: 0.83rem;
color: var(--ft-text-muted);
word-break: break-word;
}
.meal-price {
font-weight: 700;
color: var(--ft-text);
white-space: nowrap;
padding-top: 1px;
}
.meal-actions {
display: flex;
gap: 2px;
flex: 0 0 auto;
}
}
.header-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
// ============================================
// ZÁLOŽKY PŘÍJEM / VÝDEJ
// ============================================
.day-tabs {
display: flex;
gap: 4px;
margin-bottom: 16px;
border-bottom: 1px solid var(--ft-border);
}
.day-tab {
display: flex;
align-items: center;
gap: 8px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
padding: 10px 18px;
margin-bottom: -1px;
font-weight: 600;
font-size: 0.92rem;
color: var(--ft-text-secondary);
transition: var(--ft-transition);
&:hover {
color: var(--ft-text);
}
&.active {
color: var(--ft-primary);
border-bottom-color: var(--ft-primary);
}
.day-tab-count {
background: var(--ft-bg-hover);
border-radius: 999px;
padding: 1px 8px;
font-size: 0.76rem;
font-weight: 700;
}
&.active .day-tab-count {
background: var(--ft-primary-light);
color: var(--ft-primary);
}
}
// ============================================
// BILANCE
// ============================================
.balance-tile {
&.deficit .summary-value {
color: var(--ft-success);
}
&.surplus .summary-value {
color: var(--ft-warning);
}
}
// Spálené kalorie se odlišují od ceny, aby v seznamu nesplývaly
.meal-price.burn {
color: var(--ft-warning);
}
+351
View File
@@ -0,0 +1,351 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Spinner } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faChevronLeft, faChevronRight, faPlus, faPenToSquare, faTrash,
faUtensils, faPersonRunning, faCoins, faFire, faScaleBalanced, faDumbbell,
} from "@fortawesome/free-solid-svg-icons";
import { ActivityEntry, DayOverview, MealEntry, deleteActivity, deleteMeal, getDayOverview } from "../../../types";
import {
ACTIVITY_UNIT_SHORT, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES,
} from "../enums";
import {
formatBalance, formatCalories, formatDate, formatPrice, formatQuantity, formatWeight,
getDayOfWeek, getHumanDate, shiftDate,
} from "../Utils";
import MealModal from "../components/modals/MealModal";
import ActivityModal from "../components/modals/ActivityModal";
import WorkoutModal from "../components/modals/WorkoutModal";
import "./DayPage.scss";
/** Záložky dne — co do těla přišlo a co se vydalo. */
type Tab = 'prijem' | 'vydej';
/** Prázdný přehled dne — použije se, než dorazí data ze serveru. */
function emptyOverview(date: string): DayOverview {
return {
date,
meals: { date, entries: [], totalPrice: 0, totalCalories: 0 },
activities: { date, entries: [], totalCalories: 0 },
energy: { intake: 0, activityBurn: 0, basal: 0, totalBurn: 0, balance: 0, hasBasal: false },
};
}
export default function DayPage() {
const [date, setDate] = useState<string>(() => formatDate(new Date()));
const [day, setDay] = useState<DayOverview>(() => emptyOverview(date));
const [tab, setTab] = useState<Tab>('prijem');
const [loading, setLoading] = useState(true);
const [editedMeal, setEditedMeal] = useState<MealEntry | undefined>();
const [mealModalOpen, setMealModalOpen] = useState(false);
const [editedActivity, setEditedActivity] = useState<ActivityEntry | undefined>();
const [activityModalOpen, setActivityModalOpen] = useState(false);
const [workoutModalOpen, setWorkoutModalOpen] = useState(false);
const loadDay = useCallback(async (forDate: string) => {
setLoading(true);
const response = await getDayOverview({ query: { date: forDate } });
setDay(response.data ?? emptyOverview(forDate));
setLoading(false);
}, []);
useEffect(() => { loadDay(date); }, [date, loadDay]);
const isToday = date === formatDate(new Date());
/** Jídla rozdělená do chodů — zobrazují se jen chody, které něco obsahují. */
const mealGroups = useMemo(() => MEAL_TYPES_IN_ORDER
.map(mealType => ({
mealType,
entries: day.meals.entries.filter(entry => entry.mealType === mealType),
}))
.filter(group => group.entries.length > 0), [day.meals.entries]);
const { energy } = day;
const isDeficit = energy.balance < 0;
const removeMeal = async (entry: MealEntry) => {
await deleteMeal({ body: { id: entry.id, date: entry.date } });
loadDay(date);
};
const removeActivity = async (entry: ActivityEntry) => {
const response = await deleteActivity({ body: { id: entry.id, date: entry.date } });
if (response.data) setDay(response.data);
};
return (
<>
<div className="page-header">
<h1>Můj den</h1>
{tab === 'prijem' ? (
<Button onClick={() => { setEditedMeal(undefined); setMealModalOpen(true); }}>
<FontAwesomeIcon icon={faPlus} /> Přidat jídlo
</Button>
) : (
<div className="header-actions">
<Button variant="outline-secondary" onClick={() => setWorkoutModalOpen(true)}>
<FontAwesomeIcon icon={faDumbbell} /> Šablony
</Button>
<Button onClick={() => { setEditedActivity(undefined); setActivityModalOpen(true); }}>
<FontAwesomeIcon icon={faPlus} /> Přidat aktivitu
</Button>
</div>
)}
</div>
<div className="day-picker card-panel">
<button
className="btn-icon"
onClick={() => setDate(shiftDate(date, -1))}
aria-label="Předchozí den"
title="Předchozí den"
>
<FontAwesomeIcon icon={faChevronLeft} />
</button>
<div className="day-picker-current">
<div className="day-picker-date">{getHumanDate(date)}</div>
<div className="day-picker-weekday">{isToday ? 'dnes' : getDayOfWeek(date)}</div>
</div>
<button
className="btn-icon"
onClick={() => setDate(shiftDate(date, 1))}
aria-label="Následující den"
title="Následující den"
>
<FontAwesomeIcon icon={faChevronRight} />
</button>
<input
type="date"
className="form-control day-picker-input"
value={date}
onChange={event => event.target.value && setDate(event.target.value)}
/>
{!isToday && (
<Button variant="outline-secondary" size="sm" onClick={() => setDate(formatDate(new Date()))}>
Dnes
</Button>
)}
</div>
<div className="summary-grid">
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faUtensils} /> Příjem</div>
<div className="summary-value">{formatCalories(energy.intake)}</div>
<div className="summary-hint">{formatPrice(day.meals.totalPrice)}</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faFire} /> Výdej</div>
<div className="summary-value">{formatCalories(energy.totalBurn)}</div>
<div className="summary-hint">
{energy.hasBasal
? `klid ${energy.basal} + pohyb ${energy.activityBurn} kcal`
: `pohyb ${energy.activityBurn} kcal`}
</div>
</div>
<div className={`summary-tile balance-tile ${isDeficit ? 'deficit' : 'surplus'}`}>
<div className="summary-label"><FontAwesomeIcon icon={faScaleBalanced} /> Bilance</div>
<div className="summary-value">{formatBalance(energy.balance)}</div>
<div className="summary-hint">
{energy.hasBasal
? (isDeficit ? 'jste v deficitu' : 'jste v přebytku')
: 'nastavte klidový výdej pro skutečný deficit'}
</div>
</div>
<div className="summary-tile accent">
<div className="summary-label"><FontAwesomeIcon icon={faCoins} /> Útrata</div>
<div className="summary-value">{formatPrice(day.meals.totalPrice)}</div>
</div>
</div>
<div className="day-tabs">
<button
className={`day-tab ${tab === 'prijem' ? 'active' : ''}`}
onClick={() => setTab('prijem')}
>
<FontAwesomeIcon icon={faUtensils} /> Příjem
<span className="day-tab-count">{day.meals.entries.length}</span>
</button>
<button
className={`day-tab ${tab === 'vydej' ? 'active' : ''}`}
onClick={() => setTab('vydej')}
>
<FontAwesomeIcon icon={faPersonRunning} /> Výdej
<span className="day-tab-count">{day.activities.entries.length}</span>
</button>
</div>
{loading && (
<div className="empty-state">
<Spinner animation="border" variant="success" />
</div>
)}
{!loading && tab === 'prijem' && (
<>
{!day.meals.entries.length && (
<div className="card-panel empty-state">
<div className="empty-icon"><FontAwesomeIcon icon={faUtensils} /></div>
<div className="empty-title">Za tento den zatím nic nemáte</div>
<p className="empty-description">
Přidejte jídlo ručně, nebo si ve statistikách naimportujte měsíční přehled z Luncheru.
</p>
</div>
)}
{mealGroups.map(group => (
<div className="meal-group" key={group.mealType}>
<h2 className="meal-group-title">
<span className="meal-group-icon">{MEAL_TYPE_ICONS[group.mealType]}</span>
{MEAL_TYPE_NAMES[group.mealType]}
<span className="meal-group-total">
{formatPrice(group.entries.reduce((sum, entry) => sum + (entry.price ?? 0), 0))}
</span>
</h2>
<div className="meal-list card-panel">
{group.entries.map(entry => (
<MealRow
key={entry.id}
entry={entry}
onEdit={() => { setEditedMeal(entry); setMealModalOpen(true); }}
onDelete={() => removeMeal(entry)}
/>
))}
</div>
</div>
))}
</>
)}
{!loading && tab === 'vydej' && (
<>
{!day.activities.entries.length && (
<div className="card-panel empty-state">
<div className="empty-icon"><FontAwesomeIcon icon={faPersonRunning} /></div>
<div className="empty-title">Za tento den nemáte žádný pohyb</div>
<p className="empty-description">
Přidejte aktivitu, nebo použijte šablonu tréninku a založí se celá naráz.
</p>
</div>
)}
{!!day.activities.entries.length && (
<div className="meal-list card-panel">
{day.activities.entries.map(entry => (
<ActivityRow
key={entry.id}
entry={entry}
onEdit={() => { setEditedActivity(entry); setActivityModalOpen(true); }}
onDelete={() => removeActivity(entry)}
/>
))}
</div>
)}
</>
)}
<MealModal
isOpen={mealModalOpen}
date={date}
entry={editedMeal}
onClose={() => setMealModalOpen(false)}
onSaved={saved => {
setMealModalOpen(false);
// Úprava mohla jídlo přesunout jinam — pak přepneme na jeho nový den
if (saved.date !== date) {
setDate(saved.date);
} else {
loadDay(date);
}
}}
/>
<ActivityModal
isOpen={activityModalOpen}
date={date}
entry={editedActivity}
onClose={() => setActivityModalOpen(false)}
onSaved={saved => {
setActivityModalOpen(false);
if (saved.date !== date) {
setDate(saved.date);
} else {
setDay(saved);
}
}}
/>
<WorkoutModal
isOpen={workoutModalOpen}
date={date}
onClose={() => setWorkoutModalOpen(false)}
onApplied={saved => {
setDay(saved);
setWorkoutModalOpen(false);
}}
/>
</>
);
}
type MealRowProps = {
entry: MealEntry,
onEdit: () => void,
onDelete: () => void,
};
/** Jeden řádek seznamu jídel. */
function MealRow({ entry, onEdit, onDelete }: Readonly<MealRowProps>) {
return (
<div className="meal-row">
<div className="meal-main">
<div className="meal-name">{entry.name}</div>
<div className="meal-meta">
{entry.source && <span className="tag">{entry.source}</span>}
{entry.importSource && <span className="tag tag-import">z Luncheru</span>}
{entry.weight != null && <span className="tag">{formatWeight(entry.weight)}</span>}
{entry.calories != null && <span className="tag">{formatCalories(entry.calories)}</span>}
</div>
{entry.note && <div className="meal-note">{entry.note}</div>}
</div>
<div className="meal-price">{formatPrice(entry.price)}</div>
<RowActions onEdit={onEdit} onDelete={onDelete} label="jídlo" />
</div>
);
}
type ActivityRowProps = {
entry: ActivityEntry,
onEdit: () => void,
onDelete: () => void,
};
/** Jeden řádek seznamu aktivit. */
function ActivityRow({ entry, onEdit, onDelete }: Readonly<ActivityRowProps>) {
return (
<div className="meal-row">
<div className="meal-main">
<div className="meal-name">{entry.name}</div>
<div className="meal-meta">
<span className="tag">{formatQuantity(entry.quantity, ACTIVITY_UNIT_SHORT[entry.unit])}</span>
{entry.templateId && <span className="tag tag-import">ze šablony</span>}
</div>
{entry.note && <div className="meal-note">{entry.note}</div>}
</div>
<div className="meal-price burn">{formatCalories(entry.calories)}</div>
<RowActions onEdit={onEdit} onDelete={onDelete} label="aktivitu" />
</div>
);
}
/** Tlačítka pro úpravu a smazání řádku. */
function RowActions({ onEdit, onDelete, label }: Readonly<{ onEdit: () => void, onDelete: () => void, label: string }>) {
return (
<div className="meal-actions">
<button className="btn-icon" onClick={onEdit} title="Upravit" aria-label={`Upravit ${label}`}>
<FontAwesomeIcon icon={faPenToSquare} />
</button>
<button className="btn-icon danger" onClick={onDelete} title="Smazat" aria-label={`Smazat ${label}`}>
<FontAwesomeIcon icon={faTrash} />
</button>
</div>
);
}
+142
View File
@@ -0,0 +1,142 @@
.range-picker {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 16px;
padding: 12px 16px;
.range-modes {
display: inline-flex;
background: var(--ft-bg-hover);
border-radius: var(--ft-radius-sm);
padding: 3px;
gap: 2px;
}
.range-mode {
background: transparent;
border: none;
border-radius: calc(var(--ft-radius-sm) - 2px);
padding: 5px 16px;
font-size: 0.87rem;
font-weight: 600;
color: var(--ft-text-secondary);
transition: var(--ft-transition);
&:hover {
color: var(--ft-text);
}
&.active {
background: var(--ft-bg-card);
color: var(--ft-primary);
box-shadow: var(--ft-shadow-sm);
}
}
.range-input {
width: auto;
min-width: 140px;
}
}
.chart-panel {
margin-bottom: 16px;
.chart-title {
font-size: 0.85rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ft-text-secondary);
margin: 0 0 14px;
}
// Recharts kreslí popisky vlastními <text>, barvu jim musíme vnutit
.recharts-legend-item-text,
.recharts-cartesian-axis-tick-value {
fill: var(--ft-text-secondary) !important;
color: var(--ft-text-secondary) !important;
font-size: 0.8rem;
}
}
.chart-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 16px;
}
.source-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
th {
text-align: left;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--ft-text-muted);
padding-bottom: 8px;
border-bottom: 1px solid var(--ft-border);
}
td {
padding: 9px 0;
border-bottom: 1px solid var(--ft-border-light);
color: var(--ft-text);
}
tr:last-child td {
border-bottom: none;
}
.numeric {
text-align: right;
white-space: nowrap;
}
td.numeric {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
}
.import-preview-list {
list-style: none;
margin: 12px 0 0;
padding: 0;
font-size: 0.85rem;
li {
display: flex;
align-items: baseline;
gap: 8px;
padding: 4px 0;
border-top: 1px solid var(--ft-border-light);
}
.import-preview-date {
font-variant-numeric: tabular-nums;
color: var(--ft-text-secondary);
flex: 0 0 auto;
}
.import-preview-price {
margin-left: auto;
font-weight: 600;
white-space: nowrap;
}
}
.import-warning {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 0.82rem;
color: var(--ft-warning);
padding: 3px 0;
}
+255
View File
@@ -0,0 +1,255 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Spinner } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCoins, faFire, faListCheck, faCalendarDay, faFileImport, faChartColumn } from "@fortawesome/free-solid-svg-icons";
import {
Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis, Legend,
} from "recharts";
import { FoodStats, MealType, getFoodStats } from "../../../types";
import { MEAL_TYPE_NAMES } from "../enums";
import {
formatCalories, formatDateString, formatMonth, formatPrice, formatPriceShort,
getHumanMonth, getMonthEnd, getMonthStart,
} from "../Utils";
import ImportModal from "../components/modals/ImportModal";
import "./StatsPage.scss";
/** Rozsahy, za které lze statistiky zobrazit. */
type RangeMode = 'month' | 'year' | 'all';
const RANGE_LABELS: Record<RangeMode, string> = {
month: 'Měsíc',
year: 'Rok',
all: 'Vše',
};
/** Nejstarší rok, do kterého sahá volba "Vše". Starší data nikdo evidovat nebude. */
const EARLIEST_YEAR = 2015;
/** Barvy výsečí grafu dle typu jídla — laděné do zelené palety aplikace. */
const PIE_COLORS = ['#16a34a', '#22c55e', '#4ade80', '#86efac', '#0ea5e9', '#a3a3a3'];
/** Vzhled bubliny s hodnotou — recharts ji kreslí inline stylem, ne přes CSS třídu. */
const TOOLTIP_STYLE = {
background: 'var(--ft-bg-card)',
border: '1px solid var(--ft-border)',
borderRadius: 'var(--ft-radius-sm)',
color: 'var(--ft-text)',
};
/** Naformátuje hodnotu v bublině grafu na koruny. Hodnoty chodí z rechartsu jako `unknown`. */
function formatChartPrice(value: unknown): string {
return `${Number(value).toLocaleString('cs-CZ')}`;
}
export default function StatsPage() {
const [mode, setMode] = useState<RangeMode>('month');
const [month, setMonth] = useState<string>(() => formatMonth(new Date()));
const [year, setYear] = useState<number>(() => new Date().getFullYear());
const [stats, setStats] = useState<FoodStats | undefined>();
const [loading, setLoading] = useState(true);
const [importOpen, setImportOpen] = useState(false);
/** Přepočte hranice období z aktuálně zvoleného rozsahu. */
const range = useMemo(() => {
if (mode === 'month') {
return { from: getMonthStart(month), to: getMonthEnd(month) };
}
if (mode === 'year') {
return { from: `${year}-01-01`, to: `${year}-12-31` };
}
return { from: `${EARLIEST_YEAR}-01-01`, to: `${new Date().getFullYear()}-12-31` };
}, [mode, month, year]);
const load = useCallback(async () => {
setLoading(true);
const response = await getFoodStats({ query: range });
setStats(response.data);
setLoading(false);
}, [range]);
useEffect(() => { load(); }, [load]);
/** Řada pro sloupcový graf — dny u měsíce, měsíce u roku, roky u celého přehledu. */
const series = useMemo(() => {
if (!stats) return [];
if (mode === 'month') {
return stats.byDay.map(total => ({
label: formatDateString(total.period).slice(0, 5),
price: total.price / 100,
calories: total.calories,
}));
}
if (mode === 'year') {
return stats.byMonth.map(total => ({
label: getHumanMonth(total.period).split(' ')[0],
price: total.price / 100,
calories: total.calories,
}));
}
return stats.byYear.map(total => ({
label: total.period,
price: total.price / 100,
calories: total.calories,
}));
}, [stats, mode]);
const pieData = useMemo(() => (stats?.byMealType ?? [])
.filter(total => total.price > 0)
.map(total => ({
name: MEAL_TYPE_NAMES[total.key as MealType] ?? total.key,
value: total.price / 100,
})), [stats]);
const averagePerDay = stats?.dayCount ? Math.round(stats.totalPrice / stats.dayCount) : 0;
return (
<>
<div className="page-header">
<div>
<h1>Statistiky</h1>
<p className="page-subtitle">Kolik jste utratili za jídlo a za co</p>
</div>
<Button onClick={() => setImportOpen(true)}>
<FontAwesomeIcon icon={faFileImport} /> Import z Luncheru
</Button>
</div>
<div className="range-picker card-panel">
<div className="range-modes">
{(Object.keys(RANGE_LABELS) as RangeMode[]).map(value => (
<button
key={value}
className={`range-mode ${mode === value ? 'active' : ''}`}
onClick={() => setMode(value)}
>
{RANGE_LABELS[value]}
</button>
))}
</div>
{mode === 'month' && (
<input
type="month"
className="form-control range-input"
value={month}
onChange={event => event.target.value && setMonth(event.target.value)}
/>
)}
{mode === 'year' && (
<input
type="number"
className="form-control range-input"
value={year}
min={EARLIEST_YEAR}
max={new Date().getFullYear()}
onChange={event => setYear(Number(event.target.value))}
/>
)}
</div>
<div className="summary-grid">
<div className="summary-tile accent">
<div className="summary-label"><FontAwesomeIcon icon={faCoins} /> Celkem utraceno</div>
<div className="summary-value">{formatPrice(stats?.totalPrice ?? 0)}</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faCalendarDay} /> Průměr na den</div>
<div className="summary-value">{formatPrice(averagePerDay)}</div>
<div className="summary-hint">z {stats?.dayCount ?? 0} vykázaných dní</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faListCheck} /> Jídel</div>
<div className="summary-value">{stats?.entryCount ?? 0}</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faFire} /> Kalorie</div>
<div className="summary-value">{formatCalories(stats?.totalCalories)}</div>
</div>
</div>
{loading && (
<div className="empty-state">
<Spinner animation="border" variant="success" />
</div>
)}
{!loading && !stats?.entryCount && (
<div className="card-panel empty-state">
<div className="empty-icon"><FontAwesomeIcon icon={faChartColumn} /></div>
<div className="empty-title">Za zvolené období nemáte žádná data</div>
<p className="empty-description">
Přidejte jídla v přehledu dne, nebo si sem naimportujte měsíční přehled z Luncheru.
</p>
</div>
)}
{!loading && !!stats?.entryCount && (
<>
<div className="card-panel chart-panel">
<h2 className="chart-title">Útrata v čase</h2>
<ResponsiveContainer width="100%" height={260}>
<BarChart data={series} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--ft-border)" vertical={false} />
<XAxis dataKey="label" tick={{ fontSize: 12 }} stroke="var(--ft-text-muted)" />
<YAxis tick={{ fontSize: 12 }} stroke="var(--ft-text-muted)" width={56} />
<Tooltip
formatter={value => [formatChartPrice(value), 'Útrata']}
contentStyle={TOOLTIP_STYLE}
/>
<Bar dataKey="price" fill="var(--ft-primary)" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="chart-row">
<div className="card-panel chart-panel">
<h2 className="chart-title">Podle chodu</h2>
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie data={pieData} dataKey="value" nameKey="name" innerRadius={50} outerRadius={85}>
{pieData.map((entry, index) => (
<Cell key={entry.name} fill={PIE_COLORS[index % PIE_COLORS.length]} />
))}
</Pie>
<Legend />
<Tooltip
formatter={value => formatChartPrice(value)}
contentStyle={TOOLTIP_STYLE}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="card-panel chart-panel">
<h2 className="chart-title">Kde nejvíc utrácíte</h2>
<table className="source-table">
<thead>
<tr>
<th>Zdroj</th>
<th className="numeric">Jídel</th>
<th className="numeric">Útrata</th>
</tr>
</thead>
<tbody>
{stats.bySource.slice(0, 8).map(total => (
<tr key={total.key}>
<td>{total.key}</td>
<td className="numeric">{total.count}</td>
<td className="numeric">{formatPriceShort(total.price)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
)}
<ImportModal
isOpen={importOpen}
onClose={() => setImportOpen(false)}
onImported={load}
/>
</>
);
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Adresy stránek aplikace.
*
* Žijí ve vlastním modulu, a ne v AppRoutes — hlavička je potřebuje, AppRoutes
* potřebuje hlavičku, a kruhový import by konstanty nechal při načtení nedefinované.
*/
export const DEN_URL = '/';
export const STATISTIKY_URL = '/statistiky';
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"types": ["vite/client"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"moduleResolution": "bundler",
"module": "ESNext",
"target": "ESNext",
"resolveJsonModule": true,
"isolatedModules": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src", "../types"]
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import viteTsconfigPaths from 'vite-tsconfig-paths'
export default defineConfig({
base: '',
plugins: [react(), viteTsconfigPaths()],
build: {
sourcemap: true,
},
server: {
open: true,
host: '0.0.0.0',
port: 3000,
proxy: {
'/api': 'http://localhost:3001',
}
},
})
+1527
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
services:
app:
build: .
ports:
- "3001:3001"
environment:
NODE_ENV: production
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET musí být nastaven, minimálně 32 znaků}
STORAGE: redis
REDIS_HOST: redis
REDIS_PORT: 6379
TZ: Europe/Prague
depends_on:
redis:
condition: service_healthy
restart: unless-stopped
redis:
image: redis:7-alpine
command: redis-server --appendonly yes
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
restart: unless-stopped
volumes:
redis-data:
+14
View File
@@ -0,0 +1,14 @@
# Spustí vývojové prostředí — server na 3001, klienta na 3000, každý ve svém okně.
$root = $PSScriptRoot
Start-Process powershell -ArgumentList @(
'-NoExit', '-Command',
"Set-Location '$root\server'; `$env:NODE_ENV='development'; yarn startReload"
)
Start-Process powershell -ArgumentList @(
'-NoExit', '-Command',
"Set-Location '$root\client'; yarn start"
)
Write-Host 'Server: http://localhost:3001, klient: http://localhost:3000'
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
# Spustí vývojové prostředí v tmuxu — server na 3001, klienta na 3000.
set -e
cd "$(dirname "$0")"
if ! command -v tmux &> /dev/null; then
echo "tmux není nainstalovaný. Spusťte server a klienta ručně ve dvou terminálech:"
echo " cd server && NODE_ENV=development yarn startReload"
echo " cd client && yarn start"
exit 1
fi
SESSION=food-tracer
tmux kill-session -t $SESSION 2>/dev/null || true
tmux new-session -d -s $SESSION -n dev
tmux send-keys -t $SESSION "cd server && NODE_ENV=development yarn startReload" C-m
tmux split-window -h -t $SESSION
tmux send-keys -t $SESSION "cd client && yarn start" C-m
tmux attach -t $SESSION
+4
View File
@@ -0,0 +1,4 @@
JWT_SECRET=vyvojovy-klic-ktery-ma-aspon-32-znaku-delky
STORAGE=json
PORT=3001
HOST=0.0.0.0
+16
View File
@@ -0,0 +1,16 @@
# Tajný klíč pro podepisování JWT tokenů, minimálně 32 znaků
JWT_SECRET=
# Úložiště dat: json (výchozí, soubor server/data/db.json), redis nebo memory (testy)
STORAGE=json
# Připojení k Redisu, pokud STORAGE=redis
REDIS_HOST=localhost
REDIS_PORT=6379
# Přihlášení z hlavičky reverzní proxy
HTTP_REMOTE_USER_ENABLED=false
HTTP_REMOTE_USER_HEADER_NAME=remote-user
HTTP_REMOTE_TRUSTED_IPS=
LOGOUT_URL=
# Port a adresa serveru
PORT=3001
HOST=0.0.0.0
CALORIE_PROVIDER= # 'none' vypne externí dotazy na energetické hodnoty
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
'@babel/preset-typescript',
],
};
+5
View File
@@ -0,0 +1,5 @@
module.exports = {
testEnvironment: 'node',
setupFiles: ['<rootDir>/src/tests/setupEnv.ts'],
testMatch: ['<rootDir>/src/tests/**/*.test.ts'],
};
+39
View File
@@ -0,0 +1,39 @@
{
"name": "@food-tracer/server",
"version": "1.0.0",
"main": "src/index.ts",
"license": "MIT",
"private": true,
"scripts": {
"start": "ts-node src/index.ts",
"startReload": "nodemon --watch src src/index.ts",
"build": "tsc -p .",
"test": "jest"
},
"devDependencies": {
"@babel/core": "^7.28.5",
"@babel/preset-env": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.5",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.10.0",
"@types/supertest": "^6.0.0",
"babel-jest": "^30.2.0",
"jest": "^30.2.0",
"nodemon": "^3.1.10",
"supertest": "^7.0.0",
"ts-node": "^10.9.1",
"typescript": "^5.9.3"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"exceljs": "^4.4.0",
"express": "^5.1.0",
"jsonwebtoken": "^9.0.0",
"redis": "^5.9.0",
"simple-json-db": "^2.0.0"
}
}
+248
View File
@@ -0,0 +1,248 @@
import { randomUUID } from 'crypto';
import { ActivityDay, ActivityEntry, ActivityUnit } from "../../types/gen/types.gen";
import getStorage from "./storage";
import { BadRequestError, NotFoundError, isValidIsoDate } from "./utils";
const storage = getStorage();
/** Maximální délka textových polí. */
const MAX_TEXT_LENGTH = 500;
/** Podporované jednotky aktivit. */
const UNITS: ActivityUnit[] = [
ActivityUnit.KROKY,
ActivityUnit.MINUTY,
ActivityUnit.KM,
ActivityUnit.OPAKOVANI,
];
/** Vrátí true, pokud je hodnota podporovanou jednotkou aktivity. */
export function isActivityUnit(value: unknown): value is ActivityUnit {
return typeof value === 'string' && (UNITS as string[]).includes(value);
}
/** Klíč úložiště s aktivitami jednoho dne jednoho uživatele. */
function getDayKey(login: string, date: string): string {
return `activities:${login}:${date}`;
}
/** Sestaví přehled pohybu dne včetně součtu spálených kcal. */
export function buildActivityDay(date: string, entries: ActivityEntry[]): ActivityDay {
const sorted = [...entries].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
return {
date,
entries: sorted,
totalCalories: sorted.reduce((sum, entry) => sum + (entry.calories ?? 0), 0),
};
}
/**
* Vrátí pohyb uživatele za jeden den.
*
* @param login přihlašovací jméno uživatele
* @param date datum ve formátu YYYY-MM-DD
*/
export async function getActivityDay(login: string, date: string): Promise<ActivityDay> {
const entries = await storage.getData<ActivityEntry[]>(getDayKey(login, date));
return buildActivityDay(date, entries ?? []);
}
/** Ořízne a znormalizuje volitelný textový vstup. */
function normalizeText(value: unknown, name: string): string | undefined {
if (value == null) return undefined;
if (typeof value !== 'string') {
throw new BadRequestError(`Pole '${name}' musí být text`);
}
const trimmed = value.trim();
if (!trimmed.length) return undefined;
if (trimmed.length > MAX_TEXT_LENGTH) {
throw new BadRequestError(`Pole '${name}' může mít nejvýše ${MAX_TEXT_LENGTH} znaků`);
}
return trimmed;
}
/** Ověří volitelnou nezápornou celočíselnou hodnotu. */
function normalizeNumber(value: unknown, name: string): number | undefined {
if (value == null) return undefined;
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new BadRequestError(`Pole '${name}' musí být číslo`);
}
const rounded = Math.round(value);
if (rounded < 0) {
throw new BadRequestError(`Pole '${name}' nesmí být záporné`);
}
return rounded;
}
/** Znormalizovaný vstup aktivity. */
export type NormalizedActivityInput = {
date: string;
name: string;
unit: ActivityUnit;
quantity: number;
caloriesPer100Units?: number;
calories?: number;
note?: string;
};
/**
* Dopočte spálené kalorie z množství a sazby na 100 jednotek.
*
* Sto jednotek místo jedné proto, že u kroků by sazba na jeden krok byla
* zlomek (~0,04 kcal) a celá aplikace pracuje s celými čísly.
*/
export function deriveActivityCalories(input: NormalizedActivityInput): NormalizedActivityInput {
const calories = input.caloriesPer100Units
? Math.round(input.quantity / 100 * input.caloriesPer100Units)
: input.calories;
return { ...input, calories };
}
/** Ověří a znormalizuje vstupní data aktivity. */
export function normalizeActivityInput(input: unknown): NormalizedActivityInput {
const value = (input ?? {}) as Record<string, unknown>;
if (!isValidIsoDate(value.date)) {
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
}
if (!isActivityUnit(value.unit)) {
throw new BadRequestError("Nebyla předána platná jednotka aktivity");
}
const name = normalizeText(value.name, 'name');
if (!name) {
throw new BadRequestError("Nebyl předán název aktivity");
}
const quantity = normalizeNumber(value.quantity, 'quantity');
if (quantity == null) {
throw new BadRequestError("Nebylo předáno množství aktivity");
}
return deriveActivityCalories({
date: value.date,
name,
unit: value.unit,
quantity,
caloriesPer100Units: normalizeNumber(value.caloriesPer100Units, 'caloriesPer100Units'),
calories: normalizeNumber(value.calories, 'calories'),
note: normalizeText(value.note, 'note'),
});
}
/**
* Přidá aktivitu do zvoleného dne.
*
* @param login přihlašovací jméno uživatele
* @param input data aktivity
* @returns datum, do kterého se aktivita založila
*/
export async function addActivity(login: string, input: unknown): Promise<string> {
const normalized = normalizeActivityInput(input);
const entry: ActivityEntry = {
id: randomUUID(),
createdAt: new Date().toISOString(),
...normalized,
};
await storage.updateData<ActivityEntry[]>(
getDayKey(login, normalized.date),
current => [...(current ?? []), entry]
);
return normalized.date;
}
/**
* Uloží najednou více aktivit do jednoho dne (používá aplikace šablony).
*
* @param login přihlašovací jméno uživatele
* @param date den, do kterého se aktivity založí
* @param entries aktivity k uložení
*/
export async function addActivities(login: string, date: string, entries: ActivityEntry[]): Promise<void> {
if (!entries.length) return;
await storage.updateData<ActivityEntry[]>(
getDayKey(login, date),
current => [...(current ?? []), ...entries]
);
}
/**
* Upraví existující aktivitu. Změna data ji přesune do jiného dne.
*
* @param login přihlašovací jméno uživatele
* @param id identifikátor upravovaného záznamu
* @param input nová data aktivity
* @returns datum, ve kterém aktivita po úpravě je
*/
export async function updateActivity(login: string, id: unknown, input: unknown): Promise<string> {
if (typeof id !== 'string' || !id.length) {
throw new BadRequestError("Nebyl předán identifikátor záznamu");
}
const normalized = normalizeActivityInput(input);
const originalDate = await findActivityDate(login, id, normalized.date);
if (!originalDate) {
throw new NotFoundError("Aktivita nebyla nalezena");
}
let original: ActivityEntry | undefined;
await storage.updateData<ActivityEntry[]>(getDayKey(login, originalDate), current => {
original = (current ?? []).find(entry => entry.id === id);
return (current ?? []).filter(entry => entry.id !== id);
});
if (!original) {
throw new NotFoundError("Aktivita nebyla nalezena");
}
// Volitelná pole se přepisují včetně vymazání, proto se berou z normalizovaného vstupu
const updated: ActivityEntry = {
...original,
...normalized,
caloriesPer100Units: normalized.caloriesPer100Units,
calories: normalized.calories,
note: normalized.note,
updatedAt: new Date().toISOString(),
};
await storage.updateData<ActivityEntry[]>(
getDayKey(login, normalized.date),
current => [...(current ?? []), updated]
);
return normalized.date;
}
/**
* Smaže aktivitu.
*
* @param login přihlašovací jméno uživatele
* @param id identifikátor mazaného záznamu
* @param date datum záznamu (YYYY-MM-DD)
*/
export async function deleteActivity(login: string, id: unknown, date: unknown): Promise<string> {
if (typeof id !== 'string' || !id.length) {
throw new BadRequestError("Nebyl předán identifikátor záznamu");
}
if (!isValidIsoDate(date)) {
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
}
let found = false;
await storage.updateData<ActivityEntry[]>(getDayKey(login, date), current => {
found = (current ?? []).some(entry => entry.id === id);
return (current ?? []).filter(entry => entry.id !== id);
});
if (!found) {
throw new NotFoundError("Aktivita nebyla nalezena");
}
return date;
}
/** Najde datum, pod kterým je aktivita uložená. Úprava totiž může měnit i datum. */
async function findActivityDate(login: string, id: string, expectedDate: string): Promise<string | undefined> {
const expected = await storage.getData<ActivityEntry[]>(getDayKey(login, expectedDate));
if (expected?.some(entry => entry.id === id)) {
return expectedDate;
}
const prefix = `activities:${login}:`;
for (const key of await storage.listKeys(prefix)) {
if (!key.startsWith(prefix)) continue;
const entries = await storage.getData<ActivityEntry[]>(key);
if (entries?.some(entry => entry.id === id)) {
return key.substring(prefix.length);
}
}
return undefined;
}
+55
View File
@@ -0,0 +1,55 @@
import jwt from 'jsonwebtoken';
/**
* Vygeneruje a vrátí podepsaný JWT token pro daný login.
*
* @param login přihlašovací jméno uživatele
* @param trusted příznak, zda se jedná o ověřeného uživatele
* @returns JWT token
*/
export function generateToken(login?: string, trusted?: boolean): string {
if (!process.env.JWT_SECRET) {
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET");
}
if (process.env.JWT_SECRET.length < 32) {
throw new Error("Proměnná prostředí JWT_SECRET musí být minimálně 32 znaků");
}
if (!login || login.trim().length === 0) {
throw new Error("Nebyl předán login");
}
const payload = { login, trusted: trusted || false, logoutUrl: process.env.LOGOUT_URL };
return jwt.sign(payload, process.env.JWT_SECRET);
}
/**
* Vrátí true, pokud je předaný JWT token platný.
*
* @param token JWT token
*/
export function verify(token: string): boolean {
if (!process.env.JWT_SECRET) {
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET");
}
try {
jwt.verify(token, process.env.JWT_SECRET);
return true;
} catch {
return false;
}
}
/**
* Vrátí login z daného JWT tokenu, pokud je token platný.
*
* @param token JWT token
*/
export function getLogin(token?: string): string {
if (!process.env.JWT_SECRET) {
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET");
}
if (!token) {
throw new Error("Nebyl předán token");
}
const payload: any = jwt.verify(token, process.env.JWT_SECRET);
return payload.login;
}
+106
View File
@@ -0,0 +1,106 @@
import { CalorieSuggestion } from "../../types/gen/types.gen";
/**
* Externí zdroj energetických hodnot.
*
* Záměrně je to úzké rozhraní o jedné metodě — poskytovatelé bývají nespolehliví
* (viz {@link OpenFoodFactsProvider}) a je potřeba je umět vyměnit beze změny
* zbytku aplikace. Implementace nesmí vyhazovat výjimky: při nedostupnosti
* vrací `unavailable`, aby hledání jen přišlo o návrhy a nespadlo celé.
*/
export interface CalorieProvider {
/** Název poskytovatele, propisuje se do odpovědi API. */
readonly name: string;
/**
* Najde potraviny odpovídající názvu.
*
* @param query hledaný název
* @param limit maximální počet návrhů
*/
search(query: string, limit: number): Promise<ProviderResult>;
}
/** Výsledek dotazu na poskytovatele. `unavailable` značí, že zdroj neodpověděl. */
export type ProviderResult =
| { unavailable: false, suggestions: CalorieSuggestion[] }
| { unavailable: true };
/** Timeout dotazu na poskytovatele. Hledání kalorií nesmí blokovat zadávání jídla. */
const REQUEST_TIMEOUT_MS = 6000;
/**
* Open Food Facts jako zdroj energetických hodnot.
*
* Používá `search.openfoodfacts.org`, ne hlavní `world.openfoodfacts.org` —
* tamní `/cgi/search.pl` i `/api/v2/search` vrací kvůli zátěži 503, zatímco
* samostatná vyhledávací služba odpovídá. Data jsou CC, klíč není potřeba.
*/
export class OpenFoodFactsProvider implements CalorieProvider {
readonly name = 'openfoodfacts';
private readonly baseUrl = 'https://search.openfoodfacts.org/search';
async search(query: string, limit: number): Promise<ProviderResult> {
// Poskytovatel vrací i produkty bez energetické hodnoty, tak si jich vyžádáme víc
const url = `${this.baseUrl}?q=${encodeURIComponent(query)}&page_size=${limit * 4}`;
try {
const response = await fetch(url, {
headers: { 'User-Agent': 'FoodTracer (osobni projekt)' },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
console.warn(`Open Food Facts odpověděl ${response.status}`);
return { unavailable: true };
}
const body = await response.json() as { hits?: unknown[] };
return { unavailable: false, suggestions: this.toSuggestions(body.hits ?? [], limit) };
} catch (error) {
console.warn('Open Food Facts není dostupný:', (error as Error).message);
return { unavailable: true };
}
}
/** Vybere z odpovědi produkty s použitelnou energetickou hodnotou. */
private toSuggestions(hits: unknown[], limit: number): CalorieSuggestion[] {
const suggestions: CalorieSuggestion[] = [];
for (const hit of hits) {
const product = hit as { product_name?: unknown, brands?: unknown, nutriments?: Record<string, unknown> };
const kcal = product.nutriments?.['energy-kcal_100g'];
const name = typeof product.product_name === 'string' ? product.product_name.trim() : '';
if (!name.length || typeof kcal !== 'number' || !Number.isFinite(kcal) || kcal <= 0) {
continue;
}
suggestions.push({
name,
caloriesPer100g: Math.round(kcal),
origin: this.name,
...(Array.isArray(product.brands) && typeof product.brands[0] === 'string'
? { brand: product.brands[0] }
: {}),
});
if (suggestions.length >= limit) break;
}
return suggestions;
}
}
/** Poskytovatel, který nic nenajde. Používá se, když je externí vyhledávání vypnuté. */
export class NoopProvider implements CalorieProvider {
readonly name = 'none';
search(): Promise<ProviderResult> {
return Promise.resolve({ unavailable: false, suggestions: [] });
}
}
/**
* Vrátí poskytovatele dle konfigurace.
* `CALORIE_PROVIDER=none` vypne externí dotazy úplně (offline provoz, testy).
*/
export function getCalorieProvider(): CalorieProvider {
return process.env.CALORIE_PROVIDER?.toLowerCase() === 'none'
? new NoopProvider()
: new OpenFoodFactsProvider();
}
+146
View File
@@ -0,0 +1,146 @@
import { CalorieSearchResult, CalorieSuggestion } from "../../types/gen/types.gen";
import getStorage from "./storage";
import { getCalorieProvider } from "./calorieProvider";
import { BadRequestError } from "./utils";
const storage = getStorage();
/** Označení návrhů pocházejících z vlastní knihovny uživatele. */
const LIBRARY_ORIGIN = 'library';
/** Kolik návrhů celkem vrátit. */
const SUGGESTION_LIMIT = 8;
/** Kolik z nich smí obsadit vlastní knihovna — zbytek zůstane na poskytovatele. */
const LIBRARY_LIMIT = 3;
/**
* Tabulka potravin na KalorickéTabulky.cz.
*
* Odkaz se uživateli jen otevře v novém panelu, nic se odtud nestahuje — veřejné
* API neexistuje, jejich robots.txt vyhledávání robotům zakazuje a smluvní
* podmínky omezují užití nad rámec zamýšleného účelu.
*
* Vede na tabulku potravin bez předvyplněného hledání: jejich vyhledávání běží
* v JavaScriptu a nedá se nastavit z URL (ověřeno — parametry v query stringu
* stránka ignoruje). Název jídla proto klient zkopíruje do schránky, aby ho
* uživatel jen vložil do jejich vyhledávacího pole.
*/
const EXTERNAL_SEARCH_BASE = 'https://www.kaloricketabulky.cz/tabulka-potravin';
/** Klíč úložiště s knihovnou energetických hodnot jednoho uživatele. */
function getLibraryKey(login: string): string {
return `calorieLibrary:${login}`;
}
/**
* Znormalizuje název jídla na klíč knihovny — bez diakritiky, malými písmeny
* a bez interpunkce, aby si "Kuřecí gyros" a "kureci gyros!" odpovídaly.
*/
export function normalizeName(name: string): string {
return name
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
/** Jeden zapamatovaný záznam knihovny. */
type LibraryEntry = {
/** Název tak, jak ho uživatel napsal naposledy */
name: string;
caloriesPer100g: number;
/** Kolikrát už uživatel tuto hodnotu použil — určuje pořadí návrhů */
uses: number;
updatedAt: string;
};
type Library = Record<string, LibraryEntry>;
/**
* Zapamatuje si energetickou hodnotu pod názvem jídla, aby ji šlo příště nabídnout.
* Volá se při uložení jídla, které má vyplněné caloriesPer100g.
*
* @param login přihlašovací jméno uživatele
* @param name název jídla
* @param caloriesPer100g energetická hodnota na 100 g v kcal
*/
export async function rememberCalories(login: string, name: string, caloriesPer100g: number): Promise<void> {
const key = normalizeName(name);
if (!key.length || !Number.isFinite(caloriesPer100g) || caloriesPer100g <= 0) return;
await storage.updateData<Library>(getLibraryKey(login), current => {
const library = current ?? {};
const existing = library[key];
return {
...library,
[key]: {
name: name.trim(),
caloriesPer100g: Math.round(caloriesPer100g),
// Opakované použití stejné hodnoty ji posouvá výš mezi návrhy
uses: (existing?.uses ?? 0) + 1,
updatedAt: new Date().toISOString(),
},
};
});
}
/** Najde v knihovně záznamy, jejichž název odpovídá hledanému výrazu. */
async function searchLibrary(login: string, query: string): Promise<CalorieSuggestion[]> {
const library = await storage.getData<Library>(getLibraryKey(login)) ?? {};
const needle = normalizeName(query);
if (!needle.length) return [];
return Object.entries(library)
.filter(([key]) => key === needle || key.includes(needle) || needle.includes(key))
// Přesná shoda první, pak nejpoužívanější
.sort(([keyA, a], [keyB, b]) =>
Number(keyB === needle) - Number(keyA === needle) || b.uses - a.uses)
.slice(0, LIBRARY_LIMIT)
.map(([, entry]) => ({
name: entry.name,
caloriesPer100g: entry.caloriesPer100g,
origin: LIBRARY_ORIGIN,
}));
}
/**
* Najde návrhy energetické hodnoty pro název jídla.
*
* Nejdřív se hledá ve vlastní knihovně (dřívější zadání uživatele sedí na
* kantýnová jídla líp než veřejná databáze balených potravin), teprve pak se
* doplní návrhy od poskytovatele. Když je poskytovatel nedostupný, vrátí se
* jen knihovna a příznak `providerAvailable: false`.
*
* @param login přihlašovací jméno uživatele
* @param query hledaný název jídla
*/
export async function searchCalories(login: string, query: unknown): Promise<CalorieSearchResult> {
if (typeof query !== 'string' || !query.trim().length) {
throw new BadRequestError("Nebyl předán hledaný název");
}
const trimmed = query.trim();
const provider = getCalorieProvider();
const fromLibrary = await searchLibrary(login, trimmed);
const result = await provider.search(trimmed, SUGGESTION_LIMIT - fromLibrary.length);
const suggestions = [...fromLibrary];
if (!result.unavailable) {
// Co už nabídla knihovna, nemá smysl opakovat z externího zdroje
const seen = new Set(fromLibrary.map(item => normalizeName(item.name)));
for (const suggestion of result.suggestions) {
if (seen.has(normalizeName(suggestion.name))) continue;
seen.add(normalizeName(suggestion.name));
suggestions.push(suggestion);
}
}
return {
query: trimmed,
providerAvailable: !result.unavailable,
providerName: provider.name,
suggestions,
externalSearchUrl: EXTERNAL_SEARCH_BASE,
};
}
+48
View File
@@ -0,0 +1,48 @@
import { DayOverview, EnergyBalance } from "../../types/gen/types.gen";
import { getDay } from "./meals";
import { getActivityDay } from "./activities";
import { getSettings } from "./settings";
/**
* Spočítá energetickou bilanci dne.
*
* Výdej je součet klidového metabolismu a pohybu. Bez nastaveného klidového
* výdeje porovnává bilance jen jídlo proti pohybu — to není skutečný deficit,
* proto se to příznakem `hasBasal` propisuje do UI.
*
* @param intake přijaté kcal z jídla
* @param activityBurn spálené kcal pohybem
* @param basal klidový výdej v kcal za den, nebo nic
*/
export function buildEnergyBalance(intake: number, activityBurn: number, basal?: number): EnergyBalance {
const resolvedBasal = basal ?? 0;
const totalBurn = resolvedBasal + activityBurn;
return {
intake,
activityBurn,
basal: resolvedBasal,
totalBurn,
balance: intake - totalBurn,
hasBasal: !!basal,
};
}
/**
* Sestaví kompletní přehled dne — jídlo, pohyb a jejich bilanci.
*
* @param login přihlašovací jméno uživatele
* @param date datum ve formátu YYYY-MM-DD
*/
export async function getDayOverview(login: string, date: string): Promise<DayOverview> {
const [meals, activities, settings] = await Promise.all([
getDay(login, date),
getActivityDay(login, date),
getSettings(login),
]);
return {
date,
meals,
activities,
energy: buildEnergyBalance(meals.totalCalories, activities.totalCalories, settings.basalCalories),
};
}
+178
View File
@@ -0,0 +1,178 @@
import express from "express";
import cors from 'cors';
import dotenv from 'dotenv';
import path from 'path';
import { generateToken, verify } from "./auth";
import { BadRequestError, NotFoundError } from "./utils";
import getStorage, { storageReady } from "./storage";
import { shutdownRedisStorage } from "./storage/redis";
import mealRoutes from "./routes/mealRoutes";
import statsRoutes from "./routes/statsRoutes";
import importRoutes from "./routes/importRoutes";
import calorieRoutes from "./routes/calorieRoutes";
import settingsRoutes from "./routes/settingsRoutes";
import activityRoutes from "./routes/activityRoutes";
import workoutRoutes from "./routes/workoutRoutes";
import dayRoutes from "./routes/dayRoutes";
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
if (!process.env.JWT_SECRET) {
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET");
}
export const app = express();
const server = require("http").createServer(app);
// Keep-alive delší než idle timeout reverzní proxy; headersTimeout musí být větší než keepAliveTimeout
server.keepAliveTimeout = 65_000;
server.headersTimeout = 66_000;
server.requestTimeout = 30_000;
// Importovaný XLSX chodí v těle jako Base64, výchozí limit 100 kB by na měsíční přehled nestačil
app.use(express.json({ limit: '15mb' }));
app.use(cors({ origin: '*' }));
const HTTP_REMOTE_USER_ENABLED = process.env.HTTP_REMOTE_USER_ENABLED === 'true';
const HTTP_REMOTE_USER_HEADER_NAME = process.env.HTTP_REMOTE_USER_HEADER_NAME ?? 'remote-user';
if (HTTP_REMOTE_USER_ENABLED) {
if (!process.env.HTTP_REMOTE_TRUSTED_IPS) {
throw new Error('Je zapnutý login z hlaviček, ale není nastaven rozsah adres ze kterých hlavička může přijít.');
}
app.set('trust proxy', process.env.HTTP_REMOTE_TRUSTED_IPS.split(',').map(ip => ip.trim()));
console.log('Zapnutý login přes hlavičky z proxy.');
}
// ─── Shutdown ─────────────────────────────────────────────────────────────────
let shuttingDown = false;
async function shutdown(signal: string) {
if (shuttingDown) return;
shuttingDown = true;
console.log(`${signal} received — initiating graceful shutdown`);
setTimeout(() => {
console.error('Graceful shutdown timed out, forcing exit');
process.exit(1);
}, 25_000).unref();
(server as any).closeIdleConnections?.();
await new Promise<void>(resolve => server.close(() => resolve()));
if (process.env.STORAGE?.toLowerCase() === 'redis') {
await shutdownRedisStorage();
}
console.log('Graceful shutdown complete');
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// ─── Routes bez autentizace ───────────────────────────────────────────────────
/** Liveness probe — levná, bez externích závislostí. */
app.get("/api/health", (_req, res) => {
res.status(200).json({ ok: true });
});
/** Readiness probe — ověří dostupnost úložiště a odmítá provoz při shutdownu. */
app.get("/api/health/ready", async (_req, res) => {
if (shuttingDown) {
res.status(503).json({ ok: false, reason: 'shutting down' });
return;
}
const healthy = await getStorage().healthCheck?.() ?? true;
if (!healthy) {
res.status(503).json({ ok: false, reason: 'storage unavailable' });
return;
}
res.status(200).json({ ok: true });
});
/** Veřejná runtime konfigurace pro klienta. */
app.get("/api/config", (_req, res) => {
res.status(200).json({
sentry: {
dsn: process.env.SENTRY_CLIENT_DSN ?? process.env.SENTRY_DSN ?? null,
environment: ENVIRONMENT,
},
});
});
app.post("/api/login", (req, res, next) => {
try {
if (HTTP_REMOTE_USER_ENABLED) {
const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME);
if (!remoteUser?.length) {
throw new Error("Je zapnuto přihlášení přes hlavičky, ale hlavička nedorazila");
}
res.status(200).json(generateToken(Buffer.from(remoteUser, 'latin1').toString(), true));
return;
}
if (!req.body?.login || req.body.login.trim().length === 0) {
throw new BadRequestError("Nebyl předán login");
}
res.status(200).json(generateToken(req.body.login, false));
} catch (e) { next(e); }
});
// ─── Autentizace ──────────────────────────────────────────────────────────────
app.use("/api/", (req, res, next) => {
if (!req.headers.authorization) {
res.status(401).json({ error: 'Nebyl předán autentizační token' });
return;
}
const token = req.headers.authorization.split(' ')[1];
if (!verify(token)) {
res.status(403).json({ error: 'Neplatný autentizační token' });
return;
}
next();
});
// ─── Routes vyžadující autentizaci ────────────────────────────────────────────
app.use("/api/meals", mealRoutes);
app.use("/api/stats", statsRoutes);
app.use("/api/import", importRoutes);
app.use("/api/calories", calorieRoutes);
app.use("/api/settings", settingsRoutes);
app.use("/api/activities", activityRoutes);
app.use("/api/workouts", workoutRoutes);
app.use("/api/day", dayRoutes);
app.use(express.static(path.join(process.cwd(), 'public')));
app.get('*splat', (_req, res) => {
res.sendFile(path.join(process.cwd(), 'public', 'index.html'));
});
// Error handling middleware
app.use((err: any, _req: any, res: any, next: any) => {
if (err instanceof BadRequestError) {
res.status(400).send({ error: err.message });
} else if (err instanceof NotFoundError) {
res.status(404).send({ error: err.message });
} else {
console.error(err);
res.status(500).send({ error: err.message });
}
next();
});
// ─── Bootstrap ────────────────────────────────────────────────────────────────
const PORT = process.env.PORT ?? 3001;
const HOST = process.env.HOST ?? '0.0.0.0';
// Testy si aplikaci importují a volají přes supertest, server v nich nespouštíme
if (require.main === module) {
storageReady.then(() => {
server.listen(PORT, () => {
console.log(`Server listening on ${HOST}, port ${PORT}`);
});
});
}
+434
View File
@@ -0,0 +1,434 @@
import ExcelJS from 'exceljs';
import { randomUUID } from 'crypto';
import { ImportResult, MealEntry, MealType } from "../../types/gen/types.gen";
import { addEntries, getExistingImportKeys, isMealType } from "./meals";
import { BadRequestError, isValidIsoDate } from "./utils";
/** Označení zdroje, kterým se importované záznamy značí. */
export const IMPORT_SOURCE = 'luncher';
/** Podporované formáty importu (shodné s formáty exportu Luncheru). */
export const IMPORT_FORMATS = ['xlsx', 'csv', 'json'] as const;
export type ImportFormat = typeof IMPORT_FORMATS[number];
/** Název listu s jednotlivými záznamy v XLSX exportu. Druhý list ("Souhrn") se ignoruje. */
const DETAIL_SHEET_NAME = 'Přehled';
/** Oddělovač polí v CSV exportu — Luncher generuje CSV pro český Excel. */
const CSV_SEPARATOR = ';';
/**
* Mapování hlaviček sloupců exportu na klíče. Sloupce se hledají podle názvu,
* ne podle pozice, aby import přežil případné přeuspořádání exportu.
*/
const COLUMN_HEADERS: Record<string, keyof LuncherRow> = {
'datum': 'date',
'den': 'dayOfWeek',
'typ': 'type',
'vybrané jídlo': 'food',
'poznámka': 'note',
'objednávka': 'store',
'objednával': 'orderedBy',
'částka': 'amount',
};
/**
* Typy záznamů, které nejsou podnikem — jde o stav volby, ne o místo, kde se jedlo.
* Odpovídají volbám OBJEDNAVAM / ROZHODUJI / NEOBEDVAM a objednávce bez volby
* v exportu Luncheru. Jako zdroj jídla by nedávaly smysl, tak se nepoužijí.
*/
const NON_PLACE_TYPES = new Set([
'Budu objednávat',
'Rozhoduji se',
'Mám vlastní/neobědvám',
'Objednávka',
]);
/** Jeden řádek přehledu z Luncheru, převedený do jednotné podoby. */
type LuncherRow = {
/** Datum ve formátu YYYY-MM-DD */
date: string;
dayOfWeek?: string;
/** Typ záznamu — název podniku/volby, nebo "Objednávka" */
type: string;
food?: string;
note?: string;
store?: string;
orderedBy?: string;
/** Částka v haléřích */
amount?: number;
};
/** Výsledek zpracování souboru — řádky plus upozornění na to, co se nepovedlo. */
type ParsedFile = { rows: LuncherRow[], warnings: string[] };
/**
* Rozpozná formát souboru podle přípony.
*
* @param fileName název souboru
*/
export function detectFormat(fileName: string): ImportFormat {
const extension = fileName.toLowerCase().split('.').pop();
if (extension && (IMPORT_FORMATS as readonly string[]).includes(extension)) {
return extension as ImportFormat;
}
throw new BadRequestError(`Nepodporovaný formát souboru '${fileName}'. Podporované jsou .xlsx, .csv a .json.`);
}
/** Ořízne text a prázdnou hodnotu vrátí jako undefined. */
function text(value: unknown): string | undefined {
if (value == null) return undefined;
const trimmed = String(value).trim();
return trimmed.length ? trimmed : undefined;
}
/**
* Převede částku v korunách na haléře.
* Přijímá i český zápis s desetinnou čárkou a mezerami mezi řády ("1 234,50 Kč").
*/
function parseAmount(value: unknown): number | undefined {
if (value == null || value === '') return undefined;
if (typeof value === 'number') {
return Number.isFinite(value) ? Math.round(value * 100) : undefined;
}
const cleaned = String(value)
.replace(/\s| /g, '') // mezery i nezlomitelné mezery mezi řády
.replace(/Kč/gi, '')
.replace(',', '.');
if (!cleaned.length) return undefined;
const parsed = Number(cleaned);
return Number.isFinite(parsed) ? Math.round(parsed * 100) : undefined;
}
/**
* Převede datum z exportu na ISO formát YYYY-MM-DD.
* Zvládne ISO řetězec (JSON export), český zápis DD.MM.YYYY (CSV) i Date (XLSX).
*/
function parseDate(value: unknown): string | undefined {
if (value == null) return undefined;
if (value instanceof Date) {
// XLSX ukládá datum v UTC (viz export Luncheru), proto se čte v UTC
const year = value.getUTCFullYear();
const month = String(value.getUTCMonth() + 1).padStart(2, '0');
const day = String(value.getUTCDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
const raw = String(value).trim();
if (isValidIsoDate(raw)) {
return raw;
}
const czech = /^(\d{1,2})\.\s*(\d{1,2})\.\s*(\d{4})$/.exec(raw);
if (czech) {
const [, day, month, year] = czech;
const iso = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
return isValidIsoDate(iso) ? iso : undefined;
}
return undefined;
}
/**
* Rozdělí CSV na buňky. Respektuje uvozovky i zdvojené uvozovky uvnitř hodnoty,
* takže si poradí i s poznámkou obsahující oddělovač nebo konec řádku.
*/
function parseCsv(content: string): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let cell = '';
let inQuotes = false;
for (let index = 0; index < content.length; index++) {
const char = content[index];
if (inQuotes) {
if (char === '"') {
if (content[index + 1] === '"') {
cell += '"';
index++;
} else {
inQuotes = false;
}
} else {
cell += char;
}
continue;
}
if (char === '"') {
inQuotes = true;
} else if (char === CSV_SEPARATOR) {
row.push(cell);
cell = '';
} else if (char === '\n') {
row.push(cell);
rows.push(row);
row = [];
cell = '';
} else if (char !== '\r') {
cell += char;
}
}
if (cell.length || row.length) {
row.push(cell);
rows.push(row);
}
return rows.filter(current => current.some(value => value.trim().length));
}
/**
* Sestaví z hlavičkového řádku mapu index sloupce -> klíč řádku.
* Nerozpoznané sloupce se ignorují.
*/
function mapHeaders(headers: unknown[]): Map<number, keyof LuncherRow> {
const mapping = new Map<number, keyof LuncherRow>();
headers.forEach((header, index) => {
const key = COLUMN_HEADERS[String(header ?? '').trim().toLowerCase()];
if (key) {
mapping.set(index, key);
}
});
return mapping;
}
/** Poskládá řádek přehledu z buněk dle mapování sloupců. */
function buildRow(cells: unknown[], mapping: Map<number, keyof LuncherRow>): LuncherRow | undefined {
const raw: Record<string, unknown> = {};
for (const [index, key] of mapping) {
raw[key] = cells[index];
}
const date = parseDate(raw.date);
if (!date) return undefined;
return {
date,
dayOfWeek: text(raw.dayOfWeek),
type: text(raw.type) ?? '',
food: text(raw.food),
note: text(raw.note),
store: text(raw.store),
orderedBy: text(raw.orderedBy),
amount: parseAmount(raw.amount),
};
}
/** Zpracuje CSV podobu exportu. */
function parseCsvExport(content: Buffer): ParsedFile {
// Luncher píše CSV s BOM kvůli Excelu, JS ho musí odstranit ručně
const csv = content.toString('utf-8').replace(/^/, '');
const table = parseCsv(csv);
if (!table.length) {
throw new BadRequestError("CSV soubor je prázdný");
}
const mapping = mapHeaders(table[0]);
if (!mapping.size) {
throw new BadRequestError("V CSV se nepodařilo najít očekávané sloupce přehledu z Luncheru");
}
const rows: LuncherRow[] = [];
const warnings: string[] = [];
table.slice(1).forEach((cells, index) => {
const row = buildRow(cells, mapping);
if (row) {
rows.push(row);
} else {
warnings.push(`Řádek ${index + 2} přeskočen — nepodařilo se přečíst datum`);
}
});
return { rows, warnings };
}
/** Zpracuje XLSX podobu exportu. */
async function parseXlsxExport(content: Buffer): Promise<ParsedFile> {
const workbook = new ExcelJS.Workbook();
try {
await workbook.xlsx.load(content as unknown as ArrayBuffer);
} catch {
throw new BadRequestError("Soubor se nepodařilo přečíst jako XLSX");
}
const sheet = workbook.getWorksheet(DETAIL_SHEET_NAME) ?? workbook.worksheets[0];
if (!sheet) {
throw new BadRequestError("XLSX soubor neobsahuje žádný list");
}
/** Vrátí hodnoty buněk řádku v poli indexovaném od nuly. */
const cellsOf = (rowNumber: number): unknown[] => {
const row = sheet.getRow(rowNumber);
const values: unknown[] = [];
for (let column = 1; column <= sheet.columnCount; column++) {
const cell = row.getCell(column);
// Vzorce vracejí objekt { formula, result } — zajímá nás spočtená hodnota
const value = cell.value;
values.push(value && typeof value === 'object' && 'result' in value ? value.result : value);
}
return values;
};
const mapping = mapHeaders(cellsOf(1));
if (!mapping.size) {
throw new BadRequestError("V XLSX se nepodařilo najít očekávané sloupce přehledu z Luncheru");
}
const rows: LuncherRow[] = [];
const warnings: string[] = [];
for (let rowNumber = 2; rowNumber <= sheet.rowCount; rowNumber++) {
const cells = cellsOf(rowNumber);
if (cells.every(value => value == null || String(value).trim() === '')) continue;
const row = buildRow(cells, mapping);
if (row) {
rows.push(row);
} else {
warnings.push(`Řádek ${rowNumber} přeskočen — nepodařilo se přečíst datum`);
}
}
return { rows, warnings };
}
/** Zpracuje JSON podobu exportu (schéma UserExport z Luncheru). */
function parseJsonExport(content: Buffer): ParsedFile {
let parsed: any;
try {
parsed = JSON.parse(content.toString('utf-8'));
} catch {
throw new BadRequestError("Soubor se nepodařilo přečíst jako JSON");
}
const rawRows = Array.isArray(parsed) ? parsed : parsed?.rows;
if (!Array.isArray(rawRows)) {
throw new BadRequestError("JSON neobsahuje pole 'rows' s řádky přehledu");
}
const rows: LuncherRow[] = [];
const warnings: string[] = [];
rawRows.forEach((raw: any, index: number) => {
const date = parseDate(raw?.date);
if (!date) {
warnings.push(`Záznam ${index + 1} přeskočen — nepodařilo se přečíst datum`);
return;
}
rows.push({
date,
dayOfWeek: text(raw.dayOfWeek),
type: text(raw.type) ?? '',
food: text(raw.food),
note: text(raw.note),
store: text(raw.store),
orderedBy: text(raw.orderedBy),
amount: parseAmount(raw.amount),
});
});
return { rows, warnings };
}
/**
* Sestaví klíč pro rozpoznání duplicit.
*
* Součástí je i pořadí řádku v rámci dne — v jednom dni může být víc stejných
* záznamů (dvě totožné objednávky) a ty se nesmí navzájem odfiltrovat.
* Export Luncheru řadí řádky deterministicky, takže při opakovaném exportu
* téhož měsíce vyjde stejné pořadí.
*/
function buildImportKey(row: LuncherRow, indexWithinDay: number): string {
return [
IMPORT_SOURCE,
row.date,
indexWithinDay,
row.type,
row.food ?? '',
row.amount ?? '',
].join('|');
}
/**
* Převede řádek přehledu na záznam o jídle.
*
* Název se bere z vybraného jídla. U voleb jako "Budu objednávat" nebo
* "Rozhoduji se" je ale sloupec s jídlem prázdný a co uživatel reálně jedl stojí
* v poznámce ("Chefie - Těstovinový salát"), tak se použije ta. Teprve když není
* ani poznámka, zbude typ záznamu — uživatel si zvolil podnik, ale jídlo neuvedl.
*
* Zdrojem je obchod objednávky, jinak podnik. Stav volby se jako zdroj nepoužije.
*/
function toMealEntry(row: LuncherRow, mealType: MealType, importKey: string): MealEntry {
const noteAsName = !row.food && !!row.note;
const name = row.food ?? row.note ?? row.type;
// Poznámku, ze které se stal název, už do poznámky nekopírujeme
const remainingNote = noteAsName ? undefined : row.note;
const note = row.orderedBy
? [remainingNote, `objednal(a): ${row.orderedBy}`].filter(Boolean).join(' | ')
: remainingNote;
return {
id: randomUUID(),
date: row.date,
mealType,
name,
source: row.store ?? (NON_PLACE_TYPES.has(row.type) ? undefined : row.type),
price: row.amount,
note,
importSource: IMPORT_SOURCE,
importKey,
createdAt: new Date().toISOString(),
};
}
/**
* Naimportuje přehled vyexportovaný z Luncheru do jídel uživatele.
*
* Export je typicky za celý měsíc — záznamy se rozdělí do dnů, ke kterým patří.
* Řádky, které už z dřívějšího importu existují, se přeskočí, takže opakovaný
* import stejného měsíce data nezduplikuje.
*
* @param login přihlašovací jméno uživatele
* @param fileName název souboru (určuje formát)
* @param content obsah souboru
* @param defaultMealType typ jídla pro importované řádky, výchozí je oběd
* @param dryRun pokud true, výsledek se jen spočítá a nic se neuloží
*/
export async function importLuncherExport(
login: string,
fileName: string,
content: Buffer,
defaultMealType?: unknown,
dryRun = false,
): Promise<ImportResult> {
const format = detectFormat(fileName);
if (defaultMealType != null && !isMealType(defaultMealType)) {
throw new BadRequestError("Nebyl předán platný typ jídla");
}
// Luncher řeší výběr obědů, takže bez upřesnění zakládáme importy jako oběd
const mealType: MealType = isMealType(defaultMealType) ? defaultMealType : MealType.OBED;
let parsed: ParsedFile;
if (format === 'csv') {
parsed = parseCsvExport(content);
} else if (format === 'json') {
parsed = parseJsonExport(content);
} else {
parsed = await parseXlsxExport(content);
}
const dates = Array.from(new Set(parsed.rows.map(row => row.date)));
const existingKeys = await getExistingImportKeys(login, dates);
const perDayIndex = new Map<string, number>();
const entries: MealEntry[] = [];
let skipped = 0;
for (const row of parsed.rows) {
const indexWithinDay = perDayIndex.get(row.date) ?? 0;
perDayIndex.set(row.date, indexWithinDay + 1);
const importKey = buildImportKey(row, indexWithinDay);
if (existingKeys.has(importKey)) {
skipped++;
continue;
}
entries.push(toMealEntry(row, mealType, importKey));
}
const days = dryRun
? Array.from(new Set(entries.map(entry => entry.date))).sort()
: await addEntries(login, entries);
return {
dryRun,
format,
rowCount: parsed.rows.length,
imported: entries.length,
skipped,
days,
totalPrice: entries.reduce((sum, entry) => sum + (entry.price ?? 0), 0),
entries,
warnings: parsed.warnings,
};
}
+337
View File
@@ -0,0 +1,337 @@
import { randomUUID } from 'crypto';
import { DayRecord, MealEntry, MealType } from "../../types/gen/types.gen";
import getStorage from "./storage";
import { BadRequestError, NotFoundError, isValidIsoDate, listDatesInRange } from "./utils";
import { rememberCalories } from "./calories";
const storage = getStorage();
/** Maximální délka textových polí, aby se do úložiště nedostaly nesmyslně velké hodnoty. */
const MAX_TEXT_LENGTH = 500;
/**
* Pořadí typů jídel v rámci dne. Podle něj se řadí záznamy dne,
* aby přehled odpovídal skutečnému průběhu dne, ne pořadí zadání.
*/
const MEAL_TYPE_ORDER: MealType[] = [
MealType.SNIDANE,
MealType.DOPOLEDNI_SVACINA,
MealType.OBED,
MealType.ODPOLEDNI_SVACINA,
MealType.VECERE,
MealType.JINE,
];
/** Vrátí true, pokud je hodnota podporovaným typem jídla. */
export function isMealType(value: unknown): value is MealType {
return typeof value === 'string' && (MEAL_TYPE_ORDER as string[]).includes(value);
}
/** Sestaví klíč úložiště pro jeden den jednoho uživatele. */
function getDayKey(login: string, date: string): string {
return `meals:${login}:${date}`;
}
/** Seřadí jídla dne dle typu (snídaně -> večeře) a při shodě dle času vzniku. */
function sortEntries(entries: MealEntry[]): MealEntry[] {
return [...entries].sort((a, b) => {
const typeDiff = MEAL_TYPE_ORDER.indexOf(a.mealType) - MEAL_TYPE_ORDER.indexOf(b.mealType);
return typeDiff !== 0 ? typeDiff : a.createdAt.localeCompare(b.createdAt);
});
}
/** Sestaví přehled dne včetně součtů z předaných záznamů. */
export function buildDayRecord(date: string, entries: MealEntry[]): DayRecord {
const sorted = sortEntries(entries);
return {
date,
entries: sorted,
totalPrice: sorted.reduce((sum, entry) => sum + (entry.price ?? 0), 0),
totalCalories: sorted.reduce((sum, entry) => sum + (entry.calories ?? 0), 0),
};
}
/**
* Vrátí jídla uživatele za jeden den.
*
* @param login přihlašovací jméno uživatele
* @param date datum ve formátu YYYY-MM-DD
*/
export async function getDay(login: string, date: string): Promise<DayRecord> {
const entries = await storage.getData<MealEntry[]>(getDayKey(login, date));
return buildDayRecord(date, entries ?? []);
}
/**
* Vrátí dny uživatele v rozsahu, které obsahují alespoň jeden záznam.
*
* @param login přihlašovací jméno uživatele
* @param from první den rozsahu (včetně), YYYY-MM-DD
* @param to poslední den rozsahu (včetně), YYYY-MM-DD
*/
export async function getRange(login: string, from: string, to: string): Promise<DayRecord[]> {
if (from > to) {
throw new BadRequestError("Začátek rozsahu je až za jeho koncem");
}
const days = await Promise.all(
listDatesInRange(from, to).map(date => getDay(login, date))
);
return days.filter(day => day.entries.length > 0);
}
/** Ořízne a znormalizuje volitelný textový vstup. Prázdný text vrací jako undefined. */
function normalizeText(value: unknown, name: string): string | undefined {
if (value == null) return undefined;
if (typeof value !== 'string') {
throw new BadRequestError(`Pole '${name}' musí být text`);
}
const trimmed = value.trim();
if (!trimmed.length) return undefined;
if (trimmed.length > MAX_TEXT_LENGTH) {
throw new BadRequestError(`Pole '${name}' může mít nejvýše ${MAX_TEXT_LENGTH} znaků`);
}
return trimmed;
}
/** Ověří volitelnou nezápornou celočíselnou hodnotu (cena v haléřích, kalorie). */
function normalizeNumber(value: unknown, name: string): number | undefined {
if (value == null) return undefined;
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new BadRequestError(`Pole '${name}' musí být číslo`);
}
const rounded = Math.round(value);
if (rounded < 0) {
throw new BadRequestError(`Pole '${name}' nesmí být záporné`);
}
return rounded;
}
/**
* Znormalizovaný vstup jídla. Oproti {@link MealInput} jsou datum, typ i název po
* validaci vždy vyplněné a nevyplněná volitelná pole jsou `undefined` místo `null`,
* takže je lze rovnou rozprostřít do {@link MealEntry}.
*/
export type NormalizedMealInput = {
date: string;
mealType: MealType;
name: string;
source?: string;
note?: string;
price?: number;
calories?: number;
weight?: number;
pricePer100g?: number;
caloriesPer100g?: number;
};
/**
* Dopočte gramáž a kalorie z jednotkových hodnot.
*
* Podniky jako TechTower účtují podle váhy, takže z ceny a sazby za 100 g jde
* gramáž odvodit. Ručně zadaná gramáž má přednost — porce se dá zvážit přesněji,
* než kolik řekne cena, a u položek s jinou sazbou (salát) uživatel sazbu přepíše.
* Kalorie se stejně tak dopočtou z gramáže, pokud je známá energie na 100 g.
*/
export function deriveAmounts(input: NormalizedMealInput): NormalizedMealInput {
const weight = input.weight ?? (
input.price != null && input.pricePer100g
? Math.round(input.price / input.pricePer100g * 100)
: undefined
);
const calories = weight != null && input.caloriesPer100g
? Math.round(weight / 100 * input.caloriesPer100g)
: input.calories;
return { ...input, weight, calories };
}
/** Ověří a znormalizuje vstupní data jídla. */
export function normalizeInput(input: unknown): NormalizedMealInput {
const value = (input ?? {}) as Record<string, unknown>;
if (!isValidIsoDate(value.date)) {
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
}
if (!isMealType(value.mealType)) {
throw new BadRequestError("Nebyl předán platný typ jídla");
}
const name = normalizeText(value.name, 'name');
if (!name) {
throw new BadRequestError("Nebyl předán název jídla");
}
return deriveAmounts({
date: value.date,
mealType: value.mealType,
name,
source: normalizeText(value.source, 'source'),
note: normalizeText(value.note, 'note'),
price: normalizeNumber(value.price, 'price'),
calories: normalizeNumber(value.calories, 'calories'),
weight: normalizeNumber(value.weight, 'weight'),
pricePer100g: normalizeNumber(value.pricePer100g, 'pricePer100g'),
caloriesPer100g: normalizeNumber(value.caloriesPer100g, 'caloriesPer100g'),
});
}
/**
* Přidá jídlo do zvoleného dne a vrátí aktualizovaný přehled dne.
*
* @param login přihlašovací jméno uživatele
* @param input data jídla
*/
export async function addMeal(login: string, input: unknown): Promise<DayRecord> {
const normalized = normalizeInput(input);
const entry: MealEntry = {
id: randomUUID(),
createdAt: new Date().toISOString(),
...normalized,
};
const entries = await storage.updateData<MealEntry[]>(
getDayKey(login, normalized.date),
current => [...(current ?? []), entry]
);
if (normalized.caloriesPer100g) {
await rememberCalories(login, normalized.name, normalized.caloriesPer100g);
}
return buildDayRecord(normalized.date, entries);
}
/**
* Uloží najednou více záznamů (používá import). Záznamy se rozdělí dle data.
* Vrátí dny, kterých se zápis dotkl.
*
* @param login přihlašovací jméno uživatele
* @param entries záznamy k uložení
*/
export async function addEntries(login: string, entries: MealEntry[]): Promise<string[]> {
const byDate = new Map<string, MealEntry[]>();
for (const entry of entries) {
const forDate = byDate.get(entry.date) ?? [];
forDate.push(entry);
byDate.set(entry.date, forDate);
}
for (const [date, forDate] of byDate) {
await storage.updateData<MealEntry[]>(
getDayKey(login, date),
current => [...(current ?? []), ...forDate]
);
}
return Array.from(byDate.keys()).sort();
}
/**
* Upraví existující záznam o jídle a vrátí aktualizovaný přehled dne.
*
* Změna data znamená přesun mezi dny — záznam se ze starého dne odebere
* a do nového přidá. Vrací se vždy přehled cílového (nového) dne.
*
* @param login přihlašovací jméno uživatele
* @param id identifikátor upravovaného záznamu
* @param input nová data jídla
*/
export async function updateMeal(login: string, id: unknown, input: unknown): Promise<DayRecord> {
if (typeof id !== 'string' || !id.length) {
throw new BadRequestError("Nebyl předán identifikátor záznamu");
}
const normalized = normalizeInput(input);
const originalDate = await findEntryDate(login, id, normalized.date);
if (!originalDate) {
throw new NotFoundError("Záznam o jídle nebyl nalezen");
}
let original: MealEntry | undefined;
await storage.updateData<MealEntry[]>(getDayKey(login, originalDate), current => {
original = (current ?? []).find(entry => entry.id === id);
return (current ?? []).filter(entry => entry.id !== id);
});
if (!original) {
throw new NotFoundError("Záznam o jídle nebyl nalezen");
}
// Cena, kalorie, zdroj i poznámka se přepisují včetně vymazání na prázdno,
// proto se berou z normalizovaného vstupu a ne z původního záznamu.
const updated: MealEntry = {
...original,
...normalized,
price: normalized.price,
calories: normalized.calories,
source: normalized.source,
note: normalized.note,
weight: normalized.weight,
pricePer100g: normalized.pricePer100g,
caloriesPer100g: normalized.caloriesPer100g,
updatedAt: new Date().toISOString(),
};
const entries = await storage.updateData<MealEntry[]>(
getDayKey(login, normalized.date),
current => [...(current ?? []), updated]
);
if (normalized.caloriesPer100g) {
await rememberCalories(login, normalized.name, normalized.caloriesPer100g);
}
return buildDayRecord(normalized.date, entries);
}
/**
* Smaže záznam o jídle a vrátí aktualizovaný přehled dne.
*
* @param login přihlašovací jméno uživatele
* @param id identifikátor mazaného záznamu
* @param date datum záznamu (YYYY-MM-DD)
*/
export async function deleteMeal(login: string, id: unknown, date: unknown): Promise<DayRecord> {
if (typeof id !== 'string' || !id.length) {
throw new BadRequestError("Nebyl předán identifikátor záznamu");
}
if (!isValidIsoDate(date)) {
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
}
let found = false;
const entries = await storage.updateData<MealEntry[]>(getDayKey(login, date), current => {
found = (current ?? []).some(entry => entry.id === id);
return (current ?? []).filter(entry => entry.id !== id);
});
if (!found) {
throw new NotFoundError("Záznam o jídle nebyl nalezen");
}
return buildDayRecord(date, entries);
}
/**
* Najde datum, pod kterým je záznam uložený.
* Nejdřív zkusí očekávaný den, teprve pak prohledá ostatní klíče uživatele —
* úprava záznamu totiž může měnit i datum.
*/
async function findEntryDate(login: string, id: string, expectedDate: string): Promise<string | undefined> {
const expected = await storage.getData<MealEntry[]>(getDayKey(login, expectedDate));
if (expected?.some(entry => entry.id === id)) {
return expectedDate;
}
const prefix = `meals:${login}:`;
for (const key of await storage.listKeys(prefix)) {
if (!key.startsWith(prefix)) continue;
const entries = await storage.getData<MealEntry[]>(key);
if (entries?.some(entry => entry.id === id)) {
return key.substring(prefix.length);
}
}
return undefined;
}
/**
* Vrátí klíče importovaných záznamů uživatele v předaných dnech.
* Slouží k rozpoznání duplicit při opakovaném importu stejného měsíce.
*
* @param login přihlašovací jméno uživatele
* @param dates dny, ve kterých se má hledat
*/
export async function getExistingImportKeys(login: string, dates: string[]): Promise<Set<string>> {
const keys = new Set<string>();
for (const date of dates) {
const entries = await storage.getData<MealEntry[]>(getDayKey(login, date));
for (const entry of entries ?? []) {
if (entry.importKey) {
keys.add(entry.importKey);
}
}
}
return keys;
}
+38
View File
@@ -0,0 +1,38 @@
import express, { Request, Response, NextFunction } from "express";
import { addActivity, deleteActivity, updateActivity } from "../activities";
import { getDayOverview } from "../dayOverview";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
const router = express.Router();
/** Přidá pohybovou aktivitu a vrátí aktualizovaný přehled dne. */
router.post("/add", async (req: Request, res: Response, next: NextFunction) => {
try {
const login = getLogin(parseToken(req));
const date = await addActivity(login, req.body);
res.status(200).json(await getDayOverview(login, date));
} catch (e) { next(e); }
});
/** Upraví existující aktivitu. */
router.post("/update", async (req: Request, res: Response, next: NextFunction) => {
try {
const login = getLogin(parseToken(req));
const { id, ...input } = req.body ?? {};
const date = await updateActivity(login, id, input);
res.status(200).json(await getDayOverview(login, date));
} catch (e) { next(e); }
});
/** Smaže aktivitu. */
router.post("/delete", async (req: Request, res: Response, next: NextFunction) => {
try {
const login = getLogin(parseToken(req));
const { id, date } = req.body ?? {};
const day = await deleteActivity(login, id, date);
res.status(200).json(await getDayOverview(login, day));
} catch (e) { next(e); }
});
export default router;
+16
View File
@@ -0,0 +1,16 @@
import express, { Request, Response, NextFunction } from "express";
import { searchCalories } from "../calories";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
const router = express.Router();
/** Najde návrhy energetické hodnoty pro název jídla. */
router.get("/search", async (req: Request, res: Response, next: NextFunction) => {
try {
const login = getLogin(parseToken(req));
res.status(200).json(await searchCalories(login, req.query.q));
} catch (e) { next(e); }
});
export default router;
+19
View File
@@ -0,0 +1,19 @@
import express, { Request, Response, NextFunction } from "express";
import { getDayOverview } from "../dayOverview";
import { getLogin } from "../auth";
import { formatDate, getToday, parseToken, requireIsoDate } from "../utils";
const router = express.Router();
/** Vrátí kompletní přehled dne. Bez parametru vrací dnešek. */
router.get("/", async (req: Request, res: Response, next: NextFunction) => {
try {
const login = getLogin(parseToken(req));
const date = req.query.date == null
? formatDate(getToday())
: requireIsoDate(req.query.date, 'date');
res.status(200).json(await getDayOverview(login, date));
} catch (e) { next(e); }
});
export default router;
+33
View File
@@ -0,0 +1,33 @@
import express, { Request, Response, NextFunction } from "express";
import { importLuncherExport } from "../luncherImport";
import { getLogin } from "../auth";
import { BadRequestError, parseToken } from "../utils";
const router = express.Router();
/**
* Naimportuje měsíční přehled vyexportovaný ze stránky statistik Luncheru.
*
* Soubor přichází v těle požadavku jako Base64 — vyhneme se tím multipartu
* a XLSX (binární formát) projde stejnou cestou jako CSV a JSON.
*/
router.post("/luncher", async (req: Request, res: Response, next: NextFunction) => {
try {
const login = getLogin(parseToken(req));
const { fileName, content, defaultMealType, dryRun } = req.body ?? {};
if (typeof fileName !== 'string' || !fileName.trim().length) {
throw new BadRequestError("Nebyl předán název souboru");
}
if (typeof content !== 'string' || !content.length) {
throw new BadRequestError("Nebyl předán obsah souboru");
}
const buffer = Buffer.from(content, 'base64');
if (!buffer.length) {
throw new BadRequestError("Obsah souboru je prázdný nebo není platný Base64");
}
const result = await importLuncherExport(login, fileName.trim(), buffer, defaultMealType, dryRun === true);
res.status(200).json(result);
} catch (e) { next(e); }
});
export default router;
+59
View File
@@ -0,0 +1,59 @@
import express, { Request, Response, NextFunction } from "express";
import { addMeal, deleteMeal, getDay, getRange, updateMeal } from "../meals";
import { getLogin } from "../auth";
import { formatDate, getToday, parseToken, requireIsoDate } from "../utils";
const router = express.Router();
/**
* Vrátí jídla přihlášeného uživatele za jeden den. Bez parametru vrací dnešek.
*/
router.get("/day", async (req: Request, res: Response, next: NextFunction) => {
try {
const login = getLogin(parseToken(req));
const date = req.query.date == null
? formatDate(getToday())
: requireIsoDate(req.query.date, 'date');
res.status(200).json(await getDay(login, date));
} catch (e) { next(e); }
});
/**
* Vrátí dny přihlášeného uživatele v rozsahu, které obsahují alespoň jeden záznam.
*/
router.get("/range", async (req: Request, res: Response, next: NextFunction) => {
try {
const login = getLogin(parseToken(req));
const from = requireIsoDate(req.query.from, 'from');
const to = requireIsoDate(req.query.to, 'to');
res.status(200).json(await getRange(login, from, to));
} catch (e) { next(e); }
});
/** Přidá jídlo do zvoleného dne. */
router.post("/add", async (req: Request, res: Response, next: NextFunction) => {
try {
const login = getLogin(parseToken(req));
res.status(200).json(await addMeal(login, req.body));
} catch (e) { next(e); }
});
/** Upraví existující záznam o jídle. */
router.post("/update", async (req: Request, res: Response, next: NextFunction) => {
try {
const login = getLogin(parseToken(req));
const { id, ...input } = req.body ?? {};
res.status(200).json(await updateMeal(login, id, input));
} catch (e) { next(e); }
});
/** Smaže záznam o jídle. */
router.post("/delete", async (req: Request, res: Response, next: NextFunction) => {
try {
const login = getLogin(parseToken(req));
const { id, date } = req.body ?? {};
res.status(200).json(await deleteMeal(login, id, date));
} catch (e) { next(e); }
});
export default router;
+33
View File
@@ -0,0 +1,33 @@
import express, { Request, Response, NextFunction } from "express";
import { getSettings, saveBasalCalories, saveSourceRate } from "../settings";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
const router = express.Router();
/** Vrátí nastavení přihlášeného uživatele. */
router.get("/", async (req: Request, res: Response, next: NextFunction) => {
try {
const login = getLogin(parseToken(req));
res.status(200).json(await getSettings(login));
} catch (e) { next(e); }
});
/** Uloží nebo smaže cenu za 100 g u jednoho zdroje. */
router.post("/", async (req: Request, res: Response, next: NextFunction) => {
try {
const login = getLogin(parseToken(req));
const { source, pricePer100g } = req.body ?? {};
res.status(200).json(await saveSourceRate(login, source, pricePer100g));
} catch (e) { next(e); }
});
/** Uloží klidový výdej (bazální metabolismus). */
router.post("/basal", async (req: Request, res: Response, next: NextFunction) => {
try {
const login = getLogin(parseToken(req));
res.status(200).json(await saveBasalCalories(login, req.body?.basalCalories));
} catch (e) { next(e); }
});
export default router;
+18
View File
@@ -0,0 +1,18 @@
import express, { Request, Response, NextFunction } from "express";
import { getFoodStats } from "../statsService";
import { getLogin } from "../auth";
import { parseToken, requireIsoDate } from "../utils";
const router = express.Router();
/** Vrátí statistiky útraty a jídel přihlášeného uživatele za zvolené období. */
router.get("/summary", async (req: Request, res: Response, next: NextFunction) => {
try {
const login = getLogin(parseToken(req));
const from = requireIsoDate(req.query.from, 'from');
const to = requireIsoDate(req.query.to, 'to');
res.status(200).json(await getFoodStats(login, from, to));
} catch (e) { next(e); }
});
export default router;
+44
View File
@@ -0,0 +1,44 @@
import express, { Request, Response, NextFunction } from "express";
import { applyWorkoutTemplate, deleteWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate } from "../workouts";
import { getDayOverview } from "../dayOverview";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
const router = express.Router();
/** Vrátí šablony tréninků přihlášeného uživatele. */
router.get("/", async (req: Request, res: Response, next: NextFunction) => {
try {
const login = getLogin(parseToken(req));
res.status(200).json(await getWorkoutTemplates(login));
} catch (e) { next(e); }
});
/** Založí novou šablonu tréninku, nebo upraví existující. */
router.post("/save", async (req: Request, res: Response, next: NextFunction) => {
try {
const login = getLogin(parseToken(req));
const { id, name, items } = req.body ?? {};
res.status(200).json(await saveWorkoutTemplate(login, id, name, items));
} catch (e) { next(e); }
});
/** Smaže šablonu tréninku. */
router.post("/delete", async (req: Request, res: Response, next: NextFunction) => {
try {
const login = getLogin(parseToken(req));
res.status(200).json(await deleteWorkoutTemplate(login, req.body?.id));
} catch (e) { next(e); }
});
/** Založí do zvoleného dne všechny položky šablony. */
router.post("/apply", async (req: Request, res: Response, next: NextFunction) => {
try {
const login = getLogin(parseToken(req));
const { id, date } = req.body ?? {};
const day = await applyWorkoutTemplate(login, id, date);
res.status(200).json(await getDayOverview(login, day));
} catch (e) { next(e); }
});
export default router;
+101
View File
@@ -0,0 +1,101 @@
import { UserSettings } from "../../types/gen/types.gen";
import getStorage from "./storage";
import { BadRequestError } from "./utils";
import { normalizeName } from "./calories";
const storage = getStorage();
/** Horní mez sazby (10 000 Kč za 100 g) — chrání před překlepem o řád. */
const MAX_PRICE_PER_100G = 1_000_000;
/** Horní mez klidového výdeje — nad 10 000 kcal/den už jde o překlep. */
const MAX_BASAL_CALORIES = 10_000;
/** Klíč úložiště s nastavením jednoho uživatele. */
function getSettingsKey(login: string): string {
return `settings:${login}`;
}
/** Prázdné nastavení pro uživatele, který si zatím nic nenastavil. */
function emptySettings(): UserSettings {
return { sourceRates: [] };
}
/**
* Vrátí nastavení uživatele.
*
* @param login přihlašovací jméno uživatele
*/
export async function getSettings(login: string): Promise<UserSettings> {
return await storage.getData<UserSettings>(getSettingsKey(login)) ?? emptySettings();
}
/**
* Uloží cenu za 100 g u jednoho zdroje. Prázdná hodnota sazbu odstraní.
*
* Zdroje se porovnávají znormalizovaně, aby "TechTower" a "techtower"
* neskončily jako dvě různé sazby.
*
* @param login přihlašovací jméno uživatele
* @param source název zdroje (podniku)
* @param pricePer100g cena za 100 g v haléřích, nebo null pro odstranění
*/
export async function saveSourceRate(login: string, source: unknown, pricePer100g: unknown): Promise<UserSettings> {
if (typeof source !== 'string' || !source.trim().length) {
throw new BadRequestError("Nebyl předán název zdroje");
}
if (pricePer100g != null && (typeof pricePer100g !== 'number' || !Number.isFinite(pricePer100g))) {
throw new BadRequestError("Cena za 100 g musí být číslo");
}
const rate = pricePer100g == null ? 0 : Math.round(pricePer100g);
if (rate < 0 || rate > MAX_PRICE_PER_100G) {
throw new BadRequestError("Cena za 100 g je mimo rozsah");
}
const name = source.trim();
const key = normalizeName(name);
return await storage.updateData<UserSettings>(getSettingsKey(login), current => {
const rates = (current ?? emptySettings()).sourceRates.filter(item => normalizeName(item.source) !== key);
// Nulová sazba znamená smazání — ukládat ji nemá smysl
return { sourceRates: rate > 0 ? [...rates, { source: name, pricePer100g: rate }] : rates };
});
}
/**
* Uloží klidový výdej (bazální metabolismus) v kcal za den.
* Prázdná nebo nulová hodnota ho odstraní a bilance dne pak nepočítá deficit.
*
* @param login přihlašovací jméno uživatele
* @param basalCalories klidový výdej v kcal, nebo null pro odstranění
*/
export async function saveBasalCalories(login: string, basalCalories: unknown): Promise<UserSettings> {
if (basalCalories != null && (typeof basalCalories !== 'number' || !Number.isFinite(basalCalories))) {
throw new BadRequestError("Klidový výdej musí být číslo");
}
const value = basalCalories == null ? 0 : Math.round(basalCalories);
if (value < 0 || value > MAX_BASAL_CALORIES) {
throw new BadRequestError("Klidový výdej je mimo rozsah");
}
return await storage.updateData<UserSettings>(getSettingsKey(login), current => {
const settings = current ?? emptySettings();
// Nula znamená smazání — ukládat ji nemá smysl
if (value > 0) {
return { ...settings, basalCalories: value };
}
const { basalCalories: _removed, ...rest } = settings;
return rest;
});
}
/**
* Vrátí sazbu za 100 g nastavenou pro daný zdroj, pokud existuje.
*
* @param login přihlašovací jméno uživatele
* @param source název zdroje
*/
export async function getSourceRate(login: string, source?: string): Promise<number | undefined> {
if (!source?.trim().length) return undefined;
const key = normalizeName(source);
const settings = await getSettings(login);
return settings.sourceRates.find(rate => normalizeName(rate.source) === key)?.pricePer100g;
}
+82
View File
@@ -0,0 +1,82 @@
import { FoodStats, GroupTotal, MealEntry, PeriodTotal } from "../../types/gen/types.gen";
import { getRange } from "./meals";
/** Zdroj přiřazený záznamům, které ho nemají vyplněný. */
const UNKNOWN_SOURCE = 'Neuvedeno';
/** Průběžně načítaný součet jedné skupiny nebo období. */
type Bucket = { price: number, calories: number, count: number };
/** Přičte záznam do kbelíku pod daným klíčem. */
function accumulate(buckets: Map<string, Bucket>, key: string, entry: MealEntry): void {
const bucket = buckets.get(key) ?? { price: 0, calories: 0, count: 0 };
bucket.price += entry.price ?? 0;
bucket.calories += entry.calories ?? 0;
bucket.count++;
buckets.set(key, bucket);
}
/** Převede kbelíky na součty období seřazené vzestupně dle označení období. */
function toPeriodTotals(buckets: Map<string, Bucket>): PeriodTotal[] {
return Array.from(buckets.entries())
.map(([period, bucket]) => ({ period, ...bucket }))
.sort((a, b) => a.period.localeCompare(b.period));
}
/** Převede kbelíky na součty skupin seřazené sestupně dle útraty. */
function toGroupTotals(buckets: Map<string, Bucket>): GroupTotal[] {
return Array.from(buckets.entries())
.map(([key, bucket]) => ({ key, ...bucket }))
.sort((a, b) => b.price - a.price || b.count - a.count || a.key.localeCompare(b.key));
}
/**
* Sestaví statistiky útraty a jídel uživatele za zvolené období.
*
* Dny bez záznamu se do `dayCount` ani do `byDay` nepromítají — průměr na den
* tak vychází z reálně vykázaných dní, ne z délky vybraného rozsahu.
*
* @param login přihlašovací jméno uživatele
* @param from první den období (včetně), YYYY-MM-DD
* @param to poslední den období (včetně), YYYY-MM-DD
*/
export async function getFoodStats(login: string, from: string, to: string): Promise<FoodStats> {
const days = await getRange(login, from, to);
const byDay = new Map<string, Bucket>();
const byMonth = new Map<string, Bucket>();
const byYear = new Map<string, Bucket>();
const byMealType = new Map<string, Bucket>();
const bySource = new Map<string, Bucket>();
let totalPrice = 0;
let totalCalories = 0;
let entryCount = 0;
for (const day of days) {
for (const entry of day.entries) {
accumulate(byDay, entry.date, entry);
accumulate(byMonth, entry.date.substring(0, 7), entry);
accumulate(byYear, entry.date.substring(0, 4), entry);
accumulate(byMealType, entry.mealType, entry);
accumulate(bySource, entry.source ?? UNKNOWN_SOURCE, entry);
totalPrice += entry.price ?? 0;
totalCalories += entry.calories ?? 0;
entryCount++;
}
}
return {
from,
to,
totalPrice,
totalCalories,
entryCount,
dayCount: days.length,
byDay: toPeriodTotals(byDay),
byMonth: toPeriodTotals(byMonth),
byYear: toPeriodTotals(byYear),
byMealType: toGroupTotals(byMealType),
bySource: toGroupTotals(bySource),
};
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Interface pro úložiště dat.
*/
export interface StorageInterface {
initialize?(): Promise<void>;
hasData(key: string): Promise<boolean>;
getData<Type>(key: string): Promise<Type | undefined>;
setData<Type>(key: string, data: Type): Promise<void>;
/**
* Vrátí seznam všech klíčů, případně jen těch obsahujících předaný podřetězec.
* @param contains volitelný podřetězec, který musí klíč obsahovat
*/
listKeys(contains?: string): Promise<string[]>;
/**
* Atomicky načte, zmutuje a uloží data pod daným klíčem.
* V Redis implementaci používá WATCH/MULTI/EXEC retry loop.
* Vrátí výslednou hodnotu po aplikaci mutátoru.
*/
updateData<Type>(key: string, mutator: (current: Type | undefined) => Type): Promise<Type>;
/** Ověří dostupnost úložiště. Vrátí false pokud není dostupné. */
healthCheck?(): Promise<boolean>;
}
+32
View File
@@ -0,0 +1,32 @@
import dotenv from 'dotenv';
import path from 'path';
import { StorageInterface } from "./StorageInterface";
import JsonStorage from "./json";
import RedisStorage from "./redis";
import MemoryStorage from "./memory";
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
dotenv.config({ path: path.resolve(__dirname, `../../.env.${ENVIRONMENT}`) });
const JSON_KEY = 'json';
const REDIS_KEY = 'redis';
const MEMORY_KEY = 'memory';
let storage: StorageInterface;
if (!process.env.STORAGE || process.env.STORAGE?.toLowerCase() === JSON_KEY) {
storage = new JsonStorage();
} else if (process.env.STORAGE?.toLowerCase() === REDIS_KEY) {
storage = new RedisStorage();
} else if (process.env.STORAGE?.toLowerCase() === MEMORY_KEY) {
storage = new MemoryStorage();
} else {
throw new Error("Nepodporovaná hodnota proměnné STORAGE: " + process.env.STORAGE + ", podporované jsou 'json', 'redis' nebo 'memory'");
}
export const storageReady: Promise<void> = storage.initialize
? storage.initialize()
: Promise.resolve();
export default function getStorage(): StorageInterface {
return storage;
}
+48
View File
@@ -0,0 +1,48 @@
import JSONdb from 'simple-json-db';
import { StorageInterface } from "./StorageInterface";
import * as fs from 'fs';
import * as path from 'path';
const dbPath = path.resolve(__dirname, '../../data/db.json');
const dbDir = path.dirname(dbPath);
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
const db = new JSONdb(dbPath);
/**
* Implementace úložiště používající JSON soubor. Určená pro vývoj.
*/
export default class JsonStorage implements StorageInterface {
hasData(key: string): Promise<boolean> {
return Promise.resolve(db.has(key));
}
getData<Type>(key: string): Promise<Type | undefined> {
return Promise.resolve(db.get(key));
}
setData<Type>(key: string, data: Type): Promise<void> {
db.set(key, data);
return Promise.resolve();
}
listKeys(contains?: string): Promise<string[]> {
const keys = Object.keys(db.JSON());
return Promise.resolve(contains ? keys.filter(k => k.includes(contains)) : keys);
}
updateData<Type>(key: string, mutator: (current: Type | undefined) => Type): Promise<Type> {
const current = db.get(key) as Type | undefined;
const next = mutator(current);
db.set(key, next);
return Promise.resolve(next);
}
healthCheck(): Promise<boolean> {
return Promise.resolve(true);
}
}
+43
View File
@@ -0,0 +1,43 @@
import { StorageInterface } from "./StorageInterface";
const store = new Map<string, unknown>();
/** Vymaže všechna data z in-memory úložiště. Slouží k resetu mezi testy. */
export function resetMemoryStorage(): void {
store.clear();
}
/**
* In-memory implementace úložiště. Používá se výhradně v testovacím prostředí.
*/
export default class MemoryStorage implements StorageInterface {
hasData(key: string): Promise<boolean> {
return Promise.resolve(store.has(key));
}
getData<Type>(key: string): Promise<Type | undefined> {
return Promise.resolve(store.get(key) as Type | undefined);
}
setData<Type>(key: string, data: Type): Promise<void> {
store.set(key, data);
return Promise.resolve();
}
listKeys(contains?: string): Promise<string[]> {
const keys = Array.from(store.keys());
return Promise.resolve(contains ? keys.filter(k => k.includes(contains)) : keys);
}
updateData<Type>(key: string, mutator: (current: Type | undefined) => Type): Promise<Type> {
const current = store.get(key) as Type | undefined;
const next = mutator(current);
store.set(key, next);
return Promise.resolve(next);
}
healthCheck(): Promise<boolean> {
return Promise.resolve(true);
}
}
+92
View File
@@ -0,0 +1,92 @@
import { createClient } from 'redis';
import { StorageInterface } from "./StorageInterface";
/** Počet pokusů o atomický zápis, než se WATCH/MULTI/EXEC smyčka vzdá. */
const MAX_RETRIES = 10;
/**
* Typ klienta se odvozuje z {@link createClient}, ne z exportovaného `RedisClientType`.
* Ten je generický přes registrované moduly a bez parametrů z něj vypadnou metody
* jako `executeIsolated`.
*/
type Client = ReturnType<typeof createClient>;
let client: Client | undefined;
/** Ukončí spojení hlavního Redis klienta (volá se při graceful shutdownu). */
export async function shutdownRedisStorage(): Promise<void> {
if (client?.isOpen) {
await client.quit();
}
}
/**
* Implementace úložiště nad Redisem. Určená pro produkční provoz.
*/
export default class RedisStorage implements StorageInterface {
async initialize(): Promise<void> {
const host = process.env.REDIS_HOST ?? 'localhost';
const port = Number(process.env.REDIS_PORT ?? 6379);
client = createClient({ socket: { host, port } });
client.on('error', error => console.error('Chyba Redisu:', error));
await client.connect();
console.log(`Připojeno k Redisu na ${host}:${port}`);
}
private getClient(): Client {
if (!client) {
throw new Error('Redis klient není inicializovaný');
}
return client;
}
async hasData(key: string): Promise<boolean> {
return (await this.getClient().exists(key)) === 1;
}
async getData<Type>(key: string): Promise<Type | undefined> {
const value = await this.getClient().get(key);
return value == null ? undefined : JSON.parse(value) as Type;
}
async setData<Type>(key: string, data: Type): Promise<void> {
await this.getClient().set(key, JSON.stringify(data));
}
async listKeys(contains?: string): Promise<string[]> {
const keys: string[] = [];
for await (const key of this.getClient().scanIterator({ MATCH: contains ? `*${contains}*` : '*', COUNT: 1000 })) {
keys.push(...(Array.isArray(key) ? key : [key]));
}
return keys;
}
async updateData<Type>(key: string, mutator: (current: Type | undefined) => Type): Promise<Type> {
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
// Izolovaný klient, aby WATCH nekolidoval s ostatními požadavky
const attemptWrite = async (isolated: Client): Promise<Type | undefined> => {
await isolated.watch(key);
const value = await isolated.get(key);
const current = value == null ? undefined : JSON.parse(value) as Type;
const next = mutator(current);
const execResult = await isolated.multi().set(key, JSON.stringify(next)).exec();
// null z EXEC = klíč se mezitím změnil, zkusíme znovu
return execResult === null ? undefined : next;
};
const result = await this.getClient().executeIsolated(attemptWrite as never) as Type | undefined;
if (result !== undefined) {
return result;
}
}
throw new Error(`Nepodařilo se atomicky uložit klíč ${key} ani po ${MAX_RETRIES} pokusech`);
}
async healthCheck(): Promise<boolean> {
try {
return (await this.getClient().ping()) === 'PONG';
} catch {
return false;
}
}
}
+231
View File
@@ -0,0 +1,231 @@
process.env.CALORIE_PROVIDER = 'none';
import {
addActivity, deleteActivity, deriveActivityCalories, getActivityDay, updateActivity,
} from "../activities";
import { applyWorkoutTemplate, deleteWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate } from "../workouts";
import { buildEnergyBalance, getDayOverview } from "../dayOverview";
import { addMeal } from "../meals";
import { saveBasalCalories } from "../settings";
import { resetMemoryStorage } from "../storage/memory";
import { ActivityUnit, MealType } from "../../../types/gen/types.gen";
const LOGIN = 'tester';
const DATE = '2025-03-03';
/** Přibližná sazba chůze: 4 kcal na 100 kroků, tedy 400 kcal za 10 000 kroků. */
const STEP_RATE = 4;
function activity(overrides: Record<string, unknown> = {}) {
return {
date: DATE,
name: 'Chůze',
unit: ActivityUnit.KROKY,
quantity: 10000,
caloriesPer100Units: STEP_RATE,
...overrides,
};
}
beforeEach(() => resetMemoryStorage());
describe('evidence pohybu', () => {
it('dopočte spálené kalorie z počtu kroků', () => {
const result = deriveActivityCalories({
date: DATE, name: 'Chůze', unit: ActivityUnit.KROKY,
quantity: 10000, caloriesPer100Units: STEP_RATE,
});
expect(result.calories).toBe(400);
});
it('bez sazby použije ručně zadané kalorie', () => {
const result = deriveActivityCalories({
date: DATE, name: 'Plavání', unit: ActivityUnit.MINUTY,
quantity: 30, calories: 250,
});
expect(result.calories).toBe(250);
});
it('přidá aktivitu a spočítá součet dne', async () => {
await addActivity(LOGIN, activity());
await addActivity(LOGIN, activity({
name: 'Kliky', unit: ActivityUnit.OPAKOVANI, quantity: 60, caloriesPer100Units: 50,
}));
const day = await getActivityDay(LOGIN, DATE);
expect(day.entries).toHaveLength(2);
expect(day.totalCalories).toBe(430);
});
it('upraví aktivitu a přepočítá kalorie', async () => {
await addActivity(LOGIN, activity());
const [entry] = (await getActivityDay(LOGIN, DATE)).entries;
await updateActivity(LOGIN, entry.id, activity({ quantity: 5000 }));
const day = await getActivityDay(LOGIN, DATE);
expect(day.entries[0].quantity).toBe(5000);
expect(day.totalCalories).toBe(200);
});
it('při změně data přesune aktivitu do jiného dne', async () => {
await addActivity(LOGIN, activity());
const [entry] = (await getActivityDay(LOGIN, DATE)).entries;
await updateActivity(LOGIN, entry.id, activity({ date: '2025-03-10' }));
expect((await getActivityDay(LOGIN, DATE)).entries).toHaveLength(0);
expect((await getActivityDay(LOGIN, '2025-03-10')).entries).toHaveLength(1);
});
it('smaže aktivitu', async () => {
await addActivity(LOGIN, activity());
const [entry] = (await getActivityDay(LOGIN, DATE)).entries;
await deleteActivity(LOGIN, entry.id, DATE);
expect((await getActivityDay(LOGIN, DATE)).totalCalories).toBe(0);
});
it('odmítne neplatný vstup', async () => {
await expect(addActivity(LOGIN, activity({ name: ' ' }))).rejects.toThrow(/název aktivity/);
await expect(addActivity(LOGIN, activity({ unit: 'SKOKY' }))).rejects.toThrow(/jednotka/);
await expect(addActivity(LOGIN, activity({ quantity: undefined }))).rejects.toThrow(/množství/);
await expect(addActivity(LOGIN, activity({ quantity: -5 }))).rejects.toThrow(/záporné/);
});
it('pohyb je oddělený mezi uživateli', async () => {
await addActivity(LOGIN, activity());
expect((await getActivityDay('nekdo-jiny', DATE)).entries).toHaveLength(0);
});
});
describe('šablony tréninků', () => {
const items = [
{ name: 'Kliky', unit: ActivityUnit.OPAKOVANI, quantity: 60, caloriesPer100Units: 50 },
{ name: 'Běh', unit: ActivityUnit.MINUTY, quantity: 20, caloriesPer100Units: 1000 },
];
it('uloží šablonu a spočítá odhad kalorií', async () => {
const templates = await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', items);
expect(templates).toHaveLength(1);
expect(templates[0].name).toBe('Workout day 1');
// 60 kliků × 0,5 kcal + 20 minut × 10 kcal
expect(templates[0].estimatedCalories).toBe(230);
});
it('úprava podle id šablonu přepíše, nezaloží druhou', async () => {
const [created] = await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', items);
const templates = await saveWorkoutTemplate(LOGIN, created.id, 'Workout day 1 (těžší)', items);
expect(templates).toHaveLength(1);
expect(templates[0].name).toBe('Workout day 1 (těžší)');
});
it('použití šablony založí do dne všechny její položky', async () => {
const [template] = await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', items);
await applyWorkoutTemplate(LOGIN, template.id, DATE);
const day = await getActivityDay(LOGIN, DATE);
expect(day.entries.map(entry => entry.name)).toEqual(['Kliky', 'Běh']);
expect(day.totalCalories).toBe(230);
expect(day.entries[0].templateId).toBe(template.id);
});
it('položky ze šablony jsou samostatné — úprava neovlivní šablonu', async () => {
const [template] = await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', items);
await applyWorkoutTemplate(LOGIN, template.id, DATE);
const [entry] = (await getActivityDay(LOGIN, DATE)).entries;
await updateActivity(LOGIN, entry.id, activity({
name: 'Kliky', unit: ActivityUnit.OPAKOVANI, quantity: 100, caloriesPer100Units: 50,
}));
expect((await getWorkoutTemplates(LOGIN))[0].items[0].quantity).toBe(60);
});
it('smaže šablonu', async () => {
const [template] = await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', items);
expect(await deleteWorkoutTemplate(LOGIN, template.id)).toEqual([]);
});
it('odmítne šablonu bez názvu nebo bez položek', async () => {
await expect(saveWorkoutTemplate(LOGIN, undefined, '', items)).rejects.toThrow(/název šablony/);
await expect(saveWorkoutTemplate(LOGIN, undefined, 'Prázdná', [])).rejects.toThrow(/alespoň jednu položku/);
});
it('hlásí chybu u neexistující šablony', async () => {
await expect(applyWorkoutTemplate(LOGIN, 'neexistuje', DATE)).rejects.toThrow(/nebyla nalezena/);
await expect(deleteWorkoutTemplate(LOGIN, 'neexistuje')).rejects.toThrow(/nebyla nalezena/);
});
it('šablony jsou oddělené mezi uživateli', async () => {
await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', items);
expect(await getWorkoutTemplates('nekdo-jiny')).toEqual([]);
});
});
describe('energetická bilance dne', () => {
it('bez klidového výdeje porovná jen jídlo proti pohybu', () => {
const energy = buildEnergyBalance(2000, 400);
expect(energy.totalBurn).toBe(400);
expect(energy.balance).toBe(1600);
expect(energy.hasBasal).toBe(false);
});
it('s klidovým výdejem spočítá skutečný deficit', () => {
const energy = buildEnergyBalance(1800, 400, 1600);
expect(energy.totalBurn).toBe(2000);
expect(energy.balance).toBe(-200);
expect(energy.hasBasal).toBe(true);
});
it('přebytek vyjde kladně', () => {
const energy = buildEnergyBalance(2500, 200, 1600);
expect(energy.balance).toBe(700);
});
it('přehled dne spojí jídlo, pohyb i bilanci', async () => {
await saveBasalCalories(LOGIN, 1600);
await addMeal(LOGIN, {
date: DATE, mealType: MealType.OBED, name: 'Gyros',
weight: 400, caloriesPer100g: 172, price: 17600,
});
await addActivity(LOGIN, activity());
const overview = await getDayOverview(LOGIN, DATE);
expect(overview.meals.totalCalories).toBe(688);
expect(overview.activities.totalCalories).toBe(400);
expect(overview.energy.intake).toBe(688);
expect(overview.energy.totalBurn).toBe(2000);
expect(overview.energy.balance).toBe(-1312);
expect(overview.energy.hasBasal).toBe(true);
});
it('prázdný den vrátí nuly', async () => {
const overview = await getDayOverview(LOGIN, DATE);
expect(overview.energy.intake).toBe(0);
expect(overview.energy.balance).toBe(0);
expect(overview.energy.hasBasal).toBe(false);
});
it('klidový výdej jde uložit i odstranit', async () => {
expect((await saveBasalCalories(LOGIN, 1600)).basalCalories).toBe(1600);
expect((await saveBasalCalories(LOGIN, 0)).basalCalories).toBeUndefined();
await expect(saveBasalCalories(LOGIN, 99999)).rejects.toThrow(/mimo rozsah/);
});
});
+200
View File
@@ -0,0 +1,200 @@
// Externí poskytovatel se v testech nevolá — ověřujeme vlastní logiku, ne cizí službu
process.env.CALORIE_PROVIDER = 'none';
import { addMeal, deriveAmounts, getDay, updateMeal } from "../meals";
import { searchCalories } from "../calories";
import { getSettings, getSourceRate, saveSourceRate } from "../settings";
import { resetMemoryStorage } from "../storage/memory";
import { MealType } from "../../../types/gen/types.gen";
const LOGIN = 'tester';
/** Sazba TechTower: 44 Kč za 100 g, v haléřích. */
const TOWER_RATE = 4400;
function meal(overrides: Record<string, unknown> = {}) {
return { date: '2025-03-03', mealType: MealType.OBED, name: 'Gyros', ...overrides };
}
beforeEach(() => resetMemoryStorage());
describe('dopočet gramáže a kalorií', () => {
it('dopočte gramáž z ceny a sazby za 100 g', () => {
// 176 Kč při 44 Kč/100 g = 400 g
const result = deriveAmounts({
date: '2025-03-03', mealType: MealType.OBED, name: 'Gyros',
price: 17600, pricePer100g: TOWER_RATE,
});
expect(result.weight).toBe(400);
});
it('ručně zadaná gramáž má přednost před dopočtem z ceny', () => {
const result = deriveAmounts({
date: '2025-03-03', mealType: MealType.OBED, name: 'Gyros',
price: 17600, pricePer100g: TOWER_RATE, weight: 350,
});
expect(result.weight).toBe(350);
});
it('dopočte kalorie z gramáže a energie na 100 g', () => {
const result = deriveAmounts({
date: '2025-03-03', mealType: MealType.OBED, name: 'Gyros',
weight: 400, caloriesPer100g: 172,
});
expect(result.calories).toBe(688);
});
it('spojí oba dopočty — z ceny na gramáž a z gramáže na kalorie', () => {
const result = deriveAmounts({
date: '2025-03-03', mealType: MealType.OBED, name: 'Gyros',
price: 17600, pricePer100g: TOWER_RATE, caloriesPer100g: 172,
});
expect(result.weight).toBe(400);
expect(result.calories).toBe(688);
});
it('bez sazby nechá gramáž i kalorie na ručně zadaných hodnotách', () => {
const result = deriveAmounts({
date: '2025-03-03', mealType: MealType.OBED, name: 'Gyros',
price: 17600, calories: 500,
});
expect(result.weight).toBeUndefined();
expect(result.calories).toBe(500);
});
it('různé položky téhož dne mohou mít různou sazbu', async () => {
// Hlavní jídlo za 44 Kč/100 g a salát s vlastním cenováním
await addMeal(LOGIN, meal({ name: 'Gyros', price: 17600, pricePer100g: TOWER_RATE, caloriesPer100g: 172 }));
const day = await addMeal(LOGIN, meal({ name: 'Salát', price: 6000, pricePer100g: 3000, caloriesPer100g: 40 }));
const gyros = day.entries.find(e => e.name === 'Gyros')!;
const salat = day.entries.find(e => e.name === 'Salát')!;
expect(gyros.weight).toBe(400);
expect(gyros.calories).toBe(688);
expect(salat.weight).toBe(200);
expect(salat.calories).toBe(80);
expect(day.totalCalories).toBe(768);
expect(day.totalPrice).toBe(23600);
});
it('úprava umí gramáž i sazbu přepsat a vymazat', async () => {
const created = await addMeal(LOGIN, meal({ price: 17600, pricePer100g: TOWER_RATE, caloriesPer100g: 172 }));
const id = created.entries[0].id;
const day = await updateMeal(LOGIN, id, meal({ price: 17600, weight: 500, caloriesPer100g: 172 }));
expect(day.entries[0].weight).toBe(500);
expect(day.entries[0].pricePer100g).toBeUndefined();
expect(day.entries[0].calories).toBe(860);
});
});
describe('knihovna energetických hodnot', () => {
it('zapamatuje si energii na 100 g a příště ji nabídne', async () => {
await addMeal(LOGIN, meal({ name: 'Kuřecí gyros', caloriesPer100g: 172, weight: 300 }));
const result = await searchCalories(LOGIN, 'kuřecí gyros');
expect(result.suggestions).toEqual([
{ name: 'Kuřecí gyros', caloriesPer100g: 172, origin: 'library' },
]);
});
it('najde i při odlišné diakritice a velikosti písmen', async () => {
await addMeal(LOGIN, meal({ name: 'Kuřecí gyros', caloriesPer100g: 172, weight: 300 }));
const result = await searchCalories(LOGIN, 'KURECI GYROS');
expect(result.suggestions[0].caloriesPer100g).toBe(172);
});
it('nabídne i částečnou shodu názvu', async () => {
await addMeal(LOGIN, meal({ name: 'Kuřecí gyros s hranolkami', caloriesPer100g: 172, weight: 300 }));
const result = await searchCalories(LOGIN, 'gyros');
expect(result.suggestions[0].name).toBe('Kuřecí gyros s hranolkami');
});
it('jídlo bez energie na 100 g si do knihovny neuloží', async () => {
await addMeal(LOGIN, meal({ name: 'Něco', calories: 500 }));
const result = await searchCalories(LOGIN, 'Něco');
expect(result.suggestions).toEqual([]);
});
it('novější zadání přepíše dřívější hodnotu', async () => {
await addMeal(LOGIN, meal({ name: 'Gyros', caloriesPer100g: 172, weight: 300 }));
await addMeal(LOGIN, meal({ name: 'Gyros', caloriesPer100g: 180, weight: 300 }));
const result = await searchCalories(LOGIN, 'Gyros');
expect(result.suggestions[0].caloriesPer100g).toBe(180);
});
it('knihovna je oddělená mezi uživateli', async () => {
await addMeal(LOGIN, meal({ name: 'Gyros', caloriesPer100g: 172, weight: 300 }));
const result = await searchCalories('nekdo-jiny', 'Gyros');
expect(result.suggestions).toEqual([]);
});
it('vrátí odkaz na tabulku potravin na KalorickéTabulky.cz', async () => {
const result = await searchCalories(LOGIN, 'kuřecí gyros');
// Předvyplnit hledání nejde — jejich vyhledávání je v JS a URL parametry ignoruje
expect(result.externalSearchUrl).toBe('https://www.kaloricketabulky.cz/tabulka-potravin');
});
it('odmítne prázdný dotaz', async () => {
await expect(searchCalories(LOGIN, ' ')).rejects.toThrow(/hledaný název/);
});
});
describe('sazby za 100 g u zdrojů', () => {
it('uloží a vrátí sazbu podniku', async () => {
await saveSourceRate(LOGIN, 'TechTower', TOWER_RATE);
expect(await getSourceRate(LOGIN, 'TechTower')).toBe(TOWER_RATE);
expect((await getSettings(LOGIN)).sourceRates).toEqual([
{ source: 'TechTower', pricePer100g: TOWER_RATE },
]);
});
it('najde sazbu bez ohledu na velikost písmen', async () => {
await saveSourceRate(LOGIN, 'TechTower', TOWER_RATE);
expect(await getSourceRate(LOGIN, 'techtower')).toBe(TOWER_RATE);
});
it('opakované uložení sazbu přepíše, nezaloží druhou', async () => {
await saveSourceRate(LOGIN, 'TechTower', TOWER_RATE);
const settings = await saveSourceRate(LOGIN, 'TechTower', 4800);
expect(settings.sourceRates).toHaveLength(1);
expect(settings.sourceRates[0].pricePer100g).toBe(4800);
});
it('nulová sazba ji odstraní', async () => {
await saveSourceRate(LOGIN, 'TechTower', TOWER_RATE);
const settings = await saveSourceRate(LOGIN, 'TechTower', 0);
expect(settings.sourceRates).toEqual([]);
});
it('odmítne prázdný zdroj a nesmyslnou sazbu', async () => {
await expect(saveSourceRate(LOGIN, '', TOWER_RATE)).rejects.toThrow(/název zdroje/);
await expect(saveSourceRate(LOGIN, 'TechTower', -1)).rejects.toThrow(/mimo rozsah/);
});
it('sazby jsou oddělené mezi uživateli', async () => {
await saveSourceRate(LOGIN, 'TechTower', TOWER_RATE);
expect(await getSourceRate('nekdo-jiny', 'TechTower')).toBeUndefined();
});
});
+280
View File
@@ -0,0 +1,280 @@
import ExcelJS from 'exceljs';
import { importLuncherExport } from "../luncherImport";
import { getDay } from "../meals";
import { resetMemoryStorage } from "../storage/memory";
import { MealType } from "../../../types/gen/types.gen";
const LOGIN = 'tester';
/** Hlavička přehledu tak, jak ji generuje Luncher (server/src/userExport.ts). */
const HEADERS = ['Datum', 'Den', 'Typ', 'Vybrané jídlo', 'Poznámka', 'Objednávka', 'Objednával', 'Částka'];
/** Řádky používané napříč testy — pokrývají volbu podniku, pizzu i objednávku. */
const ROWS = [
{ date: '2025-03-03', dayOfWeek: 'pondělí', type: 'Sladovnická', food: 'Svíčková na smetaně', note: '', store: '', orderedBy: '', amount: 159 },
{ date: '2025-03-04', dayOfWeek: 'úterý', type: 'Pizza day', food: 'Margherita (32cm)', note: 'bez oregana', store: '', orderedBy: '', amount: 215.5 },
{ date: '2025-03-05', dayOfWeek: 'středa', type: 'Budu objednávat', food: 'Kuřecí burger', note: '', store: 'Bolt Food', orderedBy: 'novak', amount: 249 },
];
/** Sestaví CSV přesně tak, jak ho Luncher exportuje — BOM, ';' a desetinná čárka. */
function buildLuncherCsv(): Buffer {
const escape = (value: string) => `"${value.replace(/"/g, '""')}"`;
const lines = [HEADERS.map(escape).join(';')];
for (const row of ROWS) {
const [y, m, d] = row.date.split('-');
lines.push([
escape(`${d}.${m}.${y}`),
escape(row.dayOfWeek),
escape(row.type),
escape(row.food),
escape(row.note),
escape(row.store),
escape(row.orderedBy),
escape(row.amount.toFixed(2).replace('.', ',')),
].join(';'));
}
return Buffer.from(`${lines.join('\r\n')}\r\n`, 'utf-8');
}
/** Sestaví JSON přehled ve schématu UserExport. */
function buildLuncherJson(): Buffer {
return Buffer.from(JSON.stringify({
login: 'tester',
year: 2025,
month: 3,
rowCount: ROWS.length,
totalAmount: ROWS.reduce((sum, row) => sum + row.amount, 0),
rows: ROWS.map(row => ({
date: row.date,
dayOfWeek: row.dayOfWeek,
type: row.type,
...(row.food ? { food: row.food } : {}),
...(row.note ? { note: row.note } : {}),
...(row.store ? { store: row.store } : {}),
...(row.orderedBy ? { orderedBy: row.orderedBy } : {}),
amount: row.amount,
})),
}), 'utf-8');
}
/** Sestaví XLSX se dvěma listy — detailem a souhrnem — jako skutečný export. */
async function buildLuncherXlsx(): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Přehled');
sheet.addRow(HEADERS);
for (const row of ROWS) {
const [y, m, d] = row.date.split('-').map(Number);
sheet.addRow([
new Date(Date.UTC(y, m - 1, d)),
row.dayOfWeek,
row.type,
row.food,
row.note,
row.store,
row.orderedBy,
row.amount,
]);
}
// Souhrnný list má vlastní sloupce a import ho musí ignorovat
const summary = workbook.addWorksheet('Souhrn');
summary.addRow(['Typ', 'Počet záznamů', 'Částka celkem']);
summary.addRow(['Celkem', ROWS.length, ROWS.reduce((sum, row) => sum + row.amount, 0)]);
return Buffer.from(await workbook.xlsx.writeBuffer());
}
beforeEach(() => resetMemoryStorage());
describe('import přehledu z Luncheru', () => {
it.each([
['CSV', 'luncher-tester-2025-03.csv', () => buildLuncherCsv()],
['JSON', 'luncher-tester-2025-03.json', () => buildLuncherJson()],
['XLSX', 'luncher-tester-2025-03.xlsx', () => buildLuncherXlsx()],
])('%s naimportuje všechny řádky do jejich dnů', async (_label, fileName, build) => {
const result = await importLuncherExport(LOGIN, fileName, await build());
expect(result.warnings).toEqual([]);
expect(result.rowCount).toBe(3);
expect(result.imported).toBe(3);
expect(result.skipped).toBe(0);
expect(result.days).toEqual(['2025-03-03', '2025-03-04', '2025-03-05']);
// 159 + 215,50 + 249 Kč v haléřích
expect(result.totalPrice).toBe(62350);
});
it('založí importované řádky jako oběd, pokud se neuvede jinak', async () => {
await importLuncherExport(LOGIN, 'export.json', buildLuncherJson());
const day = await getDay(LOGIN, '2025-03-03');
expect(day.entries).toHaveLength(1);
expect(day.entries[0].mealType).toBe(MealType.OBED);
});
it('respektuje zvolený typ jídla', async () => {
await importLuncherExport(LOGIN, 'export.json', buildLuncherJson(), MealType.VECERE);
const day = await getDay(LOGIN, '2025-03-03');
expect(day.entries[0].mealType).toBe(MealType.VECERE);
});
it('mapuje jídlo, zdroj i cenu na záznam', async () => {
await importLuncherExport(LOGIN, 'export.json', buildLuncherJson());
const [entry] = (await getDay(LOGIN, '2025-03-03')).entries;
expect(entry.name).toBe('Svíčková na smetaně');
expect(entry.source).toBe('Sladovnická');
expect(entry.price).toBe(15900);
expect(entry.importSource).toBe('luncher');
});
it('u objednávky bere jako zdroj obchod a objednávajícího přidá do poznámky', async () => {
await importLuncherExport(LOGIN, 'export.json', buildLuncherJson());
const [entry] = (await getDay(LOGIN, '2025-03-05')).entries;
expect(entry.name).toBe('Kuřecí burger');
expect(entry.source).toBe('Bolt Food');
expect(entry.note).toBe('objednal(a): novak');
});
it('u volby bez jídla i poznámky použije jako název podnik', async () => {
const json = Buffer.from(JSON.stringify({
rows: [{ date: '2025-03-06', dayOfWeek: 'čtvrtek', type: 'TechTower', amount: 145 }],
}), 'utf-8');
await importLuncherExport(LOGIN, 'export.json', json);
const [entry] = (await getDay(LOGIN, '2025-03-06')).entries;
expect(entry.name).toBe('TechTower');
expect(entry.source).toBe('TechTower');
});
it('u "Budu objednávat" bez jídla vezme jako název poznámku', async () => {
// Skutečný tvar řádku z exportu: jídlo prázdné, obsah stojí v poznámce
const json = Buffer.from(JSON.stringify({
rows: [{
date: '2025-03-06', dayOfWeek: 'čtvrtek',
type: 'Budu objednávat', note: 'Chefie - Těstovinový salát',
}],
}), 'utf-8');
await importLuncherExport(LOGIN, 'export.json', json);
const [entry] = (await getDay(LOGIN, '2025-03-06')).entries;
expect(entry.name).toBe('Chefie - Těstovinový salát');
// Poznámka se stala názvem, nemá se opakovat
expect(entry.note).toBeUndefined();
// "Budu objednávat" není podnik, jako zdroj nedává smysl
expect(entry.source).toBeUndefined();
});
it('u "Rozhoduji se" vezme jako název poznámku a nenastaví zdroj', async () => {
const json = Buffer.from(JSON.stringify({
rows: [{ date: '2025-03-07', dayOfWeek: 'pátek', type: 'Rozhoduji se', note: 'asi pudu tower' }],
}), 'utf-8');
await importLuncherExport(LOGIN, 'export.json', json);
const [entry] = (await getDay(LOGIN, '2025-03-07')).entries;
expect(entry.name).toBe('asi pudu tower');
expect(entry.source).toBeUndefined();
});
it('když je vyplněné jídlo i poznámka, poznámka zůstane poznámkou', async () => {
const json = Buffer.from(JSON.stringify({
rows: [{
date: '2025-03-08', dayOfWeek: 'sobota', type: 'Budu objednávat',
food: 'Chicken tikka massala', note: 'EVEREST',
store: 'Everest', orderedBy: 'Anděl Ondřej', amount: 215.54,
}],
}), 'utf-8');
await importLuncherExport(LOGIN, 'export.json', json);
const [entry] = (await getDay(LOGIN, '2025-03-08')).entries;
expect(entry.name).toBe('Chicken tikka massala');
expect(entry.note).toBe('EVEREST | objednal(a): Anděl Ondřej');
expect(entry.source).toBe('Everest');
expect(entry.price).toBe(21554);
});
it('řádek bez částky uloží bez ceny', async () => {
const json = Buffer.from(JSON.stringify({
rows: [{ date: '2025-03-06', dayOfWeek: 'čtvrtek', type: 'SPŠE', food: 'Guláš' }],
}), 'utf-8');
await importLuncherExport(LOGIN, 'export.json', json);
const [entry] = (await getDay(LOGIN, '2025-03-06')).entries;
expect(entry.price).toBeUndefined();
expect((await getDay(LOGIN, '2025-03-06')).totalPrice).toBe(0);
});
it('opakovaný import stejného měsíce data nezduplikuje', async () => {
await importLuncherExport(LOGIN, 'export.json', buildLuncherJson());
const second = await importLuncherExport(LOGIN, 'export.json', buildLuncherJson());
expect(second.imported).toBe(0);
expect(second.skipped).toBe(3);
expect((await getDay(LOGIN, '2025-03-03')).entries).toHaveLength(1);
});
it('naimportuje i dva shodné záznamy v jednom dni', async () => {
const row = { date: '2025-03-07', dayOfWeek: 'pátek', type: 'Objednávka', food: 'Kebab', amount: 120 };
const json = Buffer.from(JSON.stringify({ rows: [row, row] }), 'utf-8');
const first = await importLuncherExport(LOGIN, 'export.json', json);
expect(first.imported).toBe(2);
// Ani při opakování se nesmí jeden z nich považovat za duplicitu toho druhého
const second = await importLuncherExport(LOGIN, 'export.json', json);
expect(second.imported).toBe(0);
expect(second.skipped).toBe(2);
expect((await getDay(LOGIN, '2025-03-07')).entries).toHaveLength(2);
});
it('dryRun vrátí náhled, ale nic neuloží', async () => {
const result = await importLuncherExport(LOGIN, 'export.json', buildLuncherJson(), undefined, true);
expect(result.dryRun).toBe(true);
expect(result.imported).toBe(3);
expect(result.entries).toHaveLength(3);
expect((await getDay(LOGIN, '2025-03-03')).entries).toHaveLength(0);
});
it('poradí si s poznámkou obsahující oddělovač a uvozovky', async () => {
const csv = Buffer.from(
`"Datum";"Typ";"Vybrané jídlo";"Poznámka";"Částka"\r\n`
+ `"03.03.2025";"Sladovnická";"Svíčková";"bez knedlíku; navíc ""extra"" omáčka";"159,00"\r\n`,
'utf-8');
await importLuncherExport(LOGIN, 'export.csv', csv);
const [entry] = (await getDay(LOGIN, '2025-03-03')).entries;
expect(entry.note).toBe('bez knedlíku; navíc "extra" omáčka');
});
it('upozorní na řádek s nečitelným datem a ostatní naimportuje', async () => {
const json = Buffer.from(JSON.stringify({
rows: [
{ date: 'nesmysl', type: 'Sladovnická', food: 'Svíčková' },
{ date: '2025-03-03', type: 'Sladovnická', food: 'Guláš' },
],
}), 'utf-8');
const result = await importLuncherExport(LOGIN, 'export.json', json);
expect(result.imported).toBe(1);
expect(result.warnings).toHaveLength(1);
});
it('odmítne nepodporovaný formát souboru', async () => {
await expect(importLuncherExport(LOGIN, 'export.pdf', Buffer.from('cokoliv')))
.rejects.toThrow(/Nepodporovaný formát/);
});
it('odmítne soubor bez očekávaných sloupců', async () => {
const csv = Buffer.from('"Sloupec A";"Sloupec B"\r\n"1";"2"\r\n', 'utf-8');
await expect(importLuncherExport(LOGIN, 'export.csv', csv))
.rejects.toThrow(/nepodařilo najít očekávané sloupce/);
});
});
+176
View File
@@ -0,0 +1,176 @@
import { addMeal, deleteMeal, getDay, getRange, updateMeal } from "../meals";
import { getFoodStats } from "../statsService";
import { resetMemoryStorage } from "../storage/memory";
import { MealType } from "../../../types/gen/types.gen";
const LOGIN = 'tester';
/** Zkratka pro založení jídla s rozumnými výchozími hodnotami. */
function meal(overrides: Record<string, unknown> = {}) {
return {
date: '2025-03-03',
mealType: MealType.OBED,
name: 'Svíčková',
price: 15900,
...overrides,
};
}
beforeEach(() => resetMemoryStorage());
describe('evidence jídel', () => {
it('přidá jídlo a spočítá součty dne', async () => {
await addMeal(LOGIN, meal({ name: 'Rohlík', mealType: MealType.SNIDANE, price: 500, calories: 130 }));
const day = await addMeal(LOGIN, meal({ price: 15900, calories: 850 }));
expect(day.entries).toHaveLength(2);
expect(day.totalPrice).toBe(16400);
expect(day.totalCalories).toBe(980);
});
it('řadí jídla dne dle chodu, ne dle pořadí zadání', async () => {
await addMeal(LOGIN, meal({ name: 'Večeře', mealType: MealType.VECERE }));
await addMeal(LOGIN, meal({ name: 'Snídaně', mealType: MealType.SNIDANE }));
const day = await addMeal(LOGIN, meal({ name: 'Oběd', mealType: MealType.OBED }));
expect(day.entries.map(entry => entry.name)).toEqual(['Snídaně', 'Oběd', 'Večeře']);
});
it('do součtu ceny nezapočítává jídla bez ceny', async () => {
await addMeal(LOGIN, meal({ price: 15900 }));
const day = await addMeal(LOGIN, meal({ name: 'Jablko', price: undefined }));
expect(day.entries).toHaveLength(2);
expect(day.totalPrice).toBe(15900);
});
it('upraví existující jídlo', async () => {
const created = await addMeal(LOGIN, meal());
const id = created.entries[0].id;
const day = await updateMeal(LOGIN, id, meal({ name: 'Guláš', price: 12000 }));
expect(day.entries).toHaveLength(1);
expect(day.entries[0].name).toBe('Guláš');
expect(day.entries[0].price).toBe(12000);
expect(day.entries[0].updatedAt).toBeDefined();
});
it('umí u jídla vymazat cenu i poznámku', async () => {
const created = await addMeal(LOGIN, meal({ price: 15900, note: 'dobré' }));
const day = await updateMeal(LOGIN, created.entries[0].id, meal({ price: undefined, note: undefined }));
expect(day.entries[0].price).toBeUndefined();
expect(day.entries[0].note).toBeUndefined();
expect(day.totalPrice).toBe(0);
});
it('při změně data přesune jídlo do jiného dne', async () => {
const created = await addMeal(LOGIN, meal());
const day = await updateMeal(LOGIN, created.entries[0].id, meal({ date: '2025-03-10' }));
expect(day.date).toBe('2025-03-10');
expect(day.entries).toHaveLength(1);
expect((await getDay(LOGIN, '2025-03-03')).entries).toHaveLength(0);
});
it('smaže jídlo', async () => {
const created = await addMeal(LOGIN, meal());
const day = await deleteMeal(LOGIN, created.entries[0].id, '2025-03-03');
expect(day.entries).toHaveLength(0);
expect(day.totalPrice).toBe(0);
});
it('hlásí chybu u neexistujícího záznamu', async () => {
await expect(deleteMeal(LOGIN, 'neexistuje', '2025-03-03')).rejects.toThrow(/nebyl nalezen/);
await expect(updateMeal(LOGIN, 'neexistuje', meal())).rejects.toThrow(/nebyl nalezen/);
});
it('odmítne jídlo bez názvu nebo s neplatným datem', async () => {
await expect(addMeal(LOGIN, meal({ name: ' ' }))).rejects.toThrow(/název jídla/);
await expect(addMeal(LOGIN, meal({ date: '3.3.2025' }))).rejects.toThrow(/platné datum/);
await expect(addMeal(LOGIN, meal({ date: '2025-02-31' }))).rejects.toThrow(/platné datum/);
await expect(addMeal(LOGIN, meal({ mealType: 'BRUNCH' }))).rejects.toThrow(/typ jídla/);
await expect(addMeal(LOGIN, meal({ price: -5 }))).rejects.toThrow(/záporné/);
});
it('data jednoho uživatele nevidí druhý', async () => {
await addMeal(LOGIN, meal());
expect((await getDay('nekdo-jiny', '2025-03-03')).entries).toHaveLength(0);
});
it('vrátí jen dny s alespoň jedním záznamem', async () => {
await addMeal(LOGIN, meal({ date: '2025-03-03' }));
await addMeal(LOGIN, meal({ date: '2025-03-07' }));
const days = await getRange(LOGIN, '2025-03-01', '2025-03-31');
expect(days.map(day => day.date)).toEqual(['2025-03-03', '2025-03-07']);
});
});
describe('statistiky', () => {
beforeEach(async () => {
await addMeal(LOGIN, meal({ date: '2025-03-03', name: 'Svíčková', source: 'Sladovnická', price: 15900, calories: 850 }));
await addMeal(LOGIN, meal({ date: '2025-03-03', name: 'Rohlík', mealType: MealType.SNIDANE, source: 'Doma', price: 500 }));
await addMeal(LOGIN, meal({ date: '2025-04-10', name: 'Guláš', source: 'Sladovnická', price: 14000, calories: 700 }));
await addMeal(LOGIN, meal({ date: '2024-12-24', name: 'Kapr', source: 'Doma', price: 30000 }));
});
it('spočítá celkovou útratu a kalorie za období', async () => {
const stats = await getFoodStats(LOGIN, '2025-01-01', '2025-12-31');
expect(stats.totalPrice).toBe(30400);
expect(stats.totalCalories).toBe(1550);
expect(stats.entryCount).toBe(3);
expect(stats.dayCount).toBe(2);
});
it('rozpadne útratu po dnech, měsících a letech', async () => {
const stats = await getFoodStats(LOGIN, '2024-01-01', '2025-12-31');
expect(stats.byDay.map(total => total.period)).toEqual(['2024-12-24', '2025-03-03', '2025-04-10']);
expect(stats.byMonth).toEqual([
{ period: '2024-12', price: 30000, calories: 0, count: 1 },
{ period: '2025-03', price: 16400, calories: 850, count: 2 },
{ period: '2025-04', price: 14000, calories: 700, count: 1 },
]);
expect(stats.byYear).toEqual([
{ period: '2024', price: 30000, calories: 0, count: 1 },
{ period: '2025', price: 30400, calories: 1550, count: 3 },
]);
});
it('rozpadne útratu dle typu jídla a zdroje, sestupně', async () => {
const stats = await getFoodStats(LOGIN, '2025-01-01', '2025-12-31');
expect(stats.byMealType).toEqual([
{ key: MealType.OBED, price: 29900, calories: 1550, count: 2 },
{ key: MealType.SNIDANE, price: 500, calories: 0, count: 1 },
]);
expect(stats.bySource[0]).toEqual({ key: 'Sladovnická', price: 29900, calories: 1550, count: 2 });
});
it('období bez záznamů vrátí nuly', async () => {
const stats = await getFoodStats(LOGIN, '2023-01-01', '2023-12-31');
expect(stats.totalPrice).toBe(0);
expect(stats.entryCount).toBe(0);
expect(stats.byDay).toEqual([]);
});
it('jídla bez zdroje spadnou do skupiny Neuvedeno', async () => {
await addMeal(LOGIN, meal({ date: '2025-05-01', name: 'Něco', source: undefined, price: 100 }));
const stats = await getFoodStats(LOGIN, '2025-05-01', '2025-05-31');
expect(stats.bySource).toEqual([{ key: 'Neuvedeno', price: 100, calories: 0, count: 1 }]);
});
});
+4
View File
@@ -0,0 +1,4 @@
// Testy běží vždy nad in-memory úložištěm a s pevným klíčem, ať nezávisí na .env
process.env.STORAGE = 'memory';
process.env.JWT_SECRET = 'testovaci-klic-ktery-ma-aspon-32-znaku-delky';
process.env.NODE_ENV = 'test';
+75
View File
@@ -0,0 +1,75 @@
import { Request } from 'express';
/** Regulární výraz pro datum ve formátu YYYY-MM-DD. */
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
/** Chyba způsobená neplatným vstupem uživatele — mapuje se na HTTP 400. */
export class BadRequestError extends Error { }
/** Chyba hledaného, ale neexistujícího záznamu — mapuje se na HTTP 404. */
export class NotFoundError extends Error { }
/** Vrátí datum v ISO formátu (YYYY-MM-DD) v lokální časové zóně. */
export function formatDate(date: Date): string {
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = String(date.getFullYear());
return `${year}-${month}-${day}`;
}
/** Vrátí dnešní datum s vynulovaným časem. */
export function getToday(): Date {
const today = new Date();
today.setHours(0, 0, 0, 0);
return today;
}
/**
* Ověří, že řetězec je datum ve formátu YYYY-MM-DD a odpovídá reálnému dni.
* Zachytí i přetečení typu 2024-02-31, které by `new Date` tiše posunul na březen.
*/
export function isValidIsoDate(value: unknown): value is string {
if (typeof value !== 'string' || !ISO_DATE_PATTERN.test(value)) {
return false;
}
const [year, month, day] = value.split('-').map(Number);
const date = new Date(year, month - 1, day);
return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day;
}
/**
* Vrátí datum z query parametru, nebo vyhodí {@link BadRequestError}.
*
* @param value hodnota query parametru
* @param name název parametru pro chybovou hlášku
*/
export function requireIsoDate(value: unknown, name: string): string {
if (!isValidIsoDate(value)) {
throw new BadRequestError(`Parametr '${name}' není platné datum ve formátu YYYY-MM-DD`);
}
return value;
}
/** Vrátí JWT token z hlavičky Authorization, pokud tam je. */
export function parseToken(req: Request): string | undefined {
return req.headers?.authorization?.split(' ')[1];
}
/**
* Vrátí všechna data v rozsahu (včetně obou krajů) jako ISO řetězce.
*
* @param from první den rozsahu (YYYY-MM-DD)
* @param to poslední den rozsahu (YYYY-MM-DD)
*/
export function listDatesInRange(from: string, to: string): string[] {
const dates: string[] = [];
const [fromYear, fromMonth, fromDay] = from.split('-').map(Number);
const [toYear, toMonth, toDay] = to.split('-').map(Number);
const cursor = new Date(fromYear, fromMonth - 1, fromDay);
const end = new Date(toYear, toMonth - 1, toDay);
while (cursor <= end) {
dates.push(formatDate(cursor));
cursor.setDate(cursor.getDate() + 1);
}
return dates;
}
+161
View File
@@ -0,0 +1,161 @@
import { randomUUID } from 'crypto';
import { ActivityEntry, WorkoutTemplate, WorkoutTemplateItem } from "../../types/gen/types.gen";
import getStorage from "./storage";
import { addActivities, isActivityUnit } from "./activities";
import { BadRequestError, NotFoundError, isValidIsoDate } from "./utils";
const storage = getStorage();
/** Maximální počet položek jedné šablony — pojistka proti nesmyslnému vstupu. */
const MAX_ITEMS = 50;
/** Klíč úložiště se šablonami tréninků jednoho uživatele. */
function getKey(login: string): string {
return `workouts:${login}`;
}
/** Spočítá odhad spálených kcal za celou šablonu. */
function estimateCalories(items: WorkoutTemplateItem[]): number {
return items.reduce((sum, item) => sum + Math.round(item.quantity / 100 * (item.caloriesPer100Units ?? 0)), 0);
}
/** Doplní šabloně dopočítaný odhad kalorií. */
function withEstimate(template: Omit<WorkoutTemplate, 'estimatedCalories'>): WorkoutTemplate {
return { ...template, estimatedCalories: estimateCalories(template.items) };
}
/** Uložená podoba šablony — odhad kalorií se dopočítává, neukládá se. */
type StoredTemplate = Omit<WorkoutTemplate, 'estimatedCalories'>;
/**
* Vrátí šablony tréninků uživatele seřazené podle názvu.
*
* @param login přihlašovací jméno uživatele
*/
export async function getWorkoutTemplates(login: string): Promise<WorkoutTemplate[]> {
const templates = await storage.getData<StoredTemplate[]>(getKey(login)) ?? [];
return templates
.map(withEstimate)
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
}
/** Ověří a znormalizuje jednu položku šablony. */
function normalizeItem(raw: unknown, index: number): WorkoutTemplateItem {
const value = (raw ?? {}) as Record<string, unknown>;
const name = typeof value.name === 'string' ? value.name.trim() : '';
if (!name.length) {
throw new BadRequestError(`Položka ${index + 1} nemá název`);
}
if (!isActivityUnit(value.unit)) {
throw new BadRequestError(`Položka '${name}' nemá platnou jednotku`);
}
if (typeof value.quantity !== 'number' || !Number.isFinite(value.quantity) || value.quantity < 0) {
throw new BadRequestError(`Položka '${name}' nemá platné množství`);
}
const rate = value.caloriesPer100Units;
return {
name,
unit: value.unit,
quantity: Math.round(value.quantity),
...(typeof rate === 'number' && Number.isFinite(rate) && rate >= 0
? { caloriesPer100Units: Math.round(rate) }
: {}),
};
}
/**
* Založí novou šablonu, nebo přepíše existující podle předaného id.
*
* @param login přihlašovací jméno uživatele
* @param id identifikátor upravované šablony, nebo nic pro založení nové
* @param name název šablony
* @param items položky šablony
*/
export async function saveWorkoutTemplate(
login: string,
id: unknown,
name: unknown,
items: unknown,
): Promise<WorkoutTemplate[]> {
if (typeof name !== 'string' || !name.trim().length) {
throw new BadRequestError("Nebyl předán název šablony");
}
if (!Array.isArray(items) || !items.length) {
throw new BadRequestError("Šablona musí mít alespoň jednu položku");
}
if (items.length > MAX_ITEMS) {
throw new BadRequestError(`Šablona může mít nejvýše ${MAX_ITEMS} položek`);
}
const normalized: StoredTemplate = {
id: typeof id === 'string' && id.length ? id : randomUUID(),
name: name.trim(),
items: items.map(normalizeItem),
};
await storage.updateData<StoredTemplate[]>(getKey(login), current => {
const others = (current ?? []).filter(template => template.id !== normalized.id);
return [...others, normalized];
});
return getWorkoutTemplates(login);
}
/**
* Smaže šablonu tréninku.
*
* @param login přihlašovací jméno uživatele
* @param id identifikátor šablony
*/
export async function deleteWorkoutTemplate(login: string, id: unknown): Promise<WorkoutTemplate[]> {
if (typeof id !== 'string' || !id.length) {
throw new BadRequestError("Nebyl předán identifikátor šablony");
}
let found = false;
await storage.updateData<StoredTemplate[]>(getKey(login), current => {
found = (current ?? []).some(template => template.id === id);
return (current ?? []).filter(template => template.id !== id);
});
if (!found) {
throw new NotFoundError("Šablona nebyla nalezena");
}
return getWorkoutTemplates(login);
}
/**
* Založí do zvoleného dne všechny položky šablony.
*
* Vzniknou z nich běžné aktivity, takže je pak jde jednotlivě upravit nebo smazat,
* aniž by to ovlivnilo šablonu. `templateId` u nich zůstane jen jako informace,
* odkud přišly.
*
* @param login přihlašovací jméno uživatele
* @param id identifikátor šablony
* @param date den, do kterého se položky založí
*/
export async function applyWorkoutTemplate(login: string, id: unknown, date: unknown): Promise<string> {
if (typeof id !== 'string' || !id.length) {
throw new BadRequestError("Nebyl předán identifikátor šablony");
}
if (!isValidIsoDate(date)) {
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
}
const template = (await getWorkoutTemplates(login)).find(item => item.id === id);
if (!template) {
throw new NotFoundError("Šablona nebyla nalezena");
}
const now = new Date().toISOString();
const entries: ActivityEntry[] = template.items.map(item => ({
id: randomUUID(),
date,
name: item.name,
unit: item.unit,
quantity: item.quantity,
...(item.caloriesPer100Units != null ? { caloriesPer100Units: item.caloriesPer100Units } : {}),
calories: Math.round(item.quantity / 100 * (item.caloriesPer100Units ?? 0)),
templateId: template.id,
createdAt: now,
}));
await addActivities(login, date, entries);
return date;
}
+20
View File
@@ -0,0 +1,20 @@
{
"include": [
"src/**/*",
"../types/**/*"
],
"exclude": [
"src/tests/**/*"
],
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "node16",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "../",
"strict": true
}
}
+4816
View File
File diff suppressed because it is too large Load Diff
+75
View File
@@ -0,0 +1,75 @@
openapi: 3.0.4
info:
title: Food Tracer API
version: 1.0.0
servers:
- url: /api
paths:
# Obecné (/api)
/login:
$ref: "./paths/login.yml"
/config:
$ref: "./paths/config/config.yml"
# Jídla (/api/meals)
/meals/day:
$ref: "./paths/meals/day.yml"
/meals/range:
$ref: "./paths/meals/range.yml"
/meals/add:
$ref: "./paths/meals/add.yml"
/meals/update:
$ref: "./paths/meals/update.yml"
/meals/delete:
$ref: "./paths/meals/delete.yml"
# Statistiky (/api/stats)
/stats/summary:
$ref: "./paths/stats/summary.yml"
# Přehled dne (/api/day)
/day:
$ref: "./paths/day/overview.yml"
# Pohyb (/api/activities)
/activities/add:
$ref: "./paths/activities/add.yml"
/activities/update:
$ref: "./paths/activities/update.yml"
/activities/delete:
$ref: "./paths/activities/delete.yml"
# Šablony tréninků (/api/workouts)
/workouts:
$ref: "./paths/workouts/list.yml"
/workouts/save:
$ref: "./paths/workouts/save.yml"
/workouts/delete:
$ref: "./paths/workouts/delete.yml"
/workouts/apply:
$ref: "./paths/workouts/apply.yml"
# Kalorie (/api/calories)
/calories/search:
$ref: "./paths/calories/search.yml"
# Nastavení (/api/settings)
/settings:
$ref: "./paths/settings/settings.yml"
/settings/basal:
$ref: "./paths/settings/basal.yml"
# Importy (/api/import)
/import/luncher:
$ref: "./paths/import/luncher.yml"
components:
schemas:
$ref: "./schemas/_index.yml"
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
security:
- bearerAuth: []
+1
View File
@@ -0,0 +1 @@
export * from './gen';
+14
View File
@@ -0,0 +1,14 @@
import { defaultPlugins } from '@hey-api/openapi-ts';
export default {
input: 'api.yml',
output: 'gen',
plugins: [
...defaultPlugins,
'@hey-api/client-fetch',
{
enums: 'javascript',
name: '@hey-api/typescript',
},
],
};
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@food-tracer/types",
"version": "1.0.0",
"license": "MIT",
"private": true,
"scripts": {
"openapi-ts": "openapi-ts"
},
"devDependencies": {
"@hey-api/client-fetch": "^0.8.2",
"@hey-api/openapi-ts": "^0.64.7",
"typescript": "^5.9.3"
}
}
+20
View File
@@ -0,0 +1,20 @@
post:
operationId: addActivity
summary: Přidá pohybovou aktivitu do zvoleného dne.
requestBody:
required: true
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/ActivityInput"
responses:
"200":
description: Aktualizovaný přehled dne
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/DayOverview"
"400":
description: Neplatná data
"401":
description: Neautentizovaný uživatel
+29
View File
@@ -0,0 +1,29 @@
post:
operationId: deleteActivity
summary: Smaže aktivitu.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [id, date]
properties:
id:
type: string
date:
type: string
format: date
responses:
"200":
description: Aktualizovaný přehled dne
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/DayOverview"
"400":
description: Neplatná data
"401":
description: Neautentizovaný uživatel
"404":
description: Záznam neexistuje
+28
View File
@@ -0,0 +1,28 @@
post:
operationId: updateActivity
summary: Upraví existující aktivitu.
requestBody:
required: true
content:
application/json:
schema:
allOf:
- type: object
required: [id]
properties:
id:
type: string
- $ref: "../../schemas/_index.yml#/ActivityInput"
responses:
"200":
description: Aktualizovaný přehled dne
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/DayOverview"
"400":
description: Neplatná data
"401":
description: Neautentizovaný uživatel
"404":
description: Záznam neexistuje
+23
View File
@@ -0,0 +1,23 @@
get:
operationId: searchCalories
summary: |
Najde návrhy energetické hodnoty pro název jídla — z vlastní knihovny
dřívějších zadání a od externího poskytovatele.
parameters:
- in: query
name: q
required: true
schema:
type: string
description: Název jídla, ke kterému se hledá energetická hodnota
responses:
"200":
description: Nalezené návrhy
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/CalorieSearchResult"
"400":
description: Nebyl předán hledaný název
"401":
description: Neautentizovaný uživatel
+11
View File
@@ -0,0 +1,11 @@
get:
operationId: getConfig
summary: Vrátí veřejnou runtime konfiguraci klienta.
security: []
responses:
"200":
description: Runtime konfigurace
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/Config"
+22
View File
@@ -0,0 +1,22 @@
get:
operationId: getDayOverview
summary: Vrátí kompletní přehled dne — jídlo, pohyb a energetickou bilanci.
parameters:
- in: query
name: date
required: false
schema:
type: string
format: date
description: Datum ve formátu YYYY-MM-DD. Výchozí je dnešek.
responses:
"200":
description: Přehled dne
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/DayOverview"
"400":
description: Neplatné datum
"401":
description: Neautentizovaný uživatel
+22
View File
@@ -0,0 +1,22 @@
post:
operationId: importLuncher
summary: |
Naimportuje měsíční přehled vyexportovaný z Luncheru (XLSX, CSV nebo JSON).
Řádky se založí do dnů, ke kterým patří, ve výchozím stavu jako oběd.
requestBody:
required: true
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/ImportRequest"
responses:
"200":
description: Výsledek importu, nebo jeho náhled při dryRun
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/ImportResult"
"400":
description: Nepodporovaný formát nebo poškozený soubor
"401":
description: Neautentizovaný uživatel
+22
View File
@@ -0,0 +1,22 @@
post:
operationId: login
summary: Přihlásí uživatele a vrátí JWT token.
security: []
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
login:
type: string
responses:
"200":
description: JWT token přihlášeného uživatele
content:
application/json:
schema:
type: string
"500":
description: Nebyl předán login
+20
View File
@@ -0,0 +1,20 @@
post:
operationId: addMeal
summary: Přidá jídlo do zvoleného dne.
requestBody:
required: true
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/MealInput"
responses:
"200":
description: Aktualizovaný přehled dne
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/DayRecord"
"400":
description: Neplatná data
"401":
description: Neautentizovaný uživatel
+22
View File
@@ -0,0 +1,22 @@
get:
operationId: getDay
summary: Vrátí jídla přihlášeného uživatele za jeden den.
parameters:
- in: query
name: date
required: false
schema:
type: string
format: date
description: Datum ve formátu YYYY-MM-DD. Výchozí je dnešek.
responses:
"200":
description: Přehled jídel daného dne
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/DayRecord"
"400":
description: Neplatné datum
"401":
description: Neautentizovaný uživatel
+31
View File
@@ -0,0 +1,31 @@
post:
operationId: deleteMeal
summary: Smaže záznam o jídle.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [id, date]
properties:
id:
description: Identifikátor mazaného záznamu
type: string
date:
description: Datum záznamu (YYYY-MM-DD)
type: string
format: date
responses:
"200":
description: Aktualizovaný přehled dne
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/DayRecord"
"400":
description: Neplatná data
"401":
description: Neautentizovaný uživatel
"404":
description: Záznam neexistuje
+31
View File
@@ -0,0 +1,31 @@
get:
operationId: getRange
summary: Vrátí jídla přihlášeného uživatele za rozsah dnů.
parameters:
- in: query
name: from
required: true
schema:
type: string
format: date
description: První den rozsahu (včetně), YYYY-MM-DD
- in: query
name: to
required: true
schema:
type: string
format: date
description: Poslední den rozsahu (včetně), YYYY-MM-DD
responses:
"200":
description: Dny s alespoň jedním záznamem, vzestupně dle data
content:
application/json:
schema:
type: array
items:
$ref: "../../schemas/_index.yml#/DayRecord"
"400":
description: Neplatný rozsah
"401":
description: Neautentizovaný uživatel
+29
View File
@@ -0,0 +1,29 @@
post:
operationId: updateMeal
summary: Upraví existující záznam o jídle.
requestBody:
required: true
content:
application/json:
schema:
allOf:
- type: object
required: [id]
properties:
id:
description: Identifikátor upravovaného záznamu
type: string
- $ref: "../../schemas/_index.yml#/MealInput"
responses:
"200":
description: Aktualizovaný přehled dne
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/DayRecord"
"400":
description: Neplatná data
"401":
description: Neautentizovaný uživatel
"404":
description: Záznam neexistuje
+25
View File
@@ -0,0 +1,25 @@
post:
operationId: saveBasalCalories
summary: Uloží klidový výdej (bazální metabolismus) přihlášeného uživatele.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
basalCalories:
description: Klidový výdej v kcal za den. Null nebo 0 ho odstraní.
type: integer
nullable: true
responses:
"200":
description: Aktualizované nastavení
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/UserSettings"
"400":
description: Neplatná data
"401":
description: Neautentizovaný uživatel
+41
View File
@@ -0,0 +1,41 @@
get:
operationId: getSettings
summary: Vrátí nastavení přihlášeného uživatele.
responses:
"200":
description: Nastavení uživatele
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/UserSettings"
"401":
description: Neautentizovaný uživatel
post:
operationId: saveSourceRate
summary: Uloží nebo smaže cenu za 100 g u jednoho zdroje.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [source]
properties:
source:
description: Název zdroje (podniku)
type: string
pricePer100g:
description: Cena za 100 g v haléřích. Null nebo 0 sazbu odstraní.
type: integer
nullable: true
responses:
"200":
description: Aktualizované nastavení
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/UserSettings"
"400":
description: Neplatná data
"401":
description: Neautentizovaný uživatel
+29
View File
@@ -0,0 +1,29 @@
get:
operationId: getFoodStats
summary: Vrátí statistiky útraty a jídel za zvolené období.
parameters:
- in: query
name: from
required: true
schema:
type: string
format: date
description: První den období (včetně), YYYY-MM-DD
- in: query
name: to
required: true
schema:
type: string
format: date
description: Poslední den období (včetně), YYYY-MM-DD
responses:
"200":
description: Statistiky za období
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/FoodStats"
"400":
description: Neplatný rozsah
"401":
description: Neautentizovaný uživatel
+31
View File
@@ -0,0 +1,31 @@
post:
operationId: applyWorkoutTemplate
summary: Založí do zvoleného dne všechny položky šablony tréninku.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [id, date]
properties:
id:
description: Identifikátor šablony
type: string
date:
description: Den, do kterého se položky založí
type: string
format: date
responses:
"200":
description: Aktualizovaný přehled dne
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/DayOverview"
"400":
description: Neplatná data
"401":
description: Neautentizovaný uživatel
"404":
description: Šablona neexistuje
+26
View File
@@ -0,0 +1,26 @@
post:
operationId: deleteWorkoutTemplate
summary: Smaže šablonu tréninku.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [id]
properties:
id:
type: string
responses:
"200":
description: Aktualizovaný seznam šablon
content:
application/json:
schema:
type: array
items:
$ref: "../../schemas/_index.yml#/WorkoutTemplate"
"400":
description: Neplatná data
"401":
description: Neautentizovaný uživatel
+14
View File
@@ -0,0 +1,14 @@
get:
operationId: getWorkoutTemplates
summary: Vrátí šablony tréninků přihlášeného uživatele.
responses:
"200":
description: Šablony seřazené podle názvu
content:
application/json:
schema:
type: array
items:
$ref: "../../schemas/_index.yml#/WorkoutTemplate"
"401":
description: Neautentizovaný uživatel
+33
View File
@@ -0,0 +1,33 @@
post:
operationId: saveWorkoutTemplate
summary: Založí novou šablonu tréninku, nebo upraví existující.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name, items]
properties:
id:
description: Identifikátor upravované šablony. Bez něj se založí nová.
type: string
name:
type: string
items:
type: array
items:
$ref: "../../schemas/_index.yml#/WorkoutTemplateItem"
responses:
"200":
description: Aktualizovaný seznam šablon
content:
application/json:
schema:
type: array
items:
$ref: "../../schemas/_index.yml#/WorkoutTemplate"
"400":
description: Neplatná data
"401":
description: Neautentizovaný uživatel
+664
View File
@@ -0,0 +1,664 @@
MealType:
description: |
Typ jídla (chod v rámci dne). Importy z Luncheru se zakládají jako OBED,
pokud se při importu neurčí jinak.
type: string
enum:
- SNIDANE
- DOPOLEDNI_SVACINA
- OBED
- ODPOLEDNI_SVACINA
- VECERE
- JINE
MealEntry:
description: Jeden snědený pokrm v konkrétní den
type: object
additionalProperties: false
required:
- id
- date
- mealType
- name
- createdAt
properties:
id:
description: Identifikátor záznamu (UUID)
type: string
date:
description: Datum konzumace ve formátu YYYY-MM-DD
type: string
format: date
mealType:
$ref: "#/MealType"
name:
description: Název jídla
type: string
source:
description: Zdroj jídla (podnik, obchod, "doma", ...)
type: string
price:
description: Cena v haléřích. Celé číslo, aby nevznikaly chyby zaokrouhlením.
type: integer
calories:
description: |
Energetická hodnota v kcal. Pokud je vyplněná gramáž i caloriesPer100g,
dopočte se z nich; jinak platí ručně zadaná hodnota.
type: integer
weight:
description: |
Gramáž porce v gramech. U podniků, které účtují podle váhy (TechTower),
se dopočítá z ceny a pricePer100g, ale dá se přepsat.
type: integer
pricePer100g:
description: Cena za 100 g v haléřích. Slouží k dopočtu gramáže z ceny.
type: integer
caloriesPer100g:
description: Energetická hodnota na 100 g v kcal. Slouží k dopočtu kalorií z gramáže.
type: integer
note:
description: Poznámka uživatele k jídlu
type: string
importSource:
description: Odkud byl záznam naimportován (např. "luncher"). U ručně přidaných chybí.
type: string
importKey:
description: |
Klíč pro rozpoznání duplicit při opakovaném importu stejného období.
Odvozuje se z data, typu, názvu jídla a částky importovaného řádku.
type: string
createdAt:
description: Čas vzniku záznamu
type: string
format: date-time
updatedAt:
description: Čas poslední úpravy záznamu
type: string
format: date-time
MealInput:
description: Data pro založení nebo úpravu záznamu o jídle
type: object
additionalProperties: false
required:
- date
- mealType
- name
properties:
date:
description: Datum konzumace ve formátu YYYY-MM-DD
type: string
format: date
mealType:
$ref: "#/MealType"
name:
description: Název jídla
type: string
source:
description: Zdroj jídla (podnik, obchod, "doma", ...)
type: string
price:
description: Cena v haléřích
type: integer
nullable: true
calories:
description: |
Energetická hodnota v kcal. Ignoruje se, pokud jde dopočíst
z gramáže a caloriesPer100g.
type: integer
nullable: true
weight:
description: Gramáž porce v gramech. Nevyplněná se dopočte z ceny a pricePer100g.
type: integer
nullable: true
pricePer100g:
description: Cena za 100 g v haléřích
type: integer
nullable: true
caloriesPer100g:
description: Energetická hodnota na 100 g v kcal
type: integer
nullable: true
note:
description: Poznámka uživatele k jídlu
type: string
nullable: true
DayRecord:
description: Přehled jídel jednoho dne včetně součtů
type: object
additionalProperties: false
required:
- date
- entries
- totalPrice
- totalCalories
properties:
date:
description: Datum ve formátu YYYY-MM-DD
type: string
format: date
entries:
description: Jídla daného dne seřazená dle typu (snídaně → večeře)
type: array
items:
$ref: "#/MealEntry"
totalPrice:
description: Součet cen všech jídel dne v haléřích
type: integer
totalCalories:
description: Součet kalorií jídel dne v kcal (jídla bez kalorií se nezapočítávají)
type: integer
PeriodTotal:
description: Součty za jedno období (den, měsíc nebo rok)
type: object
additionalProperties: false
required:
- period
- price
- calories
- count
properties:
period:
description: Označení období — YYYY-MM-DD (den), YYYY-MM (měsíc) nebo YYYY (rok)
type: string
price:
description: Utracená částka v haléřích
type: integer
calories:
description: Součet kalorií v kcal
type: integer
count:
description: Počet záznamů o jídle
type: integer
GroupTotal:
description: Součty za jednu skupinu (typ jídla nebo zdroj)
type: object
additionalProperties: false
required:
- key
- price
- calories
- count
properties:
key:
description: Klíč skupiny (hodnota MealType, nebo název zdroje)
type: string
price:
description: Utracená částka v haléřích
type: integer
calories:
description: Součet kalorií v kcal
type: integer
count:
description: Počet záznamů o jídle
type: integer
FoodStats:
description: Statistiky útraty a jídel za zvolené období
type: object
additionalProperties: false
required:
- from
- to
- totalPrice
- totalCalories
- entryCount
- dayCount
- byDay
- byMonth
- byYear
- byMealType
- bySource
properties:
from:
description: Začátek období (YYYY-MM-DD, včetně)
type: string
format: date
to:
description: Konec období (YYYY-MM-DD, včetně)
type: string
format: date
totalPrice:
description: Celková útrata za období v haléřích
type: integer
totalCalories:
description: Celkový součet kalorií za období v kcal
type: integer
entryCount:
description: Počet záznamů o jídle v období
type: integer
dayCount:
description: Počet dní, ve kterých je alespoň jeden záznam
type: integer
byDay:
description: Součty po dnech, vzestupně dle data
type: array
items:
$ref: "#/PeriodTotal"
byMonth:
description: Součty po měsících, vzestupně
type: array
items:
$ref: "#/PeriodTotal"
byYear:
description: Součty po letech, vzestupně
type: array
items:
$ref: "#/PeriodTotal"
byMealType:
description: Součty dle typu jídla, sestupně dle útraty
type: array
items:
$ref: "#/GroupTotal"
bySource:
description: Součty dle zdroje jídla, sestupně dle útraty
type: array
items:
$ref: "#/GroupTotal"
ImportRequest:
description: Požadavek na import přehledu z Luncheru
type: object
additionalProperties: false
required:
- fileName
- content
properties:
fileName:
description: Původní název souboru — určuje formát (.xlsx, .csv, .json)
type: string
content:
description: Obsah souboru zakódovaný v Base64
type: string
defaultMealType:
description: |
Typ jídla, pod kterým se importované řádky založí.
Luncher řeší výběr obědů, takže výchozí hodnota je OBED.
allOf:
- $ref: "#/MealType"
dryRun:
description: |
Pokud je true, import se pouze vyhodnotí a vrátí náhled, ale nic se neuloží.
type: boolean
ImportResult:
description: Výsledek (nebo náhled) importu přehledu z Luncheru
type: object
additionalProperties: false
required:
- dryRun
- format
- rowCount
- imported
- skipped
- days
- totalPrice
- entries
- warnings
properties:
dryRun:
description: True, pokud šlo pouze o náhled a data se neuložila
type: boolean
format:
description: Rozpoznaný formát vstupního souboru
type: string
enum: [xlsx, csv, json]
rowCount:
description: Počet řádků nalezených v souboru
type: integer
imported:
description: Počet záznamů, které import založil (u náhledu by založil)
type: integer
skipped:
description: Počet přeskočených řádků, protože už z dřívějšího importu existují
type: integer
days:
description: Dny (YYYY-MM-DD), kterých se import dotkl, vzestupně
type: array
items:
type: string
format: date
totalPrice:
description: Součet cen importovaných záznamů v haléřích
type: integer
entries:
description: Záznamy, které import založil (u náhledu založí)
type: array
items:
$ref: "#/MealEntry"
warnings:
description: Upozornění na řádky, které se nepodařilo zpracovat
type: array
items:
type: string
Config:
description: Veřejná runtime konfigurace pro klienta
type: object
additionalProperties: false
required:
- sentry
properties:
sentry:
type: object
additionalProperties: false
required:
- dsn
- environment
properties:
dsn:
type: string
nullable: true
environment:
type: string
CalorieSuggestion:
description: Návrh energetické hodnoty pro jídlo
type: object
additionalProperties: false
required:
- name
- caloriesPer100g
- origin
properties:
name:
description: Název nalezené potraviny
type: string
caloriesPer100g:
description: Energetická hodnota na 100 g v kcal
type: integer
origin:
description: |
Odkud návrh pochází — "library" je dřívější vlastní zadání uživatele,
ostatní hodnoty označují externího poskytovatele.
type: string
brand:
description: Značka nebo výrobce, pokud je známý
type: string
CalorieSearchResult:
description: Výsledek hledání energetické hodnoty pro název jídla
type: object
additionalProperties: false
required:
- query
- providerAvailable
- suggestions
- externalSearchUrl
properties:
query:
description: Hledaný název
type: string
providerAvailable:
description: |
False, pokud je externí poskytovatel nedostupný. Návrhy z vlastní
knihovny se vrací i tak — hledání kvůli tomu neselže.
type: boolean
providerName:
description: Název externího poskytovatele, který návrhy dodal
type: string
suggestions:
description: Nalezené návrhy, nejdřív z vlastní knihovny
type: array
items:
$ref: "#/CalorieSuggestion"
externalSearchUrl:
description: |
Odkaz na tabulku potravin na KalorickéTabulky.cz. Otevírá se uživateli
v novém panelu — hodnoty se odtud nestahují automaticky. Předvyplnit
hledání nejde, jejich vyhledávání běží v JavaScriptu a parametry v URL
ignoruje, proto klient název jídla kopíruje do schránky.
type: string
SourceRate:
description: Cena za 100 g u jednoho zdroje jídla
type: object
additionalProperties: false
required:
- source
- pricePer100g
properties:
source:
description: Název zdroje (podniku), např. "TechTower"
type: string
pricePer100g:
description: Cena za 100 g v haléřích
type: integer
UserSettings:
description: Uživatelské nastavení aplikace
type: object
additionalProperties: false
required:
- sourceRates
properties:
sourceRates:
description: |
Ceny za 100 g u podniků, které účtují podle váhy. Slouží jako výchozí
hodnota při zadávání jídla; u konkrétního jídla jde sazbu přepsat.
type: array
items:
$ref: "#/SourceRate"
basalCalories:
description: |
Klidový výdej (bazální metabolismus) v kcal za den. Bez něj se bilance dne
počítá jen jako jídlo proti pohybu a nejde o skutečný deficit.
type: integer
ActivityUnit:
description: Jednotka, ve které se aktivita měří
type: string
enum:
- KROKY
- MINUTY
- KM
- OPAKOVANI
ActivityEntry:
description: Jedna pohybová aktivita v konkrétní den
type: object
additionalProperties: false
required:
- id
- date
- name
- unit
- quantity
- createdAt
properties:
id:
description: Identifikátor záznamu (UUID)
type: string
date:
description: Datum aktivity ve formátu YYYY-MM-DD
type: string
format: date
name:
description: Název aktivity (Chůze, Kliky, Běh, ...)
type: string
unit:
$ref: "#/ActivityUnit"
quantity:
description: Množství v dané jednotce — počet kroků, minut, kilometrů nebo opakování
type: integer
caloriesPer100Units:
description: |
Spálené kcal na 100 jednotek. Pro kroky vychází kolem 4 (tj. 400 kcal
za 10 000 kroků), u minut běhu kolem 1000. Sto jednotek se používá kvůli
celým číslům — stejná konvence jako u energie jídla na 100 g.
type: integer
calories:
description: |
Spálené kcal. Pokud je vyplněné caloriesPer100Units, dopočte se z množství;
jinak platí ručně zadaná hodnota.
type: integer
note:
description: Poznámka uživatele k aktivitě
type: string
templateId:
description: Identifikátor šablony, ze které aktivita vznikla
type: string
createdAt:
description: Čas vzniku záznamu
type: string
format: date-time
updatedAt:
description: Čas poslední úpravy záznamu
type: string
format: date-time
ActivityInput:
description: Data pro založení nebo úpravu aktivity
type: object
additionalProperties: false
required:
- date
- name
- unit
- quantity
properties:
date:
type: string
format: date
name:
type: string
unit:
$ref: "#/ActivityUnit"
quantity:
description: Množství v dané jednotce
type: integer
caloriesPer100Units:
description: Spálené kcal na 100 jednotek
type: integer
nullable: true
calories:
description: Spálené kcal. Ignoruje se, pokud jde dopočíst z množství.
type: integer
nullable: true
note:
type: string
nullable: true
ActivityDay:
description: Přehled pohybu jednoho dne včetně součtu
type: object
additionalProperties: false
required:
- date
- entries
- totalCalories
properties:
date:
type: string
format: date
entries:
description: Aktivity daného dne, nejstarší první
type: array
items:
$ref: "#/ActivityEntry"
totalCalories:
description: Součet spálených kcal za den
type: integer
WorkoutTemplateItem:
description: Jedna položka šablony tréninku
type: object
additionalProperties: false
required:
- name
- unit
- quantity
properties:
name:
type: string
unit:
$ref: "#/ActivityUnit"
quantity:
type: integer
caloriesPer100Units:
type: integer
WorkoutTemplate:
description: |
Předpřipravený trénink. Použitím se jeho položky založí do zvoleného dne,
takže opakovaný trénink není potřeba zadávat cvik po cviku znovu.
type: object
additionalProperties: false
required:
- id
- name
- items
- estimatedCalories
properties:
id:
type: string
name:
description: Název šablony, např. "Workout day 1"
type: string
items:
type: array
items:
$ref: "#/WorkoutTemplateItem"
estimatedCalories:
description: Odhad spálených kcal za celou šablonu
type: integer
EnergyBalance:
description: Energetická bilance dne — kolik přišlo jídlem a kolik se vydalo
type: object
additionalProperties: false
required:
- intake
- activityBurn
- basal
- totalBurn
- balance
- hasBasal
properties:
intake:
description: Přijaté kcal z jídla
type: integer
activityBurn:
description: Spálené kcal pohybem
type: integer
basal:
description: |
Klidový výdej (bazální metabolismus) v kcal za den z nastavení.
Nula, pokud si ho uživatel nenastavil.
type: integer
totalBurn:
description: Celkový výdej, tedy klidový výdej plus pohyb
type: integer
balance:
description: |
Rozdíl příjmu a výdeje v kcal. Záporná hodnota znamená deficit,
kladná přebytek.
type: integer
hasBasal:
description: |
False, pokud uživatel nemá nastavený klidový výdej. Bilance pak
porovnává jen jídlo proti pohybu a není to skutečný deficit.
type: boolean
DayOverview:
description: Kompletní přehled dne — jídlo, pohyb a jejich bilance
type: object
additionalProperties: false
required:
- date
- meals
- activities
- energy
properties:
date:
type: string
format: date
meals:
$ref: "#/DayRecord"
activities:
$ref: "#/ActivityDay"
energy:
$ref: "#/EnergyBalance"
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"noEmit": true
},
"include": ["*.ts", "gen/**/*"]
}
+309
View File
@@ -0,0 +1,309 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@hey-api/client-fetch@^0.8.2":
version "0.8.4"
resolved "https://registry.yarnpkg.com/@hey-api/client-fetch/-/client-fetch-0.8.4.tgz#2dc2d368b4dd2137789f46adcae68ccd04ea2adb"
integrity sha512-SWtUjVEFIUdiJGR2NiuF0njsSrSdTe7WHWkp3BLH3DEl2bRhiflOnBo29NSDdrY90hjtTQiTQkBxUgGOF29Xzg==
"@hey-api/json-schema-ref-parser@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.0.3.tgz#d20ed12edcbe679540af2647fb28ed61b7fbca7e"
integrity sha512-jgyNFPUReGpdB0ihWv6m+Q3dcawtXx4t6cvi0NS4xxblulcCfEjThP5xVwShFiTRScckIQ/GsuZv20arRTIDkg==
dependencies:
"@jsdevtools/ono" "^7.1.3"
"@types/json-schema" "^7.0.15"
js-yaml "^4.1.0"
"@hey-api/openapi-ts@^0.64.7":
version "0.64.15"
resolved "https://registry.yarnpkg.com/@hey-api/openapi-ts/-/openapi-ts-0.64.15.tgz#655e8ee4039f9f190244e72595bcfee0adf43ad6"
integrity sha512-bXpi9z3YEPVt9bVqlFA3hHmgDzfM8ID5kjnXR7t6PFxlmqo8CH8Y8aCNP7rMk1q7MXk42y2CXC2ehbU9cthCyw==
dependencies:
"@hey-api/json-schema-ref-parser" "1.0.3"
c12 "2.0.1"
commander "13.0.0"
handlebars "4.7.8"
"@jsdevtools/ono@^7.1.3":
version "7.1.3"
resolved "https://registry.yarnpkg.com/@jsdevtools/ono/-/ono-7.1.3.tgz#9df03bbd7c696a5c58885c34aa06da41c8543796"
integrity sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==
"@types/json-schema@^7.0.15":
version "7.0.15"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
acorn@^8.16.0:
version "8.18.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.18.0.tgz#4faf01b2d6d326bfeed97aea1f52220b5f4c1940"
integrity sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==
argparse@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
c12@2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/c12/-/c12-2.0.1.tgz#5702d280b31a08abba39833494c9b1202f0f5aec"
integrity sha512-Z4JgsKXHG37C6PYUtIxCfLJZvo6FyhHJoClwwb9ftUkLpPSkuYqn6Tr+vnaN8hymm0kIbcg6Ey3kv/Q71k5w/A==
dependencies:
chokidar "^4.0.1"
confbox "^0.1.7"
defu "^6.1.4"
dotenv "^16.4.5"
giget "^1.2.3"
jiti "^2.3.0"
mlly "^1.7.1"
ohash "^1.1.4"
pathe "^1.1.2"
perfect-debounce "^1.0.0"
pkg-types "^1.2.0"
rc9 "^2.1.2"
chokidar@^4.0.1:
version "4.0.3"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30"
integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==
dependencies:
readdirp "^4.0.1"
chownr@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece"
integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==
citty@^0.1.6:
version "0.1.6"
resolved "https://registry.yarnpkg.com/citty/-/citty-0.1.6.tgz#0f7904da1ed4625e1a9ea7e0fa780981aab7c5e4"
integrity sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==
dependencies:
consola "^3.2.3"
commander@13.0.0:
version "13.0.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-13.0.0.tgz#1b161f60ee3ceb8074583a0f95359a4f8701845c"
integrity sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ==
confbox@^0.1.7, confbox@^0.1.8:
version "0.1.8"
resolved "https://registry.yarnpkg.com/confbox/-/confbox-0.1.8.tgz#820d73d3b3c82d9bd910652c5d4d599ef8ff8b06"
integrity sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==
consola@^3.2.3, consola@^3.4.0:
version "3.4.2"
resolved "https://registry.yarnpkg.com/consola/-/consola-3.4.2.tgz#5af110145397bb67afdab77013fdc34cae590ea7"
integrity sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==
defu@^6.1.4:
version "6.1.7"
resolved "https://registry.yarnpkg.com/defu/-/defu-6.1.7.tgz#72543567c8e9f97ff13ce402b6dbe09ac5ae4d23"
integrity sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==
destr@^2.0.3:
version "2.0.5"
resolved "https://registry.yarnpkg.com/destr/-/destr-2.0.5.tgz#7d112ff1b925fb8d2079fac5bdb4a90973b51fdb"
integrity sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==
dotenv@^16.4.5:
version "16.6.1"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.6.1.tgz#773f0e69527a8315c7285d5ee73c4459d20a8020"
integrity sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==
fs-minipass@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb"
integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==
dependencies:
minipass "^3.0.0"
giget@^1.2.3:
version "1.2.5"
resolved "https://registry.yarnpkg.com/giget/-/giget-1.2.5.tgz#0bd4909356a0da75cc1f2b33538f93adec0d202f"
integrity sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug==
dependencies:
citty "^0.1.6"
consola "^3.4.0"
defu "^6.1.4"
node-fetch-native "^1.6.6"
nypm "^0.5.4"
pathe "^2.0.3"
tar "^6.2.1"
handlebars@4.7.8:
version "4.7.8"
resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.8.tgz#41c42c18b1be2365439188c77c6afae71c0cd9e9"
integrity sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==
dependencies:
minimist "^1.2.5"
neo-async "^2.6.2"
source-map "^0.6.1"
wordwrap "^1.0.0"
optionalDependencies:
uglify-js "^3.1.4"
jiti@^2.3.0:
version "2.7.0"
resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.7.0.tgz#974228f2f4ca2bc21885a1797b45fea68e950c64"
integrity sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==
js-yaml@^4.1.0:
version "4.3.2"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.2.tgz#8e44fb14a2643c59726bb15787b5f1512cb3d3fb"
integrity sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==
dependencies:
argparse "^2.0.1"
minimist@^1.2.5:
version "1.2.8"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
minipass@^3.0.0:
version "3.3.6"
resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.3.6.tgz#7bba384db3a1520d18c9c0e5251c3444e95dd94a"
integrity sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==
dependencies:
yallist "^4.0.0"
minipass@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/minipass/-/minipass-5.0.0.tgz#3e9788ffb90b694a5d0ec94479a45b5d8738133d"
integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==
minizlib@^2.1.1:
version "2.1.2"
resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931"
integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==
dependencies:
minipass "^3.0.0"
yallist "^4.0.0"
mkdirp@^1.0.3:
version "1.0.4"
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
mlly@^1.7.1, mlly@^1.7.4:
version "1.8.2"
resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.8.2.tgz#e7f7919a82d13b174405613117249a3f449d78bb"
integrity sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==
dependencies:
acorn "^8.16.0"
pathe "^2.0.3"
pkg-types "^1.3.1"
ufo "^1.6.3"
neo-async@^2.6.2:
version "2.6.2"
resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
node-fetch-native@^1.6.6:
version "1.6.7"
resolved "https://registry.yarnpkg.com/node-fetch-native/-/node-fetch-native-1.6.7.tgz#9d09ca63066cc48423211ed4caf5d70075d76a71"
integrity sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==
nypm@^0.5.4:
version "0.5.4"
resolved "https://registry.yarnpkg.com/nypm/-/nypm-0.5.4.tgz#a5ab0d8d37f96342328479f88ef58699f29b3051"
integrity sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA==
dependencies:
citty "^0.1.6"
consola "^3.4.0"
pathe "^2.0.3"
pkg-types "^1.3.1"
tinyexec "^0.3.2"
ufo "^1.5.4"
ohash@^1.1.4:
version "1.1.6"
resolved "https://registry.yarnpkg.com/ohash/-/ohash-1.1.6.tgz#9ff7b0271d7076290794537d68ec2b40a60d133e"
integrity sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg==
pathe@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.2.tgz#6c4cb47a945692e48a1ddd6e4094d170516437ec"
integrity sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==
pathe@^2.0.1, pathe@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716"
integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==
perfect-debounce@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz#9c2e8bc30b169cc984a58b7d5b28049839591d2a"
integrity sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==
pkg-types@^1.2.0, pkg-types@^1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.3.1.tgz#bd7cc70881192777eef5326c19deb46e890917df"
integrity sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==
dependencies:
confbox "^0.1.8"
mlly "^1.7.4"
pathe "^2.0.1"
rc9@^2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/rc9/-/rc9-2.1.2.tgz#6282ff638a50caa0a91a31d76af4a0b9cbd1080d"
integrity sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==
dependencies:
defu "^6.1.4"
destr "^2.0.3"
readdirp@^4.0.1:
version "4.1.2"
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d"
integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==
source-map@^0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
tar@^6.2.1:
version "6.2.1"
resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.1.tgz#717549c541bc3c2af15751bea94b1dd068d4b03a"
integrity sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==
dependencies:
chownr "^2.0.0"
fs-minipass "^2.0.0"
minipass "^5.0.0"
minizlib "^2.1.1"
mkdirp "^1.0.3"
yallist "^4.0.0"
tinyexec@^0.3.2:
version "0.3.2"
resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-0.3.2.tgz#941794e657a85e496577995c6eef66f53f42b3d2"
integrity sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==
typescript@^5.9.3:
version "5.9.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f"
integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
ufo@^1.5.4, ufo@^1.6.3:
version "1.6.4"
resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.6.4.tgz#7a8fb875fcc6382d2c7d0b3692738b0500a92467"
integrity sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==
uglify-js@^3.1.4:
version "3.19.3"
resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.19.3.tgz#82315e9bbc6f2b25888858acd1fff8441035b77f"
integrity sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==
wordwrap@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==
yallist@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==