feat: zlepšení šablon, přidány osobní údaje a spánkový režim
This commit is contained in:
@@ -79,12 +79,25 @@ export type NormalizedActivityInput = {
|
||||
date: string;
|
||||
name: string;
|
||||
unit: ActivityUnit;
|
||||
/** Množství na jednu sérii. */
|
||||
quantity: number;
|
||||
/** Počet sérií; nevyplněno znamená jednu. */
|
||||
sets?: number;
|
||||
caloriesPer100Units?: number;
|
||||
calories?: number;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export const getTotalQuantity = (quantity: number, sets?: number): number =>
|
||||
quantity * Math.max(sets ?? 1, 1);
|
||||
|
||||
/**
|
||||
* Dopočte spálené kalorie z množství a sazby na 100 jednotek.
|
||||
*
|
||||
@@ -93,7 +106,7 @@ export type NormalizedActivityInput = {
|
||||
*/
|
||||
export function deriveActivityCalories(input: NormalizedActivityInput): NormalizedActivityInput {
|
||||
const calories = input.caloriesPer100Units
|
||||
? Math.round(input.quantity / 100 * input.caloriesPer100Units)
|
||||
? Math.round(getTotalQuantity(input.quantity, input.sets) / 100 * input.caloriesPer100Units)
|
||||
: input.calories;
|
||||
return { ...input, calories };
|
||||
}
|
||||
@@ -115,11 +128,14 @@ export function normalizeActivityInput(input: unknown): NormalizedActivityInput
|
||||
if (quantity == null) {
|
||||
throw new 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'),
|
||||
@@ -193,6 +209,7 @@ export async function updateActivity(login: string, id: unknown, input: unknown)
|
||||
const updated: ActivityEntry = {
|
||||
...original,
|
||||
...normalized,
|
||||
sets: normalized.sets,
|
||||
caloriesPer100Units: normalized.caloriesPer100Units,
|
||||
calories: normalized.calories,
|
||||
note: normalized.note,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { DayOverview, EnergyBalance } from "../../types/gen/types.gen";
|
||||
import { getDay } from "./meals";
|
||||
import { getActivityDay } from "./activities";
|
||||
import { getSettings } from "./settings";
|
||||
import { getSleep } from "./sleep";
|
||||
|
||||
/**
|
||||
* Spočítá energetickou bilanci dne.
|
||||
@@ -10,11 +11,15 @@ import { getSettings } from "./settings";
|
||||
* 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
|
||||
*/
|
||||
export function buildEnergyBalance(intake: number, activityBurn: number, basal?: number): EnergyBalance {
|
||||
export const buildEnergyBalance = (intake: number, activityBurn: number, basal?: number): EnergyBalance => {
|
||||
const resolvedBasal = basal ?? 0;
|
||||
const totalBurn = resolvedBasal + activityBurn;
|
||||
return {
|
||||
@@ -25,24 +30,27 @@ export function buildEnergyBalance(intake: number, activityBurn: number, basal?:
|
||||
balance: intake - totalBurn,
|
||||
hasBasal: !!basal,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sestaví kompletní přehled dne — jídlo, pohyb a jejich bilanci.
|
||||
* 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
|
||||
*/
|
||||
export async function getDayOverview(login: string, date: string): Promise<DayOverview> {
|
||||
const [meals, activities, settings] = await Promise.all([
|
||||
export const getDayOverview = async (login: string, date: string): Promise<DayOverview> => {
|
||||
const [meals, activities, settings, sleepMinutes] = await Promise.all([
|
||||
getDay(login, date),
|
||||
getActivityDay(login, date),
|
||||
getSettings(login),
|
||||
getSleep(login, date),
|
||||
]);
|
||||
return {
|
||||
date,
|
||||
meals,
|
||||
activities,
|
||||
energy: buildEnergyBalance(meals.totalCalories, activities.totalCalories, settings.basalCalories),
|
||||
// Ručně zadaný klidový výdej vyhrává nad spočítaným z tělesných údajů
|
||||
energy: buildEnergyBalance(meals.totalCalories, activities.totalCalories, settings.effectiveBasalCalories),
|
||||
...(sleepMinutes ? { sleepMinutes } : {}),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ import settingsRoutes from "./routes/settingsRoutes";
|
||||
import activityRoutes from "./routes/activityRoutes";
|
||||
import workoutRoutes from "./routes/workoutRoutes";
|
||||
import dayRoutes from "./routes/dayRoutes";
|
||||
import mealTemplateRoutes from "./routes/mealTemplateRoutes";
|
||||
|
||||
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
|
||||
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
|
||||
@@ -144,6 +145,7 @@ app.use("/api/settings", settingsRoutes);
|
||||
app.use("/api/activities", activityRoutes);
|
||||
app.use("/api/workouts", workoutRoutes);
|
||||
app.use("/api/day", dayRoutes);
|
||||
app.use("/api/mealTemplates", mealTemplateRoutes);
|
||||
|
||||
app.use(express.static(path.join(process.cwd(), 'public')));
|
||||
app.get('*splat', (_req, res) => {
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { MealTemplate, MealType } from "../../types/gen/types.gen";
|
||||
import getStorage from "./storage";
|
||||
import { addMeal, getDay, isMealType } from "./meals";
|
||||
import { normalizeName } from "./calories";
|
||||
import { BadRequestError, NotFoundError, isValidIsoDate } from "./utils";
|
||||
|
||||
const storage = getStorage();
|
||||
|
||||
/** 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;
|
||||
|
||||
/** Klíč úložiště se šablonami jídel jednoho uživatele. */
|
||||
const getKey = (login: string): string => `mealTemplates:${login}`;
|
||||
|
||||
/**
|
||||
* Vrátí šablony jídel uživatele seřazené podle názvu.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
*/
|
||||
export const getMealTemplates = async (login: string): Promise<MealTemplate[]> =>
|
||||
(await storage.getData<MealTemplate[]>(getKey(login)) ?? [])
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
|
||||
|
||||
/** Ořízne a znormalizuje volitelný textový vstup. */
|
||||
const normalizeText = (value: unknown, name: string, maxLength = MAX_NAME_LENGTH): string | undefined => {
|
||||
if (value == null) return undefined;
|
||||
if (typeof value !== 'string') {
|
||||
throw new BadRequestError(`Pole '${name}' musí být text`);
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.length) return undefined;
|
||||
if (trimmed.length > maxLength) {
|
||||
throw new BadRequestError(`Pole '${name}' může mít nejvýše ${maxLength} znaků`);
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
/** Ověří volitelnou nezápornou celočíselnou hodnotu. */
|
||||
const normalizeNumber = (value: unknown, name: string): number | undefined => {
|
||||
if (value == null) return undefined;
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
throw new BadRequestError(`Pole '${name}' musí být číslo`);
|
||||
}
|
||||
const rounded = Math.round(value);
|
||||
if (rounded < 0) {
|
||||
throw new BadRequestError(`Pole '${name}' nesmí být záporné`);
|
||||
}
|
||||
return rounded;
|
||||
};
|
||||
|
||||
/**
|
||||
* Založí novou šablonu jídla, nebo přepíše existující podle předaného id.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param input data šablony
|
||||
*/
|
||||
export const saveMealTemplate = async (login: string, input: unknown): Promise<MealTemplate[]> => {
|
||||
const value = (input ?? {}) as Record<string, unknown>;
|
||||
const name = normalizeText(value.name, 'name');
|
||||
if (!name) {
|
||||
throw new BadRequestError("Nebyl předán název šablony");
|
||||
}
|
||||
if (value.mealType != null && !isMealType(value.mealType)) {
|
||||
throw new BadRequestError("Nebyl předán platný typ jídla");
|
||||
}
|
||||
|
||||
const template: MealTemplate = {
|
||||
id: typeof value.id === 'string' && value.id.length ? value.id : randomUUID(),
|
||||
name,
|
||||
...(value.mealType != null ? { mealType: value.mealType as MealType } : {}),
|
||||
...(normalizeText(value.source, 'source') != null
|
||||
? { source: normalizeText(value.source, 'source') } : {}),
|
||||
...(normalizeText(value.note, 'note', MAX_NOTE_LENGTH) != null
|
||||
? { note: normalizeText(value.note, 'note', MAX_NOTE_LENGTH) } : {}),
|
||||
...(normalizeNumber(value.calories, 'calories') != null
|
||||
? { calories: normalizeNumber(value.calories, 'calories') } : {}),
|
||||
...(normalizeNumber(value.caloriesPer100g, 'caloriesPer100g') != null
|
||||
? { caloriesPer100g: normalizeNumber(value.caloriesPer100g, 'caloriesPer100g') } : {}),
|
||||
...(normalizeNumber(value.weight, 'weight') != null
|
||||
? { weight: normalizeNumber(value.weight, 'weight') } : {}),
|
||||
...(normalizeNumber(value.price, 'price') != null
|
||||
? { price: normalizeNumber(value.price, 'price') } : {}),
|
||||
...(normalizeNumber(value.pricePer100g, 'pricePer100g') != null
|
||||
? { pricePer100g: normalizeNumber(value.pricePer100g, 'pricePer100g') } : {}),
|
||||
};
|
||||
|
||||
await storage.updateData<MealTemplate[]>(getKey(login), current => {
|
||||
const others = (current ?? []).filter(item => item.id !== template.id);
|
||||
return [...others, template];
|
||||
});
|
||||
return getMealTemplates(login);
|
||||
};
|
||||
|
||||
/**
|
||||
* Smaže šablonu jídla.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param id identifikátor šablony
|
||||
*/
|
||||
export const deleteMealTemplate = async (login: string, id: unknown): Promise<MealTemplate[]> => {
|
||||
if (typeof id !== 'string' || !id.length) {
|
||||
throw new BadRequestError("Nebyl předán identifikátor šablony");
|
||||
}
|
||||
let found = false;
|
||||
await storage.updateData<MealTemplate[]>(getKey(login), current => {
|
||||
found = (current ?? []).some(item => item.id === id);
|
||||
return (current ?? []).filter(item => item.id !== id);
|
||||
});
|
||||
if (!found) {
|
||||
throw new NotFoundError("Šablona nebyla nalezena");
|
||||
}
|
||||
return getMealTemplates(login);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export const applyMealTemplate = async (
|
||||
login: string,
|
||||
id: unknown,
|
||||
date: unknown,
|
||||
mealType?: unknown,
|
||||
): Promise<string> => {
|
||||
if (typeof id !== 'string' || !id.length) {
|
||||
throw new BadRequestError("Nebyl předán identifikátor šablony");
|
||||
}
|
||||
if (!isValidIsoDate(date)) {
|
||||
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
|
||||
}
|
||||
if (mealType != null && !isMealType(mealType)) {
|
||||
throw new BadRequestError("Nebyl předán platný typ jídla");
|
||||
}
|
||||
const template = (await getMealTemplates(login)).find(item => item.id === id);
|
||||
if (!template) {
|
||||
throw new NotFoundError("Šablona nebyla nalezena");
|
||||
}
|
||||
|
||||
await addMeal(login, {
|
||||
date,
|
||||
mealType: mealType ?? template.mealType ?? MealType.OBED,
|
||||
name: template.name,
|
||||
source: template.source,
|
||||
weight: template.weight,
|
||||
price: template.price,
|
||||
pricePer100g: template.pricePer100g,
|
||||
calories: template.calories,
|
||||
caloriesPer100g: template.caloriesPer100g,
|
||||
note: template.note,
|
||||
});
|
||||
return date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Vytvoří šablonu jídla z existujícího záznamu v přehledu dne.
|
||||
*
|
||||
* Když už šablona se stejným názvem existuje, přepíše se — opakované "uložit
|
||||
* jako šablonu" u téhož jídla má dát jednu šablonu, ne několik stejných.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param date den, ve kterém jídlo je
|
||||
* @param id identifikátor jídla
|
||||
*/
|
||||
export const createMealTemplateFromMeal = async (
|
||||
login: string,
|
||||
date: unknown,
|
||||
id: unknown,
|
||||
): Promise<MealTemplate[]> => {
|
||||
if (typeof id !== 'string' || !id.length) {
|
||||
throw new BadRequestError("Nebyl předán identifikátor jídla");
|
||||
}
|
||||
if (!isValidIsoDate(date)) {
|
||||
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
|
||||
}
|
||||
const meal = (await getDay(login, date)).entries.find(entry => entry.id === id);
|
||||
if (!meal) {
|
||||
throw new NotFoundError("Jídlo nebylo nalezeno");
|
||||
}
|
||||
|
||||
const key = normalizeName(meal.name);
|
||||
const existing = (await getMealTemplates(login)).find(item => normalizeName(item.name) === key);
|
||||
|
||||
return saveMealTemplate(login, {
|
||||
...(existing ? { id: existing.id } : {}),
|
||||
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,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { PersonalProfile, Pohlavi } from "../../types/gen/types.gen";
|
||||
import { BadRequestError } from "./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. */
|
||||
export const isPohlavi = (value: unknown): value is Pohlavi =>
|
||||
value === Pohlavi.MUZ || value === Pohlavi.ZENA;
|
||||
|
||||
/** Ověří volitelné celé číslo v daném rozsahu. */
|
||||
const normalizeRanged = (value: unknown, name: keyof typeof LIMITS): number | undefined => {
|
||||
if (value == null) return undefined;
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
throw new BadRequestError(`Pole '${name}' musí být číslo`);
|
||||
}
|
||||
const rounded = Math.round(value);
|
||||
const { min, max } = LIMITS[name];
|
||||
if (rounded < min || rounded > max) {
|
||||
throw new BadRequestError(`Pole '${name}' je mimo rozsah (${min}–${max})`);
|
||||
}
|
||||
return rounded;
|
||||
};
|
||||
|
||||
/** Ověří a znormalizuje tělesné údaje. Prázdné hodnoty se uloží jako nevyplněné. */
|
||||
export const normalizeProfile = (input: unknown): PersonalProfile => {
|
||||
const value = (input ?? {}) as Record<string, unknown>;
|
||||
if (value.sex != null && !isPohlavi(value.sex)) {
|
||||
throw new BadRequestError("Nebylo předáno platné pohlaví");
|
||||
}
|
||||
return {
|
||||
...(value.sex != null ? { sex: value.sex as Pohlavi } : {}),
|
||||
...(normalizeRanged(value.birthYear, 'birthYear') != null
|
||||
? { birthYear: normalizeRanged(value.birthYear, 'birthYear') } : {}),
|
||||
...(normalizeRanged(value.heightCm, 'heightCm') != null
|
||||
? { heightCm: normalizeRanged(value.heightCm, 'heightCm') } : {}),
|
||||
...(normalizeRanged(value.weightGrams, 'weightGrams') != null
|
||||
? { weightGrams: normalizeRanged(value.weightGrams, 'weightGrams') } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 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ý
|
||||
*/
|
||||
export const computeBasalCalories = (profile?: PersonalProfile): number | undefined => {
|
||||
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 === Pohlavi.MUZ ? 5 : -161));
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import express, { Request, Response, NextFunction } from "express";
|
||||
import { getDayOverview } from "../dayOverview";
|
||||
import { saveSleep } from "../sleep";
|
||||
import { getLogin } from "../auth";
|
||||
import { formatDate, getToday, parseToken, requireIsoDate } from "../utils";
|
||||
|
||||
@@ -16,4 +17,14 @@ router.get("/", async (req: Request, res: Response, next: NextFunction) => {
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Uloží délku spánku pro zvolený den. */
|
||||
router.post("/sleep", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const { date, sleepMinutes } = req.body ?? {};
|
||||
const day = await saveSleep(login, date, sleepMinutes);
|
||||
res.status(200).json(await getDayOverview(login, day));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import express, { Request, Response, NextFunction } from "express";
|
||||
import {
|
||||
applyMealTemplate, createMealTemplateFromMeal, deleteMealTemplate, getMealTemplates, saveMealTemplate,
|
||||
} from "../mealTemplates";
|
||||
import { getDayOverview } from "../dayOverview";
|
||||
import { getLogin } from "../auth";
|
||||
import { parseToken } from "../utils";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/** Vrátí šablony jídel přihlášeného uživatele. */
|
||||
router.get("/", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
res.status(200).json(await getMealTemplates(login));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Založí novou šablonu jídla, nebo upraví existující. */
|
||||
router.post("/save", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
res.status(200).json(await saveMealTemplate(login, req.body));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Smaže šablonu jídla. */
|
||||
router.post("/delete", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
res.status(200).json(await deleteMealTemplate(login, req.body?.id));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Založí jídlo ze šablony do zvoleného dne. */
|
||||
router.post("/apply", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const { id, date, mealType } = req.body ?? {};
|
||||
const day = await applyMealTemplate(login, id, date, mealType);
|
||||
res.status(200).json(await getDayOverview(login, day));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Vytvoří šablonu z existujícího jídla v přehledu dne. */
|
||||
router.post("/fromMeal", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const { id, date } = req.body ?? {};
|
||||
res.status(200).json(await createMealTemplateFromMeal(login, date, id));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,5 +1,5 @@
|
||||
import express, { Request, Response, NextFunction } from "express";
|
||||
import { getSettings, saveBasalCalories, saveSourceRate } from "../settings";
|
||||
import { getSettings, saveBasalCalories, saveProfile, saveSourceRate } from "../settings";
|
||||
import { getLogin } from "../auth";
|
||||
import { parseToken } from "../utils";
|
||||
|
||||
@@ -30,4 +30,12 @@ router.post("/basal", async (req: Request, res: Response, next: NextFunction) =>
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Uloží tělesné údaje uživatele. */
|
||||
router.post("/profile", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
res.status(200).json(await saveProfile(login, req.body));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import express, { Request, Response, NextFunction } from "express";
|
||||
import { applyWorkoutTemplate, deleteWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate } from "../workouts";
|
||||
import {
|
||||
applyWorkoutTemplate, createWorkoutTemplateFromActivities, deleteWorkoutTemplate,
|
||||
getWorkoutTemplates, saveWorkoutTemplate,
|
||||
} from "../workouts";
|
||||
import { getDayOverview } from "../dayOverview";
|
||||
import { getLogin } from "../auth";
|
||||
import { parseToken } from "../utils";
|
||||
@@ -41,4 +44,13 @@ router.post("/apply", async (req: Request, res: Response, next: NextFunction) =>
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Vytvoří šablonu z aktivit zaznamenaných v přehledu dne. */
|
||||
router.post("/fromActivities", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const { date, name, ids } = req.body ?? {};
|
||||
res.status(200).json(await createWorkoutTemplateFromActivities(login, date, name, ids));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
+42
-6
@@ -1,7 +1,8 @@
|
||||
import { UserSettings } from "../../types/gen/types.gen";
|
||||
import { PersonalProfile, UserSettings } from "../../types/gen/types.gen";
|
||||
import getStorage from "./storage";
|
||||
import { BadRequestError } from "./utils";
|
||||
import { normalizeName } from "./calories";
|
||||
import { computeBasalCalories, normalizeProfile } from "./profile";
|
||||
|
||||
const storage = getStorage();
|
||||
|
||||
@@ -27,7 +28,39 @@ function emptySettings(): UserSettings {
|
||||
* @param login přihlašovací jméno uživatele
|
||||
*/
|
||||
export async function getSettings(login: string): Promise<UserSettings> {
|
||||
return await storage.getData<UserSettings>(getSettingsKey(login)) ?? emptySettings();
|
||||
const stored = await storage.getData<UserSettings>(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: UserSettings): UserSettings {
|
||||
const computed = 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
|
||||
*/
|
||||
export async function saveProfile(login: string, profile: unknown): Promise<UserSettings> {
|
||||
const normalized: PersonalProfile = normalizeProfile(profile);
|
||||
const saved = await storage.updateData<UserSettings>(getSettingsKey(login), current => ({
|
||||
...(current ?? emptySettings()),
|
||||
profile: normalized,
|
||||
}));
|
||||
return withDerived(saved);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,11 +87,13 @@ export async function saveSourceRate(login: string, source: unknown, pricePer100
|
||||
|
||||
const name = source.trim();
|
||||
const key = normalizeName(name);
|
||||
return await storage.updateData<UserSettings>(getSettingsKey(login), current => {
|
||||
const rates = (current ?? emptySettings()).sourceRates.filter(item => normalizeName(item.source) !== key);
|
||||
const saved = await storage.updateData<UserSettings>(getSettingsKey(login), current => {
|
||||
const settings = current ?? emptySettings();
|
||||
const rates = settings.sourceRates.filter(item => normalizeName(item.source) !== key);
|
||||
// Nulová sazba znamená smazání — ukládat ji nemá smysl
|
||||
return { sourceRates: rate > 0 ? [...rates, { source: name, pricePer100g: rate }] : rates };
|
||||
return { ...settings, sourceRates: rate > 0 ? [...rates, { source: name, pricePer100g: rate }] : rates };
|
||||
});
|
||||
return withDerived(saved);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,7 +111,7 @@ export async function saveBasalCalories(login: string, basalCalories: unknown):
|
||||
if (value < 0 || value > MAX_BASAL_CALORIES) {
|
||||
throw new BadRequestError("Klidový výdej je mimo rozsah");
|
||||
}
|
||||
return await storage.updateData<UserSettings>(getSettingsKey(login), current => {
|
||||
const saved = await storage.updateData<UserSettings>(getSettingsKey(login), current => {
|
||||
const settings = current ?? emptySettings();
|
||||
// Nula znamená smazání — ukládat ji nemá smysl
|
||||
if (value > 0) {
|
||||
@@ -85,6 +120,7 @@ export async function saveBasalCalories(login: string, basalCalories: unknown):
|
||||
const { basalCalories: _removed, ...rest } = settings;
|
||||
return rest;
|
||||
});
|
||||
return withDerived(saved);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import getStorage from "./storage";
|
||||
import { BadRequestError, isValidIsoDate } from "./utils";
|
||||
|
||||
const storage = getStorage();
|
||||
|
||||
/** 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: string, date: string): string => `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
|
||||
*/
|
||||
export const getSleep = async (login: string, date: string): Promise<number | undefined> =>
|
||||
await storage.getData<number>(getSleepKey(login, date));
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export const saveSleep = async (login: string, date: unknown, sleepMinutes: unknown): Promise<string> => {
|
||||
if (!isValidIsoDate(date)) {
|
||||
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
|
||||
}
|
||||
if (sleepMinutes != null && (typeof sleepMinutes !== 'number' || !Number.isFinite(sleepMinutes))) {
|
||||
throw new 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 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;
|
||||
};
|
||||
@@ -0,0 +1,215 @@
|
||||
process.env.CALORIE_PROVIDER = 'none';
|
||||
|
||||
import { addActivity, getActivityDay, getTotalQuantity, updateActivity } from "../activities";
|
||||
import { applyWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate } from "../workouts";
|
||||
import { applyMealTemplate, deleteMealTemplate, getMealTemplates, saveMealTemplate } from "../mealTemplates";
|
||||
import { getDay } from "../meals";
|
||||
import { resetMemoryStorage } from "../storage/memory";
|
||||
import { ActivityUnit, MealType, WorkoutItemKind } from "../../../types/gen/types.gen";
|
||||
|
||||
const LOGIN = 'tester';
|
||||
const DATE = '2025-03-03';
|
||||
|
||||
beforeEach(() => resetMemoryStorage());
|
||||
|
||||
describe('série u cviků', () => {
|
||||
|
||||
it('celkové množství je počet sérií krát množství v sérii', () => {
|
||||
expect(getTotalQuantity(40, 3)).toBe(120);
|
||||
expect(getTotalQuantity(40)).toBe(40);
|
||||
// Nula ani záporná hodnota sérií nedává smysl, bere se jako jedna
|
||||
expect(getTotalQuantity(40, 0)).toBe(40);
|
||||
});
|
||||
|
||||
it('3 × 40 spálí stejně jako 120 v kuse', async () => {
|
||||
await addActivity(LOGIN, {
|
||||
date: DATE, name: 'Kliky', unit: ActivityUnit.OPAKOVANI,
|
||||
quantity: 40, sets: 3, caloriesPer100Units: 50,
|
||||
});
|
||||
await addActivity(LOGIN, {
|
||||
date: '2025-03-04', name: 'Kliky', unit: ActivityUnit.OPAKOVANI,
|
||||
quantity: 120, caloriesPer100Units: 50,
|
||||
});
|
||||
|
||||
expect((await getActivityDay(LOGIN, DATE)).totalCalories).toBe(60);
|
||||
expect((await getActivityDay(LOGIN, '2025-03-04')).totalCalories).toBe(60);
|
||||
});
|
||||
|
||||
it('ale série zůstane v záznamu, aby šlo 3 × 40 od 120 rozlišit', async () => {
|
||||
await addActivity(LOGIN, {
|
||||
date: DATE, name: 'Kliky', unit: ActivityUnit.OPAKOVANI,
|
||||
quantity: 40, sets: 3, caloriesPer100Units: 50,
|
||||
});
|
||||
|
||||
const [entry] = (await getActivityDay(LOGIN, DATE)).entries;
|
||||
expect(entry.quantity).toBe(40);
|
||||
expect(entry.sets).toBe(3);
|
||||
});
|
||||
|
||||
it('jedna série se neukládá, je to výchozí stav', async () => {
|
||||
await addActivity(LOGIN, {
|
||||
date: DATE, name: 'Běh', unit: ActivityUnit.MINUTY, quantity: 20, sets: 1, caloriesPer100Units: 1000,
|
||||
});
|
||||
|
||||
expect((await getActivityDay(LOGIN, DATE)).entries[0].sets).toBeUndefined();
|
||||
});
|
||||
|
||||
it('úprava umí série přidat i odebrat', async () => {
|
||||
await addActivity(LOGIN, {
|
||||
date: DATE, name: 'Kliky', unit: ActivityUnit.OPAKOVANI, quantity: 40, caloriesPer100Units: 50,
|
||||
});
|
||||
const [created] = (await getActivityDay(LOGIN, DATE)).entries;
|
||||
|
||||
await updateActivity(LOGIN, created.id, {
|
||||
date: DATE, name: 'Kliky', unit: ActivityUnit.OPAKOVANI,
|
||||
quantity: 40, sets: 3, caloriesPer100Units: 50,
|
||||
});
|
||||
expect((await getActivityDay(LOGIN, DATE)).entries[0].sets).toBe(3);
|
||||
expect((await getActivityDay(LOGIN, DATE)).totalCalories).toBe(60);
|
||||
|
||||
await updateActivity(LOGIN, created.id, {
|
||||
date: DATE, name: 'Kliky', unit: ActivityUnit.OPAKOVANI,
|
||||
quantity: 40, caloriesPer100Units: 50,
|
||||
});
|
||||
expect((await getActivityDay(LOGIN, DATE)).entries[0].sets).toBeUndefined();
|
||||
expect((await getActivityDay(LOGIN, DATE)).totalCalories).toBe(20);
|
||||
});
|
||||
|
||||
it('šablona počítá odhad ze všech sérií', async () => {
|
||||
const templates = await saveWorkoutTemplate(LOGIN, undefined, 'Workout', [
|
||||
{ kind: WorkoutItemKind.CVIK, name: 'Kliky', unit: ActivityUnit.OPAKOVANI, quantity: 40, sets: 3, caloriesPer100Units: 50 },
|
||||
]);
|
||||
|
||||
expect(templates[0].estimatedCalories).toBe(60);
|
||||
expect(templates[0].items[0].sets).toBe(3);
|
||||
});
|
||||
|
||||
it('použití šablony přenese série do založené aktivity', async () => {
|
||||
const [template] = await saveWorkoutTemplate(LOGIN, undefined, 'Workout', [
|
||||
{ kind: WorkoutItemKind.CVIK, name: 'Kliky', unit: ActivityUnit.OPAKOVANI, quantity: 40, sets: 3, caloriesPer100Units: 50 },
|
||||
]);
|
||||
|
||||
await applyWorkoutTemplate(LOGIN, template.id, DATE);
|
||||
|
||||
const [entry] = (await getActivityDay(LOGIN, DATE)).entries;
|
||||
expect(entry.sets).toBe(3);
|
||||
expect(entry.quantity).toBe(40);
|
||||
expect(entry.calories).toBe(60);
|
||||
});
|
||||
|
||||
it('série se rozbalí i z vnořené šablony', async () => {
|
||||
const [zaklad] = await saveWorkoutTemplate(LOGIN, undefined, 'Základ', [
|
||||
{ kind: WorkoutItemKind.CVIK, name: 'Dřepy', unit: ActivityUnit.OPAKOVANI, quantity: 20, sets: 4, caloriesPer100Units: 40 },
|
||||
]);
|
||||
const templates = await saveWorkoutTemplate(LOGIN, undefined, 'Celý trénink', [
|
||||
{ kind: WorkoutItemKind.SABLONA, templateRefId: zaklad.id },
|
||||
]);
|
||||
const celyTrenink = templates.find(t => t.name === 'Celý trénink')!;
|
||||
expect(celyTrenink.estimatedCalories).toBe(32);
|
||||
|
||||
await applyWorkoutTemplate(LOGIN, celyTrenink.id, DATE);
|
||||
|
||||
const [entry] = (await getActivityDay(LOGIN, DATE)).entries;
|
||||
expect(entry.sets).toBe(4);
|
||||
expect(entry.calories).toBe(32);
|
||||
});
|
||||
});
|
||||
|
||||
describe('šablony jídel', () => {
|
||||
|
||||
const salat = {
|
||||
name: 'Těstovinový salát',
|
||||
source: 'Chefie',
|
||||
mealType: MealType.OBED,
|
||||
weight: 350,
|
||||
caloriesPer100g: 160,
|
||||
price: 12900,
|
||||
};
|
||||
|
||||
it('uloží šablonu a vrátí ji v seznamu', async () => {
|
||||
const templates = await saveMealTemplate(LOGIN, salat);
|
||||
|
||||
expect(templates).toHaveLength(1);
|
||||
expect(templates[0].name).toBe('Těstovinový salát');
|
||||
expect(templates[0].source).toBe('Chefie');
|
||||
});
|
||||
|
||||
it('úprava podle id šablonu přepíše, nezaloží druhou', async () => {
|
||||
const [created] = await saveMealTemplate(LOGIN, salat);
|
||||
|
||||
const templates = await saveMealTemplate(LOGIN, { ...salat, id: created.id, name: 'Salát velký' });
|
||||
|
||||
expect(templates).toHaveLength(1);
|
||||
expect(templates[0].name).toBe('Salát velký');
|
||||
});
|
||||
|
||||
it('použití založí jídlo do dne a dopočte kalorie', async () => {
|
||||
const [template] = await saveMealTemplate(LOGIN, salat);
|
||||
|
||||
await applyMealTemplate(LOGIN, template.id, DATE);
|
||||
|
||||
const day = await getDay(LOGIN, DATE);
|
||||
expect(day.entries).toHaveLength(1);
|
||||
expect(day.entries[0].name).toBe('Těstovinový salát');
|
||||
expect(day.entries[0].source).toBe('Chefie');
|
||||
// 350 g × 160 kcal/100 g
|
||||
expect(day.entries[0].calories).toBe(560);
|
||||
expect(day.totalPrice).toBe(12900);
|
||||
});
|
||||
|
||||
it('zvolený chod přebije ten ze šablony', async () => {
|
||||
const [template] = await saveMealTemplate(LOGIN, salat);
|
||||
|
||||
await applyMealTemplate(LOGIN, template.id, DATE, MealType.VECERE);
|
||||
|
||||
expect((await getDay(LOGIN, DATE)).entries[0].mealType).toBe(MealType.VECERE);
|
||||
});
|
||||
|
||||
it('bez chodu ve šabloně i v požadavku se založí jako oběd', async () => {
|
||||
const [template] = await saveMealTemplate(LOGIN, { name: 'Jablko', calories: 80 });
|
||||
|
||||
await applyMealTemplate(LOGIN, template.id, DATE);
|
||||
|
||||
expect((await getDay(LOGIN, DATE)).entries[0].mealType).toBe(MealType.OBED);
|
||||
});
|
||||
|
||||
it('šablona jen s absolutními kaloriemi funguje taky', async () => {
|
||||
const [template] = await saveMealTemplate(LOGIN, { name: 'Jablko', calories: 80 });
|
||||
|
||||
await applyMealTemplate(LOGIN, template.id, DATE);
|
||||
|
||||
expect((await getDay(LOGIN, DATE)).totalCalories).toBe(80);
|
||||
});
|
||||
|
||||
it('založené jídlo je samostatné — smazání šablony ho nesmaže', async () => {
|
||||
const [template] = await saveMealTemplate(LOGIN, salat);
|
||||
await applyMealTemplate(LOGIN, template.id, DATE);
|
||||
|
||||
await deleteMealTemplate(LOGIN, template.id);
|
||||
|
||||
expect(await getMealTemplates(LOGIN)).toEqual([]);
|
||||
expect((await getDay(LOGIN, DATE)).entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('unese poznámku s postupem přípravy', async () => {
|
||||
const recept = 'Těstoviny uvařit al dente.\nZeleninu nakrájet.\nSmíchat s dresinkem.';
|
||||
const [template] = await saveMealTemplate(LOGIN, { name: 'Domácí salát', note: recept });
|
||||
|
||||
expect(template.note).toBe(recept);
|
||||
|
||||
await applyMealTemplate(LOGIN, template.id, DATE);
|
||||
expect((await getDay(LOGIN, DATE)).entries[0].note).toBe(recept);
|
||||
});
|
||||
|
||||
it('odmítne šablonu bez názvu a neexistující id', async () => {
|
||||
await expect(saveMealTemplate(LOGIN, { name: ' ' })).rejects.toThrow(/název šablony/);
|
||||
await expect(applyMealTemplate(LOGIN, 'neexistuje', DATE)).rejects.toThrow(/nebyla nalezena/);
|
||||
await expect(deleteMealTemplate(LOGIN, 'neexistuje')).rejects.toThrow(/nebyla nalezena/);
|
||||
});
|
||||
|
||||
it('šablony jídel jsou oddělené mezi uživateli', async () => {
|
||||
await saveMealTemplate(LOGIN, salat);
|
||||
|
||||
expect(await getMealTemplates('nekdo-jiny')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
process.env.CALORIE_PROVIDER = 'none';
|
||||
|
||||
import { addActivity, getActivityDay } from "../activities";
|
||||
import { addMeal, getDay } from "../meals";
|
||||
import { createMealTemplateFromMeal, getMealTemplates } from "../mealTemplates";
|
||||
import { createWorkoutTemplateFromActivities, getWorkoutTemplates } from "../workouts";
|
||||
import { resetMemoryStorage } from "../storage/memory";
|
||||
import { ActivityUnit, MealType } from "../../../types/gen/types.gen";
|
||||
|
||||
const LOGIN = 'tester';
|
||||
const DATE = '2025-03-03';
|
||||
|
||||
beforeEach(() => resetMemoryStorage());
|
||||
|
||||
describe('překlopení jídla do šablony', () => {
|
||||
|
||||
const gyros = {
|
||||
date: DATE, mealType: MealType.OBED, name: 'Kuřecí gyros',
|
||||
source: 'TechTower', price: 17600, pricePer100g: 4400, caloriesPer100g: 172,
|
||||
};
|
||||
|
||||
it('převezme název, zdroj, gramáž i jednotkové hodnoty', async () => {
|
||||
const day = await addMeal(LOGIN, gyros);
|
||||
|
||||
const templates = await createMealTemplateFromMeal(LOGIN, DATE, day.entries[0].id);
|
||||
|
||||
expect(templates).toHaveLength(1);
|
||||
expect(templates[0]).toMatchObject({
|
||||
name: 'Kuřecí gyros',
|
||||
source: 'TechTower',
|
||||
mealType: MealType.OBED,
|
||||
weight: 400,
|
||||
caloriesPer100g: 172,
|
||||
price: 17600,
|
||||
pricePer100g: 4400,
|
||||
});
|
||||
});
|
||||
|
||||
it('jídlo v dni zůstane beze změny', async () => {
|
||||
const day = await addMeal(LOGIN, gyros);
|
||||
|
||||
await createMealTemplateFromMeal(LOGIN, DATE, day.entries[0].id);
|
||||
|
||||
expect((await getDay(LOGIN, DATE)).entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('opakované uložení téhož jídla šablonu přepíše, nezaloží druhou', async () => {
|
||||
const day = await addMeal(LOGIN, gyros);
|
||||
await createMealTemplateFromMeal(LOGIN, DATE, day.entries[0].id);
|
||||
|
||||
// Totéž jídlo jindy a s jinou gramáží
|
||||
const later = await addMeal(LOGIN, { ...gyros, date: '2025-03-10', price: 22000 });
|
||||
const templates = await createMealTemplateFromMeal(LOGIN, '2025-03-10', later.entries[0].id);
|
||||
|
||||
expect(templates).toHaveLength(1);
|
||||
expect(templates[0].weight).toBe(500);
|
||||
});
|
||||
|
||||
it('u jídla s ručními kaloriemi uloží absolutní hodnotu', async () => {
|
||||
const day = await addMeal(LOGIN, {
|
||||
date: DATE, mealType: MealType.SNIDANE, name: 'Jablko', calories: 80,
|
||||
});
|
||||
|
||||
const templates = await createMealTemplateFromMeal(LOGIN, DATE, day.entries[0].id);
|
||||
|
||||
expect(templates[0].calories).toBe(80);
|
||||
expect(templates[0].caloriesPer100g).toBeUndefined();
|
||||
});
|
||||
|
||||
it('hlásí chybu u neexistujícího jídla', async () => {
|
||||
await expect(createMealTemplateFromMeal(LOGIN, DATE, 'neexistuje'))
|
||||
.rejects.toThrow(/nebylo nalezeno/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('překlopení aktivit do šablony tréninku', () => {
|
||||
|
||||
const kliky = {
|
||||
date: DATE, name: 'Kliky', unit: ActivityUnit.OPAKOVANI,
|
||||
quantity: 40, sets: 3, caloriesPer100Units: 50,
|
||||
};
|
||||
const beh = {
|
||||
date: DATE, name: 'Běh', unit: ActivityUnit.MINUTY,
|
||||
quantity: 20, caloriesPer100Units: 1000,
|
||||
};
|
||||
|
||||
it('bez výčtu vezme všechny aktivity dne', async () => {
|
||||
await addActivity(LOGIN, kliky);
|
||||
await addActivity(LOGIN, beh);
|
||||
|
||||
const templates = await createWorkoutTemplateFromActivities(LOGIN, DATE, 'Workout day 3');
|
||||
|
||||
expect(templates).toHaveLength(1);
|
||||
expect(templates[0].items.map(item => item.name)).toEqual(['Kliky', 'Běh']);
|
||||
// 3 × 40 × 0,5 + 20 × 10
|
||||
expect(templates[0].estimatedCalories).toBe(260);
|
||||
});
|
||||
|
||||
it('přenese i počet sérií', async () => {
|
||||
await addActivity(LOGIN, kliky);
|
||||
|
||||
const templates = await createWorkoutTemplateFromActivities(LOGIN, DATE, 'Silový blok');
|
||||
|
||||
expect(templates[0].items[0].sets).toBe(3);
|
||||
expect(templates[0].items[0].quantity).toBe(40);
|
||||
});
|
||||
|
||||
it('s výčtem vezme jen vybrané aktivity', async () => {
|
||||
await addActivity(LOGIN, kliky);
|
||||
await addActivity(LOGIN, beh);
|
||||
const entries = (await getActivityDay(LOGIN, DATE)).entries;
|
||||
const klikyId = entries.find(entry => entry.name === 'Kliky')!.id;
|
||||
|
||||
const templates = await createWorkoutTemplateFromActivities(LOGIN, DATE, 'Jen kliky', [klikyId]);
|
||||
|
||||
expect(templates[0].items).toHaveLength(1);
|
||||
expect(templates[0].items[0].name).toBe('Kliky');
|
||||
});
|
||||
|
||||
it('aktivity v dni zůstanou beze změny', async () => {
|
||||
await addActivity(LOGIN, kliky);
|
||||
|
||||
await createWorkoutTemplateFromActivities(LOGIN, DATE, 'Workout day 3');
|
||||
|
||||
expect((await getActivityDay(LOGIN, DATE)).entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('aktivita bez sazby dá položku bez odhadu kalorií', async () => {
|
||||
await addActivity(LOGIN, {
|
||||
date: DATE, name: 'Plavání', unit: ActivityUnit.MINUTY, quantity: 30, calories: 250,
|
||||
});
|
||||
|
||||
const templates = await createWorkoutTemplateFromActivities(LOGIN, DATE, 'Bazén');
|
||||
|
||||
expect(templates[0].items[0].caloriesPer100Units).toBeUndefined();
|
||||
expect(templates[0].estimatedCalories).toBe(0);
|
||||
});
|
||||
|
||||
it('odmítne den bez aktivit a den bez názvu', async () => {
|
||||
await expect(createWorkoutTemplateFromActivities(LOGIN, DATE, 'Prázdný'))
|
||||
.rejects.toThrow(/nejsou žádné aktivity/);
|
||||
|
||||
await addActivity(LOGIN, kliky);
|
||||
await expect(createWorkoutTemplateFromActivities(LOGIN, DATE, ' '))
|
||||
.rejects.toThrow(/název šablony/);
|
||||
});
|
||||
|
||||
it('vytvořená šablona jde rovnou použít', async () => {
|
||||
await addActivity(LOGIN, kliky);
|
||||
const [template] = await createWorkoutTemplateFromActivities(LOGIN, DATE, 'Silový blok');
|
||||
|
||||
const { applyWorkoutTemplate } = await import("../workouts");
|
||||
await applyWorkoutTemplate(LOGIN, template.id, '2025-03-20');
|
||||
|
||||
const [entry] = (await getActivityDay(LOGIN, '2025-03-20')).entries;
|
||||
expect(entry.name).toBe('Kliky');
|
||||
expect(entry.sets).toBe(3);
|
||||
expect(entry.calories).toBe(60);
|
||||
});
|
||||
|
||||
it('šablony jsou oddělené mezi uživateli', async () => {
|
||||
await addActivity(LOGIN, kliky);
|
||||
await createWorkoutTemplateFromActivities(LOGIN, DATE, 'Silový blok');
|
||||
|
||||
expect(await getWorkoutTemplates('nekdo-jiny')).toEqual([]);
|
||||
expect(await getMealTemplates('nekdo-jiny')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
process.env.CALORIE_PROVIDER = 'none';
|
||||
|
||||
import { applyWorkoutTemplate, deleteWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate } from "../workouts";
|
||||
import { getActivityDay } from "../activities";
|
||||
import { getSettings, saveBasalCalories, saveProfile } from "../settings";
|
||||
import { computeBasalCalories } from "../profile";
|
||||
import { getDayOverview } from "../dayOverview";
|
||||
import { saveSleep } from "../sleep";
|
||||
import { resetMemoryStorage } from "../storage/memory";
|
||||
import { ActivityUnit, Pohlavi, WorkoutItemKind } from "../../../types/gen/types.gen";
|
||||
|
||||
const LOGIN = 'tester';
|
||||
const DATE = '2025-03-03';
|
||||
|
||||
/** Cvik jako položka šablony. */
|
||||
const cvik = (name: string, quantity: number, rate: number) => ({
|
||||
kind: WorkoutItemKind.CVIK,
|
||||
name,
|
||||
unit: ActivityUnit.OPAKOVANI,
|
||||
quantity,
|
||||
caloriesPer100Units: rate,
|
||||
});
|
||||
|
||||
/** Odkaz na jinou šablonu. */
|
||||
const odkaz = (templateRefId: string) => ({ kind: WorkoutItemKind.SABLONA, templateRefId });
|
||||
|
||||
beforeEach(() => resetMemoryStorage());
|
||||
|
||||
describe('skládání šablon z jiných šablon', () => {
|
||||
|
||||
it('šablona složená z jiné sečte kalorie obou', async () => {
|
||||
const [rozcvicka] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]);
|
||||
|
||||
const templates = await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', [
|
||||
odkaz(rozcvicka.id),
|
||||
cvik('Kliky', 60, 50),
|
||||
]);
|
||||
|
||||
const workout = templates.find(t => t.name === 'Workout day 1')!;
|
||||
// 40 dřepů × 0,4 kcal + 60 kliků × 0,5 kcal
|
||||
expect(workout.estimatedCalories).toBe(46);
|
||||
expect(workout.hasNested).toBe(true);
|
||||
});
|
||||
|
||||
it('doplní název a kalorie vnořené šablony', async () => {
|
||||
const [rozcvicka] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]);
|
||||
await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', [odkaz(rozcvicka.id)]);
|
||||
|
||||
const workout = (await getWorkoutTemplates(LOGIN)).find(t => t.name === 'Workout day 1')!;
|
||||
expect(workout.items[0].templateRefName).toBe('Rozcvička');
|
||||
expect(workout.items[0].templateRefCalories).toBe(16);
|
||||
});
|
||||
|
||||
it('použití rozbalí vnořenou šablonu na její cviky', async () => {
|
||||
const [rozcvicka] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [
|
||||
cvik('Dřepy', 40, 40),
|
||||
cvik('Výpady', 20, 30),
|
||||
]);
|
||||
const templates = await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', [
|
||||
odkaz(rozcvicka.id),
|
||||
cvik('Kliky', 60, 50),
|
||||
]);
|
||||
const workout = templates.find(t => t.name === 'Workout day 1')!;
|
||||
|
||||
await applyWorkoutTemplate(LOGIN, workout.id, DATE);
|
||||
|
||||
const day = await getActivityDay(LOGIN, DATE);
|
||||
expect(day.entries.map(e => e.name)).toEqual(['Dřepy', 'Výpady', 'Kliky']);
|
||||
expect(day.totalCalories).toBe(52);
|
||||
});
|
||||
|
||||
it('počítá, kolik jiných šablon danou šablonu používá', async () => {
|
||||
const [rozcvicka] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]);
|
||||
await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', [odkaz(rozcvicka.id)]);
|
||||
await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 2', [odkaz(rozcvicka.id)]);
|
||||
|
||||
const updated = (await getWorkoutTemplates(LOGIN)).find(t => t.name === 'Rozcvička')!;
|
||||
expect(updated.usedByCount).toBe(2);
|
||||
});
|
||||
|
||||
it('nedovolí zanoření o dvě úrovně', async () => {
|
||||
const [zaklad] = await saveWorkoutTemplate(LOGIN, undefined, 'Základ', [cvik('Dřepy', 40, 40)]);
|
||||
const templates = await saveWorkoutTemplate(LOGIN, undefined, 'Střední', [odkaz(zaklad.id)]);
|
||||
const stredni = templates.find(t => t.name === 'Střední')!;
|
||||
|
||||
// "Střední" už sama skládá jinou šablonu, takže ji nelze vnořit dál
|
||||
await expect(saveWorkoutTemplate(LOGIN, undefined, 'Vrchní', [odkaz(stredni.id)]))
|
||||
.rejects.toThrow(/jen na jednu úroveň/);
|
||||
});
|
||||
|
||||
it('nedovolí začít vnořovat v šabloně, kterou už někdo skládá', async () => {
|
||||
const [rozcvicka] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]);
|
||||
const [jiny] = await saveWorkoutTemplate(LOGIN, undefined, 'Jiný blok', [cvik('Kliky', 60, 50)]);
|
||||
await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', [odkaz(rozcvicka.id)]);
|
||||
|
||||
// Rozcvičku už skládá Workout day 1, takže do ní nelze vnořit další šablonu
|
||||
await expect(saveWorkoutTemplate(LOGIN, rozcvicka.id, 'Rozcvička', [odkaz(jiny.id)]))
|
||||
.rejects.toThrow(/jen na jednu úroveň/);
|
||||
});
|
||||
|
||||
it('nedovolí odkaz sám na sebe', async () => {
|
||||
const [sablona] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]);
|
||||
|
||||
await expect(saveWorkoutTemplate(LOGIN, sablona.id, 'Rozcvička', [odkaz(sablona.id)]))
|
||||
.rejects.toThrow(/sama na sebe/);
|
||||
});
|
||||
|
||||
it('nedovolí odkaz na neexistující šablonu', async () => {
|
||||
await expect(saveWorkoutTemplate(LOGIN, undefined, 'Workout', [odkaz('neexistuje')]))
|
||||
.rejects.toThrow(/nebyla nalezena/);
|
||||
});
|
||||
|
||||
it('nedovolí smazat šablonu, kterou skládá jiná', async () => {
|
||||
const [rozcvicka] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]);
|
||||
await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', [odkaz(rozcvicka.id)]);
|
||||
|
||||
await expect(deleteWorkoutTemplate(LOGIN, rozcvicka.id)).rejects.toThrow(/nelze smazat/);
|
||||
});
|
||||
|
||||
it('šablonu bez vazeb smazat lze', async () => {
|
||||
const [rozcvicka] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]);
|
||||
|
||||
expect(await deleteWorkoutTemplate(LOGIN, rozcvicka.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it('šablonám uloženým bez druhu položky doplní CVIK', async () => {
|
||||
// Podoba dat z doby, kdy skládání z jiných šablon ještě neexistovalo
|
||||
const { default: getStorage } = await import("../storage");
|
||||
await getStorage().setData('workouts:' + LOGIN, [{
|
||||
id: 'stara', name: 'Stará šablona',
|
||||
items: [{ name: 'Kliky', unit: ActivityUnit.OPAKOVANI, quantity: 60, caloriesPer100Units: 50 }],
|
||||
}]);
|
||||
|
||||
const [template] = await getWorkoutTemplates(LOGIN);
|
||||
expect(template.items[0].kind).toBe(WorkoutItemKind.CVIK);
|
||||
expect(template.estimatedCalories).toBe(30);
|
||||
expect(template.hasNested).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tělesné údaje a bazální metabolismus', () => {
|
||||
|
||||
/** Muž, 90 kg, 185 cm, ročník 1990. */
|
||||
const muz = { sex: Pohlavi.MUZ, birthYear: 1990, heightCm: 185, weightGrams: 90_000 };
|
||||
|
||||
it('spočítá klidový výdej rovnicí Mifflin–St Jeor', () => {
|
||||
const age = new Date().getFullYear() - 1990;
|
||||
// 10 × 90 + 6,25 × 185 − 5 × věk + 5
|
||||
const expected = Math.round(900 + 1156.25 - 5 * age + 5);
|
||||
|
||||
expect(computeBasalCalories(muz)).toBe(expected);
|
||||
});
|
||||
|
||||
it('u ženy odečítá 161 místo přičtení 5', () => {
|
||||
const zena = { ...muz, sex: Pohlavi.ZENA };
|
||||
|
||||
expect(computeBasalCalories(muz)! - computeBasalCalories(zena)!).toBe(166);
|
||||
});
|
||||
|
||||
it('neúplný profil klidový výdej nespočítá', () => {
|
||||
expect(computeBasalCalories(undefined)).toBeUndefined();
|
||||
expect(computeBasalCalories({ sex: Pohlavi.MUZ, heightCm: 185 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uložený profil se propíše do nastavení i do bilance dne', async () => {
|
||||
await saveProfile(LOGIN, muz);
|
||||
|
||||
const settings = await getSettings(LOGIN);
|
||||
expect(settings.computedBasalCalories).toBe(computeBasalCalories(muz));
|
||||
expect(settings.effectiveBasalCalories).toBe(computeBasalCalories(muz));
|
||||
|
||||
const overview = await getDayOverview(LOGIN, DATE);
|
||||
expect(overview.energy.basal).toBe(computeBasalCalories(muz));
|
||||
expect(overview.energy.hasBasal).toBe(true);
|
||||
});
|
||||
|
||||
it('ručně zadaný klidový výdej má přednost před spočítaným', async () => {
|
||||
await saveProfile(LOGIN, muz);
|
||||
await saveBasalCalories(LOGIN, 1800);
|
||||
|
||||
const settings = await getSettings(LOGIN);
|
||||
expect(settings.computedBasalCalories).toBe(computeBasalCalories(muz));
|
||||
expect(settings.effectiveBasalCalories).toBe(1800);
|
||||
expect((await getDayOverview(LOGIN, DATE)).energy.basal).toBe(1800);
|
||||
});
|
||||
|
||||
it('po smazání ručního údaje se vrátí spočítaný z profilu', async () => {
|
||||
await saveProfile(LOGIN, muz);
|
||||
await saveBasalCalories(LOGIN, 1800);
|
||||
await saveBasalCalories(LOGIN, 0);
|
||||
|
||||
expect((await getSettings(LOGIN)).effectiveBasalCalories).toBe(computeBasalCalories(muz));
|
||||
});
|
||||
|
||||
it('odmítne nesmyslné tělesné údaje', async () => {
|
||||
await expect(saveProfile(LOGIN, { ...muz, heightCm: 5 })).rejects.toThrow(/mimo rozsah/);
|
||||
await expect(saveProfile(LOGIN, { ...muz, weightGrams: 5_000_000 })).rejects.toThrow(/mimo rozsah/);
|
||||
await expect(saveProfile(LOGIN, { ...muz, sex: 'JINE' })).rejects.toThrow(/pohlaví/);
|
||||
});
|
||||
|
||||
it('uložení profilu nepřepíše sazby podniků', async () => {
|
||||
const { saveSourceRate } = await import("../settings");
|
||||
await saveSourceRate(LOGIN, 'TechTower', 4400);
|
||||
|
||||
await saveProfile(LOGIN, muz);
|
||||
|
||||
expect((await getSettings(LOGIN)).sourceRates).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spánek', () => {
|
||||
|
||||
it('uloží se ke dni a vrátí v přehledu', async () => {
|
||||
await saveSleep(LOGIN, DATE, 450);
|
||||
|
||||
expect((await getDayOverview(LOGIN, DATE)).sleepMinutes).toBe(450);
|
||||
});
|
||||
|
||||
it('nulová hodnota záznam odstraní', async () => {
|
||||
await saveSleep(LOGIN, DATE, 450);
|
||||
await saveSleep(LOGIN, DATE, 0);
|
||||
|
||||
expect((await getDayOverview(LOGIN, DATE)).sleepMinutes).toBeUndefined();
|
||||
});
|
||||
|
||||
it('do energetické bilance nevstupuje', async () => {
|
||||
await saveProfile(LOGIN, { sex: Pohlavi.MUZ, birthYear: 1990, heightCm: 185, weightGrams: 90_000 });
|
||||
const bezSpanku = await getDayOverview(LOGIN, DATE);
|
||||
|
||||
await saveSleep(LOGIN, DATE, 450);
|
||||
const seSpankem = await getDayOverview(LOGIN, DATE);
|
||||
|
||||
// Klidový výdej pokrývá celých 24 hodin včetně spánku, bilance se měnit nesmí
|
||||
expect(seSpankem.energy).toEqual(bezSpanku.energy);
|
||||
});
|
||||
|
||||
it('odmítne nesmyslnou délku', async () => {
|
||||
await expect(saveSleep(LOGIN, DATE, 2000)).rejects.toThrow(/mimo rozsah/);
|
||||
await expect(saveSleep(LOGIN, 'včera', 450)).rejects.toThrow(/platné datum/);
|
||||
});
|
||||
|
||||
it('je oddělený mezi uživateli', async () => {
|
||||
await saveSleep(LOGIN, DATE, 450);
|
||||
|
||||
expect((await getDayOverview('nekdo-jiny', DATE)).sleepMinutes).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+206
-44
@@ -1,7 +1,9 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { ActivityEntry, WorkoutTemplate, WorkoutTemplateItem } from "../../types/gen/types.gen";
|
||||
import {
|
||||
ActivityEntry, ActivityUnit, WorkoutItemKind, WorkoutTemplate, WorkoutTemplateItem,
|
||||
} from "../../types/gen/types.gen";
|
||||
import getStorage from "./storage";
|
||||
import { addActivities, isActivityUnit } from "./activities";
|
||||
import { addActivities, getActivityDay, getTotalQuantity, isActivityUnit } from "./activities";
|
||||
import { BadRequestError, NotFoundError, isValidIsoDate } from "./utils";
|
||||
|
||||
const storage = getStorage();
|
||||
@@ -10,38 +12,99 @@ const storage = getStorage();
|
||||
const MAX_ITEMS = 50;
|
||||
|
||||
/** Klíč úložiště se šablonami tréninků jednoho uživatele. */
|
||||
function getKey(login: string): string {
|
||||
return `workouts:${login}`;
|
||||
}
|
||||
const getKey = (login: string): string => `workouts:${login}`;
|
||||
|
||||
/** Spočítá odhad spálených kcal za celou šablonu. */
|
||||
function estimateCalories(items: WorkoutTemplateItem[]): number {
|
||||
return items.reduce((sum, item) => sum + Math.round(item.quantity / 100 * (item.caloriesPer100Units ?? 0)), 0);
|
||||
}
|
||||
/** Uložená podoba šablony — dopočítávaná pole se neukládají. */
|
||||
type StoredTemplate = {
|
||||
id: string;
|
||||
name: string;
|
||||
items: WorkoutTemplateItem[];
|
||||
};
|
||||
|
||||
/** Doplní šabloně dopočítaný odhad kalorií. */
|
||||
function withEstimate(template: Omit<WorkoutTemplate, 'estimatedCalories'>): WorkoutTemplate {
|
||||
return { ...template, estimatedCalories: estimateCalories(template.items) };
|
||||
}
|
||||
/** Vrátí true, pokud je položka odkazem na jinou šablonu. */
|
||||
const isTemplateRef = (item: WorkoutTemplateItem): boolean => item.kind === WorkoutItemKind.SABLONA;
|
||||
|
||||
/** Uložená podoba šablony — odhad kalorií se dopočítává, neukládá se. */
|
||||
type StoredTemplate = Omit<WorkoutTemplate, 'estimatedCalories'>;
|
||||
/**
|
||||
* 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: StoredTemplate): StoredTemplate => ({
|
||||
...template,
|
||||
items: template.items.map(item => ({ ...item, kind: item.kind ?? WorkoutItemKind.CVIK })),
|
||||
});
|
||||
|
||||
/** Spočítá kcal jedné položky druhu CVIK, včetně všech jejích sérií. */
|
||||
const itemCalories = (item: WorkoutTemplateItem): number =>
|
||||
Math.round(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: StoredTemplate, all: StoredTemplate[]): WorkoutTemplate => {
|
||||
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: string): Promise<StoredTemplate[]> =>
|
||||
(await storage.getData<StoredTemplate[]>(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
|
||||
*/
|
||||
export async function getWorkoutTemplates(login: string): Promise<WorkoutTemplate[]> {
|
||||
const templates = await storage.getData<StoredTemplate[]>(getKey(login)) ?? [];
|
||||
return templates
|
||||
.map(withEstimate)
|
||||
export const getWorkoutTemplates = async (login: string): Promise<WorkoutTemplate[]> => {
|
||||
const stored = await loadTemplates(login);
|
||||
return stored
|
||||
.map(template => withDerived(template, stored))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
|
||||
}
|
||||
};
|
||||
|
||||
/** Ověří a znormalizuje jednu položku šablony. */
|
||||
function normalizeItem(raw: unknown, index: number): WorkoutTemplateItem {
|
||||
const normalizeItem = (raw: unknown, index: number): WorkoutTemplateItem => {
|
||||
const value = (raw ?? {}) as Record<string, unknown>;
|
||||
const kind = value.kind === WorkoutItemKind.SABLONA ? WorkoutItemKind.SABLONA : WorkoutItemKind.CVIK;
|
||||
|
||||
if (kind === WorkoutItemKind.SABLONA) {
|
||||
if (typeof value.templateRefId !== 'string' || !value.templateRefId.length) {
|
||||
throw new 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 BadRequestError(`Položka ${index + 1} nemá název`);
|
||||
@@ -53,15 +116,56 @@ function normalizeItem(raw: unknown, index: number): WorkoutTemplateItem {
|
||||
throw new BadRequestError(`Položka '${name}' nemá platné množství`);
|
||||
}
|
||||
const rate = value.caloriesPer100Units;
|
||||
const sets = value.sets;
|
||||
return {
|
||||
kind,
|
||||
name,
|
||||
unit: value.unit,
|
||||
unit: value.unit as ActivityUnit,
|
||||
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) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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: string, items: WorkoutTemplateItem[], all: StoredTemplate[]): void => {
|
||||
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 BadRequestError("Šablona nemůže odkazovat sama na sebe");
|
||||
}
|
||||
const nested = byId.get(item.templateRefId!);
|
||||
if (!nested) {
|
||||
throw new NotFoundError("Vnořená šablona nebyla nalezena");
|
||||
}
|
||||
if (nested.items.some(isTemplateRef)) {
|
||||
throw new 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 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.
|
||||
@@ -71,12 +175,12 @@ function normalizeItem(raw: unknown, index: number): WorkoutTemplateItem {
|
||||
* @param name název šablony
|
||||
* @param items položky šablony
|
||||
*/
|
||||
export async function saveWorkoutTemplate(
|
||||
export const saveWorkoutTemplate = async (
|
||||
login: string,
|
||||
id: unknown,
|
||||
name: unknown,
|
||||
items: unknown,
|
||||
): Promise<WorkoutTemplate[]> {
|
||||
): Promise<WorkoutTemplate[]> => {
|
||||
if (typeof name !== 'string' || !name.trim().length) {
|
||||
throw new BadRequestError("Nebyl předán název šablony");
|
||||
}
|
||||
@@ -86,42 +190,49 @@ export async function saveWorkoutTemplate(
|
||||
if (items.length > MAX_ITEMS) {
|
||||
throw new BadRequestError(`Šablona může mít nejvýše ${MAX_ITEMS} položek`);
|
||||
}
|
||||
|
||||
const normalized: StoredTemplate = {
|
||||
id: typeof id === 'string' && id.length ? id : randomUUID(),
|
||||
name: name.trim(),
|
||||
items: items.map(normalizeItem),
|
||||
};
|
||||
checkNesting(normalized.id, normalized.items, await loadTemplates(login));
|
||||
|
||||
await storage.updateData<StoredTemplate[]>(getKey(login), current => {
|
||||
const others = (current ?? []).filter(template => template.id !== normalized.id);
|
||||
return [...others, normalized];
|
||||
});
|
||||
return getWorkoutTemplates(login);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Smaže šablonu tréninku.
|
||||
* 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
|
||||
*/
|
||||
export async function deleteWorkoutTemplate(login: string, id: unknown): Promise<WorkoutTemplate[]> {
|
||||
export const deleteWorkoutTemplate = async (login: string, id: unknown): Promise<WorkoutTemplate[]> => {
|
||||
if (typeof id !== 'string' || !id.length) {
|
||||
throw new BadRequestError("Nebyl předán identifikátor šablony");
|
||||
}
|
||||
let found = false;
|
||||
await storage.updateData<StoredTemplate[]>(getKey(login), current => {
|
||||
found = (current ?? []).some(template => template.id === id);
|
||||
return (current ?? []).filter(template => template.id !== id);
|
||||
});
|
||||
if (!found) {
|
||||
const all = await loadTemplates(login);
|
||||
if (!all.some(template => template.id === id)) {
|
||||
throw new 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 BadRequestError(`Šablonu nelze smazat, protože ji skládá šablona '${usedBy.name}'`);
|
||||
}
|
||||
|
||||
await storage.updateData<StoredTemplate[]>(getKey(login),
|
||||
current => (current ?? []).filter(template => template.id !== id));
|
||||
return getWorkoutTemplates(login);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Založí do zvoleného dne všechny položky šablony.
|
||||
* 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,
|
||||
@@ -131,31 +242,82 @@ export async function deleteWorkoutTemplate(login: string, id: unknown): Promise
|
||||
* @param id identifikátor šablony
|
||||
* @param date den, do kterého se položky založí
|
||||
*/
|
||||
export async function applyWorkoutTemplate(login: string, id: unknown, date: unknown): Promise<string> {
|
||||
export const applyWorkoutTemplate = async (login: string, id: unknown, date: unknown): Promise<string> => {
|
||||
if (typeof id !== 'string' || !id.length) {
|
||||
throw new BadRequestError("Nebyl předán identifikátor šablony");
|
||||
}
|
||||
if (!isValidIsoDate(date)) {
|
||||
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
|
||||
}
|
||||
const template = (await getWorkoutTemplates(login)).find(item => item.id === id);
|
||||
const all = await loadTemplates(login);
|
||||
const template = all.find(item => item.id === id);
|
||||
if (!template) {
|
||||
throw new 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: ActivityEntry[] = template.items.map(item => ({
|
||||
const entries: ActivityEntry[] = exercises.map(item => ({
|
||||
id: randomUUID(),
|
||||
date,
|
||||
name: item.name,
|
||||
unit: item.unit,
|
||||
quantity: item.quantity,
|
||||
name: item.name!,
|
||||
unit: item.unit!,
|
||||
quantity: item.quantity!,
|
||||
...(item.sets != null ? { sets: item.sets } : {}),
|
||||
...(item.caloriesPer100Units != null ? { caloriesPer100Units: item.caloriesPer100Units } : {}),
|
||||
calories: Math.round(item.quantity / 100 * (item.caloriesPer100Units ?? 0)),
|
||||
calories: itemCalories(item),
|
||||
templateId: template.id,
|
||||
createdAt: now,
|
||||
}));
|
||||
|
||||
await addActivities(login, date, entries);
|
||||
return date;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export const createWorkoutTemplateFromActivities = async (
|
||||
login: string,
|
||||
date: unknown,
|
||||
name: unknown,
|
||||
ids?: unknown,
|
||||
): Promise<WorkoutTemplate[]> => {
|
||||
if (!isValidIsoDate(date)) {
|
||||
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
|
||||
}
|
||||
if (ids != null && !Array.isArray(ids)) {
|
||||
throw new BadRequestError("Seznam aktivit musí být pole identifikátorů");
|
||||
}
|
||||
const selected = ids == null ? undefined : new Set(ids.map(String));
|
||||
const activities = (await getActivityDay(login, date)).entries
|
||||
.filter(entry => !selected || selected.has(entry.id));
|
||||
if (!activities.length) {
|
||||
throw new BadRequestError("V tomto dni nejsou žádné aktivity, ze kterých by šla šablona vytvořit");
|
||||
}
|
||||
|
||||
const items = activities.map(entry => ({
|
||||
kind: 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 } : {}),
|
||||
}));
|
||||
|
||||
return saveWorkoutTemplate(login, undefined, name, items);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user