feat: zlepšení šablon, přidány osobní údaje a spánkový režim
This commit is contained in:
1 parent
a5711f3e1e
commit
21b01528e9
51 files changed
+3739
-504
No files matched your search
+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