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

This commit is contained in:
Ondřej Anděl committed 2026-09-15 14:16:42 +02:00
1 parent a5711f3e1e
commit 21b01528e9
51 files changed
+3739 -504

No files matched your search

+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);
};