feat: předělání historie a profilu
This commit is contained in:
1 parent
9e72f431a1
commit
8fa7f601f2
97 files changed
+7502
-1034
No files matched your search
Vendored
+289
@@ -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;
|
||||
}
|
||||
Vendored
+61
@@ -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
@@ -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();
|
||||
}
|
||||
Vendored
+128
@@ -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
@@ -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;
|
||||
Vendored
+198
@@ -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
@@ -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
@@ -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;
|
||||
Vendored
+343
@@ -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);
|
||||
}
|
||||
Vendored
+74
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
Vendored
+135
@@ -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;
|
||||
}
|
||||
Vendored
+49
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
+36
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
Vendored
+116
@@ -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
@@ -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;
|
||||
Vendored
+300
@@ -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;
|
||||
Vendored
+6
@@ -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)());
|
||||
Vendored
+19
@@ -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);
|
||||
Vendored
+585
@@ -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;
|
||||
Vendored
+66
@@ -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'
|
||||
};
|
||||
Vendored
+17
@@ -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
@@ -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',
|
||||
},
|
||||
],
|
||||
};
|
||||
Reference in new issue
Block a user