feat: založení základní stránky příjem/výdej + import a statistiky
This commit is contained in:
+11
@@ -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/
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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')} Kč`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 už 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>
|
||||
);
|
||||
}
|
||||
@@ -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">Kč</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">Kč</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 ní 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)} Kč / 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">Kč</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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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')} Kč`;
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+31
@@ -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
@@ -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
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
JWT_SECRET=vyvojovy-klic-ktery-ma-aspon-32-znaku-delky
|
||||
STORAGE=json
|
||||
PORT=3001
|
||||
HOST=0.0.0.0
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
['@babel/preset-env', { targets: { node: 'current' } }],
|
||||
'@babel/preset-typescript',
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
testEnvironment: 'node',
|
||||
setupFiles: ['<rootDir>/src/tests/setupEnv.ts'],
|
||||
testMatch: ['<rootDir>/src/tests/**/*.test.ts'],
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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: []
|
||||
@@ -0,0 +1 @@
|
||||
export * from './gen';
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["*.ts", "gen/**/*"]
|
||||
}
|
||||
+309
@@ -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==
|
||||
Reference in New Issue
Block a user