feat: zlepšení šablon, přidány osobní údaje a spánkový režim

This commit is contained in:
Ondřej Anděl
2026-09-15 14:16:42 +02:00
parent a5711f3e1e
commit 21b01528e9
51 changed files with 3739 additions and 504 deletions
+18 -1
View File
@@ -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,
+15 -7
View File
@@ -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 } : {}),
};
}
};
+2
View File
@@ -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) => {
+206
View File
@@ -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,
});
};
+73
View File
@@ -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));
};
+11
View File
@@ -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;
+54
View File
@@ -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;
+9 -1
View File
@@ -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;
+13 -1
View File
@@ -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
View File
@@ -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);
}
/**
+47
View File
@@ -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([]);
});
});
+168
View File
@@ -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([]);
});
});
+247
View File
@@ -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
View File
@@ -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);
};