feat: založení základní stránky příjem/výdej + import a statistiky
This commit is contained in:
@@ -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
Reference in New Issue
Block a user