chore: graf db

feat: workout plans (volný, fixní, plovoucí)
This commit is contained in:
Ondřej Anděl committed 2026-10-22 12:11:05 +02:00
1 parent 865c2c3b22
commit 7641e26c9f
43 files changed
+3459 -599

No files matched your search

+337 -97
View File
@@ -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 => ({