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, 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): 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 => (await storage.getData(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 => { 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; 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; 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 => { if (typeof name !== 'string' || !name.trim().length) { throw new BadRequestError("Nebyl předán název tréninku"); } const options = (plan ?? {}) as Record; 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 = { ...normalized }; if (isPlan(normalized)) delete toStore.items; await storage.updateData(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 => { 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(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 => { 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 => ({ 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 => { 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 => { 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); };