584 lines
25 KiB
TypeScript
584 lines
25 KiB
TypeScript
import { randomUUID } from 'crypto';
|
|
import {
|
|
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, formatDate, getToday, isValidIsoDate } from "./utils";
|
|
import { recordWorkoutPlan } from "./workoutPlans";
|
|
|
|
const storage = getStorage();
|
|
|
|
/** 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ý 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 = (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: 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]));
|
|
|
|
const deriveItems = (items: WorkoutTemplateItem[]): WorkoutTemplateItem[] => items.map(item => {
|
|
if (!isTemplateRef(item)) return item;
|
|
const nested = item.templateRefId ? byId.get(item.templateRefId) : undefined;
|
|
return {
|
|
...item,
|
|
...(nested ? { templateRefName: nested.name } : {}),
|
|
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,
|
|
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. */
|
|
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.
|
|
*
|
|
* @param login přihlašovací jméno uživatele
|
|
*/
|
|
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. */
|
|
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 žádný trénink`);
|
|
}
|
|
// 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`);
|
|
}
|
|
if (!isActivityUnit(value.unit)) {
|
|
throw new BadRequestError(`Položka '${name}' nemá platnou jednotku`);
|
|
}
|
|
if (typeof value.quantity !== 'number' || !Number.isFinite(value.quantity) || value.quantity < 0) {
|
|
throw new BadRequestError(`Položka '${name}' nemá platné množství`);
|
|
}
|
|
const rate = value.caloriesPer100Units;
|
|
const sets = value.sets;
|
|
const extraWeight = value.extraWeightGrams;
|
|
return {
|
|
kind,
|
|
name,
|
|
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) }
|
|
: {}),
|
|
...(typeof extraWeight === 'number' && Number.isFinite(extraWeight) && extraWeight > 0
|
|
? { extraWeightGrams: Math.round(extraWeight) }
|
|
: {}),
|
|
};
|
|
};
|
|
|
|
/** 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ěří 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ž 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 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 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("Vložený trénink nebyl nalezen");
|
|
}
|
|
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(
|
|
`Trénink '${nested.name}' už sám obsahuje jiné tréninky, nelze ho vložit — vkládání 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 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 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);
|
|
|
|
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, toStore as StoredTemplate];
|
|
});
|
|
return getWorkoutTemplates(login);
|
|
};
|
|
|
|
/**
|
|
* 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 tréninku");
|
|
}
|
|
const all = await loadTemplates(login);
|
|
if (!all.some(template => template.id === id)) {
|
|
throw new NotFoundError("Trénink nebyl nalezen");
|
|
}
|
|
const [usedBy] = [...findNestingTemplates(id, all), ...findUsingPlans(id, all)];
|
|
if (usedBy) {
|
|
throw new BadRequestError(`Nelze smazat, je součástí '${usedBy.name}'`);
|
|
}
|
|
|
|
await storage.updateData<StoredTemplate[]>(getKey(login),
|
|
current => (current ?? []).filter(template => template.id !== id));
|
|
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[]);
|
|
};
|
|
|
|
/**
|
|
* 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, dayId?: unknown, doneIndexes?: unknown,
|
|
): Promise<string> => {
|
|
if (typeof id !== 'string' || !id.length) {
|
|
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");
|
|
}
|
|
const all = await loadTemplates(login);
|
|
const template = all.find(item => item.id === id);
|
|
if (!template) {
|
|
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");
|
|
}
|
|
}
|
|
|
|
const byId = new Map(all.map(item => [item.id, 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 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.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: exercises.map((item, index) => ({
|
|
...(entries[index] ? { entryId: entries[index].id } : {}),
|
|
...item,
|
|
})),
|
|
});
|
|
return date;
|
|
};
|
|
|
|
/**
|
|
* 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.
|
|
* 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 šel trénink 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 } : {}),
|
|
...(entry.extraWeightGrams != null ? { extraWeightGrams: entry.extraWeightGrams } : {}),
|
|
}));
|
|
|
|
return saveWorkoutTemplate(login, undefined, name, items);
|
|
};
|