chore: graf db
feat: workout plans (volný, fixní, plovoucí)
This commit is contained in:
1 parent
865c2c3b22
commit
7641e26c9f
43 files changed
+3459
-599
No files matched your search
+337
-97
@@ -1,85 +1,172 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import {
|
||||
ActivityEntry, ActivityUnit, WorkoutItemKind, WorkoutTemplate, WorkoutTemplateItem,
|
||||
ActivityEntry, ActivityUnit, PlannedWorkoutItem, WorkoutItemKind, WorkoutPlanDay, WorkoutTemplate,
|
||||
WorkoutTemplateItem, WorkoutTemplateType,
|
||||
} from "../../types/gen/types.gen";
|
||||
import getStorage from "./storage";
|
||||
import { addActivities, getActivityDay, getTotalQuantity, isActivityUnit } from "./activities";
|
||||
import { BadRequestError, NotFoundError, isValidIsoDate } from "./utils";
|
||||
import { BadRequestError, NotFoundError, formatDate, getToday, isValidIsoDate } from "./utils";
|
||||
import { recordWorkoutPlan } from "./workoutPlans";
|
||||
|
||||
const storage = getStorage();
|
||||
|
||||
/** Maximální počet položek jedné šablony — pojistka proti nesmyslnému vstupu. */
|
||||
/** Maximální počet položek jedné šablony nebo dne plánu — pojistka proti nesmyslnému vstupu. */
|
||||
const MAX_ITEMS = 50;
|
||||
|
||||
/** Maximální počet dnů plovoucího plánu. Fixní jich má nejvýš sedm, jeden na den v týdnu. */
|
||||
const MAX_PLAN_DAYS = 14;
|
||||
|
||||
/** Nejdelší povolená lhůta pro náhradu vynechaného cviku ve dnech. */
|
||||
export const MAX_REPLACE_DAYS = 14;
|
||||
|
||||
/** Názvy dnů v týdnu pro výchozí název dne fixního plánu, od pondělí. */
|
||||
const WEEKDAY_NAMES = ['Pondělí', 'Úterý', 'Středa', 'Čtvrtek', 'Pátek', 'Sobota', 'Neděle'];
|
||||
|
||||
/** Klíč úložiště se šablonami tréninků jednoho uživatele. */
|
||||
const getKey = (login: string): string => `workouts:${login}`;
|
||||
|
||||
/** Uložená podoba šablony — dopočítávaná pole se neukládají. */
|
||||
type StoredTemplate = {
|
||||
/** Uložený den plánu — dopočítávaná pole se neukládají. */
|
||||
export type StoredPlanDay = {
|
||||
id: string;
|
||||
name: string;
|
||||
weekday?: number;
|
||||
items: WorkoutTemplateItem[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Uložená podoba šablony — dopočítávaná pole se neukládají.
|
||||
*
|
||||
* Volný typ je výchozí a nezapisuje se, takže šablony uložené dřív, než typy
|
||||
* přibyly, jsou volné. Plán položky nemá, cviky má ve dnech.
|
||||
*/
|
||||
export type StoredTemplate = {
|
||||
id: string;
|
||||
name: string;
|
||||
type?: WorkoutTemplateType;
|
||||
items: WorkoutTemplateItem[];
|
||||
days?: StoredPlanDay[];
|
||||
replaceWithinDays?: number;
|
||||
startDate?: string;
|
||||
};
|
||||
|
||||
/** Vrátí true, pokud je položka odkazem na jinou šablonu. */
|
||||
const isTemplateRef = (item: WorkoutTemplateItem): boolean => item.kind === WorkoutItemKind.SABLONA;
|
||||
|
||||
/** Vrátí typ šablony; starší šablony typ nemají a jsou volné. */
|
||||
export const getTemplateType = (template: StoredTemplate): WorkoutTemplateType =>
|
||||
template.type ?? WorkoutTemplateType.VOLNY;
|
||||
|
||||
/** Vrátí true, pokud je šablona plánem (fixním nebo plovoucím), ne volnou šablonou. */
|
||||
export const isPlan = (template: StoredTemplate): boolean => getTemplateType(template) !== WorkoutTemplateType.VOLNY;
|
||||
|
||||
/**
|
||||
* 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 => ({
|
||||
const withKind = (items: WorkoutTemplateItem[]): WorkoutTemplateItem[] =>
|
||||
items.map(item => ({ ...item, kind: item.kind ?? WorkoutItemKind.CVIK }));
|
||||
|
||||
/** Znormalizuje šablonu načtenou z úložiště. */
|
||||
const fromStorage = (template: StoredTemplate): StoredTemplate => ({
|
||||
...template,
|
||||
items: template.items.map(item => ({ ...item, kind: item.kind ?? WorkoutItemKind.CVIK })),
|
||||
items: withKind(template.items ?? []),
|
||||
...(template.days ? { days: template.days.map(day => ({ ...day, items: withKind(day.items) })) } : {}),
|
||||
});
|
||||
|
||||
/** 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));
|
||||
|
||||
/**
|
||||
* Rozbalí odkazy na jiné šablony na jejich cviky.
|
||||
*
|
||||
* Volné šablony se zanořují nejvýš o jednu úroveň a den plánu může odkázat
|
||||
* na volnou šablonu, která sama něco skládá — hloubka je tak nejvýš dvě.
|
||||
* Pojistka hloubky chrání jen před poškozenými daty.
|
||||
*
|
||||
* @param items položky šablony nebo dne plánu
|
||||
* @param byId všechny šablony uživatele podle id
|
||||
*/
|
||||
export const expandExercises = (
|
||||
items: WorkoutTemplateItem[], byId: Map<string, StoredTemplate>, depth = 0,
|
||||
): WorkoutTemplateItem[] => items.flatMap(item => {
|
||||
if (!isTemplateRef(item)) return [item];
|
||||
const nested = item.templateRefId ? byId.get(item.templateRefId) : undefined;
|
||||
return nested && depth < 2 ? expandExercises(nested.items, byId, depth + 1) : [];
|
||||
});
|
||||
|
||||
/** Sečte odhad kalorií položek včetně vnořených šablon. */
|
||||
const sumCalories = (items: WorkoutTemplateItem[], byId: Map<string, StoredTemplate>): number =>
|
||||
expandExercises(items, byId).reduce((sum, item) => sum + itemCalories(item), 0);
|
||||
|
||||
/** Převede cvik šablony na položku navrženého dne s dopočtenými kaloriemi. */
|
||||
export const toPlannedItem = (item: WorkoutTemplateItem): PlannedWorkoutItem => ({
|
||||
name: item.name!,
|
||||
unit: item.unit!,
|
||||
quantity: item.quantity!,
|
||||
...(item.sets != null ? { sets: item.sets } : {}),
|
||||
...(item.caloriesPer100Units != null ? { caloriesPer100Units: item.caloriesPer100Units } : {}),
|
||||
...(item.extraWeightGrams != null ? { extraWeightGrams: item.extraWeightGrams } : {}),
|
||||
calories: itemCalories(item),
|
||||
});
|
||||
|
||||
/** Vrátí true, pokud položky odkazují na šablonu s daným id. */
|
||||
const refersTo = (items: WorkoutTemplateItem[], id: string): boolean =>
|
||||
items.some(item => isTemplateRef(item) && item.templateRefId === id);
|
||||
|
||||
/** Volné šablony, které danou šablonu skládají. */
|
||||
const findNestingTemplates = (id: string, all: StoredTemplate[]): StoredTemplate[] =>
|
||||
all.filter(template => template.id !== id && !isPlan(template) && refersTo(template.items, id));
|
||||
|
||||
/** Plány, které danou šablonu zařazují do některého dne. */
|
||||
const findUsingPlans = (id: string, all: StoredTemplate[]): StoredTemplate[] =>
|
||||
all.filter(template => isPlan(template) && (template.days ?? []).some(day => refersTo(day.items, id)));
|
||||
|
||||
/**
|
||||
* 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 deriveItems = (items: WorkoutTemplateItem[]): WorkoutTemplateItem[] => items.map(item => {
|
||||
if (!isTemplateRef(item)) return item;
|
||||
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,
|
||||
templateRefCalories: nested ? sumCalories(nested.items, byId) : 0,
|
||||
};
|
||||
});
|
||||
|
||||
const days: WorkoutPlanDay[] | undefined = template.days?.map(day => ({
|
||||
...day,
|
||||
items: deriveItems(day.items),
|
||||
estimatedCalories: sumCalories(day.items, byId),
|
||||
}));
|
||||
|
||||
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,
|
||||
type: getTemplateType(template),
|
||||
items: deriveItems(template.items),
|
||||
...(days ? { days } : {}),
|
||||
...(template.replaceWithinDays != null ? { replaceWithinDays: template.replaceWithinDays } : {}),
|
||||
...(template.startDate ? { startDate: template.startDate } : {}),
|
||||
estimatedCalories: days
|
||||
? days.reduce((sum, day) => sum + day.estimatedCalories, 0)
|
||||
: sumCalories(template.items, byId),
|
||||
hasNested: template.items.some(isTemplateRef),
|
||||
usedByCount: findNestingTemplates(template.id, all).length,
|
||||
usedInPlanCount: findUsingPlans(template.id, all).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);
|
||||
export const loadTemplates = async (login: string): Promise<StoredTemplate[]> =>
|
||||
(await storage.getData<StoredTemplate[]>(getKey(login)) ?? []).map(fromStorage);
|
||||
|
||||
/**
|
||||
* Vrátí šablony tréninků uživatele seřazené podle názvu.
|
||||
@@ -100,7 +187,7 @@ const normalizeItem = (raw: unknown, index: number): WorkoutTemplateItem => {
|
||||
|
||||
if (kind === WorkoutItemKind.SABLONA) {
|
||||
if (typeof value.templateRefId !== 'string' || !value.templateRefId.length) {
|
||||
throw new BadRequestError(`Položka ${index + 1} neodkazuje na žádnou šablonu`);
|
||||
throw new BadRequestError(`Položka ${index + 1} neodkazuje na žádný trénink`);
|
||||
}
|
||||
// Název a odhad kalorií vnořené šablony si server dopočítá sám
|
||||
return { kind, templateRefId: value.templateRefId };
|
||||
@@ -137,98 +224,204 @@ const normalizeItem = (raw: unknown, index: number): WorkoutTemplateItem => {
|
||||
};
|
||||
};
|
||||
|
||||
/** Ověří a znormalizuje seznam položek šablony nebo dne plánu. */
|
||||
const normalizeItems = (items: unknown, emptyMessage: string): WorkoutTemplateItem[] => {
|
||||
if (!Array.isArray(items) || !items.length) {
|
||||
throw new BadRequestError(emptyMessage);
|
||||
}
|
||||
if (items.length > MAX_ITEMS) {
|
||||
throw new BadRequestError(`Trénink může mít nejvýše ${MAX_ITEMS} položek`);
|
||||
}
|
||||
return items.map(normalizeItem);
|
||||
};
|
||||
|
||||
/** Ověří typ šablony; nevyplněný znamená volnou. */
|
||||
const normalizeType = (value: unknown): WorkoutTemplateType => {
|
||||
if (value == null) return WorkoutTemplateType.VOLNY;
|
||||
if (!(Object.values(WorkoutTemplateType) as unknown[]).includes(value)) {
|
||||
throw new BadRequestError("Neplatný typ tréninku");
|
||||
}
|
||||
return value as WorkoutTemplateType;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ověří, že se zanořením šablon nepřekročí jedna úroveň.
|
||||
* Ověří a znormalizuje dny plánu.
|
||||
*
|
||||
* Fixní plán má u každého dne den v týdnu (každý nejvýš jednou) a dny řadí
|
||||
* od pondělí. Plovoucí je bez dnů v týdnu a zachová pořadí, ve kterém se cvičí.
|
||||
* Den bez názvu dostane výchozí — pořadí, nebo název dne v týdnu.
|
||||
*/
|
||||
const normalizeDays = (raw: unknown, type: WorkoutTemplateType): StoredPlanDay[] => {
|
||||
if (!Array.isArray(raw) || !raw.length) {
|
||||
throw new BadRequestError("Plán musí mít alespoň jeden tréninkový den");
|
||||
}
|
||||
const isFixed = type === WorkoutTemplateType.FIXNI;
|
||||
if (raw.length > (isFixed ? WEEKDAY_NAMES.length : MAX_PLAN_DAYS)) {
|
||||
throw new BadRequestError(`Plán může mít nejvýše ${isFixed ? WEEKDAY_NAMES.length : MAX_PLAN_DAYS} dnů`);
|
||||
}
|
||||
|
||||
const days = raw.map((rawDay, index): StoredPlanDay => {
|
||||
const value = (rawDay ?? {}) as Record<string, unknown>;
|
||||
const weekday = value.weekday;
|
||||
if (isFixed && (typeof weekday !== 'number' || !Number.isInteger(weekday) || weekday < 1 || weekday > 7)) {
|
||||
throw new BadRequestError(`Den ${index + 1} nemá platný den v týdnu`);
|
||||
}
|
||||
const name = typeof value.name === 'string' ? value.name.trim() : '';
|
||||
const defaultName = isFixed ? WEEKDAY_NAMES[(weekday as number) - 1] : `Den ${index + 1}`;
|
||||
return {
|
||||
id: typeof value.id === 'string' && value.id.length ? value.id : randomUUID(),
|
||||
name: name || defaultName,
|
||||
...(isFixed ? { weekday: weekday as number } : {}),
|
||||
items: normalizeItems(value.items, `Den '${name || defaultName}' musí mít alespoň jeden cvik`),
|
||||
};
|
||||
});
|
||||
|
||||
if (new Set(days.map(day => day.id)).size !== days.length) {
|
||||
throw new BadRequestError("Dny plánu mají duplicitní identifikátor");
|
||||
}
|
||||
if (isFixed) {
|
||||
if (new Set(days.map(day => day.weekday)).size !== days.length) {
|
||||
throw new BadRequestError("Každý den v týdnu může být v plánu nejvýše jednou");
|
||||
}
|
||||
days.sort((a, b) => a.weekday! - b.weekday!);
|
||||
}
|
||||
return days;
|
||||
};
|
||||
|
||||
/** Ověří lhůtu pro náhradu vynechaných cviků; nevyplněná znamená, že se náhrady nenabízejí. */
|
||||
const normalizeReplaceWithinDays = (value: unknown): number | undefined => {
|
||||
if (value == null || value === 0) return undefined;
|
||||
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > MAX_REPLACE_DAYS) {
|
||||
throw new BadRequestError(`Lhůta pro náhradu musí být 1 až ${MAX_REPLACE_DAYS} dní`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ověří odkazy šablony na jiné šablony.
|
||||
*
|
||||
* Odkazovat jde jen na volnou šablonu — plán do jiné šablony vnořit nelze.
|
||||
* U volné šablony se navíc hlídá, aby zanoření nepřekročilo jednu ú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á.
|
||||
* nesmí sama začít vnořovat, pokud ji už jiná volná šablona používá.
|
||||
* Plán úroveň zanoření nepřidává, den plánu tak smí odkázat na libovolnou
|
||||
* volnou šablonu.
|
||||
*/
|
||||
const checkNesting = (id: string, items: WorkoutTemplateItem[], all: StoredTemplate[]): void => {
|
||||
const byId = new Map(all.map(template => [template.id, template]));
|
||||
const nestedRefs = items.filter(isTemplateRef);
|
||||
const checkReferences = (template: StoredTemplate, all: StoredTemplate[]): void => {
|
||||
const byId = new Map(all.map(item => [item.id, item]));
|
||||
const plan = isPlan(template);
|
||||
const refs = [...template.items, ...(template.days ?? []).flatMap(day => day.items)].filter(isTemplateRef);
|
||||
|
||||
for (const item of nestedRefs) {
|
||||
if (item.templateRefId === id) {
|
||||
throw new BadRequestError("Šablona nemůže odkazovat sama na sebe");
|
||||
for (const item of refs) {
|
||||
if (item.templateRefId === template.id) {
|
||||
throw new BadRequestError("Trénink nemůže obsahovat sám sebe");
|
||||
}
|
||||
const nested = byId.get(item.templateRefId!);
|
||||
if (!nested) {
|
||||
throw new NotFoundError("Vnořená šablona nebyla nalezena");
|
||||
throw new NotFoundError("Vložený trénink nebyl nalezen");
|
||||
}
|
||||
if (nested.items.some(isTemplateRef)) {
|
||||
if (isPlan(nested)) {
|
||||
throw new BadRequestError(`'${nested.name}' je plán, do jiného tréninku ho vložit nelze`);
|
||||
}
|
||||
if (!plan && 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ň`);
|
||||
`Trénink '${nested.name}' už sám obsahuje jiné tréninky, nelze ho vložit — vkládání 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ň`);
|
||||
}
|
||||
const [usedBy] = findNestingTemplates(template.id, all);
|
||||
const [usedInPlan] = findUsingPlans(template.id, all);
|
||||
if (plan && (usedBy || usedInPlan)) {
|
||||
throw new BadRequestError(
|
||||
`Trénink je součástí '${(usedBy ?? usedInPlan).name}', takže z něj nejde udělat plán`);
|
||||
}
|
||||
if (!plan && refs.length && usedBy) {
|
||||
throw new BadRequestError(
|
||||
`Tento trénink už je vložený do '${usedBy.name}', takže do něj nelze vložit další — vkládání je povolené jen na jednu úroveň`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Založí novou šablonu, nebo přepíše existující podle předaného id.
|
||||
*
|
||||
* Volná šablona má položky, plán (fixní nebo plovoucí) dny. Úprava plánu
|
||||
* historii nemění — co se kdy odcvičilo, drží záznamy použití (`workoutPlans.ts`).
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param id identifikátor upravované šablony, nebo nic pro založení nové
|
||||
* @param name název šablony
|
||||
* @param items položky šablony
|
||||
* @param items položky volné šablony
|
||||
* @param plan typ a u plánu jeho dny, lhůta pro náhradu a začátek platnosti
|
||||
*/
|
||||
export const saveWorkoutTemplate = async (
|
||||
login: string,
|
||||
id: unknown,
|
||||
name: unknown,
|
||||
items: unknown,
|
||||
plan?: unknown,
|
||||
): Promise<WorkoutTemplate[]> => {
|
||||
if (typeof name !== 'string' || !name.trim().length) {
|
||||
throw new BadRequestError("Nebyl předán název šablony");
|
||||
}
|
||||
if (!Array.isArray(items) || !items.length) {
|
||||
throw new BadRequestError("Šablona musí mít alespoň jednu položku");
|
||||
}
|
||||
if (items.length > MAX_ITEMS) {
|
||||
throw new BadRequestError(`Šablona může mít nejvýše ${MAX_ITEMS} položek`);
|
||||
throw new BadRequestError("Nebyl předán název tréninku");
|
||||
}
|
||||
const options = (plan ?? {}) as Record<string, unknown>;
|
||||
const type = normalizeType(options.type);
|
||||
const all = await loadTemplates(login);
|
||||
const templateId = typeof id === 'string' && id.length ? id : randomUUID();
|
||||
const existing = all.find(template => template.id === templateId);
|
||||
|
||||
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));
|
||||
let normalized: StoredTemplate;
|
||||
if (type === WorkoutTemplateType.VOLNY) {
|
||||
normalized = {
|
||||
id: templateId,
|
||||
name: name.trim(),
|
||||
items: normalizeItems(items, "Trénink musí mít alespoň jednu položku"),
|
||||
};
|
||||
} else {
|
||||
if (options.startDate != null && !isValidIsoDate(options.startDate)) {
|
||||
throw new BadRequestError("Začátek platnosti plánu není platné datum ve formátu YYYY-MM-DD");
|
||||
}
|
||||
const replaceWithinDays = normalizeReplaceWithinDays(options.replaceWithinDays);
|
||||
normalized = {
|
||||
id: templateId,
|
||||
name: name.trim(),
|
||||
type,
|
||||
items: [],
|
||||
days: normalizeDays(options.days, type),
|
||||
...(replaceWithinDays ? { replaceWithinDays } : {}),
|
||||
startDate: (options.startDate as string | undefined) ?? existing?.startDate ?? formatDate(getToday()),
|
||||
};
|
||||
}
|
||||
checkReferences(normalized, all);
|
||||
|
||||
// Plán položky nemá, prázdný seznam se do úložiště nezapisuje
|
||||
const toStore: Partial<StoredTemplate> = { ...normalized };
|
||||
if (isPlan(normalized)) delete toStore.items;
|
||||
await storage.updateData<StoredTemplate[]>(getKey(login), current => {
|
||||
const others = (current ?? []).filter(template => template.id !== normalized.id);
|
||||
return [...others, normalized];
|
||||
return [...others, toStore as StoredTemplate];
|
||||
});
|
||||
return getWorkoutTemplates(login);
|
||||
};
|
||||
|
||||
/**
|
||||
* Smaže šablonu tréninku. Šablonu, kterou skládá jiná, smazat nelze —
|
||||
* jinak by ve složené šabloně zůstal odkaz do prázdna.
|
||||
* Smaže šablonu tréninku. Šablonu, kterou skládá jiná nebo kterou má plán
|
||||
* v některém dni, smazat nelze — jinak by zůstal odkaz do prázdna.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param id identifikátor šablony
|
||||
*/
|
||||
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");
|
||||
throw new BadRequestError("Nebyl předán identifikátor tréninku");
|
||||
}
|
||||
const all = await loadTemplates(login);
|
||||
if (!all.some(template => template.id === id)) {
|
||||
throw new NotFoundError("Šablona nebyla nalezena");
|
||||
throw new NotFoundError("Trénink nebyl nalezen");
|
||||
}
|
||||
const usedBy = all.find(template => template.id !== id
|
||||
&& template.items.some(item => isTemplateRef(item) && item.templateRefId === id));
|
||||
const [usedBy] = [...findNestingTemplates(id, all), ...findUsingPlans(id, all)];
|
||||
if (usedBy) {
|
||||
throw new BadRequestError(`Šablonu nelze smazat, protože ji skládá šablona '${usedBy.name}'`);
|
||||
throw new BadRequestError(`Nelze smazat, je součástí '${usedBy.name}'`);
|
||||
}
|
||||
|
||||
await storage.updateData<StoredTemplate[]>(getKey(login),
|
||||
@@ -236,20 +429,65 @@ export const deleteWorkoutTemplate = async (login: string, id: unknown): Promise
|
||||
return getWorkoutTemplates(login);
|
||||
};
|
||||
|
||||
/** Ověří výčet odcvičených cviků; bez něj se berou všechny. */
|
||||
const normalizeDoneIndexes = (value: unknown, count: number): Set<number> => {
|
||||
if (value == null) return new Set(Array.from({ length: count }, (_, index) => index));
|
||||
if (!Array.isArray(value) || value.some(index => !Number.isInteger(index) || index < 0 || index >= count)) {
|
||||
throw new BadRequestError("Výčet odcvičených cviků neodpovídá tréninku");
|
||||
}
|
||||
return new Set(value as number[]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Založí do zvoleného dne všechny cviky šablony, včetně těch z vnořených šablon.
|
||||
* Vytvoří aktivitu z cviku šablony.
|
||||
*
|
||||
* @param item cvik (položka druhu CVIK)
|
||||
* @param date den aktivity
|
||||
* @param createdAt čas založení
|
||||
* @param link vazba na šablonu, den plánu a případně nahrazený den
|
||||
*/
|
||||
export const createActivityFromExercise = (
|
||||
item: PlannedWorkoutItem,
|
||||
date: string,
|
||||
createdAt: string,
|
||||
link: Pick<ActivityEntry, 'templateId' | 'planDayId' | 'replacesDate'>,
|
||||
): ActivityEntry => ({
|
||||
id: randomUUID(),
|
||||
date,
|
||||
name: item.name,
|
||||
unit: item.unit,
|
||||
quantity: item.quantity,
|
||||
...(item.sets != null ? { sets: item.sets } : {}),
|
||||
...(item.caloriesPer100Units != null ? { caloriesPer100Units: item.caloriesPer100Units } : {}),
|
||||
...(item.extraWeightGrams != null ? { extraWeightGrams: item.extraWeightGrams } : {}),
|
||||
calories: item.calories,
|
||||
...link,
|
||||
createdAt,
|
||||
});
|
||||
|
||||
/**
|
||||
* Založí do zvoleného dne cviky šablony, včetně těch z vnořených šablon.
|
||||
*
|
||||
* Vzniknou z nich běžné aktivity, takže je pak jde jednotlivě upravit nebo smazat,
|
||||
* aniž by to ovlivnilo šablonu. `templateId` u nich zůstane jen jako informace,
|
||||
* odkud přišly.
|
||||
*
|
||||
* U plánu se zakládá jeden jeho den a slouží to i k potvrzení navrženého
|
||||
* tréninku: cviky mimo `doneIndexes` se zaznamenají jako vynechané (bez
|
||||
* aktivity) a v lhůtě plánu je jde nahradit. Den plánu jde potvrdit i celý
|
||||
* jako vynechaný — u plovoucího plánu se tím posune na další den sekvence.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param id identifikátor šablony
|
||||
* @param date den, do kterého se položky založí
|
||||
* @param dayId den plánu, povinný u plánu
|
||||
* @param doneIndexes pořadí odcvičených cviků v rozbaleném seznamu; bez něj všechny
|
||||
*/
|
||||
export const applyWorkoutTemplate = async (login: string, id: unknown, date: unknown): Promise<string> => {
|
||||
export const applyWorkoutTemplate = async (
|
||||
login: string, id: unknown, date: unknown, dayId?: unknown, doneIndexes?: unknown,
|
||||
): Promise<string> => {
|
||||
if (typeof id !== 'string' || !id.length) {
|
||||
throw new BadRequestError("Nebyl předán identifikátor šablony");
|
||||
throw new BadRequestError("Nebyl předán identifikátor tréninku");
|
||||
}
|
||||
if (!isValidIsoDate(date)) {
|
||||
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
|
||||
@@ -257,48 +495,50 @@ export const applyWorkoutTemplate = async (login: string, id: unknown, date: unk
|
||||
const all = await loadTemplates(login);
|
||||
const template = all.find(item => item.id === id);
|
||||
if (!template) {
|
||||
throw new NotFoundError("Šablona nebyla nalezena");
|
||||
throw new NotFoundError("Trénink nebyl nalezen");
|
||||
}
|
||||
|
||||
let day: StoredPlanDay | undefined;
|
||||
if (isPlan(template)) {
|
||||
if (typeof dayId !== 'string' || !dayId.length) {
|
||||
throw new BadRequestError("Nebyl předán den plánu");
|
||||
}
|
||||
day = template.days?.find(item => item.id === dayId);
|
||||
if (!day) {
|
||||
throw new NotFoundError("Den plánu nebyl nalezen");
|
||||
}
|
||||
}
|
||||
|
||||
// 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 exercises = expandExercises(day ? day.items : template.items, byId).map(toPlannedItem);
|
||||
const done = normalizeDoneIndexes(doneIndexes, exercises.length);
|
||||
if (!day && !done.size) {
|
||||
throw new BadRequestError("Nebyl vybrán žádný cvik");
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const entries: ActivityEntry[] = exercises.map(item => ({
|
||||
id: randomUUID(),
|
||||
date,
|
||||
name: item.name!,
|
||||
unit: item.unit!,
|
||||
quantity: item.quantity!,
|
||||
...(item.sets != null ? { sets: item.sets } : {}),
|
||||
...(item.caloriesPer100Units != null ? { caloriesPer100Units: item.caloriesPer100Units } : {}),
|
||||
...(item.extraWeightGrams != null ? { extraWeightGrams: item.extraWeightGrams } : {}),
|
||||
calories: itemCalories(item),
|
||||
templateId: template.id,
|
||||
createdAt: now,
|
||||
}));
|
||||
const link = { templateId: template.id, ...(day ? { planDayId: day.id } : {}) };
|
||||
const entries = exercises.map((item, index) =>
|
||||
done.has(index) ? createActivityFromExercise(item, date, now, link) : undefined);
|
||||
|
||||
await addActivities(login, date, entries);
|
||||
await addActivities(login, date, entries.filter(entry => !!entry));
|
||||
await recordWorkoutPlan(login, date, {
|
||||
id: randomUUID(),
|
||||
templateId: template.id,
|
||||
templateName: template.name,
|
||||
...(day ? { planDayId: day.id, planDayName: day.name } : {}),
|
||||
...(day && template.replaceWithinDays ? { replaceWithinDays: template.replaceWithinDays } : {}),
|
||||
appliedAt: now,
|
||||
items: entries.map(entry => ({
|
||||
entryId: entry.id,
|
||||
name: entry.name,
|
||||
unit: entry.unit,
|
||||
quantity: entry.quantity,
|
||||
...(entry.sets != null ? { sets: entry.sets } : {}),
|
||||
calories: entry.calories ?? 0,
|
||||
items: exercises.map((item, index) => ({
|
||||
...(entries[index] ? { entryId: entries[index].id } : {}),
|
||||
...item,
|
||||
})),
|
||||
});
|
||||
return date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Vytvoří šablonu tréninku z aktivit zaznamenaných v přehledu dne.
|
||||
* Vytvoří volnou š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.
|
||||
@@ -325,7 +565,7 @@ export const createWorkoutTemplateFromActivities = async (
|
||||
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");
|
||||
throw new BadRequestError("V tomto dni nejsou žádné aktivity, ze kterých by šel trénink vytvořit");
|
||||
}
|
||||
|
||||
const items = activities.map(entry => ({
|
||||
|
||||
Reference in new issue
Block a user