feat: předělání historie a profilu

This commit is contained in:
Ondřej Anděl committed 2026-10-07 14:48:21 +02:00
1 parent 9e72f431a1
commit 8fa7f601f2
97 files changed
+7502 -1034

No files matched your search

+289
View File
@@ -0,0 +1,289 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.getTotalQuantity = void 0;
exports.isActivityUnit = isActivityUnit;
exports.buildActivityDay = buildActivityDay;
exports.getActivityDay = getActivityDay;
exports.deriveActivityCalories = deriveActivityCalories;
exports.normalizeActivityInput = normalizeActivityInput;
exports.addActivity = addActivity;
exports.addActivities = addActivities;
exports.updateActivity = updateActivity;
exports.deleteActivity = deleteActivity;
exports.setActivityMultiplier = setActivityMultiplier;
const crypto_1 = require("crypto");
const types_gen_1 = require("../../types/gen/types.gen");
const storage_1 = __importDefault(require("./storage"));
const utils_1 = require("./utils");
const workoutPlans_1 = require("./workoutPlans");
const storage = (0, storage_1.default)();
/** Maximální délka textových polí. */
const MAX_TEXT_LENGTH = 500;
/** Podporované jednotky aktivit. */
const UNITS = [
types_gen_1.ActivityUnit.KROKY,
types_gen_1.ActivityUnit.MINUTY,
types_gen_1.ActivityUnit.SEKUNDY,
types_gen_1.ActivityUnit.KM,
types_gen_1.ActivityUnit.OPAKOVANI,
];
/** Vrátí true, pokud je hodnota podporovanou jednotkou aktivity. */
function isActivityUnit(value) {
return typeof value === 'string' && UNITS.includes(value);
}
/** Klíč úložiště s aktivitami jednoho dne jednoho uživatele. */
function getDayKey(login, date) {
return `activities:${login}:${date}`;
}
/** Sestaví přehled pohybu dne včetně součtu spálených kcal. */
function buildActivityDay(date, entries) {
const sorted = [...entries].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
return {
date,
entries: sorted,
totalCalories: sorted.reduce((sum, entry) => sum + ((0, utils_1.applyMultiplier)(entry.calories, entry.multiplier) ?? 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
*/
async function getActivityDay(login, date) {
const entries = await storage.getData(getDayKey(login, date));
return buildActivityDay(date, entries ?? []);
}
/** Ořízne a znormalizuje volitelný textový vstup. */
function normalizeText(value, name) {
if (value == null)
return undefined;
if (typeof value !== 'string') {
throw new utils_1.BadRequestError(`Pole '${name}' musí být text`);
}
const trimmed = value.trim();
if (!trimmed.length)
return undefined;
if (trimmed.length > MAX_TEXT_LENGTH) {
throw new utils_1.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, name) {
if (value == null)
return undefined;
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new utils_1.BadRequestError(`Pole '${name}' musí být číslo`);
}
const rounded = Math.round(value);
if (rounded < 0) {
throw new utils_1.BadRequestError(`Pole '${name}' nesmí být záporné`);
}
return rounded;
}
/**
* Vrátí celkové množství aktivity, tedy počet sérií krát množství v jedné.
*
* Série se drží odděleně, protože 3 × 40 opakování s pauzami je jiný trénink
* než 120 opakování v kuse — spálené kalorie jsou ale stejné, takže do výpočtu
* jde součin.
*/
const getTotalQuantity = (quantity, sets) => quantity * Math.max(sets ?? 1, 1);
exports.getTotalQuantity = getTotalQuantity;
/**
* 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.
*/
function deriveActivityCalories(input) {
const calories = input.caloriesPer100Units
? Math.round((0, exports.getTotalQuantity)(input.quantity, input.sets) / 100 * input.caloriesPer100Units)
: input.calories;
return { ...input, calories };
}
/** Ověří a znormalizuje vstupní data aktivity. */
function normalizeActivityInput(input) {
const value = (input ?? {});
if (!(0, utils_1.isValidIsoDate)(value.date)) {
throw new utils_1.BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
}
if (!isActivityUnit(value.unit)) {
throw new utils_1.BadRequestError("Nebyla předána platná jednotka aktivity");
}
const name = normalizeText(value.name, 'name');
if (!name) {
throw new utils_1.BadRequestError("Nebyl předán název aktivity");
}
const quantity = normalizeNumber(value.quantity, 'quantity');
if (quantity == null) {
throw new utils_1.BadRequestError("Nebylo předáno množství aktivity");
}
const sets = normalizeNumber(value.sets, 'sets');
return deriveActivityCalories({
date: value.date,
name,
unit: value.unit,
quantity,
// Jedna série je výchozí stav, ukládat ji zvlášť nemá smysl
...(sets && sets > 1 ? { sets } : {}),
caloriesPer100Units: normalizeNumber(value.caloriesPer100Units, 'caloriesPer100Units'),
calories: normalizeNumber(value.calories, 'calories'),
note: normalizeText(value.note, 'note'),
extraWeightGrams: normalizeNumber(value.extraWeightGrams, 'extraWeightGrams') || undefined,
multiplier: (0, utils_1.normalizeMultiplier)(value.multiplier),
});
}
/**
* 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
*/
async function addActivity(login, input) {
const normalized = normalizeActivityInput(input);
const entry = {
id: (0, crypto_1.randomUUID)(),
createdAt: new Date().toISOString(),
...normalized,
};
await storage.updateData(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í
*/
async function addActivities(login, date, entries) {
if (!entries.length)
return;
await storage.updateData(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
*/
async function updateActivity(login, id, input) {
if (typeof id !== 'string' || !id.length) {
throw new utils_1.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 utils_1.NotFoundError("Aktivita nebyla nalezena");
}
let original;
const remaining = await storage.updateData(getDayKey(login, originalDate), current => {
original = (current ?? []).find(entry => entry.id === id);
return (current ?? []).filter(entry => entry.id !== id);
});
if (!original) {
throw new utils_1.NotFoundError("Aktivita nebyla nalezena");
}
// Volitelná pole se přepisují včetně vymazání, proto se berou z normalizovaného vstupu
const updated = {
...original,
...normalized,
sets: normalized.sets,
caloriesPer100Units: normalized.caloriesPer100Units,
calories: normalized.calories,
note: normalized.note,
extraWeightGrams: normalized.extraWeightGrams,
multiplier: normalized.multiplier,
updatedAt: new Date().toISOString(),
};
await storage.updateData(getDayKey(login, normalized.date), current => [...(current ?? []), updated]);
if (originalDate !== normalized.date) {
await (0, workoutPlans_1.dropOrphanedPlans)(login, originalDate, remaining);
}
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)
*/
async function deleteActivity(login, id, date) {
if (typeof id !== 'string' || !id.length) {
throw new utils_1.BadRequestError("Nebyl předán identifikátor záznamu");
}
if (!(0, utils_1.isValidIsoDate)(date)) {
throw new utils_1.BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
}
let found = false;
const remaining = await storage.updateData(getDayKey(login, date), current => {
found = (current ?? []).some(entry => entry.id === id);
return (current ?? []).filter(entry => entry.id !== id);
});
if (!found) {
throw new utils_1.NotFoundError("Aktivita nebyla nalezena");
}
await (0, workoutPlans_1.dropOrphanedPlans)(login, date, remaining);
return date;
}
/**
* Nastaví násobek aktivity přímo z přehledu dne. Ostatní údaje zůstanou beze změny.
*
* @param login přihlašovací jméno uživatele
* @param id identifikátor záznamu
* @param date datum záznamu (YYYY-MM-DD)
* @param multiplier nový násobek; 1 násobek zruší
* @returns datum, ve kterém aktivita je
*/
async function setActivityMultiplier(login, id, date, multiplier) {
if (typeof id !== 'string' || !id.length) {
throw new utils_1.BadRequestError("Nebyl předán identifikátor záznamu");
}
if (!(0, utils_1.isValidIsoDate)(date)) {
throw new utils_1.BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
}
if (multiplier == null) {
throw new utils_1.BadRequestError("Nebyl předán násobek");
}
const normalized = (0, utils_1.normalizeMultiplier)(multiplier);
let found = false;
await storage.updateData(getDayKey(login, date), current => (current ?? []).map(entry => {
if (entry.id !== id)
return entry;
found = true;
const updated = { ...entry, multiplier: normalized, updatedAt: new Date().toISOString() };
if (normalized == null)
delete updated.multiplier;
return updated;
}));
if (!found) {
throw new utils_1.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, id, expectedDate) {
const expected = await storage.getData(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(key);
if (entries?.some(entry => entry.id === id)) {
return key.substring(prefix.length);
}
}
return undefined;
}
+61
View File
@@ -0,0 +1,61 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.generateToken = generateToken;
exports.verify = verify;
exports.getLogin = getLogin;
const jsonwebtoken_1 = __importDefault(require("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
*/
function generateToken(login, trusted) {
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 jsonwebtoken_1.default.sign(payload, process.env.JWT_SECRET);
}
/**
* Vrátí true, pokud je předaný JWT token platný.
*
* @param token JWT token
*/
function verify(token) {
if (!process.env.JWT_SECRET) {
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET");
}
try {
jsonwebtoken_1.default.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
*/
function getLogin(token) {
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 = jsonwebtoken_1.default.verify(token, process.env.JWT_SECRET);
return payload.login;
}
+78
View File
@@ -0,0 +1,78 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.NoopProvider = exports.OpenFoodFactsProvider = void 0;
exports.getCalorieProvider = getCalorieProvider;
/** 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.
*/
class OpenFoodFactsProvider {
name = 'openfoodfacts';
baseUrl = 'https://search.openfoodfacts.org/search';
async search(query, limit) {
// 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();
return { unavailable: false, suggestions: this.toSuggestions(body.hits ?? [], limit) };
}
catch (error) {
console.warn('Open Food Facts není dostupný:', error.message);
return { unavailable: true };
}
}
/** Vybere z odpovědi produkty s použitelnou energetickou hodnotou. */
toSuggestions(hits, limit) {
const suggestions = [];
for (const hit of hits) {
const product = hit;
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;
}
}
exports.OpenFoodFactsProvider = OpenFoodFactsProvider;
/** Poskytovatel, který nic nenajde. Používá se, když je externí vyhledávání vypnuté. */
class NoopProvider {
name = 'none';
search() {
return Promise.resolve({ unavailable: false, suggestions: [] });
}
}
exports.NoopProvider = NoopProvider;
/**
* Vrátí poskytovatele dle konfigurace.
* `CALORIE_PROVIDER=none` vypne externí dotazy úplně (offline provoz, testy).
*/
function getCalorieProvider() {
return process.env.CALORIE_PROVIDER?.toLowerCase() === 'none'
? new NoopProvider()
: new OpenFoodFactsProvider();
}
+128
View File
@@ -0,0 +1,128 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.normalizeName = normalizeName;
exports.rememberCalories = rememberCalories;
exports.searchCalories = searchCalories;
const storage_1 = __importDefault(require("./storage"));
const calorieProvider_1 = require("./calorieProvider");
const utils_1 = require("./utils");
const storage = (0, storage_1.default)();
/** 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) {
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.
*/
function normalizeName(name) {
return name
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
/**
* 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
*/
async function rememberCalories(login, name, caloriesPer100g) {
const key = normalizeName(name);
if (!key.length || !Number.isFinite(caloriesPer100g) || caloriesPer100g <= 0)
return;
await storage.updateData(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, query) {
const library = await storage.getData(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
*/
async function searchCalories(login, query) {
if (typeof query !== 'string' || !query.trim().length) {
throw new utils_1.BadRequestError("Nebyl předán hledaný název");
}
const trimmed = query.trim();
const provider = (0, calorieProvider_1.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,
};
}
+58
View File
@@ -0,0 +1,58 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getDayOverview = exports.buildEnergyBalance = void 0;
const meals_1 = require("./meals");
const activities_1 = require("./activities");
const settings_1 = require("./settings");
const sleep_1 = require("./sleep");
/**
* 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.
*
* Délka spánku do bilance **nevstupuje**: klidový výdej je hodnota za celých
* 24 hodin včetně spánku a žádná ze standardních rovnic (Mifflin–St Jeor,
* Harris–Benedict) délku spánku jako proměnnou nemá.
*
* @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
*/
const buildEnergyBalance = (intake, activityBurn, basal) => {
const resolvedBasal = basal ?? 0;
const totalBurn = resolvedBasal + activityBurn;
return {
intake,
activityBurn,
basal: resolvedBasal,
totalBurn,
balance: intake - totalBurn,
hasBasal: !!basal,
};
};
exports.buildEnergyBalance = buildEnergyBalance;
/**
* Sestaví kompletní přehled dne — jídlo, pohyb, spánek a energetickou bilanci.
*
* @param login přihlašovací jméno uživatele
* @param date datum ve formátu YYYY-MM-DD
*/
const getDayOverview = async (login, date) => {
const [meals, activities, settings, sleepMinutes] = await Promise.all([
(0, meals_1.getDay)(login, date),
(0, activities_1.getActivityDay)(login, date),
(0, settings_1.getSettings)(login),
(0, sleep_1.getSleep)(login, date),
]);
return {
date,
meals,
activities,
// Ručně zadaný klidový výdej vyhrává nad spočítaným z tělesných údajů
energy: (0, exports.buildEnergyBalance)(meals.totalCalories, activities.totalCalories, settings.effectiveBasalCalories),
...(sleepMinutes ? { sleepMinutes } : {}),
};
};
exports.getDayOverview = getDayOverview;
+198
View File
@@ -0,0 +1,198 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.app = void 0;
const express_1 = __importDefault(require("express"));
const cors_1 = __importDefault(require("cors"));
const dotenv_1 = __importDefault(require("dotenv"));
const path_1 = __importDefault(require("path"));
const auth_1 = require("./auth");
const utils_1 = require("./utils");
const storage_1 = __importStar(require("./storage"));
const redis_1 = require("./storage/redis");
const mealRoutes_1 = __importDefault(require("./routes/mealRoutes"));
const statsRoutes_1 = __importDefault(require("./routes/statsRoutes"));
const importRoutes_1 = __importDefault(require("./routes/importRoutes"));
const calorieRoutes_1 = __importDefault(require("./routes/calorieRoutes"));
const settingsRoutes_1 = __importDefault(require("./routes/settingsRoutes"));
const activityRoutes_1 = __importDefault(require("./routes/activityRoutes"));
const workoutRoutes_1 = __importDefault(require("./routes/workoutRoutes"));
const dayRoutes_1 = __importDefault(require("./routes/dayRoutes"));
const mealTemplateRoutes_1 = __importDefault(require("./routes/mealTemplateRoutes"));
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
dotenv_1.default.config({ path: path_1.default.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
if (!process.env.JWT_SECRET) {
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET");
}
exports.app = (0, express_1.default)();
const server = require("http").createServer(exports.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
exports.app.use(express_1.default.json({ limit: '15mb' }));
exports.app.use((0, cors_1.default)({ 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.');
}
exports.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) {
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.closeIdleConnections?.();
await new Promise(resolve => server.close(() => resolve()));
if (process.env.STORAGE?.toLowerCase() === 'redis') {
await (0, redis_1.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í. */
exports.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. */
exports.app.get("/api/health/ready", async (_req, res) => {
if (shuttingDown) {
res.status(503).json({ ok: false, reason: 'shutting down' });
return;
}
const healthy = await (0, storage_1.default)().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. */
exports.app.get("/api/config", (_req, res) => {
res.status(200).json({
sentry: {
dsn: process.env.SENTRY_CLIENT_DSN ?? process.env.SENTRY_DSN ?? null,
environment: ENVIRONMENT,
},
});
});
exports.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((0, auth_1.generateToken)(Buffer.from(remoteUser, 'latin1').toString(), true));
return;
}
if (!req.body?.login || req.body.login.trim().length === 0) {
throw new utils_1.BadRequestError("Nebyl předán login");
}
res.status(200).json((0, auth_1.generateToken)(req.body.login, false));
}
catch (e) {
next(e);
}
});
// ─── Autentizace ──────────────────────────────────────────────────────────────
exports.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 (!(0, auth_1.verify)(token)) {
res.status(403).json({ error: 'Neplatný autentizační token' });
return;
}
next();
});
// ─── Routes vyžadující autentizaci ────────────────────────────────────────────
exports.app.use("/api/meals", mealRoutes_1.default);
exports.app.use("/api/stats", statsRoutes_1.default);
exports.app.use("/api/import", importRoutes_1.default);
exports.app.use("/api/calories", calorieRoutes_1.default);
exports.app.use("/api/settings", settingsRoutes_1.default);
exports.app.use("/api/activities", activityRoutes_1.default);
exports.app.use("/api/workouts", workoutRoutes_1.default);
exports.app.use("/api/day", dayRoutes_1.default);
exports.app.use("/api/mealTemplates", mealTemplateRoutes_1.default);
exports.app.use(express_1.default.static(path_1.default.join(process.cwd(), 'public')));
exports.app.get('*splat', (_req, res) => {
res.sendFile(path_1.default.join(process.cwd(), 'public', 'index.html'));
});
// Error handling middleware
exports.app.use((err, _req, res, next) => {
if (err instanceof utils_1.BadRequestError) {
res.status(400).send({ error: err.message });
}
else if (err instanceof utils_1.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) {
storage_1.storageReady.then(() => {
server.listen(PORT, () => {
console.log(`Server listening on ${HOST}, port ${PORT}`);
});
});
}
+439
View File
@@ -0,0 +1,439 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.IMPORT_FORMATS = exports.IMPORT_SOURCE = void 0;
exports.detectFormat = detectFormat;
exports.importLuncherExport = importLuncherExport;
const exceljs_1 = __importDefault(require("exceljs"));
const crypto_1 = require("crypto");
const types_gen_1 = require("../../types/gen/types.gen");
const meals_1 = require("./meals");
const utils_1 = require("./utils");
/** Označení zdroje, kterým se importované záznamy značí. */
exports.IMPORT_SOURCE = 'luncher';
/** Podporované formáty importu (shodné s formáty exportu Luncheru). */
exports.IMPORT_FORMATS = ['xlsx', 'csv', 'json'];
/** 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 = {
'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',
]);
/**
* Rozpozná formát souboru podle přípony.
*
* @param fileName název souboru
*/
function detectFormat(fileName) {
const extension = fileName.toLowerCase().split('.').pop();
if (extension && exports.IMPORT_FORMATS.includes(extension)) {
return extension;
}
throw new utils_1.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) {
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) {
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) {
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 ((0, utils_1.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 (0, utils_1.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) {
const rows = [];
let row = [];
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) {
const mapping = new Map();
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, mapping) {
const raw = {};
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) {
// 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 utils_1.BadRequestError("CSV soubor je prázdný");
}
const mapping = mapHeaders(table[0]);
if (!mapping.size) {
throw new utils_1.BadRequestError("V CSV se nepodařilo najít očekávané sloupce přehledu z Luncheru");
}
const rows = [];
const warnings = [];
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) {
const workbook = new exceljs_1.default.Workbook();
try {
await workbook.xlsx.load(content);
}
catch {
throw new utils_1.BadRequestError("Soubor se nepodařilo přečíst jako XLSX");
}
const sheet = workbook.getWorksheet(DETAIL_SHEET_NAME) ?? workbook.worksheets[0];
if (!sheet) {
throw new utils_1.BadRequestError("XLSX soubor neobsahuje žádný list");
}
/** Vrátí hodnoty buněk řádku v poli indexovaném od nuly. */
const cellsOf = (rowNumber) => {
const row = sheet.getRow(rowNumber);
const values = [];
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 utils_1.BadRequestError("V XLSX se nepodařilo najít očekávané sloupce přehledu z Luncheru");
}
const rows = [];
const warnings = [];
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) {
let parsed;
try {
parsed = JSON.parse(content.toString('utf-8'));
}
catch {
throw new utils_1.BadRequestError("Soubor se nepodařilo přečíst jako JSON");
}
const rawRows = Array.isArray(parsed) ? parsed : parsed?.rows;
if (!Array.isArray(rawRows)) {
throw new utils_1.BadRequestError("JSON neobsahuje pole 'rows' s řádky přehledu");
}
const rows = [];
const warnings = [];
rawRows.forEach((raw, index) => {
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, indexWithinDay) {
return [
exports.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, mealType, importKey) {
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: (0, crypto_1.randomUUID)(),
date: row.date,
mealType,
name,
source: row.store ?? (NON_PLACE_TYPES.has(row.type) ? undefined : row.type),
price: row.amount,
note,
importSource: exports.IMPORT_SOURCE,
importKey,
createdAt: new Date().toISOString(),
};
}
/** Vrátí true, pokud je hodnota podporovaným režimem importu. */
const isImportMode = (value) => value === types_gen_1.ImportMode.PRIDAT || value === types_gen_1.ImportMode.NAHRADIT;
/**
* 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ří.
* V režimu PRIDAT se řádky, které už z dřívějšího importu existují, přeskočí,
* takže opakovaný import stejného měsíce data nezduplikuje. Režim NAHRADIT
* dřívější import dotčených dnů odebere a založí ho znovu — hodí se, když se
* v Luncheru něco opravilo. Ručně zadaná jídla zůstávají v obou režimech.
*
* Náhled vrací všechny řádky, které jde založit, a k nim jídla, která už
* v dotčených dnech jsou. Uživatel si v něm odškrtne, co nechce, a ostrý
* import pak dostane jen vybrané klíče.
*
* @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ží
* @param mode režim importu, výchozí je PRIDAT
* @param selectedKeys klíče řádků k založení; bez nich se založí všechny
*/
async function importLuncherExport(login, fileName, content, defaultMealType, dryRun = false, mode, selectedKeys) {
const format = detectFormat(fileName);
if (defaultMealType != null && !(0, meals_1.isMealType)(defaultMealType)) {
throw new utils_1.BadRequestError("Nebyl předán platný typ jídla");
}
if (mode != null && !isImportMode(mode)) {
throw new utils_1.BadRequestError("Nebyl předán platný režim importu");
}
if (selectedKeys != null && !Array.isArray(selectedKeys)) {
throw new utils_1.BadRequestError("Vybrané řádky musí být pole klíčů");
}
// Luncher řeší výběr obědů, takže bez upřesnění zakládáme importy jako oběd
const mealType = (0, meals_1.isMealType)(defaultMealType) ? defaultMealType : types_gen_1.MealType.OBED;
const resolvedMode = isImportMode(mode) ? mode : types_gen_1.ImportMode.PRIDAT;
const replace = resolvedMode === types_gen_1.ImportMode.NAHRADIT;
let parsed;
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 existing = await (0, meals_1.findMealsForDays)(login, dates);
// Při nahrazení se dřívější import založí znovu, duplicitou tedy není
const existingKeys = new Set(replace
? []
: existing.map(entry => entry.importKey).filter(key => !!key));
const perDayIndex = new Map();
const candidates = [];
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;
}
candidates.push(toMealEntry(row, mealType, importKey));
}
const selectedKeyList = selectedKeys == null ? undefined : new Set(selectedKeys.map(String));
const selected = candidates.filter(entry => !selectedKeyList || selectedKeyList.has(entry.importKey));
skipped += candidates.length - selected.length;
// Nahrazuje se jen ve dnech, do kterých se opravdu něco zakládá
const selectedDays = new Set(selected.map(entry => entry.date));
const replaced = replace
? existing.filter(entry => entry.importSource === exports.IMPORT_SOURCE && selectedDays.has(entry.date)).length
: 0;
const days = dryRun
? Array.from(selectedDays).sort()
: await (0, meals_1.addEntries)(login, selected, replace ? exports.IMPORT_SOURCE : undefined);
return {
dryRun,
format,
mode: resolvedMode,
rowCount: parsed.rows.length,
imported: selected.length,
skipped,
replaced,
days,
totalPrice: selected.reduce((sum, entry) => sum + (entry.price ?? 0), 0),
entries: dryRun ? candidates : selected,
existing,
warnings: parsed.warnings,
};
}
+319
View File
@@ -0,0 +1,319 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.createMealTemplateFromMeal = exports.applyMealTemplate = exports.deleteMealTemplate = exports.saveMealTemplate = exports.deriveRecipe = exports.getMealTemplates = void 0;
const crypto_1 = require("crypto");
const types_gen_1 = require("../../types/gen/types.gen");
const storage_1 = __importDefault(require("./storage"));
const meals_1 = require("./meals");
const calories_1 = require("./calories");
const utils_1 = require("./utils");
const storage = (0, storage_1.default)();
/** Maximální délka textových polí. Poznámka je delší, aby se do ní vešel postup. */
const MAX_NAME_LENGTH = 200;
const MAX_NOTE_LENGTH = 4000;
/** Maximální počet surovin receptu — pojistka proti nesmyslnému vstupu. */
const MAX_INGREDIENTS = 50;
/** Klíč úložiště se šablonami jídel jednoho uživatele. */
const getKey = (login) => `mealTemplates:${login}`;
/**
* Doplní šabloně druh, pokud chybí.
*
* Šablony uložené dřív, než přibyly recepty, pole `kind` nemají — všechny jsou
* ploché záznamy s výslednými hodnotami, tedy hotová jídla. V úložišti se nic
* nepřepisuje, druh se doplní jen při čtení.
*/
const withKind = (template) => ({
...template,
kind: template.kind ?? types_gen_1.MealTemplateKind.HOTOVE,
});
/**
* Vrátí šablony jídel uživatele seřazené podle názvu.
*
* @param login přihlašovací jméno uživatele
*/
const getMealTemplates = async (login) => (await storage.getData(getKey(login)) ?? [])
.map(withKind)
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
exports.getMealTemplates = getMealTemplates;
/**
* Energie jedné suroviny — z gramáže a energie na 100 g, jinak ručně zadaná.
* Stejné pravidlo jako u jídla v `deriveAmounts`.
*/
const ingredientCalories = (ingredient) => ingredient.weight != null && ingredient.caloriesPer100g
? Math.round(ingredient.weight / 100 * ingredient.caloriesPer100g)
: ingredient.calories;
/** Sečte vyplněné hodnoty; když není vyplněná žádná, vrátí undefined. */
const sumDefined = (valueList) => {
const definedList = valueList.filter(value => value != null);
return definedList.length ? definedList.reduce((sum, value) => sum + value, 0) : undefined;
};
/**
* Dopočte ze surovin hodnoty hotového jídla a jedné porce.
*
* Energie na 100 g se počítá z hmotnosti hotového jídla, ne ze součtu surovin —
* vařením se voda odpaří nebo nasákne (rýže, těstoviny), takže by vyšla špatně.
* Porci si pak jde převážit a kalorie se dopočtou z gramáže jako u každého jídla.
*
* Klient má stejnou funkci v `Utils.tsx` pro živý náhled — musí dávat stejné výsledky.
*
* @param ingredients suroviny receptu
* @param servings počet porcí, nevyplněný znamená jednu
* @param cookedWeight hmotnost hotového jídla v gramech
*/
const deriveRecipe = (ingredients, servings, cookedWeight) => {
const portions = Math.max(servings ?? 1, 1);
const totalCalories = sumDefined(ingredients.map(ingredientCalories));
const totalWeight = cookedWeight || sumDefined(ingredients.map(ingredient => ingredient.weight)) || undefined;
const totalPrice = sumDefined(ingredients.map(ingredient => ingredient.price));
return {
totalCalories,
totalWeight,
totalPrice,
weight: totalWeight != null ? Math.round(totalWeight / portions) : undefined,
calories: totalCalories != null ? Math.round(totalCalories / portions) : undefined,
caloriesPer100g: totalCalories != null && totalWeight
? Math.round(totalCalories / totalWeight * 100)
: undefined,
price: totalPrice != null ? Math.round(totalPrice / portions) : undefined,
};
};
exports.deriveRecipe = deriveRecipe;
/** Ořízne a znormalizuje volitelný textový vstup. */
const normalizeText = (value, name, maxLength = MAX_NAME_LENGTH) => {
if (value == null)
return undefined;
if (typeof value !== 'string') {
throw new utils_1.BadRequestError(`Pole '${name}' musí být text`);
}
const trimmed = value.trim();
if (!trimmed.length)
return undefined;
if (trimmed.length > maxLength) {
throw new utils_1.BadRequestError(`Pole '${name}' může mít nejvýše ${maxLength} znaků`);
}
return trimmed;
};
/** Ověří volitelnou nezápornou celočíselnou hodnotu. */
const normalizeNumber = (value, name) => {
if (value == null)
return undefined;
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new utils_1.BadRequestError(`Pole '${name}' musí být číslo`);
}
const rounded = Math.round(value);
if (rounded < 0) {
throw new utils_1.BadRequestError(`Pole '${name}' nesmí být záporné`);
}
return rounded;
};
/** Vynechá nevyplněné hodnoty, aby se do úložiště nepsaly prázdné klíče. */
const definedOnly = (value) => Object.fromEntries(Object.entries(value).filter(([, item]) => item != null));
/** Ověří a znormalizuje jednu surovinu receptu. */
const normalizeIngredient = (raw, index) => {
const value = (raw ?? {});
const name = normalizeText(value.name, 'name');
if (!name) {
throw new utils_1.BadRequestError(`Surovina ${index + 1} nemá název`);
}
const weight = normalizeNumber(value.weight, 'weight');
const caloriesPer100g = normalizeNumber(value.caloriesPer100g, 'caloriesPer100g');
const calories = normalizeNumber(value.calories, 'calories');
const price = normalizeNumber(value.price, 'price');
return definedOnly({
name,
weight,
caloriesPer100g,
// Ručně zadaná energie platí jen tam, kde ji nejde dopočítat z gramáže
calories: weight != null && caloriesPer100g ? undefined : calories,
price,
});
};
/**
* Založí novou šablonu jídla, nebo přepíše existující podle předaného id.
*
* U hotového jídla se ukládají zadané výsledné hodnoty. U receptu se hodnoty
* porce (gramáž, energie, cena) dopočtou ze surovin a uloží do stejných polí —
* použití šablony pak funguje pro oba druhy stejně.
*
* @param login přihlašovací jméno uživatele
* @param input data šablony
*/
const saveMealTemplate = async (login, input) => {
const value = (input ?? {});
const name = normalizeText(value.name, 'name');
if (!name) {
throw new utils_1.BadRequestError("Nebyl předán název šablony");
}
if (value.mealType != null && !(0, meals_1.isMealType)(value.mealType)) {
throw new utils_1.BadRequestError("Nebyl předán platný typ jídla");
}
if (value.kind != null && value.kind !== types_gen_1.MealTemplateKind.HOTOVE && value.kind !== types_gen_1.MealTemplateKind.RECEPT) {
throw new utils_1.BadRequestError("Nebyl předán platný druh šablony");
}
const kind = value.kind === types_gen_1.MealTemplateKind.RECEPT ? types_gen_1.MealTemplateKind.RECEPT : types_gen_1.MealTemplateKind.HOTOVE;
const common = {
id: typeof value.id === 'string' && value.id.length ? value.id : (0, crypto_1.randomUUID)(),
kind,
name,
mealType: value.mealType != null ? value.mealType : undefined,
source: normalizeText(value.source, 'source'),
note: normalizeText(value.note, 'note', MAX_NOTE_LENGTH),
};
let template;
if (kind === types_gen_1.MealTemplateKind.RECEPT) {
if (!Array.isArray(value.ingredients) || !value.ingredients.length) {
throw new utils_1.BadRequestError("Recept musí mít alespoň jednu surovinu");
}
if (value.ingredients.length > MAX_INGREDIENTS) {
throw new utils_1.BadRequestError(`Recept může mít nejvýše ${MAX_INGREDIENTS} surovin`);
}
const ingredients = value.ingredients.map(normalizeIngredient);
const servings = normalizeNumber(value.servings, 'servings');
const cookedWeight = normalizeNumber(value.cookedWeight, 'cookedWeight') || undefined;
const summary = (0, exports.deriveRecipe)(ingredients, servings, cookedWeight);
template = definedOnly({
...common,
ingredients,
// Jedna porce je výchozí stav, ukládat ji zvlášť nemá smysl
servings: servings && servings > 1 ? servings : undefined,
cookedWeight,
weight: summary.weight,
caloriesPer100g: summary.caloriesPer100g,
calories: summary.calories,
price: summary.price,
});
for (const ingredient of ingredients) {
if (ingredient.caloriesPer100g) {
await (0, calories_1.rememberCalories)(login, ingredient.name, ingredient.caloriesPer100g);
}
}
}
else {
template = definedOnly({
...common,
calories: normalizeNumber(value.calories, 'calories'),
caloriesPer100g: normalizeNumber(value.caloriesPer100g, 'caloriesPer100g'),
weight: normalizeNumber(value.weight, 'weight'),
price: normalizeNumber(value.price, 'price'),
pricePer100g: normalizeNumber(value.pricePer100g, 'pricePer100g'),
});
}
await storage.updateData(getKey(login), current => {
const others = (current ?? []).filter(item => item.id !== template.id);
return [...others, template];
});
return (0, exports.getMealTemplates)(login);
};
exports.saveMealTemplate = saveMealTemplate;
/**
* Smaže šablonu jídla.
*
* @param login přihlašovací jméno uživatele
* @param id identifikátor šablony
*/
const deleteMealTemplate = async (login, id) => {
if (typeof id !== 'string' || !id.length) {
throw new utils_1.BadRequestError("Nebyl předán identifikátor šablony");
}
let found = false;
await storage.updateData(getKey(login), current => {
found = (current ?? []).some(item => item.id === id);
return (current ?? []).filter(item => item.id !== id);
});
if (!found) {
throw new utils_1.NotFoundError("Šablona nebyla nalezena");
}
return (0, exports.getMealTemplates)(login);
};
exports.deleteMealTemplate = deleteMealTemplate;
/**
* Založí do zvoleného dne jídlo ze šablony.
*
* Vznikne z ní běžný záznam jídla, takže jde pak upravit nebo smazat, aniž by
* to ovlivnilo šablonu. Gramáž, cenu a kalorie dopočítá `addMeal` stejně jako
* u ručně zadaného jídla.
*
* @param login přihlašovací jméno uživatele
* @param id identifikátor šablony
* @param date den, do kterého se jídlo založí
* @param mealType chod; bez něj se použije chod ze šablony, jinak oběd
* @param multiplier násobek porce, třeba 2 za dvě stejné sušenky
*/
const applyMealTemplate = async (login, id, date, mealType, multiplier) => {
if (typeof id !== 'string' || !id.length) {
throw new utils_1.BadRequestError("Nebyl předán identifikátor šablony");
}
if (!(0, utils_1.isValidIsoDate)(date)) {
throw new utils_1.BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
}
if (mealType != null && !(0, meals_1.isMealType)(mealType)) {
throw new utils_1.BadRequestError("Nebyl předán platný typ jídla");
}
const template = (await (0, exports.getMealTemplates)(login)).find(item => item.id === id);
if (!template) {
throw new utils_1.NotFoundError("Šablona nebyla nalezena");
}
await (0, meals_1.addMeal)(login, {
date,
mealType: mealType ?? template.mealType ?? types_gen_1.MealType.OBED,
name: template.name,
source: template.source,
weight: template.weight,
price: template.price,
pricePer100g: template.pricePer100g,
calories: template.calories,
caloriesPer100g: template.caloriesPer100g,
// Postup receptu do záznamu dne nepatří, zůstává u šablony
note: template.kind === types_gen_1.MealTemplateKind.RECEPT ? undefined : template.note,
multiplier: (0, utils_1.normalizeMultiplier)(multiplier),
}, template.id);
return date;
};
exports.applyMealTemplate = applyMealTemplate;
/**
* Vytvoří šablonu jídla z existujícího záznamu v přehledu dne.
*
* Když už šablona se stejným názvem existuje, přepíše se — opakované "uložit
* jako šablonu" u téhož jídla má dát jednu šablonu, ne několik stejných.
* Výjimkou je recept: nese suroviny a postup, které záznam dne nemá, takže by
* přepsáním zmizely. Ze záznamu dne vzniká vždy hotové jídlo.
*
* @param login přihlašovací jméno uživatele
* @param date den, ve kterém jídlo je
* @param id identifikátor jídla
*/
const createMealTemplateFromMeal = async (login, date, id) => {
if (typeof id !== 'string' || !id.length) {
throw new utils_1.BadRequestError("Nebyl předán identifikátor jídla");
}
if (!(0, utils_1.isValidIsoDate)(date)) {
throw new utils_1.BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
}
const meal = (await (0, meals_1.getDay)(login, date)).entries.find(entry => entry.id === id);
if (!meal) {
throw new utils_1.NotFoundError("Jídlo nebylo nalezeno");
}
const key = (0, calories_1.normalizeName)(meal.name);
const existing = (await (0, exports.getMealTemplates)(login)).find(item => (0, calories_1.normalizeName)(item.name) === key);
if (existing?.kind === types_gen_1.MealTemplateKind.RECEPT) {
throw new utils_1.BadRequestError(`Jídlo „${meal.name}" už máte jako recept, šablona se nepřepíše`);
}
return (0, exports.saveMealTemplate)(login, {
...(existing ? { id: existing.id } : {}),
kind: types_gen_1.MealTemplateKind.HOTOVE,
name: meal.name,
source: meal.source,
mealType: meal.mealType,
weight: meal.weight,
price: meal.price,
pricePer100g: meal.pricePer100g,
// Absolutní kalorie ukládáme jen tam, kde je nejde dopočítat z gramáže
calories: meal.caloriesPer100g ? undefined : meal.calories,
caloriesPer100g: meal.caloriesPer100g,
note: meal.note,
});
};
exports.createMealTemplateFromMeal = createMealTemplateFromMeal;
+343
View File
@@ -0,0 +1,343 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.isMealType = isMealType;
exports.buildDayRecord = buildDayRecord;
exports.getDay = getDay;
exports.getRange = getRange;
exports.deriveAmounts = deriveAmounts;
exports.normalizeInput = normalizeInput;
exports.addMeal = addMeal;
exports.addEntries = addEntries;
exports.updateMeal = updateMeal;
exports.deleteMeal = deleteMeal;
exports.setMealMultiplier = setMealMultiplier;
exports.findMealsForDays = findMealsForDays;
const crypto_1 = require("crypto");
const types_gen_1 = require("../../types/gen/types.gen");
const storage_1 = __importDefault(require("./storage"));
const utils_1 = require("./utils");
const calories_1 = require("./calories");
const storage = (0, storage_1.default)();
/** 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 = [
types_gen_1.MealType.SNIDANE,
types_gen_1.MealType.DOPOLEDNI_SVACINA,
types_gen_1.MealType.OBED,
types_gen_1.MealType.ODPOLEDNI_SVACINA,
types_gen_1.MealType.VECERE,
types_gen_1.MealType.PULNOCNI_SVACINA,
types_gen_1.MealType.JINE,
];
/** Vrátí true, pokud je hodnota podporovaným typem jídla. */
function isMealType(value) {
return typeof value === 'string' && MEAL_TYPE_ORDER.includes(value);
}
/** Sestaví klíč úložiště pro jeden den jednoho uživatele. */
function getDayKey(login, date) {
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) {
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ů. */
function buildDayRecord(date, entries) {
const sorted = sortEntries(entries);
return {
date,
entries: sorted,
totalPrice: sorted.reduce((sum, entry) => sum + (entry.price ?? 0), 0),
totalCalories: sorted.reduce((sum, entry) => sum + ((0, utils_1.applyMultiplier)(entry.calories, entry.multiplier) ?? 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
*/
async function getDay(login, date) {
const entries = await storage.getData(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
*/
async function getRange(login, from, to) {
if (from > to) {
throw new utils_1.BadRequestError("Začátek rozsahu je až za jeho koncem");
}
const days = await Promise.all((0, utils_1.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, name) {
if (value == null)
return undefined;
if (typeof value !== 'string') {
throw new utils_1.BadRequestError(`Pole '${name}' musí být text`);
}
const trimmed = value.trim();
if (!trimmed.length)
return undefined;
if (trimmed.length > MAX_TEXT_LENGTH) {
throw new utils_1.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, name) {
if (value == null)
return undefined;
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new utils_1.BadRequestError(`Pole '${name}' musí být číslo`);
}
const rounded = Math.round(value);
if (rounded < 0) {
throw new utils_1.BadRequestError(`Pole '${name}' nesmí být záporné`);
}
return rounded;
}
/**
* 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.
*/
function deriveAmounts(input) {
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. */
function normalizeInput(input) {
const value = (input ?? {});
if (!(0, utils_1.isValidIsoDate)(value.date)) {
throw new utils_1.BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
}
if (!isMealType(value.mealType)) {
throw new utils_1.BadRequestError("Nebyl předán platný typ jídla");
}
const name = normalizeText(value.name, 'name');
if (!name) {
throw new utils_1.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'),
multiplier: (0, utils_1.normalizeMultiplier)(value.multiplier),
});
}
/**
* 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
* @param templateId šablona, ze které jídlo vzniká — klient ji poslat nemůže, nastavuje ji jen server
*/
async function addMeal(login, input, templateId) {
const normalized = normalizeInput(input);
const entry = {
id: (0, crypto_1.randomUUID)(),
createdAt: new Date().toISOString(),
...normalized,
...(templateId ? { templateId } : {}),
};
const entries = await storage.updateData(getDayKey(login, normalized.date), current => [...(current ?? []), entry]);
if (normalized.caloriesPer100g) {
await (0, calories_1.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í
* @param replaceImportSource pokud je vyplněný, odeberou se z dotčených dnů nejdřív
* dřívější záznamy z tohoto importu; ručně zadaná jídla zůstanou
*/
async function addEntries(login, entries, replaceImportSource) {
const byDate = new Map();
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(getDayKey(login, date), current => [
...(current ?? []).filter(entry => !replaceImportSource || entry.importSource !== replaceImportSource),
...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
*/
async function updateMeal(login, id, input) {
if (typeof id !== 'string' || !id.length) {
throw new utils_1.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 utils_1.NotFoundError("Záznam o jídle nebyl nalezen");
}
let original;
await storage.updateData(getDayKey(login, originalDate), current => {
original = (current ?? []).find(entry => entry.id === id);
return (current ?? []).filter(entry => entry.id !== id);
});
if (!original) {
throw new utils_1.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 = {
...original,
...normalized,
price: normalized.price,
calories: normalized.calories,
source: normalized.source,
note: normalized.note,
weight: normalized.weight,
pricePer100g: normalized.pricePer100g,
caloriesPer100g: normalized.caloriesPer100g,
multiplier: normalized.multiplier,
updatedAt: new Date().toISOString(),
};
const entries = await storage.updateData(getDayKey(login, normalized.date), current => [...(current ?? []), updated]);
if (normalized.caloriesPer100g) {
await (0, calories_1.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)
*/
async function deleteMeal(login, id, date) {
if (typeof id !== 'string' || !id.length) {
throw new utils_1.BadRequestError("Nebyl předán identifikátor záznamu");
}
if (!(0, utils_1.isValidIsoDate)(date)) {
throw new utils_1.BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
}
let found = false;
const entries = await storage.updateData(getDayKey(login, date), current => {
found = (current ?? []).some(entry => entry.id === id);
return (current ?? []).filter(entry => entry.id !== id);
});
if (!found) {
throw new utils_1.NotFoundError("Záznam o jídle nebyl nalezen");
}
return buildDayRecord(date, entries);
}
/**
* Nastaví násobek porce jednoho jídla. Ostatní údaje záznamu zůstanou beze změny,
* takže jde násobek měnit přímo z přehledu dne bez otevření formuláře.
*
* @param login přihlašovací jméno uživatele
* @param id identifikátor záznamu
* @param date datum záznamu (YYYY-MM-DD)
* @param multiplier nový násobek; 1 násobek zruší
*/
async function setMealMultiplier(login, id, date, multiplier) {
if (typeof id !== 'string' || !id.length) {
throw new utils_1.BadRequestError("Nebyl předán identifikátor záznamu");
}
if (!(0, utils_1.isValidIsoDate)(date)) {
throw new utils_1.BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
}
if (multiplier == null) {
throw new utils_1.BadRequestError("Nebyl předán násobek");
}
const normalized = (0, utils_1.normalizeMultiplier)(multiplier);
let found = false;
await storage.updateData(getDayKey(login, date), current => (current ?? []).map(entry => {
if (entry.id !== id)
return entry;
found = true;
const updated = { ...entry, multiplier: normalized, updatedAt: new Date().toISOString() };
if (normalized == null)
delete updated.multiplier;
return updated;
}));
if (!found) {
throw new utils_1.NotFoundError("Záznam o jídle nebyl nalezen");
}
return date;
}
/**
* 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, id, expectedDate) {
const expected = await storage.getData(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(key);
if (entries?.some(entry => entry.id === id)) {
return key.substring(prefix.length);
}
}
return undefined;
}
/**
* Vrátí všechna jídla uživatele v předaných dnech, seřazená dle dne a chodu.
* Import podle nich rozpoznává duplicity a v náhledu je ukazuje uživateli.
*
* @param login přihlašovací jméno uživatele
* @param dates dny, ve kterých se má hledat
*/
async function findMealsForDays(login, dates) {
const days = await Promise.all([...dates].sort().map(date => getDay(login, date)));
return days.flatMap(day => day.entries);
}
+74
View File
@@ -0,0 +1,74 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.computeBasalCalories = exports.normalizeProfile = exports.isPohlavi = void 0;
const types_gen_1 = require("../../types/gen/types.gen");
const utils_1 = require("./utils");
/** Meze tělesných údajů — chrání před překlepem o řád, ne před neobvyklými hodnotami. */
const LIMITS = {
birthYear: { min: 1900, max: new Date().getFullYear() },
heightCm: { min: 50, max: 250 },
weightGrams: { min: 20_000, max: 400_000 },
};
/** Vrátí true, pokud je hodnota podporovaným pohlavím. */
const isPohlavi = (value) => value === types_gen_1.Pohlavi.MUZ || value === types_gen_1.Pohlavi.ZENA;
exports.isPohlavi = isPohlavi;
/** Ověří volitelné celé číslo v daném rozsahu. */
const normalizeRanged = (value, name) => {
if (value == null)
return undefined;
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new utils_1.BadRequestError(`Pole '${name}' musí být číslo`);
}
const rounded = Math.round(value);
const { min, max } = LIMITS[name];
if (rounded < min || rounded > max) {
throw new utils_1.BadRequestError(`Pole '${name}' je mimo rozsah (${min}–${max})`);
}
return rounded;
};
/** Ověří a znormalizuje tělesné údaje. Prázdné hodnoty se uloží jako nevyplněné. */
const normalizeProfile = (input) => {
const value = (input ?? {});
if (value.sex != null && !(0, exports.isPohlavi)(value.sex)) {
throw new utils_1.BadRequestError("Nebylo předáno platné pohlaví");
}
return {
...(value.sex != null ? { sex: value.sex } : {}),
...(normalizeRanged(value.birthYear, 'birthYear') != null
? { birthYear: normalizeRanged(value.birthYear, 'birthYear') } : {}),
...(normalizeRanged(value.heightCm, 'heightCm') != null
? { heightCm: normalizeRanged(value.heightCm, 'heightCm') } : {}),
...(normalizeRanged(value.weightGrams, 'weightGrams') != null
? { weightGrams: normalizeRanged(value.weightGrams, 'weightGrams') } : {}),
};
};
exports.normalizeProfile = normalizeProfile;
/**
* Spočítá bazální metabolismus rovnicí Mifflin–St Jeor.
*
* ```
* muž: 10 × kg + 6,25 × cm − 5 × věk + 5
* žena: 10 × kg + 6,25 × cm − 5 × věk − 161
* ```
*
* Je to klidový výdej za **celých 24 hodin**, tedy včetně spánku. Záměrně se
* nenásobí koeficientem fyzické aktivity (PAL), jak to dělají kalkulačky TDEE —
* pohyb si aplikace eviduje zvlášť a přičítá ho k tomuto číslu, takže vynásobení
* by ho započítalo dvakrát.
*
* Výsledek je odhad s běžnou chybou kolem ±10 %, ne měřená hodnota.
*
* @param profile tělesné údaje uživatele
* @returns klidový výdej v kcal za den, nebo nic, když profil není úplný
*/
const computeBasalCalories = (profile) => {
if (!profile?.sex || !profile.birthYear || !profile.heightCm || !profile.weightGrams) {
return undefined;
}
const age = new Date().getFullYear() - profile.birthYear;
const base = 10 * (profile.weightGrams / 1000)
+ 6.25 * profile.heightCm
- 5 * age;
return Math.round(base + (profile.sex === types_gen_1.Pohlavi.MUZ ? 5 : -161));
};
exports.computeBasalCalories = computeBasalCalories;
+59
View File
@@ -0,0 +1,59 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = __importDefault(require("express"));
const activities_1 = require("../activities");
const dayOverview_1 = require("../dayOverview");
const auth_1 = require("../auth");
const utils_1 = require("../utils");
const router = express_1.default.Router();
/** Přidá pohybovou aktivitu a vrátí aktualizovaný přehled dne. */
router.post("/add", async (req, res, next) => {
try {
const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req));
const date = await (0, activities_1.addActivity)(login, req.body);
res.status(200).json(await (0, dayOverview_1.getDayOverview)(login, date));
}
catch (e) {
next(e);
}
});
/** Upraví existující aktivitu. */
router.post("/update", async (req, res, next) => {
try {
const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req));
const { id, ...input } = req.body ?? {};
const date = await (0, activities_1.updateActivity)(login, id, input);
res.status(200).json(await (0, dayOverview_1.getDayOverview)(login, date));
}
catch (e) {
next(e);
}
});
/** Smaže aktivitu. */
router.post("/delete", async (req, res, next) => {
try {
const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req));
const { id, date } = req.body ?? {};
const day = await (0, activities_1.deleteActivity)(login, id, date);
res.status(200).json(await (0, dayOverview_1.getDayOverview)(login, day));
}
catch (e) {
next(e);
}
});
/** Nastaví násobek aktivity z přehledu dne. */
router.post("/multiplier", async (req, res, next) => {
try {
const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req));
const { id, date, multiplier } = req.body ?? {};
const day = await (0, activities_1.setActivityMultiplier)(login, id, date, multiplier);
res.status(200).json(await (0, dayOverview_1.getDayOverview)(login, day));
}
catch (e) {
next(e);
}
});
exports.default = router;
+21
View File
@@ -0,0 +1,21 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = __importDefault(require("express"));
const calories_1 = require("../calories");
const auth_1 = require("../auth");
const utils_1 = require("../utils");
const router = express_1.default.Router();
/** Najde návrhy energetické hodnoty pro název jídla. */
router.get("/search", async (req, res, next) => {
try {
const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req));
res.status(200).json(await (0, calories_1.searchCalories)(login, req.query.q));
}
catch (e) {
next(e);
}
});
exports.default = router;
+37
View File
@@ -0,0 +1,37 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = __importDefault(require("express"));
const dayOverview_1 = require("../dayOverview");
const sleep_1 = require("../sleep");
const auth_1 = require("../auth");
const utils_1 = require("../utils");
const router = express_1.default.Router();
/** Vrátí kompletní přehled dne. Bez parametru vrací dnešek. */
router.get("/", async (req, res, next) => {
try {
const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req));
const date = req.query.date == null
? (0, utils_1.formatDate)((0, utils_1.getToday)())
: (0, utils_1.requireIsoDate)(req.query.date, 'date');
res.status(200).json(await (0, dayOverview_1.getDayOverview)(login, date));
}
catch (e) {
next(e);
}
});
/** Uloží délku spánku pro zvolený den. */
router.post("/sleep", async (req, res, next) => {
try {
const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req));
const { date, sleepMinutes } = req.body ?? {};
const day = await (0, sleep_1.saveSleep)(login, date, sleepMinutes);
res.status(200).json(await (0, dayOverview_1.getDayOverview)(login, day));
}
catch (e) {
next(e);
}
});
exports.default = router;
+38
View File
@@ -0,0 +1,38 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = __importDefault(require("express"));
const luncherImport_1 = require("../luncherImport");
const auth_1 = require("../auth");
const utils_1 = require("../utils");
const router = express_1.default.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, res, next) => {
try {
const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req));
const { fileName, content, defaultMealType, dryRun, mode, selectedKeys } = req.body ?? {};
if (typeof fileName !== 'string' || !fileName.trim().length) {
throw new utils_1.BadRequestError("Nebyl předán název souboru");
}
if (typeof content !== 'string' || !content.length) {
throw new utils_1.BadRequestError("Nebyl předán obsah souboru");
}
const buffer = Buffer.from(content, 'base64');
if (!buffer.length) {
throw new utils_1.BadRequestError("Obsah souboru je prázdný nebo není platný Base64");
}
const result = await (0, luncherImport_1.importLuncherExport)(login, fileName.trim(), buffer, defaultMealType, dryRun === true, mode, selectedKeys);
res.status(200).json(result);
}
catch (e) {
next(e);
}
});
exports.default = router;
+85
View File
@@ -0,0 +1,85 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = __importDefault(require("express"));
const meals_1 = require("../meals");
const dayOverview_1 = require("../dayOverview");
const auth_1 = require("../auth");
const utils_1 = require("../utils");
const router = express_1.default.Router();
/**
* Vrátí jídla přihlášeného uživatele za jeden den. Bez parametru vrací dnešek.
*/
router.get("/day", async (req, res, next) => {
try {
const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req));
const date = req.query.date == null
? (0, utils_1.formatDate)((0, utils_1.getToday)())
: (0, utils_1.requireIsoDate)(req.query.date, 'date');
res.status(200).json(await (0, meals_1.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, res, next) => {
try {
const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req));
const from = (0, utils_1.requireIsoDate)(req.query.from, 'from');
const to = (0, utils_1.requireIsoDate)(req.query.to, 'to');
res.status(200).json(await (0, meals_1.getRange)(login, from, to));
}
catch (e) {
next(e);
}
});
/** Přidá jídlo do zvoleného dne. */
router.post("/add", async (req, res, next) => {
try {
const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req));
res.status(200).json(await (0, meals_1.addMeal)(login, req.body));
}
catch (e) {
next(e);
}
});
/** Upraví existující záznam o jídle. */
router.post("/update", async (req, res, next) => {
try {
const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req));
const { id, ...input } = req.body ?? {};
res.status(200).json(await (0, meals_1.updateMeal)(login, id, input));
}
catch (e) {
next(e);
}
});
/** Smaže záznam o jídle. */
router.post("/delete", async (req, res, next) => {
try {
const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req));
const { id, date } = req.body ?? {};
res.status(200).json(await (0, meals_1.deleteMeal)(login, id, date));
}
catch (e) {
next(e);
}
});
/** Nastaví násobek porce z přehledu dne a vrátí celý přehled, protože se mění i bilance. */
router.post("/multiplier", async (req, res, next) => {
try {
const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req));
const { id, date, multiplier } = req.body ?? {};
const day = await (0, meals_1.setMealMultiplier)(login, id, date, multiplier);
res.status(200).json(await (0, dayOverview_1.getDayOverview)(login, day));
}
catch (e) {
next(e);
}
});
exports.default = router;
+65
View File
@@ -0,0 +1,65 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = __importDefault(require("express"));
const mealTemplates_1 = require("../mealTemplates");
const dayOverview_1 = require("../dayOverview");
const auth_1 = require("../auth");
const utils_1 = require("../utils");
const router = express_1.default.Router();
/** Vrátí šablony jídel přihlášeného uživatele. */
router.get("/", async (req, res, next) => {
try {
const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req));
res.status(200).json(await (0, mealTemplates_1.getMealTemplates)(login));
}
catch (e) {
next(e);
}
});
/** Založí novou šablonu jídla, nebo upraví existující. */
router.post("/save", async (req, res, next) => {
try {
const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req));
res.status(200).json(await (0, mealTemplates_1.saveMealTemplate)(login, req.body));
}
catch (e) {
next(e);
}
});
/** Smaže šablonu jídla. */
router.post("/delete", async (req, res, next) => {
try {
const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req));
res.status(200).json(await (0, mealTemplates_1.deleteMealTemplate)(login, req.body?.id));
}
catch (e) {
next(e);
}
});
/** Založí jídlo ze šablony do zvoleného dne. */
router.post("/apply", async (req, res, next) => {
try {
const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req));
const { id, date, mealType, multiplier } = req.body ?? {};
const day = await (0, mealTemplates_1.applyMealTemplate)(login, id, date, mealType, multiplier);
res.status(200).json(await (0, dayOverview_1.getDayOverview)(login, day));
}
catch (e) {
next(e);
}
});
/** Vytvoří šablonu z existujícího jídla v přehledu dne. */
router.post("/fromMeal", async (req, res, next) => {
try {
const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req));
const { id, date } = req.body ?? {};
res.status(200).json(await (0, mealTemplates_1.createMealTemplateFromMeal)(login, date, id));
}
catch (e) {
next(e);
}
});
exports.default = router;
+52
View File
@@ -0,0 +1,52 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = __importDefault(require("express"));
const settings_1 = require("../settings");
const auth_1 = require("../auth");
const utils_1 = require("../utils");
const router = express_1.default.Router();
/** Vrátí nastavení přihlášeného uživatele. */
router.get("/", async (req, res, next) => {
try {
const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req));
res.status(200).json(await (0, settings_1.getSettings)(login));
}
catch (e) {
next(e);
}
});
/** Uloží nebo smaže cenu za 100 g u jednoho zdroje. */
router.post("/", async (req, res, next) => {
try {
const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req));
const { source, pricePer100g } = req.body ?? {};
res.status(200).json(await (0, settings_1.saveSourceRate)(login, source, pricePer100g));
}
catch (e) {
next(e);
}
});
/** Uloží klidový výdej (bazální metabolismus). */
router.post("/basal", async (req, res, next) => {
try {
const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req));
res.status(200).json(await (0, settings_1.saveBasalCalories)(login, req.body?.basalCalories));
}
catch (e) {
next(e);
}
});
/** Uloží tělesné údaje uživatele. */
router.post("/profile", async (req, res, next) => {
try {
const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req));
res.status(200).json(await (0, settings_1.saveProfile)(login, req.body));
}
catch (e) {
next(e);
}
});
exports.default = router;
+35
View File
@@ -0,0 +1,35 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = __importDefault(require("express"));
const statsService_1 = require("../statsService");
const auth_1 = require("../auth");
const utils_1 = require("../utils");
const router = express_1.default.Router();
/** Vrátí statistiky útraty a jídel přihlášeného uživatele za zvolené období. */
router.get("/summary", async (req, res, next) => {
try {
const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req));
const from = (0, utils_1.requireIsoDate)(req.query.from, 'from');
const to = (0, utils_1.requireIsoDate)(req.query.to, 'to');
res.status(200).json(await (0, statsService_1.getFoodStats)(login, from, to));
}
catch (e) {
next(e);
}
});
/** Vrátí statistiky výdeje pohybem přihlášeného uživatele za zvolené období. */
router.get("/activity", async (req, res, next) => {
try {
const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req));
const from = (0, utils_1.requireIsoDate)(req.query.from, 'from');
const to = (0, utils_1.requireIsoDate)(req.query.to, 'to');
res.status(200).json(await (0, statsService_1.getActivityStats)(login, from, to));
}
catch (e) {
next(e);
}
});
exports.default = router;
+66
View File
@@ -0,0 +1,66 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = __importDefault(require("express"));
const workouts_1 = require("../workouts");
const dayOverview_1 = require("../dayOverview");
const auth_1 = require("../auth");
const utils_1 = require("../utils");
const router = express_1.default.Router();
/** Vrátí šablony tréninků přihlášeného uživatele. */
router.get("/", async (req, res, next) => {
try {
const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req));
res.status(200).json(await (0, workouts_1.getWorkoutTemplates)(login));
}
catch (e) {
next(e);
}
});
/** Založí novou šablonu tréninku, nebo upraví existující. */
router.post("/save", async (req, res, next) => {
try {
const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req));
const { id, name, items } = req.body ?? {};
res.status(200).json(await (0, workouts_1.saveWorkoutTemplate)(login, id, name, items));
}
catch (e) {
next(e);
}
});
/** Smaže šablonu tréninku. */
router.post("/delete", async (req, res, next) => {
try {
const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req));
res.status(200).json(await (0, workouts_1.deleteWorkoutTemplate)(login, req.body?.id));
}
catch (e) {
next(e);
}
});
/** Založí do zvoleného dne všechny položky šablony. */
router.post("/apply", async (req, res, next) => {
try {
const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req));
const { id, date } = req.body ?? {};
const day = await (0, workouts_1.applyWorkoutTemplate)(login, id, date);
res.status(200).json(await (0, dayOverview_1.getDayOverview)(login, day));
}
catch (e) {
next(e);
}
});
/** Vytvoří šablonu z aktivit zaznamenaných v přehledu dne. */
router.post("/fromActivities", async (req, res, next) => {
try {
const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req));
const { date, name, ids } = req.body ?? {};
res.status(200).json(await (0, workouts_1.createWorkoutTemplateFromActivities)(login, date, name, ids));
}
catch (e) {
next(e);
}
});
exports.default = router;
+135
View File
@@ -0,0 +1,135 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.getSettings = getSettings;
exports.saveProfile = saveProfile;
exports.saveSourceRate = saveSourceRate;
exports.saveBasalCalories = saveBasalCalories;
exports.getSourceRate = getSourceRate;
const storage_1 = __importDefault(require("./storage"));
const utils_1 = require("./utils");
const calories_1 = require("./calories");
const profile_1 = require("./profile");
const storage = (0, storage_1.default)();
/** 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) {
return `settings:${login}`;
}
/** Prázdné nastavení pro uživatele, který si zatím nic nenastavil. */
function emptySettings() {
return { sourceRates: [] };
}
/**
* Vrátí nastavení uživatele.
*
* @param login přihlašovací jméno uživatele
*/
async function getSettings(login) {
const stored = await storage.getData(getSettingsKey(login)) ?? emptySettings();
return withDerived(stored);
}
/**
* Doplní nastavení o hodnoty, které se dopočítávají a neukládají.
*
* Ručně zadaný klidový výdej má přednost před spočítaným z tělesných údajů —
* kdo si změřil vlastní, nechce ho mít přebitý odhadem z rovnice.
*/
function withDerived(settings) {
const computed = (0, profile_1.computeBasalCalories)(settings.profile);
const effective = settings.basalCalories ?? computed;
return {
...settings,
...(computed != null ? { computedBasalCalories: computed } : {}),
...(effective != null ? { effectiveBasalCalories: effective } : {}),
};
}
/**
* Uloží tělesné údaje uživatele.
*
* @param login přihlašovací jméno uživatele
* @param profile tělesné údaje
*/
async function saveProfile(login, profile) {
const normalized = (0, profile_1.normalizeProfile)(profile);
const saved = await storage.updateData(getSettingsKey(login), current => ({
...(current ?? emptySettings()),
profile: normalized,
}));
return withDerived(saved);
}
/**
* 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í
*/
async function saveSourceRate(login, source, pricePer100g) {
if (typeof source !== 'string' || !source.trim().length) {
throw new utils_1.BadRequestError("Nebyl předán název zdroje");
}
if (pricePer100g != null && (typeof pricePer100g !== 'number' || !Number.isFinite(pricePer100g))) {
throw new utils_1.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 utils_1.BadRequestError("Cena za 100 g je mimo rozsah");
}
const name = source.trim();
const key = (0, calories_1.normalizeName)(name);
const saved = await storage.updateData(getSettingsKey(login), current => {
const settings = current ?? emptySettings();
const rates = settings.sourceRates.filter(item => (0, calories_1.normalizeName)(item.source) !== key);
// Nulová sazba znamená smazání — ukládat ji nemá smysl
return { ...settings, sourceRates: rate > 0 ? [...rates, { source: name, pricePer100g: rate }] : rates };
});
return withDerived(saved);
}
/**
* 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í
*/
async function saveBasalCalories(login, basalCalories) {
if (basalCalories != null && (typeof basalCalories !== 'number' || !Number.isFinite(basalCalories))) {
throw new utils_1.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 utils_1.BadRequestError("Klidový výdej je mimo rozsah");
}
const saved = await storage.updateData(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;
});
return withDerived(saved);
}
/**
* 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
*/
async function getSourceRate(login, source) {
if (!source?.trim().length)
return undefined;
const key = (0, calories_1.normalizeName)(source);
const settings = await getSettings(login);
return settings.sourceRates.find(rate => (0, calories_1.normalizeName)(rate.source) === key)?.pricePer100g;
}
+49
View File
@@ -0,0 +1,49 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.saveSleep = exports.getSleep = void 0;
const storage_1 = __importDefault(require("./storage"));
const utils_1 = require("./utils");
const storage = (0, storage_1.default)();
/** Horní mez délky spánku — víc než 24 hodin za den je překlep. */
const MAX_SLEEP_MINUTES = 24 * 60;
/** Klíč úložiště se spánkem jednoho dne jednoho uživatele. */
const getSleepKey = (login, date) => `sleep:${login}:${date}`;
/**
* Vrátí délku spánku daného dne v minutách, pokud je zaznamenaná.
*
* @param login přihlašovací jméno uživatele
* @param date datum ve formátu YYYY-MM-DD
*/
const getSleep = async (login, date) => await storage.getData(getSleepKey(login, date));
exports.getSleep = getSleep;
/**
* Uloží délku spánku pro zvolený den. Nulová hodnota záznam odstraní.
*
* Spánek se eviduje jako kontext dne, ne jako složka energetické bilance —
* klidový výdej z rovnice Mifflin–St Jeor už pokrývá celých 24 hodin včetně
* spánku, takže přičítat nebo odečítat za něj kalorie by bylo dvojí započtení.
*
* @param login přihlašovací jméno uživatele
* @param date datum ve formátu YYYY-MM-DD
* @param sleepMinutes délka spánku v minutách, nebo null pro odstranění
* @returns datum, kterého se zápis týkal
*/
const saveSleep = async (login, date, sleepMinutes) => {
if (!(0, utils_1.isValidIsoDate)(date)) {
throw new utils_1.BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
}
if (sleepMinutes != null && (typeof sleepMinutes !== 'number' || !Number.isFinite(sleepMinutes))) {
throw new utils_1.BadRequestError("Délka spánku musí být číslo");
}
const value = sleepMinutes == null ? 0 : Math.round(sleepMinutes);
if (value < 0 || value > MAX_SLEEP_MINUTES) {
throw new utils_1.BadRequestError("Délka spánku je mimo rozsah");
}
// Nula znamená smazání — ukládat ji nemá smysl
await storage.setData(getSleepKey(login, date), value > 0 ? value : undefined);
return date;
};
exports.saveSleep = saveSleep;
+265
View File
@@ -0,0 +1,265 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getFoodStats = getFoodStats;
exports.getActivityStats = getActivityStats;
const types_gen_1 = require("../../types/gen/types.gen");
const meals_1 = require("./meals");
const activities_1 = require("./activities");
const workouts_1 = require("./workouts");
const workoutPlans_1 = require("./workoutPlans");
const utils_1 = require("./utils");
/** Zdroj přiřazený záznamům, které ho nemají vyplněný. */
const UNKNOWN_SOURCE = 'Neuvedeno';
/** Přičte záznam do kbelíku pod daným klíčem. */
function accumulate(buckets, key, entry) {
const bucket = buckets.get(key) ?? { price: 0, calories: 0, count: 0 };
bucket.price += entry.price ?? 0;
bucket.calories += (0, utils_1.applyMultiplier)(entry.calories, entry.multiplier) ?? 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) {
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) {
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
*/
async function getFoodStats(login, from, to) {
const days = await (0, meals_1.getRange)(login, from, to);
const byDay = new Map();
const byMonth = new Map();
const byYear = new Map();
const byMealType = new Map();
const bySource = new Map();
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 += (0, utils_1.applyMultiplier)(entry.calories, entry.multiplier) ?? 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),
};
}
/** Název šablony, která už neexistuje a nemá ani uložený plán s názvem. */
const DELETED_TEMPLATE = 'Smazaná šablona';
/** Přičte výdej dne do období pod daným klíčem. */
function accumulateActivity(buckets, period, template, planned, extra) {
const bucket = buckets.get(period) ?? { period, templateCalories: 0, plannedCalories: 0, extraCalories: 0 };
bucket.templateCalories += template;
bucket.plannedCalories += planned;
bucket.extraCalories += extra;
buckets.set(period, bucket);
}
/** Seřadí součty období vzestupně dle označení období. */
const sortPeriods = (buckets) => Array.from(buckets.values()).sort((a, b) => a.period.localeCompare(b.period));
/** Stručný popis aktivity nebo cviku plánu; jedna série a násobek 1 se vynechávají. */
const summarize = (item, calories) => ({
name: item.name,
unit: item.unit,
quantity: item.quantity,
...(item.sets != null && item.sets > 1 ? { sets: item.sets } : {}),
...(item.multiplier != null && item.multiplier !== 1 ? { multiplier: item.multiplier } : {}),
calories,
});
/** Vrátí true, pokud se aktivita provedla jinak, než plán cviku sliboval. */
const differsFromPlan = (entry, item, calories) => entry.quantity !== item.quantity
|| (entry.sets ?? 1) !== (item.sets ?? 1)
|| (entry.multiplier ?? 1) !== 1
|| calories !== item.calories;
/**
* Rozebere jeden den: ke každé použité šabloně dohledá odchylky od plánu
* a aktivity mimo šablony vrátí zvlášť.
*
* Aktivity ze šablony bez uloženého plánu (cvik přesunutý z jiného dne) se
* berou jako splněné přesně dle plánu, protože plán z nich zjistit nejde.
*/
function analyzeDay(entries, plans, templateNames, date) {
const byId = new Map(entries.map(entry => [entry.id, entry]));
const planned = new Set(plans.flatMap(plan => plan.items.map(item => item.entryId)));
const usages = new Map();
const counts = new Map();
const usageFor = (templateId, name, hasPlan) => {
const usage = usages.get(templateId) ?? {
templateId, name, plannedCalories: 0, actualCalories: 0, hasPlan, deviations: [],
};
usages.set(templateId, usage);
if (!counts.has(templateId))
counts.set(templateId, { planned: 0, removed: 0 });
return usage;
};
for (const plan of plans) {
// Stejná šablona použitá v jednom dni dvakrát má dva plány, sčítají se
const usage = usageFor(plan.templateId, plan.templateName, true);
const count = counts.get(plan.templateId);
for (const item of plan.items) {
usage.plannedCalories += item.calories;
count.planned++;
const entry = item.entryId ? byId.get(item.entryId) : undefined;
if (!entry) {
usage.deviations.push({ kind: types_gen_1.PlanDeviationKind.ODEBRANO, planned: summarize(item, item.calories) });
count.removed++;
continue;
}
const calories = (0, utils_1.applyMultiplier)(entry.calories, entry.multiplier) ?? 0;
usage.actualCalories += calories;
if (differsFromPlan(entry, item, calories)) {
usage.deviations.push({
kind: types_gen_1.PlanDeviationKind.UPRAVENO,
planned: summarize(item, item.calories),
actual: summarize(entry, calories),
});
}
}
}
const extras = [];
for (const entry of entries) {
if (planned.has(entry.id))
continue;
const calories = (0, utils_1.applyMultiplier)(entry.calories, entry.multiplier) ?? 0;
if (!entry.templateId) {
extras.push(summarize(entry, calories));
continue;
}
const usage = usageFor(entry.templateId, templateNames.get(entry.templateId) ?? DELETED_TEMPLATE, false);
usage.plannedCalories += calories;
usage.actualCalories += calories;
counts.get(entry.templateId).planned++;
}
const templates = Array.from(usages.values());
const extraCalories = extras.reduce((sum, item) => sum + item.calories, 0);
return {
day: {
date,
totalCalories: templates.reduce((sum, usage) => sum + usage.actualCalories, 0) + extraCalories,
templates,
extraCalories,
extras,
},
counts,
};
}
/**
* Sestaví statistiky výdeje pohybem za zvolené období.
*
* Aktivity ze šablon se porovnávají s plánem, který si šablona uložila při
* použití (`workoutPlans.ts`) — ne s její současnou podobou, ta se mohla
* mezitím změnit.
*
* @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
*/
async function getActivityStats(login, from, to) {
if (from > to) {
throw new utils_1.BadRequestError("Začátek rozsahu je až za jeho koncem");
}
const dates = (0, utils_1.listDatesInRange)(from, to);
const [activityDays, plansByDay, templates] = await Promise.all([
Promise.all(dates.map(date => (0, activities_1.getActivityDay)(login, date))),
Promise.all(dates.map(date => (0, workoutPlans_1.getWorkoutPlans)(login, date))),
(0, workouts_1.getWorkoutTemplates)(login),
]);
const templateNames = new Map(templates.map(template => [template.id, template.name]));
const byDay = new Map();
const byMonth = new Map();
const byYear = new Map();
const byTemplate = new Map();
const extras = new Map();
const days = [];
let templateCalories = 0;
let plannedCalories = 0;
let extraCalories = 0;
let entryCount = 0;
activityDays.forEach((activityDay, index) => {
if (!activityDay.entries.length)
return;
entryCount += activityDay.entries.length;
const { day, counts } = analyzeDay(activityDay.entries, plansByDay[index], templateNames, activityDay.date);
days.push(day);
let dayTemplate = 0;
let dayPlanned = 0;
for (const usage of day.templates) {
const count = counts.get(usage.templateId);
const total = byTemplate.get(usage.templateId) ?? {
templateId: usage.templateId, name: usage.name, dayCount: 0,
plannedCalories: 0, actualCalories: 0, plannedItemCount: 0, doneItemCount: 0,
};
// Dny jdou vzestupně, takže zůstane název z posledního použití
total.name = usage.name;
total.dayCount++;
total.plannedCalories += usage.plannedCalories;
total.actualCalories += usage.actualCalories;
total.plannedItemCount += count.planned;
total.doneItemCount += count.planned - count.removed;
byTemplate.set(usage.templateId, total);
dayTemplate += usage.actualCalories;
dayPlanned += usage.plannedCalories;
}
for (const item of day.extras) {
const group = extras.get(item.name) ?? { key: item.name, calories: 0, count: 0 };
group.calories += item.calories;
group.count++;
extras.set(item.name, group);
}
accumulateActivity(byDay, day.date, dayTemplate, dayPlanned, day.extraCalories);
accumulateActivity(byMonth, day.date.substring(0, 7), dayTemplate, dayPlanned, day.extraCalories);
accumulateActivity(byYear, day.date.substring(0, 4), dayTemplate, dayPlanned, day.extraCalories);
templateCalories += dayTemplate;
plannedCalories += dayPlanned;
extraCalories += day.extraCalories;
});
return {
from,
to,
totalCalories: templateCalories + extraCalories,
templateCalories,
plannedCalories,
extraCalories,
entryCount,
dayCount: days.length,
byDay: sortPeriods(byDay),
byMonth: sortPeriods(byMonth),
byYear: sortPeriods(byYear),
byTemplate: Array.from(byTemplate.values())
.sort((a, b) => b.plannedCalories - a.plannedCalories || a.name.localeCompare(b.name, 'cs')),
extras: Array.from(extras.values())
.sort((a, b) => b.calories - a.calories || a.key.localeCompare(b.key, 'cs')),
days,
};
}
+2
View File
@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
+36
View File
@@ -0,0 +1,36 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.storageReady = void 0;
exports.default = getStorage;
const dotenv_1 = __importDefault(require("dotenv"));
const path_1 = __importDefault(require("path"));
const json_1 = __importDefault(require("./json"));
const redis_1 = __importDefault(require("./redis"));
const memory_1 = __importDefault(require("./memory"));
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
dotenv_1.default.config({ path: path_1.default.resolve(__dirname, `../../.env.${ENVIRONMENT}`) });
const JSON_KEY = 'json';
const REDIS_KEY = 'redis';
const MEMORY_KEY = 'memory';
let storage;
if (!process.env.STORAGE || process.env.STORAGE?.toLowerCase() === JSON_KEY) {
storage = new json_1.default();
}
else if (process.env.STORAGE?.toLowerCase() === REDIS_KEY) {
storage = new redis_1.default();
}
else if (process.env.STORAGE?.toLowerCase() === MEMORY_KEY) {
storage = new memory_1.default();
}
else {
throw new Error("Nepodporovaná hodnota proměnné STORAGE: " + process.env.STORAGE + ", podporované jsou 'json', 'redis' nebo 'memory'");
}
exports.storageReady = storage.initialize
? storage.initialize()
: Promise.resolve();
function getStorage() {
return storage;
}
+76
View File
@@ -0,0 +1,76 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const simple_json_db_1 = __importDefault(require("simple-json-db"));
const fs = __importStar(require("fs"));
const path = __importStar(require("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 simple_json_db_1.default(dbPath);
/**
* Implementace úložiště používající JSON soubor. Určená pro vývoj.
*/
class JsonStorage {
hasData(key) {
return Promise.resolve(db.has(key));
}
getData(key) {
return Promise.resolve(db.get(key));
}
setData(key, data) {
db.set(key, data);
return Promise.resolve();
}
listKeys(contains) {
const keys = Object.keys(db.JSON());
return Promise.resolve(contains ? keys.filter(k => k.includes(contains)) : keys);
}
updateData(key, mutator) {
const current = db.get(key);
const next = mutator(current);
db.set(key, next);
return Promise.resolve(next);
}
healthCheck() {
return Promise.resolve(true);
}
}
exports.default = JsonStorage;
+37
View File
@@ -0,0 +1,37 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.resetMemoryStorage = resetMemoryStorage;
const store = new Map();
/** Vymaže všechna data z in-memory úložiště. Slouží k resetu mezi testy. */
function resetMemoryStorage() {
store.clear();
}
/**
* In-memory implementace úložiště. Používá se výhradně v testovacím prostředí.
*/
class MemoryStorage {
hasData(key) {
return Promise.resolve(store.has(key));
}
getData(key) {
return Promise.resolve(store.get(key));
}
setData(key, data) {
store.set(key, data);
return Promise.resolve();
}
listKeys(contains) {
const keys = Array.from(store.keys());
return Promise.resolve(contains ? keys.filter(k => k.includes(contains)) : keys);
}
updateData(key, mutator) {
const current = store.get(key);
const next = mutator(current);
store.set(key, next);
return Promise.resolve(next);
}
healthCheck() {
return Promise.resolve(true);
}
}
exports.default = MemoryStorage;
+77
View File
@@ -0,0 +1,77 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.shutdownRedisStorage = shutdownRedisStorage;
const redis_1 = require("redis");
/** Počet pokusů o atomický zápis, než se WATCH/MULTI/EXEC smyčka vzdá. */
const MAX_RETRIES = 10;
let client;
/** Ukončí spojení hlavního Redis klienta (volá se při graceful shutdownu). */
async function shutdownRedisStorage() {
if (client?.isOpen) {
await client.quit();
}
}
/**
* Implementace úložiště nad Redisem. Určená pro produkční provoz.
*/
class RedisStorage {
async initialize() {
const host = process.env.REDIS_HOST ?? 'localhost';
const port = Number(process.env.REDIS_PORT ?? 6379);
client = (0, redis_1.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}`);
}
getClient() {
if (!client) {
throw new Error('Redis klient není inicializovaný');
}
return client;
}
async hasData(key) {
return (await this.getClient().exists(key)) === 1;
}
async getData(key) {
const value = await this.getClient().get(key);
return value == null ? undefined : JSON.parse(value);
}
async setData(key, data) {
await this.getClient().set(key, JSON.stringify(data));
}
async listKeys(contains) {
const keys = [];
for await (const key of this.getClient().scanIterator({ MATCH: contains ? `*${contains}*` : '*', COUNT: 1000 })) {
keys.push(...(Array.isArray(key) ? key : [key]));
}
return keys;
}
async updateData(key, mutator) {
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
// Izolovaný klient, aby WATCH nekolidoval s ostatními požadavky
const attemptWrite = async (isolated) => {
await isolated.watch(key);
const value = await isolated.get(key);
const current = value == null ? undefined : JSON.parse(value);
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);
if (result !== undefined) {
return result;
}
}
throw new Error(`Nepodařilo se atomicky uložit klíč ${key} ani po ${MAX_RETRIES} pokusech`);
}
async healthCheck() {
try {
return (await this.getClient().ping()) === 'PONG';
}
catch {
return false;
}
}
}
exports.default = RedisStorage;
+116
View File
@@ -0,0 +1,116 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.applyMultiplier = exports.normalizeMultiplier = exports.NotFoundError = exports.BadRequestError = void 0;
exports.formatDate = formatDate;
exports.getToday = getToday;
exports.isValidIsoDate = isValidIsoDate;
exports.requireIsoDate = requireIsoDate;
exports.parseToken = parseToken;
exports.listDatesInRange = listDatesInRange;
/** 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. */
class BadRequestError extends Error {
}
exports.BadRequestError = BadRequestError;
/** Chyba hledaného, ale neexistujícího záznamu — mapuje se na HTTP 404. */
class NotFoundError extends Error {
}
exports.NotFoundError = NotFoundError;
/** Vrátí datum v ISO formátu (YYYY-MM-DD) v lokální časové zóně. */
function formatDate(date) {
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. */
function getToday() {
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.
*/
function isValidIsoDate(value) {
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
*/
function requireIsoDate(value, name) {
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. */
function parseToken(req) {
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)
*/
function listDatesInRange(from, to) {
const dates = [];
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;
}
/** Nejvyšší povolený násobek porce nebo aktivity — pojistka proti překlepu. */
const MAX_MULTIPLIER = 100;
/**
* Ověří násobek porce nebo aktivity a zaokrouhlí ho na setiny.
*
* Násobek 1 je výchozí stav, proto se vrací jako undefined a neukládá se —
* stejně jako jedna série u aktivit.
*
* @param value vstupní hodnota
*/
const normalizeMultiplier = (value) => {
if (value == null)
return undefined;
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new BadRequestError("Násobek musí být číslo");
}
const rounded = Math.round(value * 100) / 100;
if (rounded <= 0) {
throw new BadRequestError("Násobek musí být kladné číslo");
}
if (rounded > MAX_MULTIPLIER) {
throw new BadRequestError(`Násobek může být nejvýše ${MAX_MULTIPLIER}`);
}
return rounded === 1 ? undefined : rounded;
};
exports.normalizeMultiplier = normalizeMultiplier;
/**
* Vrátí kalorie záznamu po započtení násobku.
*
* Uložené kalorie jsou vždy za jednu porci (jedno provedení), aby šlo násobek
* měnit tam a zpět bez ztráty přesnosti zaokrouhlováním. Klient má stejnou
* funkci v `Utils.tsx` — musí dávat stejné výsledky.
*
* @param calories kcal jedné porce
* @param multiplier násobek, nevyplněný znamená 1
*/
const applyMultiplier = (calories, multiplier) => calories == null ? undefined : Math.round(calories * (multiplier ?? 1));
exports.applyMultiplier = applyMultiplier;
+48
View File
@@ -0,0 +1,48 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.dropOrphanedPlans = exports.recordWorkoutPlan = exports.getWorkoutPlans = void 0;
const storage_1 = __importDefault(require("./storage"));
const storage = (0, storage_1.default)();
/** Klíč úložiště s plány šablon jednoho dne jednoho uživatele. */
const getKey = (login, date) => `workoutPlans:${login}:${date}`;
/**
* Vrátí plány šablon použitých v daném dni.
*
* @param login přihlašovací jméno uživatele
* @param date datum ve formátu YYYY-MM-DD
*/
const getWorkoutPlans = async (login, date) => await storage.getData(getKey(login, date)) ?? [];
exports.getWorkoutPlans = getWorkoutPlans;
/**
* Zapamatuje si plán použité šablony.
*
* @param login přihlašovací jméno uživatele
* @param date den, do kterého se šablona použila
* @param plan plán šablony
*/
const recordWorkoutPlan = async (login, date, plan) => {
await storage.updateData(getKey(login, date), current => [...(current ?? []), plan]);
};
exports.recordWorkoutPlan = recordWorkoutPlan;
/**
* Zahodí plány šablon, ze kterých ve dni nezůstala žádná aktivita.
*
* Smazání všech cviků šablony se bere jako vrácení jejího použití, ne jako
* neodcvičený trénink — jinak by omylem použitá šablona navždy strašila ve
* statistikách jako odchylka. Vynechání jen části cviků se jako odchylka počítá.
*
* @param login přihlašovací jméno uživatele
* @param date datum dne
* @param remaining aktivity, které ve dni zůstaly
*/
const dropOrphanedPlans = async (login, date, remaining) => {
const key = getKey(login, date);
if (!await storage.hasData(key))
return;
const ids = new Set(remaining.map(entry => entry.id));
await storage.updateData(key, current => (current ?? []).filter(plan => plan.items.some(item => item.entryId && ids.has(item.entryId))));
};
exports.dropOrphanedPlans = dropOrphanedPlans;
+300
View File
@@ -0,0 +1,300 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.createWorkoutTemplateFromActivities = exports.applyWorkoutTemplate = exports.deleteWorkoutTemplate = exports.saveWorkoutTemplate = exports.getWorkoutTemplates = void 0;
const crypto_1 = require("crypto");
const types_gen_1 = require("../../types/gen/types.gen");
const storage_1 = __importDefault(require("./storage"));
const activities_1 = require("./activities");
const utils_1 = require("./utils");
const workoutPlans_1 = require("./workoutPlans");
const storage = (0, storage_1.default)();
/** 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. */
const getKey = (login) => `workouts:${login}`;
/** Vrátí true, pokud je položka odkazem na jinou šablonu. */
const isTemplateRef = (item) => item.kind === types_gen_1.WorkoutItemKind.SABLONA;
/**
* Doplní položkám druh, pokud chybí.
*
* Šablony uložené dřív, než přibylo skládání z jiných šablon, pole `kind`
* nemají — všechny jejich položky jsou cviky.
*/
const withKind = (template) => ({
...template,
items: template.items.map(item => ({ ...item, kind: item.kind ?? types_gen_1.WorkoutItemKind.CVIK })),
});
/** Spočítá kcal jedné položky druhu CVIK, včetně všech jejích sérií. */
const itemCalories = (item) => Math.round((0, activities_1.getTotalQuantity)(item.quantity ?? 0, item.sets) / 100 * (item.caloriesPer100Units ?? 0));
/**
* Doplní šabloně dopočítávaná pole — odhad kalorií (včetně vnořených šablon),
* názvy vnořených šablon a informaci, kolik jiných šablon ji používá.
*/
const withDerived = (template, all) => {
const byId = new Map(all.map(item => [item.id, item]));
let calories = 0;
let hasNested = false;
const items = template.items.map(item => {
if (!isTemplateRef(item)) {
calories += itemCalories(item);
return item;
}
hasNested = true;
const nested = item.templateRefId ? byId.get(item.templateRefId) : undefined;
// Vnořená šablona smí obsahovat jen cviky, takže stačí sečíst její položky
const nestedCalories = (nested?.items ?? []).reduce((sum, nestedItem) => sum + itemCalories(nestedItem), 0);
calories += nestedCalories;
return {
...item,
...(nested ? { templateRefName: nested.name } : {}),
templateRefCalories: nestedCalories,
};
});
return {
id: template.id,
name: template.name,
items,
estimatedCalories: calories,
hasNested,
usedByCount: all.filter(other => other.id !== template.id
&& other.items.some(item => isTemplateRef(item) && item.templateRefId === template.id)).length,
};
};
/** Načte uložené šablony uživatele se doplněným druhem položek. */
const loadTemplates = async (login) => (await storage.getData(getKey(login)) ?? []).map(withKind);
/**
* Vrátí šablony tréninků uživatele seřazené podle názvu.
*
* @param login přihlašovací jméno uživatele
*/
const getWorkoutTemplates = async (login) => {
const stored = await loadTemplates(login);
return stored
.map(template => withDerived(template, stored))
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
};
exports.getWorkoutTemplates = getWorkoutTemplates;
/** Ověří a znormalizuje jednu položku šablony. */
const normalizeItem = (raw, index) => {
const value = (raw ?? {});
const kind = value.kind === types_gen_1.WorkoutItemKind.SABLONA ? types_gen_1.WorkoutItemKind.SABLONA : types_gen_1.WorkoutItemKind.CVIK;
if (kind === types_gen_1.WorkoutItemKind.SABLONA) {
if (typeof value.templateRefId !== 'string' || !value.templateRefId.length) {
throw new utils_1.BadRequestError(`Položka ${index + 1} neodkazuje na žádnou šablonu`);
}
// Název a odhad kalorií vnořené šablony si server dopočítá sám
return { kind, templateRefId: value.templateRefId };
}
const name = typeof value.name === 'string' ? value.name.trim() : '';
if (!name.length) {
throw new utils_1.BadRequestError(`Položka ${index + 1} nemá název`);
}
if (!(0, activities_1.isActivityUnit)(value.unit)) {
throw new utils_1.BadRequestError(`Položka '${name}' nemá platnou jednotku`);
}
if (typeof value.quantity !== 'number' || !Number.isFinite(value.quantity) || value.quantity < 0) {
throw new utils_1.BadRequestError(`Položka '${name}' nemá platné množství`);
}
const rate = value.caloriesPer100Units;
const sets = value.sets;
const extraWeight = value.extraWeightGrams;
return {
kind,
name,
unit: value.unit,
quantity: Math.round(value.quantity),
// Jedna série je výchozí stav, ukládat ji zvlášť nemá smysl
...(typeof sets === 'number' && Number.isFinite(sets) && sets > 1
? { sets: Math.round(sets) }
: {}),
...(typeof rate === 'number' && Number.isFinite(rate) && rate >= 0
? { caloriesPer100Units: Math.round(rate) }
: {}),
...(typeof extraWeight === 'number' && Number.isFinite(extraWeight) && extraWeight > 0
? { extraWeightGrams: Math.round(extraWeight) }
: {}),
};
};
/**
* Ověří, že se zanořením šablon nepřekročí jedna úroveň.
*
* Kontroluje se obojí směr, protože obě cesty vedou ke dvěma úrovním:
* šablona nesmí odkazovat na šablonu, která sama něco vnořuje, a zároveň
* nesmí sama začít vnořovat, pokud ji už někdo jiný používá.
*/
const checkNesting = (id, items, all) => {
const byId = new Map(all.map(template => [template.id, template]));
const nestedRefs = items.filter(isTemplateRef);
for (const item of nestedRefs) {
if (item.templateRefId === id) {
throw new utils_1.BadRequestError("Šablona nemůže odkazovat sama na sebe");
}
const nested = byId.get(item.templateRefId);
if (!nested) {
throw new utils_1.NotFoundError("Vnořená šablona nebyla nalezena");
}
if (nested.items.some(isTemplateRef)) {
throw new utils_1.BadRequestError(`Šablona '${nested.name}' už sama skládá jiné šablony, nelze ji vnořit — zanoření je povolené jen na jednu úroveň`);
}
}
if (nestedRefs.length) {
const usedBy = all.find(template => template.id !== id
&& template.items.some(item => isTemplateRef(item) && item.templateRefId === id));
if (usedBy) {
throw new utils_1.BadRequestError(`Tuto šablonu už skládá šablona '${usedBy.name}', takže do ní nelze vnořit další — zanoření je povolené jen na jednu úroveň`);
}
}
};
/**
* 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
*/
const saveWorkoutTemplate = async (login, id, name, items) => {
if (typeof name !== 'string' || !name.trim().length) {
throw new utils_1.BadRequestError("Nebyl předán název šablony");
}
if (!Array.isArray(items) || !items.length) {
throw new utils_1.BadRequestError("Šablona musí mít alespoň jednu položku");
}
if (items.length > MAX_ITEMS) {
throw new utils_1.BadRequestError(`Šablona může mít nejvýše ${MAX_ITEMS} položek`);
}
const normalized = {
id: typeof id === 'string' && id.length ? id : (0, crypto_1.randomUUID)(),
name: name.trim(),
items: items.map(normalizeItem),
};
checkNesting(normalized.id, normalized.items, await loadTemplates(login));
await storage.updateData(getKey(login), current => {
const others = (current ?? []).filter(template => template.id !== normalized.id);
return [...others, normalized];
});
return (0, exports.getWorkoutTemplates)(login);
};
exports.saveWorkoutTemplate = saveWorkoutTemplate;
/**
* Smaže šablonu tréninku. Šablonu, kterou skládá jiná, smazat nelze —
* jinak by ve složené šabloně zůstal odkaz do prázdna.
*
* @param login přihlašovací jméno uživatele
* @param id identifikátor šablony
*/
const deleteWorkoutTemplate = async (login, id) => {
if (typeof id !== 'string' || !id.length) {
throw new utils_1.BadRequestError("Nebyl předán identifikátor šablony");
}
const all = await loadTemplates(login);
if (!all.some(template => template.id === id)) {
throw new utils_1.NotFoundError("Šablona nebyla nalezena");
}
const usedBy = all.find(template => template.id !== id
&& template.items.some(item => isTemplateRef(item) && item.templateRefId === id));
if (usedBy) {
throw new utils_1.BadRequestError(`Šablonu nelze smazat, protože ji skládá šablona '${usedBy.name}'`);
}
await storage.updateData(getKey(login), current => (current ?? []).filter(template => template.id !== id));
return (0, exports.getWorkoutTemplates)(login);
};
exports.deleteWorkoutTemplate = deleteWorkoutTemplate;
/**
* Založí do zvoleného dne všechny cviky šablony, včetně těch z vnořených šablon.
*
* 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ží
*/
const applyWorkoutTemplate = async (login, id, date) => {
if (typeof id !== 'string' || !id.length) {
throw new utils_1.BadRequestError("Nebyl předán identifikátor šablony");
}
if (!(0, utils_1.isValidIsoDate)(date)) {
throw new utils_1.BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
}
const all = await loadTemplates(login);
const template = all.find(item => item.id === id);
if (!template) {
throw new utils_1.NotFoundError("Šablona nebyla nalezena");
}
// Odkazy na vnořené šablony se rozbalí na jejich cviky; hlubší zanoření nemůže vzniknout
const byId = new Map(all.map(item => [item.id, item]));
const exercises = template.items.flatMap(item => isTemplateRef(item) ? (byId.get(item.templateRefId)?.items ?? []) : [item]);
const now = new Date().toISOString();
const entries = exercises.map(item => ({
id: (0, crypto_1.randomUUID)(),
date,
name: item.name,
unit: item.unit,
quantity: item.quantity,
...(item.sets != null ? { sets: item.sets } : {}),
...(item.caloriesPer100Units != null ? { caloriesPer100Units: item.caloriesPer100Units } : {}),
...(item.extraWeightGrams != null ? { extraWeightGrams: item.extraWeightGrams } : {}),
calories: itemCalories(item),
templateId: template.id,
createdAt: now,
}));
await (0, activities_1.addActivities)(login, date, entries);
await (0, workoutPlans_1.recordWorkoutPlan)(login, date, {
templateId: template.id,
templateName: template.name,
appliedAt: now,
items: entries.map(entry => ({
entryId: entry.id,
name: entry.name,
unit: entry.unit,
quantity: entry.quantity,
...(entry.sets != null ? { sets: entry.sets } : {}),
calories: entry.calories ?? 0,
})),
});
return date;
};
exports.applyWorkoutTemplate = applyWorkoutTemplate;
/**
* Vytvoří šablonu tréninku z aktivit zaznamenaných v přehledu dne.
*
* Slouží k opačnému směru než {@link applyWorkoutTemplate}: trénink odcvičený
* "na truc" jde takhle povýšit na šablonu a příště použít jedním kliknutím.
* Aktivity zůstanou v dni beze změny.
*
* @param login přihlašovací jméno uživatele
* @param date den, ze kterého se aktivity berou
* @param name název nové šablony
* @param ids identifikátory aktivit; bez nich se použijí všechny aktivity dne
*/
const createWorkoutTemplateFromActivities = async (login, date, name, ids) => {
if (!(0, utils_1.isValidIsoDate)(date)) {
throw new utils_1.BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
}
if (ids != null && !Array.isArray(ids)) {
throw new utils_1.BadRequestError("Seznam aktivit musí být pole identifikátorů");
}
const selected = ids == null ? undefined : new Set(ids.map(String));
const activities = (await (0, activities_1.getActivityDay)(login, date)).entries
.filter(entry => !selected || selected.has(entry.id));
if (!activities.length) {
throw new utils_1.BadRequestError("V tomto dni nejsou žádné aktivity, ze kterých by šla šablona vytvořit");
}
const items = activities.map(entry => ({
kind: types_gen_1.WorkoutItemKind.CVIK,
name: entry.name,
unit: entry.unit,
quantity: entry.quantity,
...(entry.sets != null ? { sets: entry.sets } : {}),
// Aktivita se zadanými kaloriemi napevno sazbu nemá, šablona pak odhad nespočítá
...(entry.caloriesPer100Units != null ? { caloriesPer100Units: entry.caloriesPer100Units } : {}),
...(entry.extraWeightGrams != null ? { extraWeightGrams: entry.extraWeightGrams } : {}),
}));
return (0, exports.saveWorkoutTemplate)(login, undefined, name, items);
};
exports.createWorkoutTemplateFromActivities = createWorkoutTemplateFromActivities;
+6
View File
@@ -0,0 +1,6 @@
"use strict";
// This file is auto-generated by @hey-api/openapi-ts
Object.defineProperty(exports, "__esModule", { value: true });
exports.client = void 0;
const client_fetch_1 = require("@hey-api/client-fetch");
exports.client = (0, client_fetch_1.createClient)((0, client_fetch_1.createConfig)());
+19
View File
@@ -0,0 +1,19 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
// This file is auto-generated by @hey-api/openapi-ts
__exportStar(require("./types.gen"), exports);
__exportStar(require("./sdk.gen"), exports);
+585
View File
@@ -0,0 +1,585 @@
"use strict";
// This file is auto-generated by @hey-api/openapi-ts
Object.defineProperty(exports, "__esModule", { value: true });
exports.importLuncher = exports.saveProfile = exports.saveBasalCalories = exports.saveSourceRate = exports.getSettings = exports.searchCalories = exports.createMealTemplateFromMeal = exports.applyMealTemplate = exports.deleteMealTemplate = exports.saveMealTemplate = exports.getMealTemplates = exports.createWorkoutTemplateFromActivities = exports.applyWorkoutTemplate = exports.deleteWorkoutTemplate = exports.saveWorkoutTemplate = exports.getWorkoutTemplates = exports.setActivityMultiplier = exports.deleteActivity = exports.updateActivity = exports.addActivity = exports.saveSleep = exports.getDayOverview = exports.getFoodStats = exports.setMealMultiplier = exports.deleteMeal = exports.updateMeal = exports.addMeal = exports.getRange = exports.getDay = exports.getConfig = exports.login = void 0;
const client_gen_1 = require("./client.gen");
/**
* Přihlásí uživatele a vrátí JWT token.
*/
const login = (options) => {
return (options?.client ?? client_gen_1.client).post({
url: '/login',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
exports.login = login;
/**
* Vrátí veřejnou runtime konfiguraci klienta.
*/
const getConfig = (options) => {
return (options?.client ?? client_gen_1.client).get({
url: '/config',
...options
});
};
exports.getConfig = getConfig;
/**
* Vrátí jídla přihlášeného uživatele za jeden den.
*/
const getDay = (options) => {
return (options?.client ?? client_gen_1.client).get({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/meals/day',
...options
});
};
exports.getDay = getDay;
/**
* Vrátí jídla přihlášeného uživatele za rozsah dnů.
*/
const getRange = (options) => {
return (options.client ?? client_gen_1.client).get({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/meals/range',
...options
});
};
exports.getRange = getRange;
/**
* Přidá jídlo do zvoleného dne.
*/
const addMeal = (options) => {
return (options.client ?? client_gen_1.client).post({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/meals/add',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
exports.addMeal = addMeal;
/**
* Upraví existující záznam o jídle.
*/
const updateMeal = (options) => {
return (options.client ?? client_gen_1.client).post({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/meals/update',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
exports.updateMeal = updateMeal;
/**
* Smaže záznam o jídle.
*/
const deleteMeal = (options) => {
return (options.client ?? client_gen_1.client).post({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/meals/delete',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
exports.deleteMeal = deleteMeal;
/**
* Nastaví násobek porce jídla přímo z přehledu dne, bez úpravy ostatních údajů.
*
*/
const setMealMultiplier = (options) => {
return (options.client ?? client_gen_1.client).post({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/meals/multiplier',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
exports.setMealMultiplier = setMealMultiplier;
/**
* Vrátí statistiky útraty a jídel za zvolené období.
*/
const getFoodStats = (options) => {
return (options.client ?? client_gen_1.client).get({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/stats/summary',
...options
});
};
exports.getFoodStats = getFoodStats;
/**
* Vrátí kompletní přehled dne — jídlo, pohyb a energetickou bilanci.
*/
const getDayOverview = (options) => {
return (options?.client ?? client_gen_1.client).get({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/day',
...options
});
};
exports.getDayOverview = getDayOverview;
/**
* Uloží délku spánku pro zvolený den.
*/
const saveSleep = (options) => {
return (options.client ?? client_gen_1.client).post({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/day/sleep',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
exports.saveSleep = saveSleep;
/**
* Přidá pohybovou aktivitu do zvoleného dne.
*/
const addActivity = (options) => {
return (options.client ?? client_gen_1.client).post({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/activities/add',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
exports.addActivity = addActivity;
/**
* Upraví existující aktivitu.
*/
const updateActivity = (options) => {
return (options.client ?? client_gen_1.client).post({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/activities/update',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
exports.updateActivity = updateActivity;
/**
* Smaže aktivitu.
*/
const deleteActivity = (options) => {
return (options.client ?? client_gen_1.client).post({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/activities/delete',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
exports.deleteActivity = deleteActivity;
/**
* Nastaví násobek aktivity přímo z přehledu dne, bez úpravy ostatních údajů.
*
*/
const setActivityMultiplier = (options) => {
return (options.client ?? client_gen_1.client).post({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/activities/multiplier',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
exports.setActivityMultiplier = setActivityMultiplier;
/**
* Vrátí šablony tréninků přihlášeného uživatele.
*/
const getWorkoutTemplates = (options) => {
return (options?.client ?? client_gen_1.client).get({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/workouts',
...options
});
};
exports.getWorkoutTemplates = getWorkoutTemplates;
/**
* Založí novou šablonu tréninku, nebo upraví existující.
*/
const saveWorkoutTemplate = (options) => {
return (options.client ?? client_gen_1.client).post({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/workouts/save',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
exports.saveWorkoutTemplate = saveWorkoutTemplate;
/**
* Smaže šablonu tréninku.
*/
const deleteWorkoutTemplate = (options) => {
return (options.client ?? client_gen_1.client).post({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/workouts/delete',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
exports.deleteWorkoutTemplate = deleteWorkoutTemplate;
/**
* Založí do zvoleného dne všechny položky šablony tréninku.
*/
const applyWorkoutTemplate = (options) => {
return (options.client ?? client_gen_1.client).post({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/workouts/apply',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
exports.applyWorkoutTemplate = applyWorkoutTemplate;
/**
* Vytvoří šablonu tréninku z aktivit zaznamenaných v přehledu dne.
* Bez výčtu identifikátorů se použijí všechny aktivity daného dne.
*
*/
const createWorkoutTemplateFromActivities = (options) => {
return (options.client ?? client_gen_1.client).post({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/workouts/fromActivities',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
exports.createWorkoutTemplateFromActivities = createWorkoutTemplateFromActivities;
/**
* Vrátí šablony jídel přihlášeného uživatele.
*/
const getMealTemplates = (options) => {
return (options?.client ?? client_gen_1.client).get({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/mealTemplates',
...options
});
};
exports.getMealTemplates = getMealTemplates;
/**
* Založí novou šablonu jídla, nebo upraví existující.
*/
const saveMealTemplate = (options) => {
return (options.client ?? client_gen_1.client).post({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/mealTemplates/save',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
exports.saveMealTemplate = saveMealTemplate;
/**
* Smaže šablonu jídla.
*/
const deleteMealTemplate = (options) => {
return (options.client ?? client_gen_1.client).post({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/mealTemplates/delete',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
exports.deleteMealTemplate = deleteMealTemplate;
/**
* Založí jídlo ze šablony do zvoleného dne.
*/
const applyMealTemplate = (options) => {
return (options.client ?? client_gen_1.client).post({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/mealTemplates/apply',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
exports.applyMealTemplate = applyMealTemplate;
/**
* Vytvoří šablonu jídla z existujícího záznamu v přehledu dne.
*/
const createMealTemplateFromMeal = (options) => {
return (options.client ?? client_gen_1.client).post({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/mealTemplates/fromMeal',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
exports.createMealTemplateFromMeal = createMealTemplateFromMeal;
/**
* Najde návrhy energetické hodnoty pro název jídla — z vlastní knihovny
* dřívějších zadání a od externího poskytovatele.
*
*/
const searchCalories = (options) => {
return (options.client ?? client_gen_1.client).get({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/calories/search',
...options
});
};
exports.searchCalories = searchCalories;
/**
* Vrátí nastavení přihlášeného uživatele.
*/
const getSettings = (options) => {
return (options?.client ?? client_gen_1.client).get({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/settings',
...options
});
};
exports.getSettings = getSettings;
/**
* Uloží nebo smaže cenu za 100 g u jednoho zdroje.
*/
const saveSourceRate = (options) => {
return (options.client ?? client_gen_1.client).post({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/settings',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
exports.saveSourceRate = saveSourceRate;
/**
* Uloží klidový výdej (bazální metabolismus) přihlášeného uživatele.
*/
const saveBasalCalories = (options) => {
return (options.client ?? client_gen_1.client).post({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/settings/basal',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
exports.saveBasalCalories = saveBasalCalories;
/**
* Uloží tělesné údaje uživatele a přepočte z nich klidový výdej.
*/
const saveProfile = (options) => {
return (options.client ?? client_gen_1.client).post({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/settings/profile',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
exports.saveProfile = saveProfile;
/**
* Naimportuje měsíční přehled vyexportovaný z Luncheru (XLSX, CSV nebo JSON).
* Řádky se založí do dnů, ke kterým patří, ve výchozím stavu jako oběd.
*
*/
const importLuncher = (options) => {
return (options.client ?? client_gen_1.client).post({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/import/luncher',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
exports.importLuncher = importLuncher;
+66
View File
@@ -0,0 +1,66 @@
"use strict";
// This file is auto-generated by @hey-api/openapi-ts
Object.defineProperty(exports, "__esModule", { value: true });
exports.MealTemplateKind = exports.ImportMode = exports.WorkoutItemKind = exports.Pohlavi = exports.ActivityUnit = exports.MealType = void 0;
/**
* Typ jídla (chod v rámci dne). Importy z Luncheru se zakládají jako OBED,
* pokud se při importu neurčí jinak.
*
*/
exports.MealType = {
SNIDANE: 'SNIDANE',
DOPOLEDNI_SVACINA: 'DOPOLEDNI_SVACINA',
OBED: 'OBED',
ODPOLEDNI_SVACINA: 'ODPOLEDNI_SVACINA',
VECERE: 'VECERE',
JINE: 'JINE'
};
/**
* Jednotka, ve které se aktivita měří
*/
exports.ActivityUnit = {
KROKY: 'KROKY',
MINUTY: 'MINUTY',
SEKUNDY: 'SEKUNDY',
KM: 'KM',
OPAKOVANI: 'OPAKOVANI'
};
/**
* Pohlaví — vstupuje do výpočtu bazálního metabolismu
*/
exports.Pohlavi = {
MUZ: 'MUZ',
ZENA: 'ZENA'
};
/**
* Druh položky šablony — buď přímo cvik, nebo odkaz na jinou šablonu.
* Zanoření je povolené jen na jednu úroveň.
*
*/
exports.WorkoutItemKind = {
CVIK: 'CVIK',
SABLONA: 'SABLONA'
};
/**
* Jak naložit s dřívějším importem týchž dnů.
* PRIDAT — nové řádky se přidají, řádky už jednou naimportované se přeskočí.
* NAHRADIT — v dnech, do kterých se importuje, se dřívější importované
* záznamy odeberou a založí se znovu. Ručně zadaná jídla zůstávají vždy.
*
*/
exports.ImportMode = {
PRIDAT: 'PRIDAT',
NAHRADIT: 'NAHRADIT'
};
/**
* Druh šablony jídla.
* HOTOVE — objednané nebo koupené jídlo; stačí si pamatovat výsledné
* hodnoty porce (kcal, cenu), aby se nemusely dohledávat znovu.
* RECEPT — vařené jídlo; eviduje se, z čeho a jak vzniklo, a hodnoty
* porce se dopočítají ze surovin.
*
*/
exports.MealTemplateKind = {
HOTOVE: 'HOTOVE',
RECEPT: 'RECEPT'
};
+17
View File
@@ -0,0 +1,17 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
__exportStar(require("./gen"), exports);
+15
View File
@@ -0,0 +1,15 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const openapi_ts_1 = require("@hey-api/openapi-ts");
exports.default = {
input: 'api.yml',
output: 'gen',
plugins: [
...openapi_ts_1.defaultPlugins,
'@hey-api/client-fetch',
{
enums: 'javascript',
name: '@hey-api/typescript',
},
],
};
+3 -2
View File
@@ -5,8 +5,8 @@
"license": "MIT",
"private": true,
"scripts": {
"start": "ts-node src/index.ts",
"startReload": "nodemon --watch src src/index.ts",
"start": "cross-env NODE_ENV=development ts-node src/index.ts",
"startReload": "cross-env NODE_ENV=development nodemon --watch src src/index.ts",
"build": "tsc -p .",
"test": "jest"
},
@@ -21,6 +21,7 @@
"@types/node": "^24.10.0",
"@types/supertest": "^6.0.0",
"babel-jest": "^30.2.0",
"cross-env": "^10.1.0",
"jest": "^30.2.0",
"nodemon": "^3.1.10",
"supertest": "^7.0.0",
+154
View File
@@ -0,0 +1,154 @@
import { BmiKategorie, BodyMetrics, Goals, PersonalProfile, Pohlavi, WeightEntry } from "../../types/gen/types.gen";
import { BadRequestError } from "./utils";
import { WEIGHT_LIMITS } from "./profile";
/**
* Energie odpovídající jednomu kilogramu tělesné hmotnosti. Běžně používaná
* aproximace (tuková tkáň ~ 7 700 kcal/kg); skutečná změna hmotnosti se od ní
* liší vodou a svaly, pro plánování tempa ale stačí.
*/
const KCAL_PER_KG = 7700;
/** Meze cílů — chrání před překlepem, ne před nerozumným plánem. */
const WEEKLY_CHANGE_LIMITS = { min: -1500, max: 1500 };
const PROTEIN_PER_KG_LIMITS = { min: 0.5, max: 4 };
/** Hranice pásem BMI podle WHO. */
const BMI_NORMAL_MIN = 18.5;
const BMI_OVERWEIGHT_MIN = 25;
const BMI_OBESITY_MIN = 30;
const roundTenth = (value: number): number => Math.round(value * 10) / 10;
/**
* Spočítá BMI — hmotnost v kg dělená druhou mocninou výšky v metrech.
*
* @param weightGrams hmotnost v gramech
* @param heightCm výška v centimetrech
*/
export const computeBmi = (weightGrams: number, heightCm: number): number =>
roundTenth(weightGrams / 1000 / (heightCm / 100) ** 2);
/** Zařadí BMI do pásma podle WHO. */
export const getBmiCategory = (bmi: number): BmiKategorie => {
if (bmi < BMI_NORMAL_MIN) return BmiKategorie.PODVAHA;
if (bmi < BMI_OVERWEIGHT_MIN) return BmiKategorie.NORMA;
if (bmi < BMI_OBESITY_MIN) return BmiKategorie.NADVAHA;
return BmiKategorie.OBEZITA;
};
/** Hmotnost v gramech pro dané BMI a výšku, zaokrouhlená na desítky dekagramů. */
const weightForBmi = (bmi: number, heightCm: number): number =>
Math.round(bmi * (heightCm / 100) ** 2 * 10) * 100;
/**
* Odhadne podíl tělesného tuku metodou US Navy (obvody v cm):
*
* ```
* muž: 495 / (1,0324 − 0,19077 × log10(pas − krk) + 0,15456 × log10(výška)) − 450
* žena: 495 / (1,29579 − 0,35004 × log10(pas + boky − krk) + 0,221 × log10(výška)) − 450
* ```
*
* Odchylka proti měření DEXA bývá kolem ±3–4 procentních bodů — hodí se hlavně
* ke sledování vývoje, ne jako přesná hodnota.
*
* @param profile tělesné údaje s obvody
* @returns procento tuku, nebo nic, když chybí údaje nebo jsou obvody nesmyslné
*/
export const computeBodyFat = (profile?: PersonalProfile): number | undefined => {
if (!profile?.sex || !profile.heightCm || !profile.waistCm || !profile.neckCm) return undefined;
const isMale = profile.sex === Pohlavi.MUZ;
if (!isMale && !profile.hipCm) return undefined;
const circumference = isMale
? profile.waistCm - profile.neckCm
: profile.waistCm + profile.hipCm! - profile.neckCm;
if (circumference <= 0) return undefined;
const density = isMale
? 1.0324 - 0.19077 * Math.log10(circumference) + 0.15456 * Math.log10(profile.heightCm)
: 1.29579 - 0.35004 * Math.log10(circumference) + 0.221 * Math.log10(profile.heightCm);
const result = roundTenth(495 / density - 450);
// Mimo tenhle rozsah jde o překlep v obvodech, ne o skutečný odhad
return result > 0 && result < 75 ? result : undefined;
};
/**
* Cílová denní bilance podle týdenního tempa změny hmotnosti — půl kila týdně
* dolů je zhruba −550 kcal denně.
*/
export const getTargetDailyBalance = (goals?: Goals): number | undefined =>
goals?.weeklyChangeGrams != null
? Math.round(goals.weeklyChangeGrams / 1000 * KCAL_PER_KG / 7)
: undefined;
/** Cílový denní příjem bílkovin v gramech podle hmotnosti. */
export const getProteinTarget = (goals?: Goals, weightGrams?: number): number | undefined =>
goals?.proteinPerKg != null && weightGrams != null
? Math.round(weightGrams / 1000 * goals.proteinPerKg)
: undefined;
/**
* Za kolik týdnů se při zvoleném tempu dosáhne cílové hmotnosti. Jen když
* tempo vede k cíli — při nulovém tempu nebo opačném směru nic.
*/
const getWeeksToTarget = (goals?: Goals, weightGrams?: number): number | undefined => {
if (!goals?.weeklyChangeGrams || goals.targetWeightGrams == null || weightGrams == null) return undefined;
const weeks = (goals.targetWeightGrams - weightGrams) / goals.weeklyChangeGrams;
return weeks > 0 ? Math.ceil(weeks) : undefined;
};
/**
* Spočítá ukazatele z profilu, posledního vážení a cílů. Každý ukazatel se
* vynechá, když pro něj nejsou údaje.
*
* @param profile tělesné údaje
* @param goals cíle uživatele
* @param latestWeight poslední vážení; bez něj se použije hmotnost z profilu
*/
export const computeBodyMetrics = (
profile?: PersonalProfile,
goals?: Goals,
latestWeight?: WeightEntry,
): BodyMetrics => {
const weightGrams = latestWeight?.weightGrams ?? profile?.weightGrams;
const heightCm = profile?.heightCm;
const bmi = weightGrams != null && heightCm ? computeBmi(weightGrams, heightCm) : undefined;
const result: BodyMetrics = {
weightGrams,
weightDate: latestWeight?.date,
bmi,
bmiCategory: bmi != null ? getBmiCategory(bmi) : undefined,
healthyWeightMinGrams: heightCm ? weightForBmi(BMI_NORMAL_MIN, heightCm) : undefined,
healthyWeightMaxGrams: heightCm ? weightForBmi(BMI_OVERWEIGHT_MIN, heightCm) : undefined,
bodyFatPercent: computeBodyFat(profile),
targetDailyBalance: getTargetDailyBalance(goals),
proteinTargetGrams: getProteinTarget(goals, weightGrams),
weeksToTarget: getWeeksToTarget(goals, weightGrams),
};
return Object.fromEntries(Object.entries(result).filter(([, value]) => value != null)) as BodyMetrics;
};
/** Ověří volitelné číslo v rozsahu. */
const normalizeRanged = (
value: unknown, name: string, limits: { min: number, max: number }, round: (value: number) => number,
): 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 = round(value);
if (rounded < limits.min || rounded > limits.max) {
throw new BadRequestError(`Pole '${name}' je mimo rozsah (${limits.min}–${limits.max})`);
}
return rounded;
};
/** Ověří a znormalizuje cíle. Nevyplněné hodnoty se vynechají. */
export const normalizeGoals = (input: unknown): Goals => {
const value = (input ?? {}) as Record<string, unknown>;
const result: Goals = {
weeklyChangeGrams: normalizeRanged(value.weeklyChangeGrams, 'weeklyChangeGrams', WEEKLY_CHANGE_LIMITS, Math.round),
targetWeightGrams: normalizeRanged(value.targetWeightGrams, 'targetWeightGrams', WEIGHT_LIMITS, Math.round),
proteinPerKg: normalizeRanged(value.proteinPerKg, 'proteinPerKg', PROTEIN_PER_KG_LIMITS, roundTenth),
};
return Object.fromEntries(Object.entries(result).filter(([, item]) => item != null)) as Goals;
};
+29 -5
View File
@@ -3,6 +3,9 @@ import { getDay } from "./meals";
import { getActivityDay } from "./activities";
import { getSettings } from "./settings";
import { getSleep } from "./sleep";
import { findWeightOn, getWeights } from "./weights";
import { computeBasalCalories } from "./profile";
import { getProteinTarget, getTargetDailyBalance } from "./bodyMetrics";
/**
* Spočítá energetickou bilanci dne.
@@ -18,8 +21,14 @@ import { getSleep } from "./sleep";
* @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
* @param targetBalance cílová bilance dne podle tempa změny hmotnosti, nebo nic
*/
export const buildEnergyBalance = (intake: number, activityBurn: number, basal?: number): EnergyBalance => {
export const buildEnergyBalance = (
intake: number,
activityBurn: number,
basal?: number,
targetBalance?: number,
): EnergyBalance => {
const resolvedBasal = basal ?? 0;
const totalBurn = resolvedBasal + activityBurn;
return {
@@ -29,28 +38,43 @@ export const buildEnergyBalance = (intake: number, activityBurn: number, basal?:
totalBurn,
balance: intake - totalBurn,
hasBasal: !!basal,
...(targetBalance != null ? { targetBalance } : {}),
};
};
/**
* Sestaví kompletní přehled dne — jídlo, pohyb, spánek a energetickou bilanci.
* Sestaví kompletní přehled dne — jídlo, pohyb, spánek, vážení a energetickou bilanci.
*
* Spočítaný klidový výdej i cíl bílkovin vycházejí z hmotnosti platné k tomuto
* dni (poslední vážení v ten den nebo dřív), takže starší dny se se zhubnutím
* zpětně nepřepočítají. Bez vážení se použije hmotnost z profilu.
*
* @param login přihlašovací jméno uživatele
* @param date datum ve formátu YYYY-MM-DD
*/
export const getDayOverview = async (login: string, date: string): Promise<DayOverview> => {
const [meals, activities, settings, sleepMinutes] = await Promise.all([
const [meals, activities, settings, sleepMinutes, weightList] = await Promise.all([
getDay(login, date),
getActivityDay(login, date),
getSettings(login),
getSleep(login, date),
getWeights(login),
]);
const weightGrams = findWeightOn(weightList, date)?.weightGrams
?? settings.profile?.weightGrams
?? weightList[0]?.weightGrams;
// Ručně zadaný klidový výdej vyhrává nad spočítaným z tělesných údajů
const basal = settings.basalCalories ?? computeBasalCalories(settings.profile, weightGrams);
const dayWeight = weightList.find(entry => entry.date === date)?.weightGrams;
const proteinTargetGrams = getProteinTarget(settings.goals, weightGrams);
return {
date,
meals,
activities,
// Ručně zadaný klidový výdej vyhrává nad spočítaným z tělesných údajů
energy: buildEnergyBalance(meals.totalCalories, activities.totalCalories, settings.effectiveBasalCalories),
energy: buildEnergyBalance(
meals.totalCalories, activities.totalCalories, basal, getTargetDailyBalance(settings.goals)),
...(sleepMinutes ? { sleepMinutes } : {}),
...(dayWeight != null ? { weightGrams: dayWeight } : {}),
...(proteinTargetGrams != null ? { proteinTargetGrams } : {}),
};
};
+7 -2
View File
@@ -1,7 +1,7 @@
import ExcelJS from 'exceljs';
import { randomUUID } from 'crypto';
import { ImportMode, ImportResult, MealEntry, MealType } from "../../types/gen/types.gen";
import { addEntries, findMealsForDays, isMealType } from "./meals";
import { addEntries, createNutritionFiller, findAllMeals, findMealsForDays, isMealType } from "./meals";
import { BadRequestError, isValidIsoDate } from "./utils";
/** Označení zdroje, kterým se importované záznamy značí. */
@@ -376,6 +376,10 @@ const isImportMode = (value: unknown): value is ImportMode =>
* dřívější import dotčených dnů odebere a založí ho znovu — hodí se, když se
* v Luncheru něco opravilo. Ručně zadaná jídla zůstávají v obou režimech.
*
* Luncher kalorie nezná, takže se importovaným jídlům doplní energie a živiny
* z historie — z posledního jídla stejného názvu, které je má (viz
* `createNutritionFiller`).
*
* Náhled vrací všechny řádky, které jde založit, a k nim jídla, která už
* v dotčených dnech jsou. Uživatel si v něm odškrtne, co nechce, a ostrý
* import pak dostane jen vybrané klíče.
@@ -423,6 +427,7 @@ export async function importLuncherExport(
const dates = Array.from(new Set(parsed.rows.map(row => row.date)));
const existing = await findMealsForDays(login, dates);
const fillNutrition = createNutritionFiller(await findAllMeals(login));
// Při nahrazení se dřívější import založí znovu, duplicitou tedy není
const existingKeys = new Set(replace
? []
@@ -439,7 +444,7 @@ export async function importLuncherExport(
skipped++;
continue;
}
candidates.push(toMealEntry(row, mealType, importKey));
candidates.push(fillNutrition(toMealEntry(row, mealType, importKey)));
}
const selectedKeyList = selectedKeys == null ? undefined : new Set(selectedKeys.map(String));
+80 -137
View File
@@ -1,8 +1,8 @@
import { randomUUID } from 'crypto';
import { MealTemplate, MealTemplateKind, MealType, RecipeIngredient } from "../../types/gen/types.gen";
import { MealTemplate, MealType, RecipeIngredient } from "../../types/gen/types.gen";
import getStorage from "./storage";
import { addMeal, getDay, isMealType } from "./meals";
import { normalizeName, rememberCalories } from "./calories";
import { addMeal, isMealType } from "./meals";
import { rememberCalories } from "./calories";
import { BadRequestError, NotFoundError, isValidIsoDate, normalizeMultiplier } from "./utils";
const storage = getStorage();
@@ -14,29 +14,45 @@ const MAX_NOTE_LENGTH = 4000;
/** Maximální počet surovin receptu — pojistka proti nesmyslnému vstupu. */
const MAX_INGREDIENTS = 50;
/** Klíč úložiště se šablonami jídel jednoho uživatele. */
/** Klíč úložiště s recepty jednoho uživatele. */
const getKey = (login: string): string => `mealTemplates:${login}`;
/**
* Doplní šabloně druh, pokud chybí.
*
* Šablony uložené dřív, než přibyly recepty, pole `kind` nemají — všechny jsou
* ploché záznamy s výslednými hodnotami, tedy hotová jídla. V úložišti se nic
* nepřepisuje, druh se doplní jen při čtení.
* Záznam tak, jak může ležet v úložišti. Dřív se pod stejným klíčem ukládaly
* i šablony hotových jídel (druh `HOTOVE`, nebo bez druhu) a recepty nesly
* `kind: 'RECEPT'`.
*/
const withKind = (template: MealTemplate): MealTemplate => ({
...template,
kind: template.kind ?? MealTemplateKind.HOTOVE,
});
type StoredMealTemplate = Omit<MealTemplate, 'ingredients'> & {
ingredients?: RecipeIngredient[];
kind?: string;
pricePer100g?: number;
};
type StoredRecipe = StoredMealTemplate & { ingredients: RecipeIngredient[] };
/**
* Vrátí šablony jídel uživatele seřazené podle názvu.
* Šablony hotových jídel zanikly — opakovaně jedené jídlo se nabízí z historie.
* Poznají se podle toho, že nemají suroviny; recept bez surovin uložit nejde.
*/
const isRecipe = (stored: StoredMealTemplate): stored is StoredRecipe => !!stored.ingredients?.length;
/** Odstraní z receptu pole, která patřila jen šablonám hotových jídel. */
const toMealTemplate = (stored: StoredRecipe): MealTemplate => {
const { kind, pricePer100g, ...result } = stored;
return result;
};
/** Načte recepty z úložiště; šablony hotových jídel vynechá. */
const loadRecipes = (storedList: StoredMealTemplate[] | undefined): MealTemplate[] =>
(storedList ?? []).filter(isRecipe).map(toMealTemplate);
/**
* Vrátí recepty uživatele seřazené podle názvu.
*
* @param login přihlašovací jméno uživatele
*/
export const getMealTemplates = async (login: string): Promise<MealTemplate[]> =>
(await storage.getData<MealTemplate[]>(getKey(login)) ?? [])
.map(withKind)
loadRecipes(await storage.getData<StoredMealTemplate[]>(getKey(login)))
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
/** Hodnoty hotového jídla a jedné porce receptu dopočtené ze surovin. */
@@ -161,117 +177,97 @@ const normalizeIngredient = (raw: unknown, index: number): RecipeIngredient => {
};
/**
* Založí novou šablonu jídla, nebo přepíše existující podle předaného id.
* Založí nový recept, nebo přepíše existující podle předaného id.
*
* U hotového jídla se ukládají zadané výsledné hodnoty. U receptu se hodnoty
* porce (gramáž, energie, cena) dopočtou ze surovin a uloží do stejných polí —
* použití šablony pak funguje pro oba druhy stejně.
* Hodnoty jedné porce (gramáž, energie, cena) se dopočtou ze surovin a uloží
* do plochých polí, takže použití receptu založí běžné jídlo jako každé jiné.
* Zápis zároveň z úložiště odklidí zaniklé šablony hotových jídel.
*
* @param login přihlašovací jméno uživatele
* @param input data šablony
* @param input data receptu
*/
export const saveMealTemplate = async (login: string, input: unknown): Promise<MealTemplate[]> => {
const value = (input ?? {}) as Record<string, unknown>;
const name = normalizeText(value.name, 'name');
if (!name) {
throw new BadRequestError("Nebyl předán název šablony");
throw new BadRequestError("Nebyl předán název receptu");
}
if (value.mealType != null && !isMealType(value.mealType)) {
throw new BadRequestError("Nebyl předán platný typ jídla");
}
if (value.kind != null && value.kind !== MealTemplateKind.HOTOVE && value.kind !== MealTemplateKind.RECEPT) {
throw new BadRequestError("Nebyl předán platný druh šablony");
if (!Array.isArray(value.ingredients) || !value.ingredients.length) {
throw new BadRequestError("Recept musí mít alespoň jednu surovinu");
}
const kind = value.kind === MealTemplateKind.RECEPT ? MealTemplateKind.RECEPT : MealTemplateKind.HOTOVE;
if (value.ingredients.length > MAX_INGREDIENTS) {
throw new BadRequestError(`Recept může mít nejvýše ${MAX_INGREDIENTS} surovin`);
}
const ingredients = value.ingredients.map(normalizeIngredient);
const servings = normalizeNumber(value.servings, 'servings');
const cookedWeight = normalizeNumber(value.cookedWeight, 'cookedWeight') || undefined;
const summary = deriveRecipe(ingredients, servings, cookedWeight);
const common = {
const template: MealTemplate = definedOnly({
id: typeof value.id === 'string' && value.id.length ? value.id : randomUUID(),
kind,
name,
mealType: value.mealType != null ? value.mealType as MealType : undefined,
source: normalizeText(value.source, 'source'),
note: normalizeText(value.note, 'note', MAX_NOTE_LENGTH),
};
let template: MealTemplate;
if (kind === MealTemplateKind.RECEPT) {
if (!Array.isArray(value.ingredients) || !value.ingredients.length) {
throw new BadRequestError("Recept musí mít alespoň jednu surovinu");
ingredients,
// Jedna porce je výchozí stav, ukládat ji zvlášť nemá smysl
servings: servings && servings > 1 ? servings : undefined,
cookedWeight,
weight: summary.weight,
caloriesPer100g: summary.caloriesPer100g,
calories: summary.calories,
price: summary.price,
});
for (const ingredient of ingredients) {
if (ingredient.caloriesPer100g) {
await rememberCalories(login, ingredient.name, ingredient.caloriesPer100g);
}
if (value.ingredients.length > MAX_INGREDIENTS) {
throw new BadRequestError(`Recept může mít nejvýše ${MAX_INGREDIENTS} surovin`);
}
const ingredients = value.ingredients.map(normalizeIngredient);
const servings = normalizeNumber(value.servings, 'servings');
const cookedWeight = normalizeNumber(value.cookedWeight, 'cookedWeight') || undefined;
const summary = deriveRecipe(ingredients, servings, cookedWeight);
template = definedOnly({
...common,
ingredients,
// Jedna porce je výchozí stav, ukládat ji zvlášť nemá smysl
servings: servings && servings > 1 ? servings : undefined,
cookedWeight,
weight: summary.weight,
caloriesPer100g: summary.caloriesPer100g,
calories: summary.calories,
price: summary.price,
});
for (const ingredient of ingredients) {
if (ingredient.caloriesPer100g) {
await rememberCalories(login, ingredient.name, ingredient.caloriesPer100g);
}
}
} else {
template = definedOnly({
...common,
calories: normalizeNumber(value.calories, 'calories'),
caloriesPer100g: normalizeNumber(value.caloriesPer100g, 'caloriesPer100g'),
weight: normalizeNumber(value.weight, 'weight'),
price: normalizeNumber(value.price, 'price'),
pricePer100g: normalizeNumber(value.pricePer100g, 'pricePer100g'),
});
}
await storage.updateData<MealTemplate[]>(getKey(login), current => {
const others = (current ?? []).filter(item => item.id !== template.id);
return [...others, template];
});
await storage.updateData<StoredMealTemplate[]>(getKey(login), current => [
...loadRecipes(current).filter(item => item.id !== template.id),
template,
]);
return getMealTemplates(login);
};
/**
* Smaže šablonu jídla.
* Smaže recept. Jídla z něj založená zůstávají, jsou samostatná.
*
* @param login přihlašovací jméno uživatele
* @param id identifikátor šablony
* @param id identifikátor receptu
*/
export const deleteMealTemplate = async (login: string, id: unknown): Promise<MealTemplate[]> => {
if (typeof id !== 'string' || !id.length) {
throw new BadRequestError("Nebyl předán identifikátor šablony");
throw new BadRequestError("Nebyl předán identifikátor receptu");
}
let found = false;
await storage.updateData<MealTemplate[]>(getKey(login), current => {
found = (current ?? []).some(item => item.id === id);
return (current ?? []).filter(item => item.id !== id);
await storage.updateData<StoredMealTemplate[]>(getKey(login), current => {
const recipeList = loadRecipes(current);
found = recipeList.some(item => item.id === id);
return recipeList.filter(item => item.id !== id);
});
if (!found) {
throw new NotFoundError("Šablona nebyla nalezena");
throw new NotFoundError("Recept nebyl nalezen");
}
return getMealTemplates(login);
};
/**
* Založí do zvoleného dne jídlo ze šablony.
* Založí do zvoleného dne jednu porci receptu.
*
* Vznikne z ní běžný záznam jídla, takže jde pak upravit nebo smazat, aniž by
* to ovlivnilo šablonu. Gramáž, cenu a kalorie dopočítá `addMeal` stejně jako
* u ručně zadaného jídla.
* to ovlivnilo recept. Gramáž, cenu a kalorie dopočítá `addMeal` stejně jako
* u ručně zadaného jídla. Postup do záznamu dne nepatří, zůstává u receptu.
*
* @param login přihlašovací jméno uživatele
* @param id identifikátor šablony
* @param id identifikátor receptu
* @param date den, do kterého se jídlo založí
* @param mealType chod; bez něj se použije chod ze šablony, jinak oběd
* @param multiplier násobek porce, třeba 2 za dvě stejné sušenky
* @param mealType chod; bez něj se použije chod receptu, jinak oběd
* @param multiplier násobek porce, třeba 2 za dvě porce
*/
export const applyMealTemplate = async (
login: string,
@@ -281,7 +277,7 @@ export const applyMealTemplate = async (
multiplier?: unknown,
): Promise<string> => {
if (typeof id !== 'string' || !id.length) {
throw new BadRequestError("Nebyl předán identifikátor šablony");
throw new BadRequestError("Nebyl předán identifikátor receptu");
}
if (!isValidIsoDate(date)) {
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
@@ -291,7 +287,7 @@ export const applyMealTemplate = async (
}
const template = (await getMealTemplates(login)).find(item => item.id === id);
if (!template) {
throw new NotFoundError("Šablona nebyla nalezena");
throw new NotFoundError("Recept nebyl nalezen");
}
await addMeal(login, {
@@ -301,62 +297,9 @@ export const applyMealTemplate = async (
source: template.source,
weight: template.weight,
price: template.price,
pricePer100g: template.pricePer100g,
calories: template.calories,
caloriesPer100g: template.caloriesPer100g,
// Postup receptu do záznamu dne nepatří, zůstává u šablony
note: template.kind === MealTemplateKind.RECEPT ? undefined : template.note,
multiplier: normalizeMultiplier(multiplier),
}, template.id);
return date;
};
/**
* Vytvoří šablonu jídla z existujícího záznamu v přehledu dne.
*
* Když už šablona se stejným názvem existuje, přepíše se — opakované "uložit
* jako šablonu" u téhož jídla má dát jednu šablonu, ne několik stejných.
* Výjimkou je recept: nese suroviny a postup, které záznam dne nemá, takže by
* přepsáním zmizely. Ze záznamu dne vzniká vždy hotové jídlo.
*
* @param login přihlašovací jméno uživatele
* @param date den, ve kterém jídlo je
* @param id identifikátor jídla
*/
export const createMealTemplateFromMeal = async (
login: string,
date: unknown,
id: unknown,
): Promise<MealTemplate[]> => {
if (typeof id !== 'string' || !id.length) {
throw new BadRequestError("Nebyl předán identifikátor jídla");
}
if (!isValidIsoDate(date)) {
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
}
const meal = (await getDay(login, date)).entries.find(entry => entry.id === id);
if (!meal) {
throw new NotFoundError("Jídlo nebylo nalezeno");
}
const key = normalizeName(meal.name);
const existing = (await getMealTemplates(login)).find(item => normalizeName(item.name) === key);
if (existing?.kind === MealTemplateKind.RECEPT) {
throw new BadRequestError(`Jídlo „${meal.name}" už máte jako recept, šablona se nepřepíše`);
}
return saveMealTemplate(login, {
...(existing ? { id: existing.id } : {}),
kind: MealTemplateKind.HOTOVE,
name: meal.name,
source: meal.source,
mealType: meal.mealType,
weight: meal.weight,
price: meal.price,
pricePer100g: meal.pricePer100g,
// Absolutní kalorie ukládáme jen tam, kde je nejde dopočítat z gramáže
calories: meal.caloriesPer100g ? undefined : meal.calories,
caloriesPer100g: meal.caloriesPer100g,
note: meal.note,
});
};
+211 -2
View File
@@ -1,10 +1,13 @@
import { randomUUID } from 'crypto';
import { DayRecord, MealEntry, MealType } from "../../types/gen/types.gen";
import {
DayRecord, FillSameNameResult, MealEntry, MealSuggestion, MealType, Nutrients,
} from "../../types/gen/types.gen";
import getStorage from "./storage";
import {
BadRequestError, NotFoundError, applyMultiplier, isValidIsoDate, listDatesInRange, normalizeMultiplier,
} from "./utils";
import { rememberCalories } from "./calories";
import { normalizeName, rememberCalories } from "./calories";
import { getPortionNutrients, normalizeNutrients, sumNutrients } from "./nutrients";
const storage = getStorage();
@@ -46,11 +49,14 @@ function sortEntries(entries: MealEntry[]): MealEntry[] {
/** 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);
const totalNutrients = sumNutrients(sorted.map(entry =>
getPortionNutrients(entry.nutrientsPer100g, entry.weight, entry.multiplier)));
return {
date,
entries: sorted,
totalPrice: sorted.reduce((sum, entry) => sum + (entry.price ?? 0), 0),
totalCalories: sorted.reduce((sum, entry) => sum + (applyMultiplier(entry.calories, entry.multiplier) ?? 0), 0),
...(totalNutrients ? { totalNutrients } : {}),
};
}
@@ -125,6 +131,7 @@ export type NormalizedMealInput = {
weight?: number;
pricePer100g?: number;
caloriesPer100g?: number;
nutrientsPer100g?: Nutrients;
multiplier?: number;
};
@@ -172,6 +179,7 @@ export function normalizeInput(input: unknown): NormalizedMealInput {
weight: normalizeNumber(value.weight, 'weight'),
pricePer100g: normalizeNumber(value.pricePer100g, 'pricePer100g'),
caloriesPer100g: normalizeNumber(value.caloriesPer100g, 'caloriesPer100g'),
nutrientsPer100g: normalizeNutrients(value.nutrientsPer100g, 'nutrientsPer100g'),
multiplier: normalizeMultiplier(value.multiplier),
});
}
@@ -270,6 +278,7 @@ export async function updateMeal(login: string, id: unknown, input: unknown): Pr
weight: normalized.weight,
pricePer100g: normalized.pricePer100g,
caloriesPer100g: normalized.caloriesPer100g,
nutrientsPer100g: normalized.nutrientsPer100g,
multiplier: normalized.multiplier,
updatedAt: new Date().toISOString(),
};
@@ -374,3 +383,203 @@ export async function findMealsForDays(login: string, dates: string[]): Promise<
const days = await Promise.all([...dates].sort().map(date => getDay(login, date)));
return days.flatMap(day => day.entries);
}
/**
* Vrátí gramáž záznamu, pokud byla zadaná ručně. Gramáž dopočtená z ceny se
* neukládá zvlášť, pozná se tak, že odpovídá ceně a sazbě za 100 g.
*/
function getManualWeight(source: MealEntry): number | undefined {
const weightFromPrice = source.price != null && source.pricePer100g
? Math.round(source.price / source.pricePer100g * 100)
: undefined;
return source.weight !== weightFromPrice ? source.weight : undefined;
}
/**
* Převede záznam jídla na návrh pro zakládání nového.
*
* Přenáší se jen to, co popisuje jídlo samotné. Chod, datum, násobek a poznámka
* patří ke konkrétnímu dni. Gramáž dopočtená z ceny se vynechá, aby se v novém
* záznamu dopočetla znovu z jeho ceny, a ruční kalorie jen tam, kde nejdou
* dopočítat z gramáže — jinak by se z dopočtené hodnoty stala ručně zadaná.
*/
function createMealSuggestion(source: MealEntry): MealSuggestion {
const caloriesDerived = source.weight != null && !!source.caloriesPer100g;
return {
name: source.name,
source: source.source,
price: source.price,
pricePer100g: source.pricePer100g,
weight: getManualWeight(source),
caloriesPer100g: source.caloriesPer100g,
calories: caloriesDerived ? undefined : source.calories,
nutrientsPer100g: source.nutrientsPer100g,
lastDate: source.date,
count: 1,
};
}
/**
* Vrátí jídla z celé historie uživatele, každý název jednou — slouží
* k našeptávání při zakládání jídla místo ručně ukládaných šablon.
*
* Názvy se porovnávají znormalizované (bez diakritiky a velikosti písmen), takže
* "Kuřecí gyros" a "kureci gyros" jsou jedno jídlo. Hodnoty návrhu jsou
* z posledního záznamu, protože cena i gramáž se časem mění. Když poslední
* záznam nemá kalorie (typicky import z Luncheru), doplní se z dřívějšího stejně
* jako při importu. Řadí se od nejčastěji jedených, při shodě od naposledy jedených.
*
* @param login přihlašovací jméno uživatele
*/
export async function getMealHistory(login: string): Promise<MealSuggestion[]> {
const entryList = await findAllMeals(login);
const fillNutrition = createNutritionFiller(entryList);
const resultMap = new Map<string, MealSuggestion>();
for (const entry of entryList) {
const key = normalizeName(entry.name);
const existing = resultMap.get(key);
if (existing) {
existing.count++;
} else {
resultMap.set(key, createMealSuggestion(fillNutrition(entry)));
}
}
return [...resultMap.values()]
.sort((a, b) => b.count - a.count || b.lastDate.localeCompare(a.lastDate));
}
/**
* Načte všechna jídla uživatele napříč dny, od nejnovějších.
*
* @param login přihlašovací jméno uživatele
*/
export async function findAllMeals(login: string): Promise<MealEntry[]> {
const prefix = `meals:${login}:`;
const keyList = (await storage.listKeys(prefix)).filter(key => key.startsWith(prefix));
return (await Promise.all(keyList.map(key => storage.getData<MealEntry[]>(key))))
.flatMap(entries => entries ?? [])
.sort((a, b) => b.date.localeCompare(a.date) || b.createdAt.localeCompare(a.createdAt));
}
/** Nese záznam něco o energii nebo živinách — jen takový má smysl přebírat. */
const hasNutrition = (entry: MealEntry): boolean =>
entry.calories != null || !!entry.caloriesPer100g || !!entry.nutrientsPer100g;
/**
* Připraví doplňování energie a živin podle historie — jídlo stejného názvu
* má stejné složení, i když se cena mění.
*
* Vzorem je nejnovější jídlo stejného názvu, které energii nebo živiny má;
* přednost má to ze stejného zdroje. Sazba za 100 g se přebírá jen ze stejného
* zdroje (jinde se účtuje jinak), gramáž se pak dopočte z ceny nového záznamu.
* Bez sazby se vezme gramáž vzoru — porce bývá stejná. Jídlo, které už energii
* nebo živiny má, zůstane, jak je.
*
* @param historyList jídla uživatele od nejnovějších (`findAllMeals`)
*/
export function createNutritionFiller(historyList: MealEntry[]): (entry: MealEntry) => MealEntry {
const sourceKey = (entry: MealEntry) => `${normalizeName(entry.name)}|${normalizeName(entry.source ?? '')}`;
const byNameMap = new Map<string, MealEntry>();
const byNameSourceMap = new Map<string, MealEntry>();
for (const entry of historyList.filter(hasNutrition)) {
if (!byNameMap.has(normalizeName(entry.name))) byNameMap.set(normalizeName(entry.name), entry);
if (!byNameSourceMap.has(sourceKey(entry))) byNameSourceMap.set(sourceKey(entry), entry);
}
return (entry: MealEntry): MealEntry => {
if (hasNutrition(entry)) return entry;
const sameSourceRef = byNameSourceMap.get(sourceKey(entry));
const ref = sameSourceRef ?? byNameMap.get(normalizeName(entry.name));
if (!ref) return entry;
const pricePer100g = sameSourceRef ? ref.pricePer100g : undefined;
const fromPrice = deriveAmounts({
...entry, pricePer100g, weight: getManualWeight(ref), caloriesPer100g: ref.caloriesPer100g,
});
const weight = fromPrice.weight ?? ref.weight;
const calories = weight != null && ref.caloriesPer100g
? Math.round(weight / 100 * ref.caloriesPer100g)
: ref.calories;
const result: MealEntry = {
...entry, pricePer100g, weight, caloriesPer100g: ref.caloriesPer100g, calories,
nutrientsPer100g: ref.nutrientsPer100g,
};
// Nevyplněné hodnoty se do úložiště nezapisují
return Object.fromEntries(Object.entries(result).filter(([, value]) => value !== undefined)) as MealEntry;
};
}
/**
* Vrátí zdroje jídel, které uživatel už použil — nabídka pro pole Zdroj.
*
* Každý zdroj je jednou (porovnává se bez diakritiky a velikosti písmen),
* v zápisu z posledního použití a seřazené od nejčastěji použitých.
*
* @param login přihlašovací jméno uživatele
*/
export async function getMealSources(login: string): Promise<string[]> {
const countMap = new Map<string, { source: string, count: number }>();
for (const entry of await findAllMeals(login)) {
if (!entry.source) continue;
const key = normalizeName(entry.source);
const existing = countMap.get(key);
if (existing) {
existing.count++;
} else {
countMap.set(key, { source: entry.source, count: 1 });
}
}
return [...countMap.values()]
.sort((a, b) => b.count - a.count || a.source.localeCompare(b.source, 'cs'))
.map(item => item.source);
}
/**
* Doplní energii a živiny jídla do ostatních jídel stejného názvu, která je
* nemají — typicky po ručním doplnění kalorií jednomu naimportovanému jídlu.
*
* Přebírá se stejně jako při importu (\`createNutritionFiller\`), takže u jídel
* ze stejného zdroje se gramáž dopočte z jejich vlastní ceny. Jídla, která
* energii nebo živiny už mají, se nemění.
*
* @param login přihlašovací jméno uživatele
* @param id identifikátor jídla, ze kterého se hodnoty převezmou
* @param date datum toho jídla (YYYY-MM-DD)
* @param dryRun pokud true, jen spočítá dotčená jídla a nic nezmění
*/
export async function fillSameNameMeals(
login: string,
id: unknown,
date: unknown,
dryRun = false,
): Promise<FillSameNameResult> {
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");
}
const source = (await getDay(login, date)).entries.find(entry => entry.id === id);
if (!source) {
throw new NotFoundError("Záznam o jídle nebyl nalezen");
}
if (!hasNutrition(source)) {
throw new BadRequestError("Jídlo nemá kalorie ani živiny, není co doplnit");
}
const key = normalizeName(source.name);
const targetList = (await findAllMeals(login))
.filter(entry => entry.id !== source.id && normalizeName(entry.name) === key && !hasNutrition(entry));
const dates = [...new Set(targetList.map(entry => entry.date))].sort();
if (!dryRun) {
const fill = createNutritionFiller([source]);
const targetIds = new Set(targetList.map(entry => entry.id));
const updatedAt = new Date().toISOString();
for (const targetDate of dates) {
await storage.updateData<MealEntry[]>(getDayKey(login, targetDate), current => (current ?? [])
.map(entry => targetIds.has(entry.id) ? { ...fill(entry), updatedAt } : entry));
}
}
return { count: targetList.length, dates };
}
+83
View File
@@ -0,0 +1,83 @@
import { Nutrients } from "../../types/gen/types.gen";
import { BadRequestError } from "./utils";
/** Živiny v pořadí, v jakém je uvádí etiketa potravin. */
export const NUTRIENT_KEYS: (keyof Nutrients)[] = [
'protein', 'carbohydrates', 'sugars', 'fat', 'saturatedFat', 'fiber', 'salt',
];
/** Zaokrouhlí na desetiny gramu — přesnost, kterou uvádějí etikety. */
const roundTenth = (value: number): number => Math.round(value * 10) / 10;
/**
* Ověří a znormalizuje nutriční hodnoty na 100 g.
* Nevyplněné živiny vynechá; když není vyplněná žádná, vrátí undefined.
*
* @param value hodnoty ze vstupu
* @param name název pole pro chybovou hlášku
*/
export const normalizeNutrients = (value: unknown, name: string): Nutrients | undefined => {
if (value == null) return undefined;
if (typeof value !== 'object' || Array.isArray(value)) {
throw new BadRequestError(`Pole '${name}' musí být objekt s nutričními hodnotami`);
}
const source = value as Record<string, unknown>;
const result: Nutrients = {};
for (const key of NUTRIENT_KEYS) {
const item = source[key];
if (item == null) continue;
if (typeof item !== 'number' || !Number.isFinite(item)) {
throw new BadRequestError(`Pole '${name}.${key}' musí být číslo`);
}
if (item < 0) {
throw new BadRequestError(`Pole '${name}.${key}' nesmí být záporné`);
}
result[key] = roundTenth(item);
}
return Object.keys(result).length ? result : undefined;
};
/**
* Spočítá živiny snědené porce z hodnot na 100 g, gramáže a násobku.
* Bez gramáže je spočítat nejde — hodnoty na 100 g samy o porci nic neříkají.
*
* Klient má stejnou funkci v `Utils.tsx` pro náhled ve formuláři — musí dávat
* stejné výsledky.
*
* @param nutrientsPer100g nutriční hodnoty na 100 g
* @param weight gramáž jedné porce
* @param multiplier násobek porce, nevyplněný znamená 1
*/
export const getPortionNutrients = (
nutrientsPer100g: Nutrients | undefined,
weight: number | undefined,
multiplier?: number,
): Nutrients | undefined => {
if (!nutrientsPer100g || weight == null) return undefined;
const result: Nutrients = {};
for (const key of NUTRIENT_KEYS) {
const per100g = nutrientsPer100g[key];
if (per100g != null) {
result[key] = roundTenth(per100g * weight / 100 * (multiplier ?? 1));
}
}
return result;
};
/**
* Sečte živiny více porcí. Živina je ve výsledku, pokud ji má aspoň jedna porce;
* když nemá žádná porce nic, vrátí undefined.
*/
export const sumNutrients = (nutrientsList: (Nutrients | undefined)[]): Nutrients | undefined => {
const result: Nutrients = {};
for (const nutrients of nutrientsList) {
if (!nutrients) continue;
for (const key of NUTRIENT_KEYS) {
const value = nutrients[key];
if (value != null) {
result[key] = roundTenth((result[key] ?? 0) + value);
}
}
}
return Object.keys(result).length ? result : undefined;
};
+21 -14
View File
@@ -1,11 +1,17 @@
import { PersonalProfile, Pohlavi } from "../../types/gen/types.gen";
import { BadRequestError } from "./utils";
/** Meze hmotnosti v gramech — platí pro profil i vážení po dnech. */
export const WEIGHT_LIMITS = { min: 20_000, max: 400_000 };
/** Meze tělesných údajů — chrání před překlepem o řád, ne před neobvyklými hodnotami. */
const LIMITS = {
birthYear: { min: 1900, max: new Date().getFullYear() },
heightCm: { min: 50, max: 250 },
weightGrams: { min: 20_000, max: 400_000 },
weightGrams: WEIGHT_LIMITS,
waistCm: { min: 40, max: 250 },
neckCm: { min: 20, max: 80 },
hipCm: { min: 50, max: 250 },
};
/** Vrátí true, pokud je hodnota podporovaným pohlavím. */
@@ -32,15 +38,12 @@ export const normalizeProfile = (input: unknown): PersonalProfile => {
if (value.sex != null && !isPohlavi(value.sex)) {
throw new BadRequestError("Nebylo předáno platné pohlaví");
}
return {
...(value.sex != null ? { sex: value.sex as Pohlavi } : {}),
...(normalizeRanged(value.birthYear, 'birthYear') != null
? { birthYear: normalizeRanged(value.birthYear, 'birthYear') } : {}),
...(normalizeRanged(value.heightCm, 'heightCm') != null
? { heightCm: normalizeRanged(value.heightCm, 'heightCm') } : {}),
...(normalizeRanged(value.weightGrams, 'weightGrams') != null
? { weightGrams: normalizeRanged(value.weightGrams, 'weightGrams') } : {}),
};
const result: PersonalProfile = value.sex != null ? { sex: value.sex as Pohlavi } : {};
for (const name of Object.keys(LIMITS) as (keyof typeof LIMITS)[]) {
const normalized = normalizeRanged(value[name], name);
if (normalized != null) result[name] = normalized;
}
return result;
};
/**
@@ -59,14 +62,18 @@ export const normalizeProfile = (input: unknown): PersonalProfile => {
* Výsledek je odhad s běžnou chybou kolem ±10 %, ne měřená hodnota.
*
* @param profile tělesné údaje uživatele
* @returns klidový výdej v kcal za den, nebo nic, když profil není úplný
* @param weightGrams hmotnost — z vážení platného k danému dni, bez něj z profilu
* @returns klidový výdej v kcal za den, nebo nic, když údaje nejsou úplné
*/
export const computeBasalCalories = (profile?: PersonalProfile): number | undefined => {
if (!profile?.sex || !profile.birthYear || !profile.heightCm || !profile.weightGrams) {
export const computeBasalCalories = (
profile?: PersonalProfile,
weightGrams = profile?.weightGrams,
): number | undefined => {
if (!profile?.sex || !profile.birthYear || !profile.heightCm || !weightGrams) {
return undefined;
}
const age = new Date().getFullYear() - profile.birthYear;
const base = 10 * (profile.weightGrams / 1000)
const base = 10 * (weightGrams / 1000)
+ 6.25 * profile.heightCm
- 5 * age;
return Math.round(base + (profile.sex === Pohlavi.MUZ ? 5 : -161));
+19
View File
@@ -1,6 +1,7 @@
import express, { Request, Response, NextFunction } from "express";
import { getDayOverview } from "../dayOverview";
import { saveSleep } from "../sleep";
import { getWeights, saveWeight } from "../weights";
import { getLogin } from "../auth";
import { formatDate, getToday, parseToken, requireIsoDate } from "../utils";
@@ -27,4 +28,22 @@ router.post("/sleep", async (req: Request, res: Response, next: NextFunction) =>
} catch (e) { next(e); }
});
/** Zapíše vážení ke zvolenému dni. */
router.post("/weight", async (req: Request, res: Response, next: NextFunction) => {
try {
const login = getLogin(parseToken(req));
const { date, weightGrams } = req.body ?? {};
const day = await saveWeight(login, date, weightGrams);
res.status(200).json(await getDayOverview(login, day));
} catch (e) { next(e); }
});
/** Vrátí všechna vážení — pro graf vývoje hmotnosti. */
router.get("/weights", async (req: Request, res: Response, next: NextFunction) => {
try {
const login = getLogin(parseToken(req));
res.status(200).json(await getWeights(login));
} catch (e) { next(e); }
});
export default router;
+29 -1
View File
@@ -1,5 +1,8 @@
import express, { Request, Response, NextFunction } from "express";
import { addMeal, deleteMeal, getDay, getRange, setMealMultiplier, updateMeal } from "../meals";
import {
addMeal, deleteMeal, fillSameNameMeals, getDay, getMealHistory, getMealSources, getRange, setMealMultiplier,
updateMeal,
} from "../meals";
import { getDayOverview } from "../dayOverview";
import { getLogin } from "../auth";
import { formatDate, getToday, parseToken, requireIsoDate } from "../utils";
@@ -31,6 +34,31 @@ router.get("/range", async (req: Request, res: Response, next: NextFunction) =>
} catch (e) { next(e); }
});
/** Vrátí jídla z historie, každý název jednou — pro našeptávání při zakládání jídla. */
router.get("/history", async (req: Request, res: Response, next: NextFunction) => {
try {
const login = getLogin(parseToken(req));
res.status(200).json(await getMealHistory(login));
} catch (e) { next(e); }
});
/** Vrátí zdroje jídel, které uživatel už použil — nabídka pro pole Zdroj. */
router.get("/sources", async (req: Request, res: Response, next: NextFunction) => {
try {
const login = getLogin(parseToken(req));
res.status(200).json(await getMealSources(login));
} catch (e) { next(e); }
});
/** Doplní energii a živiny jídla do ostatních jídel stejného názvu, která je nemají. */
router.post("/fillSameName", async (req: Request, res: Response, next: NextFunction) => {
try {
const login = getLogin(parseToken(req));
const { id, date, dryRun } = req.body ?? {};
res.status(200).json(await fillSameNameMeals(login, id, date, dryRun === true));
} catch (e) { next(e); }
});
/** Přidá jídlo do zvoleného dne. */
router.post("/add", async (req: Request, res: Response, next: NextFunction) => {
try {
+5 -16
View File
@@ -1,14 +1,12 @@
import express, { Request, Response, NextFunction } from "express";
import {
applyMealTemplate, createMealTemplateFromMeal, deleteMealTemplate, getMealTemplates, saveMealTemplate,
} from "../mealTemplates";
import { applyMealTemplate, deleteMealTemplate, getMealTemplates, saveMealTemplate } from "../mealTemplates";
import { getDayOverview } from "../dayOverview";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
const router = express.Router();
/** Vrátí šablony jídel přihlášeného uživatele. */
/** Vrátí recepty přihlášeného uživatele. */
router.get("/", async (req: Request, res: Response, next: NextFunction) => {
try {
const login = getLogin(parseToken(req));
@@ -16,7 +14,7 @@ router.get("/", async (req: Request, res: Response, next: NextFunction) => {
} catch (e) { next(e); }
});
/** Založí novou šablonu jídla, nebo upraví existující. */
/** Založí nový recept, nebo upraví existující. */
router.post("/save", async (req: Request, res: Response, next: NextFunction) => {
try {
const login = getLogin(parseToken(req));
@@ -24,7 +22,7 @@ router.post("/save", async (req: Request, res: Response, next: NextFunction) =>
} catch (e) { next(e); }
});
/** Smaže šablonu jídla. */
/** Smaže recept. */
router.post("/delete", async (req: Request, res: Response, next: NextFunction) => {
try {
const login = getLogin(parseToken(req));
@@ -32,7 +30,7 @@ router.post("/delete", async (req: Request, res: Response, next: NextFunction) =
} catch (e) { next(e); }
});
/** Založí jídlo ze šablony do zvoleného dne. */
/** Založí porci receptu jako jídlo do zvoleného dne. */
router.post("/apply", async (req: Request, res: Response, next: NextFunction) => {
try {
const login = getLogin(parseToken(req));
@@ -42,13 +40,4 @@ router.post("/apply", async (req: Request, res: Response, next: NextFunction) =>
} catch (e) { next(e); }
});
/** Vytvoří šablonu z existujícího jídla v přehledu dne. */
router.post("/fromMeal", async (req: Request, res: Response, next: NextFunction) => {
try {
const login = getLogin(parseToken(req));
const { id, date } = req.body ?? {};
res.status(200).json(await createMealTemplateFromMeal(login, date, id));
} catch (e) { next(e); }
});
export default router;
+9 -1
View File
@@ -1,5 +1,5 @@
import express, { Request, Response, NextFunction } from "express";
import { getSettings, saveBasalCalories, saveProfile, saveSourceRate } from "../settings";
import { getSettings, saveBasalCalories, saveGoals, saveProfile, saveSourceRate } from "../settings";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
@@ -38,4 +38,12 @@ router.post("/profile", async (req: Request, res: Response, next: NextFunction)
} catch (e) { next(e); }
});
/** Uloží cíle uživatele. */
router.post("/goals", async (req: Request, res: Response, next: NextFunction) => {
try {
const login = getLogin(parseToken(req));
res.status(200).json(await saveGoals(login, req.body));
} catch (e) { next(e); }
});
export default router;
+31 -9
View File
@@ -3,6 +3,8 @@ import getStorage from "./storage";
import { BadRequestError } from "./utils";
import { normalizeName } from "./calories";
import { computeBasalCalories, normalizeProfile } from "./profile";
import { computeBodyMetrics, normalizeGoals } from "./bodyMetrics";
import { getWeights } from "./weights";
const storage = getStorage();
@@ -29,22 +31,27 @@ function emptySettings(): UserSettings {
*/
export async function getSettings(login: string): Promise<UserSettings> {
const stored = await storage.getData<UserSettings>(getSettingsKey(login)) ?? emptySettings();
return withDerived(stored);
return withDerived(login, stored);
}
/**
* Doplní nastavení o hodnoty, které se dopočítávají a neukládají.
* Doplní nastavení o hodnoty, které se dopočítávají a neukládají — klidový
* výdej a tělesné ukazatele. Počítají se z posledního vážení, bez něj
* z hmotnosti v profilu.
*
* Ručně zadaný klidový výdej má přednost před spočítaným z tělesných údajů —
* kdo si změřil vlastní, nechce ho mít přebitý odhadem z rovnice.
*/
function withDerived(settings: UserSettings): UserSettings {
const computed = computeBasalCalories(settings.profile);
const effective = settings.basalCalories ?? computed;
async function withDerived(login: string, settings: UserSettings): Promise<UserSettings> {
const { computedBasalCalories: _basal, effectiveBasalCalories: _effective, bodyMetrics: _metrics, ...stored } = settings;
const latestWeight = (await getWeights(login)).pop();
const computed = computeBasalCalories(stored.profile, latestWeight?.weightGrams ?? stored.profile?.weightGrams);
const effective = stored.basalCalories ?? computed;
return {
...settings,
...stored,
...(computed != null ? { computedBasalCalories: computed } : {}),
...(effective != null ? { effectiveBasalCalories: effective } : {}),
bodyMetrics: computeBodyMetrics(stored.profile, stored.goals, latestWeight),
};
}
@@ -60,7 +67,7 @@ export async function saveProfile(login: string, profile: unknown): Promise<User
...(current ?? emptySettings()),
profile: normalized,
}));
return withDerived(saved);
return withDerived(login, saved);
}
/**
@@ -93,7 +100,7 @@ export async function saveSourceRate(login: string, source: unknown, pricePer100
// Nulová sazba znamená smazání — ukládat ji nemá smysl
return { ...settings, sourceRates: rate > 0 ? [...rates, { source: name, pricePer100g: rate }] : rates };
});
return withDerived(saved);
return withDerived(login, saved);
}
/**
@@ -120,7 +127,7 @@ export async function saveBasalCalories(login: string, basalCalories: unknown):
const { basalCalories: _removed, ...rest } = settings;
return rest;
});
return withDerived(saved);
return withDerived(login, saved);
}
/**
@@ -135,3 +142,18 @@ export async function getSourceRate(login: string, source?: string): Promise<num
const settings = await getSettings(login);
return settings.sourceRates.find(rate => normalizeName(rate.source) === key)?.pricePer100g;
}
/**
* Uloží cíle uživatele. Nevyplněné hodnoty se odstraní, prázdné cíle celé.
*
* @param login přihlašovací jméno uživatele
* @param goals cíle
*/
export async function saveGoals(login: string, goals: unknown): Promise<UserSettings> {
const normalized = normalizeGoals(goals);
const saved = await storage.updateData<UserSettings>(getSettingsKey(login), current => {
const { goals: _removed, ...rest } = current ?? emptySettings();
return Object.keys(normalized).length ? { ...rest, goals: normalized } : rest;
});
return withDerived(login, saved);
}
+7 -3
View File
@@ -190,10 +190,14 @@ describe('denní detail výdeje', () => {
});
});
describe('vazba jídla na šablonu', () => {
describe('vazba jídla na recept', () => {
it('jídlo ze šablony si pamatuje, ze které vzniklo', async () => {
const [template] = await saveMealTemplate(LOGIN, { name: 'Štrůdl', mealType: MealType.PULNOCNI_SVACINA });
it('jídlo z receptu si pamatuje, ze kterého vzniklo', async () => {
const [template] = await saveMealTemplate(LOGIN, {
name: 'Štrůdl',
mealType: MealType.PULNOCNI_SVACINA,
ingredients: [{ name: 'Těsto', weight: 300, calories: 900 }],
});
await applyMealTemplate(LOGIN, template.id, DATE);
const [meal] = (await getDay(LOGIN, DATE)).entries;
+157
View File
@@ -0,0 +1,157 @@
process.env.CALORIE_PROVIDER = 'none';
import { computeBmi, computeBodyFat, computeBodyMetrics, getBmiCategory, normalizeGoals } from "../bodyMetrics";
import { getSettings, saveBasalCalories, saveGoals, saveProfile } from "../settings";
import { getWeights, saveWeight } from "../weights";
import { getDayOverview } from "../dayOverview";
import { addMeal } from "../meals";
import { resetMemoryStorage } from "../storage/memory";
import { BmiKategorie, MealType, Pohlavi } from "../../../types/gen/types.gen";
const LOGIN = 'tester';
const muz = { sex: Pohlavi.MUZ, birthYear: 1990, heightCm: 185 };
beforeEach(() => resetMemoryStorage());
describe('BMI a zdravá hmotnost', () => {
it('spočítá BMI na desetiny', () => {
// 90 / 1,85² = 26,296…
expect(computeBmi(90_000, 185)).toBe(26.3);
});
it('zařadí BMI do pásem WHO včetně hranic', () => {
expect(getBmiCategory(18.4)).toBe(BmiKategorie.PODVAHA);
expect(getBmiCategory(18.5)).toBe(BmiKategorie.NORMA);
expect(getBmiCategory(24.9)).toBe(BmiKategorie.NORMA);
expect(getBmiCategory(25)).toBe(BmiKategorie.NADVAHA);
expect(getBmiCategory(30)).toBe(BmiKategorie.OBEZITA);
});
it('rozmezí zdravé hmotnosti odpovídá BMI 18,5–25 pro výšku', () => {
const metrics = computeBodyMetrics(muz);
// 18,5 × 1,85² = 63,3 kg, 25 × 1,85² = 85,6 kg
expect(metrics.healthyWeightMinGrams).toBe(63_300);
expect(metrics.healthyWeightMaxGrams).toBe(85_600);
expect(metrics.bmi).toBeUndefined();
});
});
describe('podíl tuku (US Navy)', () => {
it('u muže počítá z pasu a krku', () => {
expect(computeBodyFat({ ...muz, waistCm: 90, neckCm: 40 })).toBe(17.6);
});
it('u ženy potřebuje i boky', () => {
const zena = { sex: Pohlavi.ZENA, heightCm: 168, waistCm: 72, neckCm: 33 };
expect(computeBodyFat(zena)).toBeUndefined();
// 495 / (1,29579 − 0,35004 × log10(137) + 0,221 × log10(168)) − 450
expect(computeBodyFat({ ...zena, hipCm: 98 })).toBe(26.1);
});
it('nesmyslné obvody nedají žádný odhad', () => {
expect(computeBodyFat({ ...muz, waistCm: 40, neckCm: 45 })).toBeUndefined();
expect(computeBodyFat({ ...muz, waistCm: 90 })).toBeUndefined();
});
});
describe('cíle', () => {
it('půl kila týdně dolů je denní bilance −550 kcal', () => {
expect(computeBodyMetrics(muz, { weeklyChangeGrams: -500 }).targetDailyBalance).toBe(-550);
});
it('bílkoviny počítá z hmotnosti a cílový termín jen ve směru tempa', () => {
const metrics = computeBodyMetrics(
muz, { weeklyChangeGrams: -500, targetWeightGrams: 85_000, proteinPerKg: 1.6 },
{ date: '2026-10-01', weightGrams: 90_000 });
expect(metrics.proteinTargetGrams).toBe(144);
expect(metrics.weeksToTarget).toBe(10);
expect(computeBodyMetrics(muz, { weeklyChangeGrams: 250, targetWeightGrams: 85_000 },
{ date: '2026-10-01', weightGrams: 90_000 }).weeksToTarget).toBeUndefined();
});
it('cíle se ukládají znormalizované a prázdné se odstraní', async () => {
const settings = await saveGoals(LOGIN, { weeklyChangeGrams: -500, proteinPerKg: 1.64 });
expect(settings.goals).toEqual({ weeklyChangeGrams: -500, proteinPerKg: 1.6 });
expect((await saveGoals(LOGIN, {})).goals).toBeUndefined();
});
it('odmítne cíl mimo rozsah', () => {
expect(() => normalizeGoals({ weeklyChangeGrams: -5000 })).toThrow(/mimo rozsah/);
expect(() => normalizeGoals({ proteinPerKg: 10 })).toThrow(/mimo rozsah/);
});
});
describe('vážení po dnech', () => {
it('ukládá, přepisuje a maže vážení dne', async () => {
await saveWeight(LOGIN, '2026-10-02', 89_500);
await saveWeight(LOGIN, '2026-10-01', 90_000);
await saveWeight(LOGIN, '2026-10-02', 89_400);
expect(await getWeights(LOGIN)).toEqual([
{ date: '2026-10-01', weightGrams: 90_000 },
{ date: '2026-10-02', weightGrams: 89_400 },
]);
await saveWeight(LOGIN, '2026-10-02', null);
expect(await getWeights(LOGIN)).toHaveLength(1);
});
it('odmítne hmotnost mimo rozsah', async () => {
await expect(saveWeight(LOGIN, '2026-10-01', 900)).rejects.toThrow(/mimo rozsah/);
});
it('ukazatele v nastavení počítají s posledním vážením místo hmotnosti z profilu', async () => {
await saveProfile(LOGIN, { ...muz, weightGrams: 95_000 });
await saveWeight(LOGIN, '2026-10-01', 90_000);
const fromWeighing = await getSettings(LOGIN);
await saveWeight(LOGIN, '2026-10-01', null);
const fromProfile = await getSettings(LOGIN);
expect(fromWeighing.bodyMetrics).toMatchObject({ weightGrams: 90_000, weightDate: '2026-10-01', bmi: 26.3 });
expect(fromProfile.bodyMetrics?.weightDate).toBeUndefined();
// 10 kcal na kilogram rozdílu
expect(fromProfile.computedBasalCalories! - fromWeighing.computedBasalCalories!).toBe(50);
});
it('bilance dne bere hmotnost platnou k tomu dni, ne poslední', async () => {
await saveProfile(LOGIN, muz);
await saveWeight(LOGIN, '2026-09-01', 95_000);
await saveWeight(LOGIN, '2026-10-01', 90_000);
const september = await getDayOverview(LOGIN, '2026-09-15');
const october = await getDayOverview(LOGIN, '2026-10-05');
expect(september.energy.basal - october.energy.basal).toBe(50);
expect(september.weightGrams).toBeUndefined();
expect((await getDayOverview(LOGIN, '2026-10-01')).weightGrams).toBe(90_000);
});
it('ruční klidový výdej má přednost i před vážením', async () => {
await saveProfile(LOGIN, muz);
await saveWeight(LOGIN, '2026-10-01', 90_000);
await saveBasalCalories(LOGIN, 1500);
expect((await getDayOverview(LOGIN, '2026-10-01')).energy.basal).toBe(1500);
});
it('přehled dne nese cílovou bilanci a cíl bílkovin', async () => {
await saveWeight(LOGIN, '2026-10-01', 90_000);
await saveGoals(LOGIN, { weeklyChangeGrams: -500, proteinPerKg: 2 });
await addMeal(LOGIN, { date: '2026-10-01', mealType: MealType.OBED, name: 'Guláš', calories: 700 });
const overview = await getDayOverview(LOGIN, '2026-10-01');
expect(overview.energy.targetBalance).toBe(-550);
expect(overview.proteinTargetGrams).toBe(180);
});
});
+80 -1
View File
@@ -1,6 +1,6 @@
import ExcelJS from 'exceljs';
import { importLuncherExport } from "../luncherImport";
import { getDay } from "../meals";
import { addMeal, getDay } from "../meals";
import { resetMemoryStorage } from "../storage/memory";
import { MealType } from "../../../types/gen/types.gen";
@@ -278,3 +278,82 @@ describe('import přehledu z Luncheru', () => {
.rejects.toThrow(/nepodařilo najít očekávané sloupce/);
});
});
describe('doplnění energie a živin z historie při importu', () => {
const svickova = {
date: '2025-02-10', mealType: MealType.OBED, name: 'Svíčková na smetaně', source: 'Sladovnická',
weight: 400, caloriesPer100g: 150, nutrientsPer100g: { protein: 8, fat: 9.5 },
};
/** Naimportovaný záznam daného dne. */
const importedOn = async (date: string) => {
await importLuncherExport(LOGIN, 'export.json', buildLuncherJson());
return (await getDay(LOGIN, date)).entries.find(entry => entry.importSource === 'luncher')!;
};
it('převezme energii, živiny i gramáž jídla stejného názvu', async () => {
await addMeal(LOGIN, svickova);
const entry = await importedOn('2025-03-03');
expect(entry).toMatchObject({
weight: 400, caloriesPer100g: 150, calories: 600, nutrientsPer100g: { protein: 8, fat: 9.5 },
});
// Cena zůstane z importu
expect(entry.price).toBe(15900);
});
it('u stejného zdroje převezme sazbu za 100 g a gramáž dopočte z nové ceny', async () => {
await addMeal(LOGIN, {
date: '2025-02-10', mealType: MealType.OBED, name: 'Kuřecí burger', source: 'Bolt Food',
price: 20000, pricePer100g: 5000, caloriesPer100g: 250,
});
const entry = await importedOn('2025-03-05');
// 249 Kč za 50 Kč/100 g = 498 g, × 250 kcal/100 g
expect(entry).toMatchObject({ pricePer100g: 5000, weight: 498, calories: 1245 });
});
it('z jiného zdroje sazbu nepřebírá a použije gramáž porce', async () => {
await addMeal(LOGIN, {
date: '2025-02-10', mealType: MealType.OBED, name: 'Kuřecí burger', source: 'Jinde',
price: 20000, pricePer100g: 5000, caloriesPer100g: 250,
});
const entry = await importedOn('2025-03-05');
expect(entry.pricePer100g).toBeUndefined();
expect(entry).toMatchObject({ weight: 400, calories: 1000 });
});
it('má přednost jídlo ze stejného zdroje, i když je starší', async () => {
await addMeal(LOGIN, svickova);
await addMeal(LOGIN, { ...svickova, date: '2025-02-20', source: 'Jinde', caloriesPer100g: 200 });
expect((await importedOn('2025-03-03')).caloriesPer100g).toBe(150);
});
it('jídlo stejného názvu bez kalorií vzorem není, vezme se starší s kaloriemi', async () => {
await addMeal(LOGIN, svickova);
await addMeal(LOGIN, { date: '2025-02-20', mealType: MealType.OBED, name: 'Svíčková na smetaně', price: 15900 });
expect((await importedOn('2025-03-03')).calories).toBe(600);
});
it('bez historie zůstane jídlo bez kalorií', async () => {
const entry = await importedOn('2025-03-03');
expect(entry.calories).toBeUndefined();
expect(entry).not.toHaveProperty('weight');
});
it('doplněné hodnoty ukáže už náhled', async () => {
await addMeal(LOGIN, svickova);
const result = await importLuncherExport(LOGIN, 'export.json', buildLuncherJson(), undefined, true);
expect(result.entries.find(entry => entry.date === '2025-03-03')?.calories).toBe(600);
});
});
+233
View File
@@ -0,0 +1,233 @@
process.env.CALORIE_PROVIDER = 'none';
import { addMeal, fillSameNameMeals, getDay, getMealHistory, getMealSources, updateMeal } from "../meals";
import { getPortionNutrients, sumNutrients } from "../nutrients";
import { resetMemoryStorage } from "../storage/memory";
import { MealType } from "../../../types/gen/types.gen";
const LOGIN = 'tester';
const DATE = '2025-03-03';
beforeEach(() => resetMemoryStorage());
describe('návrhy jídel z historie', () => {
const gyros = {
date: DATE, mealType: MealType.OBED, name: 'Kuřecí gyros',
source: 'TechTower', price: 17600, pricePer100g: 4400, caloriesPer100g: 172,
};
it('každý název nabídne jednou, i když se jedl vícekrát', async () => {
await addMeal(LOGIN, gyros);
await addMeal(LOGIN, { ...gyros, date: '2025-03-04' });
await addMeal(LOGIN, { ...gyros, date: '2025-03-05', name: 'kureci GYROS' });
await addMeal(LOGIN, { ...gyros, name: 'Polévka' });
const history = await getMealHistory(LOGIN);
expect(history.map(item => item.name)).toEqual(['kureci GYROS', 'Polévka']);
expect(history[0].count).toBe(3);
});
it('hodnoty bere z posledního záznamu', async () => {
await addMeal(LOGIN, { ...gyros, date: '2025-03-10', price: 22000 });
await addMeal(LOGIN, gyros);
const [item] = await getMealHistory(LOGIN);
expect(item.price).toBe(22000);
expect(item.lastDate).toBe('2025-03-10');
});
it('nepřenáší chod, násobek ani poznámku', async () => {
await addMeal(LOGIN, { ...gyros, mealType: MealType.VECERE, multiplier: 2, note: 's kolegy' });
const [item] = await getMealHistory(LOGIN);
expect(item).not.toHaveProperty('mealType');
expect(item).not.toHaveProperty('multiplier');
expect(item).not.toHaveProperty('note');
});
it('gramáž dopočtenou z ceny vynechá, ručně zadanou ponechá', async () => {
await addMeal(LOGIN, gyros);
await addMeal(LOGIN, { ...gyros, name: 'Salát', weight: 250 });
const history = await getMealHistory(LOGIN);
expect(history.find(item => item.name === 'Kuřecí gyros')?.weight).toBeUndefined();
expect(history.find(item => item.name === 'Salát')?.weight).toBe(250);
});
it('kalorie dopočtené z gramáže vynechá, ručně zadané ponechá', async () => {
await addMeal(LOGIN, gyros);
await addMeal(LOGIN, { date: DATE, mealType: MealType.SNIDANE, name: 'Jablko', calories: 80 });
const history = await getMealHistory(LOGIN);
expect(history.find(item => item.name === 'Kuřecí gyros')).toMatchObject({ caloriesPer100g: 172 });
expect(history.find(item => item.name === 'Kuřecí gyros')?.calories).toBeUndefined();
expect(history.find(item => item.name === 'Jablko')?.calories).toBe(80);
});
it('řadí od nejčastějších, při shodě od naposledy jedených', async () => {
await addMeal(LOGIN, { ...gyros, name: 'Starší' });
await addMeal(LOGIN, { ...gyros, name: 'Novější', date: '2025-03-04' });
await addMeal(LOGIN, { ...gyros, name: 'Častější' });
await addMeal(LOGIN, { ...gyros, name: 'Častější', date: '2025-03-02' });
const history = await getMealHistory(LOGIN);
expect(history.map(item => item.name)).toEqual(['Častější', 'Novější', 'Starší']);
});
it('historie je oddělená mezi uživateli', async () => {
await addMeal(LOGIN, gyros);
expect(await getMealHistory('nekdo-jiny')).toEqual([]);
});
});
describe('nutriční hodnoty', () => {
const tycinka = {
date: DATE, mealType: MealType.DOPOLEDNI_SVACINA, name: 'Proteinová tyčinka', weight: 50,
nutrientsPer100g: { protein: 30, carbohydrates: 40.04, sugars: 5, fat: 12.5, salt: 0.44 },
};
it('uloží hodnoty na 100 g zaokrouhlené na desetiny', async () => {
const day = await addMeal(LOGIN, tycinka);
expect(day.entries[0].nutrientsPer100g).toEqual({
protein: 30, carbohydrates: 40, sugars: 5, fat: 12.5, salt: 0.4,
});
});
it('den sečte živiny porcí včetně násobku', async () => {
await addMeal(LOGIN, tycinka);
await addMeal(LOGIN, { ...tycinka, multiplier: 2 });
const day = await getDay(LOGIN, DATE);
// 50 g = polovina hodnot na 100 g; jednou ×1 a jednou ×2
expect(day.totalNutrients).toEqual({
protein: 45, carbohydrates: 60, sugars: 7.5, fat: 18.8, salt: 0.6,
});
});
it('jídlo bez gramáže nebo bez živin do součtu nevstupuje', async () => {
await addMeal(LOGIN, { ...tycinka, weight: undefined });
await addMeal(LOGIN, { date: DATE, mealType: MealType.OBED, name: 'Guláš', weight: 400, calories: 600 });
expect((await getDay(LOGIN, DATE)).totalNutrients).toBeUndefined();
});
it('úprava umí živiny smazat', async () => {
const day = await addMeal(LOGIN, tycinka);
const { nutrientsPer100g, ...bezZivin } = tycinka;
const updated = await updateMeal(LOGIN, day.entries[0].id, bezZivin);
expect(nutrientsPer100g).toBeDefined();
expect(updated.entries[0].nutrientsPer100g).toBeUndefined();
expect(updated.totalNutrients).toBeUndefined();
});
it('odmítne zápornou nebo nečíselnou hodnotu', async () => {
await expect(addMeal(LOGIN, { ...tycinka, nutrientsPer100g: { fat: -1 } })).rejects.toThrow(/záporné/);
await expect(addMeal(LOGIN, { ...tycinka, nutrientsPer100g: { fat: 'hodně' } })).rejects.toThrow(/číslo/);
});
it('prázdné hodnoty se neukládají', async () => {
const day = await addMeal(LOGIN, { ...tycinka, nutrientsPer100g: {} });
expect(day.entries[0].nutrientsPer100g).toBeUndefined();
});
it('přenesou se do návrhu z historie', async () => {
await addMeal(LOGIN, tycinka);
const [item] = await getMealHistory(LOGIN);
expect(item.nutrientsPer100g?.protein).toBe(30);
});
it('porce a součet počítají jen vyplněné živiny', () => {
expect(getPortionNutrients({ protein: 10 }, 250, 0.5)).toEqual({ protein: 12.5 });
expect(getPortionNutrients({ protein: 10 }, undefined)).toBeUndefined();
expect(sumNutrients([{ protein: 1.1 }, undefined, { protein: 2.2, fat: 1 }])).toEqual({ protein: 3.3, fat: 1 });
expect(sumNutrients([undefined])).toBeUndefined();
});
});
describe('zdroje jídel', () => {
it('vrátí každý zdroj jednou, od nejčastějšího, v posledním zápisu', async () => {
await addMeal(LOGIN, { date: DATE, mealType: MealType.OBED, name: 'A', source: 'techtower' });
await addMeal(LOGIN, { date: '2025-03-04', mealType: MealType.OBED, name: 'B', source: 'TechTower' });
await addMeal(LOGIN, { date: DATE, mealType: MealType.OBED, name: 'C', source: 'Chefie' });
await addMeal(LOGIN, { date: DATE, mealType: MealType.OBED, name: 'D' });
expect(await getMealSources(LOGIN)).toEqual(['TechTower', 'Chefie']);
});
});
describe('doplnění do jídel stejného názvu', () => {
const salat = { mealType: MealType.OBED, name: 'Těstovinový salát', source: 'Chefie', price: 12900 };
/** Založí jídla bez kalorií a jedno s nimi; vrátí to s kaloriemi. */
const seed = async () => {
await addMeal(LOGIN, { ...salat, date: '2025-03-01' });
await addMeal(LOGIN, { ...salat, date: '2025-03-02', name: 'těstovinový SALÁT' });
await addMeal(LOGIN, { ...salat, date: '2025-03-02', name: 'Jiné jídlo' });
await addMeal(LOGIN, { ...salat, date: '2025-03-04', calories: 300, caloriesPer100g: 120 });
const day = await addMeal(LOGIN, {
...salat, date: DATE, weight: 350, caloriesPer100g: 160, nutrientsPer100g: { protein: 6 },
});
return day.entries[0];
};
it('náhled spočítá jídla bez kalorií a nic nezmění', async () => {
const source = await seed();
const result = await fillSameNameMeals(LOGIN, source.id, DATE, true);
expect(result).toEqual({ count: 2, dates: ['2025-03-01', '2025-03-02'] });
expect((await getDay(LOGIN, '2025-03-01')).totalCalories).toBe(0);
});
it('doplní energii, živiny a gramáž, jídla s kaloriemi nechá', async () => {
const source = await seed();
await fillSameNameMeals(LOGIN, source.id, DATE);
const [first] = (await getDay(LOGIN, '2025-03-01')).entries;
expect(first).toMatchObject({ weight: 350, calories: 560, nutrientsPer100g: { protein: 6 }, price: 12900 });
const second = (await getDay(LOGIN, '2025-03-02')).entries;
expect(second.find(entry => entry.name === 'těstovinový SALÁT')?.calories).toBe(560);
expect(second.find(entry => entry.name === 'Jiné jídlo')?.calories).toBeUndefined();
expect((await getDay(LOGIN, '2025-03-04')).entries[0].calories).toBe(300);
});
it('odmítne jídlo bez kalorií i živin', async () => {
const day = await addMeal(LOGIN, { ...salat, date: DATE });
await expect(fillSameNameMeals(LOGIN, day.entries[0].id, DATE)).rejects.toThrow(/není co doplnit/);
});
});
describe('návrh z historie s posledním záznamem bez kalorií', () => {
it('převezme kalorie z dřívějšího záznamu, cenu z posledního', async () => {
await addMeal(LOGIN, {
date: '2025-03-01', mealType: MealType.OBED, name: 'Salát', source: 'Chefie',
price: 12900, weight: 350, caloriesPer100g: 160,
});
await addMeal(LOGIN, { date: DATE, mealType: MealType.OBED, name: 'Salát', source: 'Chefie', price: 13900 });
const [item] = await getMealHistory(LOGIN);
expect(item).toMatchObject({ price: 13900, weight: 350, caloriesPer100g: 160, lastDate: DATE });
});
});
+103 -68
View File
@@ -5,6 +5,7 @@ import { applyWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate } from "
import { applyMealTemplate, deleteMealTemplate, getMealTemplates, saveMealTemplate } from "../mealTemplates";
import { getDay } from "../meals";
import { resetMemoryStorage } from "../storage/memory";
import getStorage from "../storage";
import { ActivityUnit, MealType, WorkoutItemKind } from "../../../types/gen/types.gen";
const LOGIN = 'tester';
@@ -115,74 +116,67 @@ describe('série u cviků', () => {
});
});
describe('šablony jídel', () => {
const salat = {
name: 'Těstovinový salát',
source: 'Chefie',
mealType: MealType.OBED,
weight: 350,
caloriesPer100g: 160,
price: 12900,
describe('recepty', () => {
const rizoto = {
name: 'Kuřecí rizoto',
source: 'doma',
mealType: MealType.VECERE,
servings: 2,
cookedWeight: 1000,
ingredients: [
{ name: 'Rýže', weight: 200, caloriesPer100g: 350, price: 3000 },
{ name: 'Kuřecí prsa', weight: 300, caloriesPer100g: 110, price: 9000 },
],
note: 'Rýži orestovat, podlít vývarem.',
};
it('uloží šablonu a vrátí ji v seznamu', async () => {
const templates = await saveMealTemplate(LOGIN, salat);
it('uloží recept a dopočte hodnoty jedné porce', async () => {
const [template] = await saveMealTemplate(LOGIN, rizoto);
// 700 + 330 kcal na 1000 g hotového jídla, rozděleno na dvě porce
expect(template).toMatchObject({
name: 'Kuřecí rizoto',
weight: 500,
calories: 515,
caloriesPer100g: 103,
price: 6000,
});
});
it('úprava podle id recept přepíše, nezaloží druhý', async () => {
const [created] = await saveMealTemplate(LOGIN, rizoto);
const templates = await saveMealTemplate(LOGIN, { ...rizoto, id: created.id, name: 'Rizoto velké' });
expect(templates).toHaveLength(1);
expect(templates[0].name).toBe('Těstovinový salát');
expect(templates[0].source).toBe('Chefie');
expect(templates[0].name).toBe('Rizoto velké');
});
it('úprava podle id šablonu přepíše, nezaloží druhou', async () => {
const [created] = await saveMealTemplate(LOGIN, salat);
const templates = await saveMealTemplate(LOGIN, { ...salat, id: created.id, name: 'Salát velký' });
expect(templates).toHaveLength(1);
expect(templates[0].name).toBe('Salát velký');
});
it('použití založí jídlo do dne a dopočte kalorie', async () => {
const [template] = await saveMealTemplate(LOGIN, salat);
it('použití založí porci do dne, bez postupu', async () => {
const [template] = await saveMealTemplate(LOGIN, rizoto);
await applyMealTemplate(LOGIN, template.id, DATE);
const day = await getDay(LOGIN, DATE);
expect(day.entries).toHaveLength(1);
expect(day.entries[0].name).toBe('Těstovinový salát');
expect(day.entries[0].source).toBe('Chefie');
// 350 g × 160 kcal/100 g
expect(day.entries[0].calories).toBe(560);
expect(day.totalPrice).toBe(12900);
const [entry] = (await getDay(LOGIN, DATE)).entries;
expect(entry).toMatchObject({ name: 'Kuřecí rizoto', mealType: MealType.VECERE, weight: 500, calories: 515 });
expect(entry.note).toBeUndefined();
});
it('zvolený chod přebije ten ze šablony', async () => {
const [template] = await saveMealTemplate(LOGIN, salat);
it('zvolený chod přebije ten z receptu a bez obou se založí oběd', async () => {
const [template] = await saveMealTemplate(LOGIN, rizoto);
const [bezChodu] = await saveMealTemplate(LOGIN, { ...rizoto, name: 'Bez chodu', mealType: undefined });
await applyMealTemplate(LOGIN, template.id, DATE, MealType.VECERE);
await applyMealTemplate(LOGIN, template.id, DATE, MealType.SNIDANE);
await applyMealTemplate(LOGIN, bezChodu.id, '2025-03-04');
expect((await getDay(LOGIN, DATE)).entries[0].mealType).toBe(MealType.VECERE);
expect((await getDay(LOGIN, DATE)).entries[0].mealType).toBe(MealType.SNIDANE);
expect((await getDay(LOGIN, '2025-03-04')).entries[0].mealType).toBe(MealType.OBED);
});
it('bez chodu ve šabloně i v požadavku se založí jako oběd', async () => {
const [template] = await saveMealTemplate(LOGIN, { name: 'Jablko', calories: 80 });
await applyMealTemplate(LOGIN, template.id, DATE);
expect((await getDay(LOGIN, DATE)).entries[0].mealType).toBe(MealType.OBED);
});
it('šablona jen s absolutními kaloriemi funguje taky', async () => {
const [template] = await saveMealTemplate(LOGIN, { name: 'Jablko', calories: 80 });
await applyMealTemplate(LOGIN, template.id, DATE);
expect((await getDay(LOGIN, DATE)).totalCalories).toBe(80);
});
it('založené jídlo je samostatné — smazání šablony ho nesmaže', async () => {
const [template] = await saveMealTemplate(LOGIN, salat);
it('založené jídlo je samostatné — smazání receptu ho nesmaže', async () => {
const [template] = await saveMealTemplate(LOGIN, rizoto);
await applyMealTemplate(LOGIN, template.id, DATE);
await deleteMealTemplate(LOGIN, template.id);
@@ -191,25 +185,66 @@ describe('šablony jídel', () => {
expect((await getDay(LOGIN, DATE)).entries).toHaveLength(1);
});
it('unese poznámku s postupem přípravy', async () => {
const recept = 'Těstoviny uvařit al dente.\nZeleninu nakrájet.\nSmíchat s dresinkem.';
const [template] = await saveMealTemplate(LOGIN, { name: 'Domácí salát', note: recept });
expect(template.note).toBe(recept);
await applyMealTemplate(LOGIN, template.id, DATE);
expect((await getDay(LOGIN, DATE)).entries[0].note).toBe(recept);
it('odmítne recept bez názvu, bez surovin a neexistující id', async () => {
await expect(saveMealTemplate(LOGIN, { ...rizoto, name: ' ' })).rejects.toThrow(/název receptu/);
await expect(saveMealTemplate(LOGIN, { ...rizoto, ingredients: [] })).rejects.toThrow(/surovinu/);
await expect(applyMealTemplate(LOGIN, 'neexistuje', DATE)).rejects.toThrow(/nebyl nalezen/);
await expect(deleteMealTemplate(LOGIN, 'neexistuje')).rejects.toThrow(/nebyl nalezen/);
});
it('odmítne šablonu bez názvu a neexistující id', async () => {
await expect(saveMealTemplate(LOGIN, { name: ' ' })).rejects.toThrow(/název šablony/);
await expect(applyMealTemplate(LOGIN, 'neexistuje', DATE)).rejects.toThrow(/nebyla nalezena/);
await expect(deleteMealTemplate(LOGIN, 'neexistuje')).rejects.toThrow(/nebyla nalezena/);
});
it('šablony jídel jsou oddělené mezi uživateli', async () => {
await saveMealTemplate(LOGIN, salat);
it('recepty jsou oddělené mezi uživateli', async () => {
await saveMealTemplate(LOGIN, rizoto);
expect(await getMealTemplates('nekdo-jiny')).toEqual([]);
});
});
describe('zaniklé šablony hotových jídel', () => {
const KEY = `mealTemplates:${LOGIN}`;
/** Úložiště tak, jak vypadalo před zrušením hotových jídel. */
const seedLegacy = async () => {
await getStorage().setData(KEY, [
{ id: 'stara', name: 'Sušenka', calories: 80 },
{ id: 'hotove', kind: 'HOTOVE', name: 'Salát', weight: 350, caloriesPer100g: 160, pricePer100g: 3700 },
{
id: 'recept', kind: 'RECEPT', name: 'Guláš', weight: 400, calories: 600,
ingredients: [{ name: 'Hovězí', weight: 400, calories: 600 }],
},
]);
};
it('se nevracejí, recepty zůstanou bez starého druhu', async () => {
await seedLegacy();
const templates = await getMealTemplates(LOGIN);
expect(templates.map(item => item.id)).toEqual(['recept']);
expect(templates[0]).not.toHaveProperty('kind');
});
it('zápis je z úložiště odklidí', async () => {
await seedLegacy();
await saveMealTemplate(LOGIN, { name: 'Polévka', ingredients: [{ name: 'Mrkev', weight: 100, calories: 40 }] });
const stored = await getStorage().getData<{ id: string, name: string, kind?: string }[]>(KEY) ?? [];
expect(stored.map(item => item.name).sort()).toEqual(['Guláš', 'Polévka']);
expect(stored.some(item => item.kind)).toBe(false);
});
it('jídla z nich založená ve dnech zůstanou', async () => {
await seedLegacy();
await getStorage().setData(`meals:${LOGIN}:${DATE}`, [{
id: 'zeSablony', date: DATE, mealType: MealType.OBED, name: 'Salát',
calories: 560, templateId: 'hotove', createdAt: '2025-03-03T10:00:00.000Z',
}]);
await deleteMealTemplate(LOGIN, 'recept');
const day = await getDay(LOGIN, DATE);
expect(day.entries).toHaveLength(1);
expect(day.totalCalories).toBe(560);
});
});
+1 -65
View File
@@ -1,78 +1,15 @@
process.env.CALORIE_PROVIDER = 'none';
import { addActivity, getActivityDay } from "../activities";
import { addMeal, getDay } from "../meals";
import { createMealTemplateFromMeal, getMealTemplates } from "../mealTemplates";
import { createWorkoutTemplateFromActivities, getWorkoutTemplates } from "../workouts";
import { resetMemoryStorage } from "../storage/memory";
import { ActivityUnit, MealType } from "../../../types/gen/types.gen";
import { ActivityUnit } from "../../../types/gen/types.gen";
const LOGIN = 'tester';
const DATE = '2025-03-03';
beforeEach(() => resetMemoryStorage());
describe('překlopení jídla do šablony', () => {
const gyros = {
date: DATE, mealType: MealType.OBED, name: 'Kuřecí gyros',
source: 'TechTower', price: 17600, pricePer100g: 4400, caloriesPer100g: 172,
};
it('převezme název, zdroj, gramáž i jednotkové hodnoty', async () => {
const day = await addMeal(LOGIN, gyros);
const templates = await createMealTemplateFromMeal(LOGIN, DATE, day.entries[0].id);
expect(templates).toHaveLength(1);
expect(templates[0]).toMatchObject({
name: 'Kuřecí gyros',
source: 'TechTower',
mealType: MealType.OBED,
weight: 400,
caloriesPer100g: 172,
price: 17600,
pricePer100g: 4400,
});
});
it('jídlo v dni zůstane beze změny', async () => {
const day = await addMeal(LOGIN, gyros);
await createMealTemplateFromMeal(LOGIN, DATE, day.entries[0].id);
expect((await getDay(LOGIN, DATE)).entries).toHaveLength(1);
});
it('opakované uložení téhož jídla šablonu přepíše, nezaloží druhou', async () => {
const day = await addMeal(LOGIN, gyros);
await createMealTemplateFromMeal(LOGIN, DATE, day.entries[0].id);
// Totéž jídlo jindy a s jinou gramáží
const later = await addMeal(LOGIN, { ...gyros, date: '2025-03-10', price: 22000 });
const templates = await createMealTemplateFromMeal(LOGIN, '2025-03-10', later.entries[0].id);
expect(templates).toHaveLength(1);
expect(templates[0].weight).toBe(500);
});
it('u jídla s ručními kaloriemi uloží absolutní hodnotu', async () => {
const day = await addMeal(LOGIN, {
date: DATE, mealType: MealType.SNIDANE, name: 'Jablko', calories: 80,
});
const templates = await createMealTemplateFromMeal(LOGIN, DATE, day.entries[0].id);
expect(templates[0].calories).toBe(80);
expect(templates[0].caloriesPer100g).toBeUndefined();
});
it('hlásí chybu u neexistujícího jídla', async () => {
await expect(createMealTemplateFromMeal(LOGIN, DATE, 'neexistuje'))
.rejects.toThrow(/nebylo nalezeno/);
});
});
describe('překlopení aktivit do šablony tréninku', () => {
const kliky = {
@@ -163,6 +100,5 @@ describe('překlopení aktivit do šablony tréninku', () => {
await createWorkoutTemplateFromActivities(LOGIN, DATE, 'Silový blok');
expect(await getWorkoutTemplates('nekdo-jiny')).toEqual([]);
expect(await getMealTemplates('nekdo-jiny')).toEqual([]);
});
});
+60
View File
@@ -0,0 +1,60 @@
import { WeightEntry } from "../../types/gen/types.gen";
import getStorage from "./storage";
import { BadRequestError, isValidIsoDate } from "./utils";
import { WEIGHT_LIMITS } from "./profile";
const storage = getStorage();
/**
* Klíč úložiště s váženími jednoho uživatele. Hodnotou je mapa den → gramy;
* vážení je málo a pro graf i dohledání hmotnosti k datu je potřeba celá řada.
*/
const getKey = (login: string): string => `weights:${login}`;
type WeightLog = Record<string, number>;
/**
* Vrátí všechna vážení uživatele od nejstaršího.
*
* @param login přihlašovací jméno uživatele
*/
export const getWeights = async (login: string): Promise<WeightEntry[]> =>
Object.entries(await storage.getData<WeightLog>(getKey(login)) ?? {})
.map(([date, weightGrams]) => ({ date, weightGrams }))
.sort((a, b) => a.date.localeCompare(b.date));
/**
* Najde vážení platné k danému dni — poslední zapsané v ten den nebo dřív.
*
* @param weightList vážení od nejstaršího
* @param date den ve formátu YYYY-MM-DD
*/
export const findWeightOn = (weightList: WeightEntry[], date: string): WeightEntry | undefined =>
weightList.filter(entry => entry.date <= date).pop();
/**
* Zapíše vážení ke zvolenému dni. Nevyplněná nebo nulová hodnota ho smaže.
*
* @param login přihlašovací jméno uživatele
* @param date den ve formátu YYYY-MM-DD
* @param weightGrams hmotnost v gramech
* @returns datum, kterého se zápis týkal
*/
export const saveWeight = async (login: string, date: unknown, weightGrams: unknown): Promise<string> => {
if (!isValidIsoDate(date)) {
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
}
if (weightGrams != null && (typeof weightGrams !== 'number' || !Number.isFinite(weightGrams))) {
throw new BadRequestError("Hmotnost musí být číslo");
}
const value = weightGrams == null ? 0 : Math.round(weightGrams);
if (value !== 0 && (value < WEIGHT_LIMITS.min || value > WEIGHT_LIMITS.max)) {
throw new BadRequestError(`Hmotnost je mimo rozsah (${WEIGHT_LIMITS.min / 1000}–${WEIGHT_LIMITS.max / 1000} kg)`);
}
await storage.updateData<WeightLog>(getKey(login), current => {
const { [date]: _removed, ...rest } = current ?? {};
// Nula znamená smazání — ukládat ji nemá smysl
return value > 0 ? { ...rest, [date]: value } : rest;
});
return date;
};
+13
View File
@@ -961,6 +961,11 @@
dependencies:
tslib "^2.4.0"
"@epic-web/invariant@^1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@epic-web/invariant/-/invariant-1.0.0.tgz#1073e5dee6dd540410784990eb73e4acd25c9813"
integrity sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==
"@fast-csv/format@4.3.5":
version "4.3.5"
resolved "https://registry.yarnpkg.com/@fast-csv/format/-/format-4.3.5.tgz#90d83d1b47b6aaf67be70d6118f84f3e12ee1ff3"
@@ -2380,6 +2385,14 @@ create-require@^1.1.0:
resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333"
integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
cross-env@^10.1.0:
version "10.1.0"
resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-10.1.0.tgz#cfd2a6200df9ed75bfb9cb3d7ce609c13ea21783"
integrity sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==
dependencies:
"@epic-web/invariant" "^1.0.0"
cross-spawn "^7.0.6"
cross-spawn@^7.0.3, cross-spawn@^7.0.6:
version "7.0.6"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f"