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
@@ -2,7 +2,7 @@ import { randomUUID } from 'crypto';
|
||||
import { ActivityDay, ActivityEntry, ActivityUnit } from "../../types/gen/types.gen";
|
||||
import getStorage from "./storage";
|
||||
import { BadRequestError, NotFoundError, applyMultiplier, isValidIsoDate, normalizeMultiplier } from "./utils";
|
||||
import { dropOrphanedPlans } from "./workoutPlans";
|
||||
import { dropOrphanedPlans, updateReplacementLink } from "./workoutPlans";
|
||||
|
||||
const storage = getStorage();
|
||||
|
||||
@@ -230,6 +230,9 @@ export async function updateActivity(login: string, id: unknown, input: unknown)
|
||||
);
|
||||
if (originalDate !== normalized.date) {
|
||||
await dropOrphanedPlans(login, originalDate, remaining);
|
||||
if (original.replacesDate) {
|
||||
await updateReplacementLink(login, original.replacesDate, id, normalized.date);
|
||||
}
|
||||
}
|
||||
return normalized.date;
|
||||
}
|
||||
@@ -248,15 +251,19 @@ export async function deleteActivity(login: string, id: unknown, date: unknown):
|
||||
if (!isValidIsoDate(date)) {
|
||||
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
|
||||
}
|
||||
let found = false;
|
||||
let deleted: ActivityEntry | undefined;
|
||||
const remaining = await storage.updateData<ActivityEntry[]>(getDayKey(login, date), current => {
|
||||
found = (current ?? []).some(entry => entry.id === id);
|
||||
deleted = (current ?? []).find(entry => entry.id === id);
|
||||
return (current ?? []).filter(entry => entry.id !== id);
|
||||
});
|
||||
if (!found) {
|
||||
if (!deleted) {
|
||||
throw new NotFoundError("Aktivita nebyla nalezena");
|
||||
}
|
||||
await dropOrphanedPlans(login, date, remaining);
|
||||
// Smazaná náhrada vrací cvik v původním dni mezi vynechané
|
||||
if (deleted.replacesDate) {
|
||||
await updateReplacementLink(login, deleted.replacesDate, id);
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getSleep } from "./sleep";
|
||||
import { findWeightOn, getWeights } from "./weights";
|
||||
import { computeBasalCalories } from "./profile";
|
||||
import { getProteinTarget, getTargetDailyBalance } from "./bodyMetrics";
|
||||
import { getPendingReplacements, getPlannedWorkouts } from "./workoutSchedule";
|
||||
|
||||
/**
|
||||
* Spočítá energetickou bilanci dne.
|
||||
@@ -56,7 +57,8 @@ export const getBasalOn = (settings: UserSettings, weightList: WeightEntry[], da
|
||||
settings.basalCalories ?? computeBasalCalories(settings.profile, getWeightOn(settings, weightList, date));
|
||||
|
||||
/**
|
||||
* Sestaví kompletní přehled dne — jídlo, pohyb, spánek, vážení a energetickou bilanci.
|
||||
* Sestaví kompletní přehled dne — jídlo, pohyb, spánek, vážení, energetickou bilanci
|
||||
* a co na den navrhují plány tréninků včetně cviků, které jde ještě nahradit.
|
||||
*
|
||||
* Spočítaný klidový výdej i cíl bílkovin vycházejí z hmotnosti platné k tomuto
|
||||
* dni (poslední vážení v ten den nebo dřív), takže starší dny se se zhubnutím
|
||||
@@ -66,12 +68,14 @@ export const getBasalOn = (settings: UserSettings, weightList: WeightEntry[], da
|
||||
* @param date datum ve formátu YYYY-MM-DD
|
||||
*/
|
||||
export const getDayOverview = async (login: string, date: string): Promise<DayOverview> => {
|
||||
const [meals, activities, settings, sleepMinutes, weightList] = await Promise.all([
|
||||
const [meals, activities, settings, sleepMinutes, weightList, plannedWorkouts, pendingReplacements] = await Promise.all([
|
||||
getDay(login, date),
|
||||
getActivityDay(login, date),
|
||||
getSettings(login),
|
||||
getSleep(login, date),
|
||||
getWeights(login),
|
||||
getPlannedWorkouts(login, date),
|
||||
getPendingReplacements(login, date),
|
||||
]);
|
||||
const weightGrams = getWeightOn(settings, weightList, date);
|
||||
const basal = getBasalOn(settings, weightList, date);
|
||||
@@ -83,6 +87,8 @@ export const getDayOverview = async (login: string, date: string): Promise<DayOv
|
||||
activities,
|
||||
energy: buildEnergyBalance(
|
||||
meals.totalCalories, activities.totalCalories, basal, getTargetDailyBalance(settings.goals)),
|
||||
plannedWorkouts,
|
||||
pendingReplacements,
|
||||
...(sleepMinutes ? { sleepMinutes } : {}),
|
||||
...(dayWeight != null ? { weightGrams: dayWeight } : {}),
|
||||
...(proteinTargetGrams != null ? { proteinTargetGrams } : {}),
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
applyWorkoutTemplate, createWorkoutTemplateFromActivities, deleteWorkoutTemplate,
|
||||
getWorkoutTemplates, saveWorkoutTemplate,
|
||||
} from "../workouts";
|
||||
import { replaceSkippedExercise } from "../workoutSchedule";
|
||||
import { getDayOverview } from "../dayOverview";
|
||||
import { getLogin } from "../auth";
|
||||
import { parseToken } from "../utils";
|
||||
@@ -21,8 +22,8 @@ router.get("/", async (req: Request, res: Response, next: NextFunction) => {
|
||||
router.post("/save", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const { id, name, items } = req.body ?? {};
|
||||
res.status(200).json(await saveWorkoutTemplate(login, id, name, items));
|
||||
const { id, name, items, ...plan } = req.body ?? {};
|
||||
res.status(200).json(await saveWorkoutTemplate(login, id, name, items, plan));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
@@ -34,12 +35,22 @@ router.post("/delete", async (req: Request, res: Response, next: NextFunction) =
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Založí do zvoleného dne všechny položky šablony. */
|
||||
/** Založí do zvoleného dne cviky šablony, u plánu jednoho jeho dne (i jako potvrzení návrhu). */
|
||||
router.post("/apply", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const { id, date } = req.body ?? {};
|
||||
const day = await applyWorkoutTemplate(login, id, date);
|
||||
const { id, date, dayId, doneIndexes } = req.body ?? {};
|
||||
const day = await applyWorkoutTemplate(login, id, date, dayId, doneIndexes);
|
||||
res.status(200).json(await getDayOverview(login, day));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Nahradí cvik vynechaný v předchozím dni plánu. */
|
||||
router.post("/replace", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const { date, sourceDate, planId, itemIndex } = req.body ?? {};
|
||||
const day = await replaceSkippedExercise(login, date, sourceDate, planId, itemIndex);
|
||||
res.status(200).json(await getDayOverview(login, day));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
+100
-27
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
ActivityDayStats, ActivityEntry, ActivityGroupTotal, ActivityPeriodTotal, ActivityStats, ActivitySummaryItem,
|
||||
ActivityUnit, FoodStats, GroupTotal, IntakeGroupTotal, IntakePeriodTotal, IntakeStats, MealEntry, Nutrients,
|
||||
PeriodTotal, PlanDeviationKind, TemplateDayUsage, TemplateUsageTotal,
|
||||
PeriodTotal, PlanDeviationKind, ReplacementUsage, TemplateDayUsage, TemplateUsageTotal,
|
||||
} from "../../types/gen/types.gen";
|
||||
import { getRange } from "./meals";
|
||||
import { getActivityDay } from "./activities";
|
||||
@@ -267,41 +267,75 @@ const differsFromPlan = (entry: ActivityEntry, item: WorkoutPlanItem, calories:
|
||||
/** Průběžné počty cviků jedné šablony v jednom dni — do kontraktu nejdou, slouží součtům. */
|
||||
type UsageCounts = { planned: number, removed: number };
|
||||
|
||||
/** Současný název šablony a jejích dnů — pro aktivity, ke kterým se plán neuložil. */
|
||||
type TemplateInfo = { name: string, dayNames: Map<string, string> };
|
||||
|
||||
/** Klíč použití šablony; plán se ve statistikách dělí po dnech. */
|
||||
const getUsageKey = (templateId: string, planDayId?: string): string =>
|
||||
planDayId ? `${templateId}:${planDayId}` : templateId;
|
||||
|
||||
/**
|
||||
* Rozebere jeden den: ke každé použité šabloně dohledá odchylky od plánu
|
||||
* a aktivity mimo šablony vrátí zvlášť.
|
||||
* Rozebere jeden den: ke každé použité šabloně (u plánu ke každému jeho dni)
|
||||
* dohledá odchylky od plánu, náhrady cviků vynechaných dřív a aktivity mimo
|
||||
* šablony vrátí zvlášť.
|
||||
*
|
||||
* Vynechaný cvik, který se později nahradil, není odchylka typu ODEBRANO, ale
|
||||
* NAHRAZENO s datem náhrady — počítá se jako provedený. Náhrada sama se
|
||||
* počítá do dne, kdy se odcvičila, bez plánu (plán nese původní den).
|
||||
*
|
||||
* Aktivity ze šablony bez uloženého plánu (cvik přesunutý z jiného dne) se
|
||||
* berou jako splněné přesně dle plánu, protože plán z nich zjistit nejde.
|
||||
*/
|
||||
function analyzeDay(
|
||||
entries: ActivityEntry[], plans: WorkoutPlan[], templateNames: Map<string, string>, date: string,
|
||||
entries: ActivityEntry[], plans: WorkoutPlan[], templateInfo: Map<string, TemplateInfo>, date: string,
|
||||
): { day: ActivityDayStats, counts: Map<string, UsageCounts> } {
|
||||
const byId = new Map(entries.map(entry => [entry.id, entry]));
|
||||
const planned = new Set(plans.flatMap(plan => plan.items.map(item => item.entryId)));
|
||||
const usages = new Map<string, TemplateDayUsage>();
|
||||
const counts = new Map<string, UsageCounts>();
|
||||
|
||||
const usageFor = (templateId: string, name: string, hasPlan: boolean): TemplateDayUsage => {
|
||||
const usage = usages.get(templateId) ?? {
|
||||
templateId, name, plannedCalories: 0, actualCalories: 0, hasPlan, deviations: [],
|
||||
const usageFor = (
|
||||
templateId: string, planDayId: string | undefined, name: string, dayName: string | undefined, hasPlan: boolean,
|
||||
): { usage: TemplateDayUsage, count: UsageCounts } => {
|
||||
const key = getUsageKey(templateId, planDayId);
|
||||
const usage = usages.get(key) ?? {
|
||||
templateId,
|
||||
name,
|
||||
...(planDayId ? { planDayId } : {}),
|
||||
...(dayName ? { dayName } : {}),
|
||||
plannedCalories: 0,
|
||||
actualCalories: 0,
|
||||
hasPlan,
|
||||
deviations: [],
|
||||
};
|
||||
usages.set(templateId, usage);
|
||||
if (!counts.has(templateId)) counts.set(templateId, { planned: 0, removed: 0 });
|
||||
return usage;
|
||||
usages.set(key, usage);
|
||||
const count = counts.get(key) ?? { planned: 0, removed: 0 };
|
||||
counts.set(key, count);
|
||||
return { usage, count };
|
||||
};
|
||||
|
||||
/** Současný název dne plánu, pokud den v šabloně pořád je. */
|
||||
const currentDayName = (templateId: string, planDayId?: string): string | undefined =>
|
||||
planDayId ? templateInfo.get(templateId)?.dayNames.get(planDayId) : undefined;
|
||||
|
||||
for (const plan of plans) {
|
||||
// Stejná šablona použitá v jednom dni dvakrát má dva plány, sčítají se
|
||||
const usage = usageFor(plan.templateId, plan.templateName, true);
|
||||
const count = counts.get(plan.templateId)!;
|
||||
const { usage, count } = usageFor(plan.templateId, plan.planDayId, plan.templateName, plan.planDayName, true);
|
||||
for (const item of plan.items) {
|
||||
usage.plannedCalories += item.calories;
|
||||
count.planned++;
|
||||
const entry = item.entryId ? byId.get(item.entryId) : undefined;
|
||||
if (!entry) {
|
||||
usage.deviations.push({ kind: PlanDeviationKind.ODEBRANO, planned: summarize(item, item.calories) });
|
||||
count.removed++;
|
||||
if (item.replacement) {
|
||||
usage.deviations.push({
|
||||
kind: PlanDeviationKind.NAHRAZENO,
|
||||
planned: summarize(item, item.calories),
|
||||
replacementDate: item.replacement.date,
|
||||
});
|
||||
} else {
|
||||
usage.deviations.push({ kind: PlanDeviationKind.ODEBRANO, planned: summarize(item, item.calories) });
|
||||
count.removed++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const calories = applyMultiplier(entry.calories, entry.multiplier) ?? 0;
|
||||
@@ -317,6 +351,7 @@ function analyzeDay(
|
||||
}
|
||||
|
||||
const extras: ActivitySummaryItem[] = [];
|
||||
const replacements: ReplacementUsage[] = [];
|
||||
for (const entry of entries) {
|
||||
if (planned.has(entry.id)) continue;
|
||||
const calories = applyMultiplier(entry.calories, entry.multiplier) ?? 0;
|
||||
@@ -324,19 +359,35 @@ function analyzeDay(
|
||||
extras.push(summarize(entry, calories));
|
||||
continue;
|
||||
}
|
||||
const usage = usageFor(entry.templateId, templateNames.get(entry.templateId) ?? DELETED_TEMPLATE, false);
|
||||
const name = templateInfo.get(entry.templateId)?.name ?? DELETED_TEMPLATE;
|
||||
const dayName = currentDayName(entry.templateId, entry.planDayId);
|
||||
if (entry.replacesDate) {
|
||||
replacements.push({
|
||||
templateId: entry.templateId,
|
||||
name,
|
||||
...(entry.planDayId ? { planDayId: entry.planDayId } : {}),
|
||||
...(dayName ? { dayName } : {}),
|
||||
sourceDate: entry.replacesDate,
|
||||
item: summarize(entry, calories),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const { usage, count } = usageFor(entry.templateId, entry.planDayId, name, dayName, false);
|
||||
usage.plannedCalories += calories;
|
||||
usage.actualCalories += calories;
|
||||
counts.get(entry.templateId)!.planned++;
|
||||
count.planned++;
|
||||
}
|
||||
|
||||
const templates = Array.from(usages.values());
|
||||
const extraCalories = extras.reduce((sum, item) => sum + item.calories, 0);
|
||||
const replacementCalories = replacements.reduce((sum, item) => sum + item.item.calories, 0);
|
||||
return {
|
||||
day: {
|
||||
date,
|
||||
totalCalories: templates.reduce((sum, usage) => sum + usage.actualCalories, 0) + extraCalories,
|
||||
totalCalories: templates.reduce((sum, usage) => sum + usage.actualCalories, 0)
|
||||
+ replacementCalories + extraCalories,
|
||||
templates,
|
||||
replacements,
|
||||
extraCalories,
|
||||
extras,
|
||||
},
|
||||
@@ -349,7 +400,9 @@ function analyzeDay(
|
||||
*
|
||||
* Aktivity ze šablon se porovnávají s plánem, který si šablona uložila při
|
||||
* použití (`workoutPlans.ts`) — ne s její současnou podobou, ta se mohla
|
||||
* mezitím změnit.
|
||||
* mezitím změnit. Plány (fixní a plovoucí šablony) se dělí po dnech. Dny,
|
||||
* kdy podle plánu bylo volno nebo se necvičilo, se nijak nezapisují — jsou
|
||||
* to mezery, ne záznamy.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param from první den období (včetně), YYYY-MM-DD
|
||||
@@ -365,7 +418,10 @@ export async function getActivityStats(login: string, from: string, to: string):
|
||||
Promise.all(dates.map(date => getWorkoutPlans(login, date))),
|
||||
getWorkoutTemplates(login),
|
||||
]);
|
||||
const templateNames = new Map(templates.map(template => [template.id, template.name]));
|
||||
const templateInfo = new Map(templates.map(template => [template.id, {
|
||||
name: template.name,
|
||||
dayNames: new Map((template.days ?? []).map(day => [day.id, day.name])),
|
||||
}]));
|
||||
|
||||
const byDay = new Map<string, ActivityPeriodTotal>();
|
||||
const byMonth = new Map<string, ActivityPeriodTotal>();
|
||||
@@ -382,28 +438,44 @@ export async function getActivityStats(login: string, from: string, to: string):
|
||||
activityDays.forEach((activityDay, index) => {
|
||||
if (!activityDay.entries.length) return;
|
||||
entryCount += activityDay.entries.length;
|
||||
const { day, counts } = analyzeDay(activityDay.entries, plansByDay[index], templateNames, activityDay.date);
|
||||
const { day, counts } = analyzeDay(activityDay.entries, plansByDay[index], templateInfo, activityDay.date);
|
||||
days.push(day);
|
||||
|
||||
/** Součet použití šablony (u plánu jejího dne) za období. */
|
||||
const totalFor = (usage: { templateId: string, name: string, planDayId?: string }): TemplateUsageTotal => {
|
||||
const key = getUsageKey(usage.templateId, usage.planDayId);
|
||||
const total = byTemplate.get(key) ?? {
|
||||
templateId: usage.templateId, name: usage.name,
|
||||
...(usage.planDayId ? { planDayId: usage.planDayId } : {}),
|
||||
dayCount: 0, plannedCalories: 0, actualCalories: 0, plannedItemCount: 0, doneItemCount: 0,
|
||||
};
|
||||
byTemplate.set(key, total);
|
||||
return total;
|
||||
};
|
||||
|
||||
let dayTemplate = 0;
|
||||
let dayPlanned = 0;
|
||||
for (const usage of day.templates) {
|
||||
const count = counts.get(usage.templateId)!;
|
||||
const total = byTemplate.get(usage.templateId) ?? {
|
||||
templateId: usage.templateId, name: usage.name, dayCount: 0,
|
||||
plannedCalories: 0, actualCalories: 0, plannedItemCount: 0, doneItemCount: 0,
|
||||
};
|
||||
const count = counts.get(getUsageKey(usage.templateId, usage.planDayId))!;
|
||||
const total = totalFor(usage);
|
||||
// Dny jdou vzestupně, takže zůstane název z posledního použití
|
||||
total.name = usage.name;
|
||||
if (usage.dayName) total.dayName = usage.dayName;
|
||||
total.dayCount++;
|
||||
total.plannedCalories += usage.plannedCalories;
|
||||
total.actualCalories += usage.actualCalories;
|
||||
total.plannedItemCount += count.planned;
|
||||
total.doneItemCount += count.planned - count.removed;
|
||||
byTemplate.set(usage.templateId, total);
|
||||
dayTemplate += usage.actualCalories;
|
||||
dayPlanned += usage.plannedCalories;
|
||||
}
|
||||
// Náhrada patří ke dni plánu, ve kterém se cvik vynechal — jeho plán už je započtený tam
|
||||
for (const replacement of day.replacements) {
|
||||
const total = totalFor(replacement);
|
||||
if (!total.dayName && replacement.dayName) total.dayName = replacement.dayName;
|
||||
total.actualCalories += replacement.item.calories;
|
||||
dayTemplate += replacement.item.calories;
|
||||
}
|
||||
for (const item of day.extras) {
|
||||
const group = extras.get(item.name) ?? { key: item.name, calories: 0, count: 0 };
|
||||
group.calories += item.calories;
|
||||
@@ -432,7 +504,8 @@ export async function getActivityStats(login: string, from: string, to: string):
|
||||
byMonth: sortPeriods(byMonth),
|
||||
byYear: sortPeriods(byYear),
|
||||
byTemplate: Array.from(byTemplate.values())
|
||||
.sort((a, b) => b.plannedCalories - a.plannedCalories || a.name.localeCompare(b.name, 'cs')),
|
||||
.sort((a, b) => b.plannedCalories - a.plannedCalories || a.name.localeCompare(b.name, 'cs')
|
||||
|| (a.dayName ?? '').localeCompare(b.dayName ?? '', 'cs')),
|
||||
extras: Array.from(extras.values())
|
||||
.sort((a, b) => b.calories - a.calories || a.key.localeCompare(b.key, 'cs')),
|
||||
days,
|
||||
|
||||
@@ -157,13 +157,13 @@ describe('šablony tréninků', () => {
|
||||
});
|
||||
|
||||
it('odmítne šablonu bez názvu nebo bez položek', async () => {
|
||||
await expect(saveWorkoutTemplate(LOGIN, undefined, '', items)).rejects.toThrow(/název šablony/);
|
||||
await expect(saveWorkoutTemplate(LOGIN, undefined, '', items)).rejects.toThrow(/název tréninku/);
|
||||
await expect(saveWorkoutTemplate(LOGIN, undefined, 'Prázdná', [])).rejects.toThrow(/alespoň jednu položku/);
|
||||
});
|
||||
|
||||
it('hlásí chybu u neexistující šablony', async () => {
|
||||
await expect(applyWorkoutTemplate(LOGIN, 'neexistuje', DATE)).rejects.toThrow(/nebyla nalezena/);
|
||||
await expect(deleteWorkoutTemplate(LOGIN, 'neexistuje')).rejects.toThrow(/nebyla nalezena/);
|
||||
await expect(applyWorkoutTemplate(LOGIN, 'neexistuje', DATE)).rejects.toThrow(/nebyl nalezen/);
|
||||
await expect(deleteWorkoutTemplate(LOGIN, 'neexistuje')).rejects.toThrow(/nebyl nalezen/);
|
||||
});
|
||||
|
||||
it('šablony jsou oddělené mezi uživateli', async () => {
|
||||
|
||||
@@ -79,7 +79,7 @@ describe('překlopení aktivit do šablony tréninku', () => {
|
||||
|
||||
await addActivity(LOGIN, kliky);
|
||||
await expect(createWorkoutTemplateFromActivities(LOGIN, DATE, ' '))
|
||||
.rejects.toThrow(/název šablony/);
|
||||
.rejects.toThrow(/název tréninku/);
|
||||
});
|
||||
|
||||
it('vytvořená šablona jde rovnou použít', async () => {
|
||||
|
||||
@@ -102,19 +102,19 @@ describe('skládání šablon z jiných šablon', () => {
|
||||
const [sablona] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]);
|
||||
|
||||
await expect(saveWorkoutTemplate(LOGIN, sablona.id, 'Rozcvička', [odkaz(sablona.id)]))
|
||||
.rejects.toThrow(/sama na sebe/);
|
||||
.rejects.toThrow(/sám sebe/);
|
||||
});
|
||||
|
||||
it('nedovolí odkaz na neexistující šablonu', async () => {
|
||||
await expect(saveWorkoutTemplate(LOGIN, undefined, 'Workout', [odkaz('neexistuje')]))
|
||||
.rejects.toThrow(/nebyla nalezena/);
|
||||
.rejects.toThrow(/nebyl nalezen/);
|
||||
});
|
||||
|
||||
it('nedovolí smazat šablonu, kterou skládá jiná', async () => {
|
||||
const [rozcvicka] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]);
|
||||
await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', [odkaz(rozcvicka.id)]);
|
||||
|
||||
await expect(deleteWorkoutTemplate(LOGIN, rozcvicka.id)).rejects.toThrow(/nelze smazat/);
|
||||
await expect(deleteWorkoutTemplate(LOGIN, rozcvicka.id)).rejects.toThrow(/Nelze smazat/);
|
||||
});
|
||||
|
||||
it('šablonu bez vazeb smazat lze', async () => {
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
process.env.CALORIE_PROVIDER = 'none';
|
||||
|
||||
import { addActivity, deleteActivity, getActivityDay, updateActivity } from "../activities";
|
||||
import {
|
||||
applyWorkoutTemplate, deleteWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate,
|
||||
} from "../workouts";
|
||||
import { getPendingReplacements, getPlannedWorkouts, replaceSkippedExercise } from "../workoutSchedule";
|
||||
import { getWorkoutPlans } from "../workoutPlans";
|
||||
import { getActivityStats } from "../statsService";
|
||||
import { getDayOverview } from "../dayOverview";
|
||||
import { resetMemoryStorage } from "../storage/memory";
|
||||
import getStorage from "../storage";
|
||||
import { formatDate, shiftIsoDate } from "../utils";
|
||||
import {
|
||||
ActivityUnit, PlanDeviationKind, PlannedWorkoutStatus, WorkoutItemKind, WorkoutTemplateType,
|
||||
} from "../../../types/gen/types.gen";
|
||||
|
||||
const LOGIN = 'tester';
|
||||
/** Pondělí — fixní plán se řídí dny v týdnu. */
|
||||
const START = '2025-03-03';
|
||||
|
||||
/** Cvik jako položka šablony. */
|
||||
const cvik = (name: string, quantity: number, rate: number) => ({
|
||||
kind: WorkoutItemKind.CVIK,
|
||||
name,
|
||||
unit: ActivityUnit.OPAKOVANI,
|
||||
quantity,
|
||||
caloriesPer100Units: rate,
|
||||
});
|
||||
|
||||
/** Datum posunuté o daný počet dní od začátku plánu. */
|
||||
const day = (offset: number): string => shiftIsoDate(START, offset);
|
||||
|
||||
/**
|
||||
* Založí plovoucí plán se třemi dny (po dvou cvicích) a lhůtou náhrady 2 dny.
|
||||
* Den 2 má Kliky 100 × 0,5 kcal a Nohy 100 × 0,3 kcal.
|
||||
*/
|
||||
async function createFloating(startDate = START, replaceWithinDays: number | null = 2) {
|
||||
const templates = await saveWorkoutTemplate(LOGIN, undefined, 'Plán', undefined, {
|
||||
type: WorkoutTemplateType.PLOVOUCI,
|
||||
startDate,
|
||||
replaceWithinDays,
|
||||
days: [
|
||||
{ name: 'Den 1', items: [cvik('Hrudník', 40, 40), cvik('Biceps', 20, 20)] },
|
||||
{ name: 'Den 2', items: [cvik('Kliky', 100, 50), cvik('Nohy', 100, 30)] },
|
||||
{ name: 'Den 3', items: [cvik('Ramena', 40, 40), cvik('Břicho', 20, 25)] },
|
||||
],
|
||||
});
|
||||
return templates.find(template => template.name === 'Plán')!;
|
||||
}
|
||||
|
||||
/** Potvrdí den plánu (podle pořadí dne v plánu) v zadaném datu. */
|
||||
async function confirm(planId: string, dayIndex: number, date: string, doneIndexes?: number[]) {
|
||||
const plan = (await getWorkoutTemplates(LOGIN)).find(template => template.id === planId)!;
|
||||
await applyWorkoutTemplate(LOGIN, planId, date, plan.days![dayIndex].id, doneIndexes);
|
||||
}
|
||||
|
||||
/** Co plán navrhuje v daný den. */
|
||||
const proposal = async (date: string) => (await getPlannedWorkouts(LOGIN, date))[0];
|
||||
|
||||
beforeEach(() => resetMemoryStorage());
|
||||
|
||||
describe('typy šablon', () => {
|
||||
|
||||
it('šablona uložená bez typu je volná', async () => {
|
||||
await getStorage().setData(`workouts:${LOGIN}`, [{ id: 'stara', name: 'Stará', items: [cvik('Kliky', 10, 50)] }]);
|
||||
const [template] = await getWorkoutTemplates(LOGIN);
|
||||
expect(template.type).toBe(WorkoutTemplateType.VOLNY);
|
||||
expect(template.days).toBeUndefined();
|
||||
});
|
||||
|
||||
it('plán nemá položky, dny si nechá v pořadí a doplní jim id a odhad', async () => {
|
||||
const plan = await createFloating();
|
||||
expect(plan.type).toBe(WorkoutTemplateType.PLOVOUCI);
|
||||
expect(plan.items).toEqual([]);
|
||||
expect(plan.days!.map(item => item.name)).toEqual(['Den 1', 'Den 2', 'Den 3']);
|
||||
expect(plan.days!.every(item => item.id.length > 0)).toBe(true);
|
||||
// Den 2: 100 × 0,5 + 100 × 0,3
|
||||
expect(plan.days![1].estimatedCalories).toBe(80);
|
||||
expect(plan.replaceWithinDays).toBe(2);
|
||||
expect(plan.startDate).toBe(START);
|
||||
});
|
||||
|
||||
it('úprava plánu zachová id dnů i začátek platnosti', async () => {
|
||||
const plan = await createFloating();
|
||||
const [updated] = await saveWorkoutTemplate(LOGIN, plan.id, 'Plán', undefined, {
|
||||
type: WorkoutTemplateType.PLOVOUCI,
|
||||
days: plan.days!.map(item => ({ id: item.id, name: item.name, items: item.items })),
|
||||
});
|
||||
expect(updated.days!.map(item => item.id)).toEqual(plan.days!.map(item => item.id));
|
||||
expect(updated.startDate).toBe(START);
|
||||
expect(updated.replaceWithinDays).toBeUndefined();
|
||||
});
|
||||
|
||||
it('fixní plán seřadí dny od pondělí a pojmenuje je podle dne v týdnu', async () => {
|
||||
const [plan] = await saveWorkoutTemplate(LOGIN, undefined, 'Týden', undefined, {
|
||||
type: WorkoutTemplateType.FIXNI,
|
||||
days: [
|
||||
{ name: '', weekday: 5, items: [cvik('Běh', 30, 50)] },
|
||||
{ name: 'Nohy', weekday: 1, items: [cvik('Dřepy', 50, 40)] },
|
||||
],
|
||||
});
|
||||
expect(plan.days!.map(item => [item.weekday, item.name])).toEqual([[1, 'Nohy'], [5, 'Pátek']]);
|
||||
});
|
||||
|
||||
it('odmítne nesmyslný plán', async () => {
|
||||
const save = (plan: unknown) => saveWorkoutTemplate(LOGIN, undefined, 'Plán', undefined, plan);
|
||||
await expect(save({ type: WorkoutTemplateType.PLOVOUCI, days: [] })).rejects.toThrow(/alespoň jeden tréninkový den/);
|
||||
await expect(save({ type: WorkoutTemplateType.PLOVOUCI, days: [{ name: 'A', items: [] }] }))
|
||||
.rejects.toThrow(/alespoň jeden cvik/);
|
||||
await expect(save({ type: WorkoutTemplateType.FIXNI, days: [{ name: 'A', items: [cvik('X', 1, 1)] }] }))
|
||||
.rejects.toThrow(/den v týdnu/);
|
||||
await expect(save({
|
||||
type: WorkoutTemplateType.FIXNI,
|
||||
days: [{ name: 'A', weekday: 2, items: [cvik('X', 1, 1)] }, { name: 'B', weekday: 2, items: [cvik('Y', 1, 1)] }],
|
||||
})).rejects.toThrow(/nejvýše jednou/);
|
||||
await expect(save({
|
||||
type: WorkoutTemplateType.PLOVOUCI, replaceWithinDays: 30, days: [{ name: 'A', items: [cvik('X', 1, 1)] }],
|
||||
})).rejects.toThrow(/Lhůta/);
|
||||
await expect(save({ type: 'NEZNAMY', days: [] })).rejects.toThrow(/typ tréninku/);
|
||||
});
|
||||
|
||||
it('den plánu může zařadit volnou šablonu, ta pak nejde smazat ani změnit na plán', async () => {
|
||||
const [rozcvicka] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]);
|
||||
await saveWorkoutTemplate(LOGIN, undefined, 'Plán', undefined, {
|
||||
type: WorkoutTemplateType.PLOVOUCI,
|
||||
days: [{ name: 'A', items: [{ kind: WorkoutItemKind.SABLONA, templateRefId: rozcvicka.id }, cvik('Kliky', 10, 50)] }],
|
||||
});
|
||||
|
||||
const templates = await getWorkoutTemplates(LOGIN);
|
||||
expect(templates.find(t => t.id === rozcvicka.id)!.usedInPlanCount).toBe(1);
|
||||
expect(templates.find(t => t.name === 'Plán')!.days![0].estimatedCalories).toBe(21);
|
||||
await expect(deleteWorkoutTemplate(LOGIN, rozcvicka.id)).rejects.toThrow(/součástí 'Plán'/);
|
||||
await expect(saveWorkoutTemplate(LOGIN, rozcvicka.id, 'Rozcvička', undefined, {
|
||||
type: WorkoutTemplateType.PLOVOUCI, days: [{ name: 'A', items: [cvik('X', 1, 1)] }],
|
||||
})).rejects.toThrow(/nejde udělat plán/);
|
||||
});
|
||||
|
||||
it('plán do jiné šablony vnořit nelze', async () => {
|
||||
const plan = await createFloating();
|
||||
await expect(saveWorkoutTemplate(LOGIN, undefined, 'Volná', [{ kind: WorkoutItemKind.SABLONA, templateRefId: plan.id }]))
|
||||
.rejects.toThrow(/je plán/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('plovoucí plán', () => {
|
||||
|
||||
it('jde den po dni a po posledním dni navrhne volno', async () => {
|
||||
const plan = await createFloating();
|
||||
expect(await proposal(day(0))).toEqual(expect.objectContaining({
|
||||
status: PlannedWorkoutStatus.NAVRH, dayName: 'Den 1', restAvailable: false,
|
||||
}));
|
||||
await confirm(plan.id, 0, day(0));
|
||||
await confirm(plan.id, 1, day(1));
|
||||
expect((await proposal(day(2))).dayName).toBe('Den 3');
|
||||
await confirm(plan.id, 2, day(2));
|
||||
|
||||
expect((await proposal(day(3))).status).toBe(PlannedWorkoutStatus.VOLNO);
|
||||
expect(await proposal(day(4))).toEqual(expect.objectContaining({ status: PlannedWorkoutStatus.NAVRH, dayName: 'Den 1' }));
|
||||
});
|
||||
|
||||
it('navrhne cviky dne s dopočtenými kaloriemi a potvrzený den ukáže jako splněný', async () => {
|
||||
const plan = await createFloating();
|
||||
expect((await proposal(day(0))).items).toEqual([
|
||||
expect.objectContaining({ name: 'Hrudník', quantity: 40, calories: 16 }),
|
||||
expect.objectContaining({ name: 'Biceps', quantity: 20, calories: 4 }),
|
||||
]);
|
||||
await confirm(plan.id, 0, day(0));
|
||||
expect(await proposal(day(0))).toEqual(expect.objectContaining({
|
||||
status: PlannedWorkoutStatus.SPLNENO, dayName: 'Den 1', items: [],
|
||||
}));
|
||||
});
|
||||
|
||||
it('den bez tréninku uprostřed cyklu je posunuté volno, cyklus pak pokračuje bez mezery', async () => {
|
||||
const plan = await createFloating();
|
||||
await confirm(plan.id, 0, day(0));
|
||||
await confirm(plan.id, 1, day(1));
|
||||
expect((await proposal(day(2))).restAvailable).toBe(true);
|
||||
// day(2) volno posunuté dopředu
|
||||
expect(await proposal(day(3))).toEqual(expect.objectContaining({ dayName: 'Den 3', restAvailable: false }));
|
||||
await confirm(plan.id, 2, day(3));
|
||||
expect((await proposal(day(4))).dayName).toBe('Den 1');
|
||||
});
|
||||
|
||||
it('druhý den bez tréninku v cyklu trénink jen odsune', async () => {
|
||||
const plan = await createFloating();
|
||||
await confirm(plan.id, 0, day(0));
|
||||
// day(1) posunuté volno, day(2) odsunutí
|
||||
expect((await proposal(day(3))).dayName).toBe('Den 2');
|
||||
});
|
||||
|
||||
it('před začátkem cyklu se den bez tréninku jen odsouvá', async () => {
|
||||
await createFloating();
|
||||
expect((await proposal(day(5))).dayName).toBe('Den 1');
|
||||
});
|
||||
|
||||
it('vynechání celého dne plán posune na další den', async () => {
|
||||
const plan = await createFloating();
|
||||
await confirm(plan.id, 0, day(0), []);
|
||||
expect((await getActivityDay(LOGIN, day(0))).entries).toEqual([]);
|
||||
expect((await proposal(day(1))).dayName).toBe('Den 2');
|
||||
});
|
||||
|
||||
it('po skutečné historii (D1 D2 D3 volno, posunuté volno, náhrady) navrhne správný den', async () => {
|
||||
const plan = await createFloating('2026-09-21');
|
||||
// Data z provozu: 24. a 25. 9. bez tréninku, 2. 10. posunuté volno, 21. 10. Den 2
|
||||
const history = [
|
||||
'2026-09-21:0', '2026-09-22:1', '2026-09-23:2', '2026-09-26:0', '2026-09-27:1', '2026-09-28:2',
|
||||
'2026-09-30:0', '2026-10-01:1', '2026-10-03:2', '2026-10-04:0', '2026-10-05:1', '2026-10-06:2',
|
||||
'2026-10-08:0', '2026-10-09:1', '2026-10-10:2', '2026-10-12:0', '2026-10-13:1', '2026-10-14:2',
|
||||
'2026-10-16:0', '2026-10-17:1', '2026-10-18:2', '2026-10-20:0', '2026-10-21:1',
|
||||
];
|
||||
for (const record of history) {
|
||||
const [date, index] = record.split(':');
|
||||
await confirm(plan.id, Number(index), date);
|
||||
}
|
||||
expect((await proposal('2026-10-02')).dayName).toBe('Den 3');
|
||||
expect((await proposal('2026-10-07')).status).toBe(PlannedWorkoutStatus.VOLNO);
|
||||
expect((await proposal('2026-10-22')).dayName).toBe('Den 3');
|
||||
});
|
||||
|
||||
it('budoucí den počítá s tím, že se dnešní návrh splní', async () => {
|
||||
const today = formatDate(new Date());
|
||||
await createFloating(today);
|
||||
expect((await proposal(shiftIsoDate(today, 1))).dayName).toBe('Den 2');
|
||||
expect((await proposal(shiftIsoDate(today, 3))).status).toBe(PlannedWorkoutStatus.VOLNO);
|
||||
});
|
||||
|
||||
it('nový den přidaný do plánu historii nepřepíše a sekvence pokračuje podle id dnů', async () => {
|
||||
const plan = await createFloating();
|
||||
await confirm(plan.id, 0, day(0));
|
||||
await saveWorkoutTemplate(LOGIN, plan.id, 'Plán', undefined, {
|
||||
type: WorkoutTemplateType.PLOVOUCI,
|
||||
days: [
|
||||
{ id: plan.days![0].id, name: 'Den 1', items: [cvik('Hrudník', 60, 40)] },
|
||||
{ name: 'Den 1b', items: [cvik('Shyby', 10, 100)] },
|
||||
...plan.days!.slice(1).map(item => ({ id: item.id, name: item.name, items: item.items })),
|
||||
],
|
||||
});
|
||||
expect((await proposal(day(1))).dayName).toBe('Den 1b');
|
||||
expect((await getActivityStats(LOGIN, day(0), day(0))).plannedCalories).toBe(20);
|
||||
});
|
||||
|
||||
it('plán, který ještě neplatí, se v přehledu dne nenabízí', async () => {
|
||||
await createFloating(day(10));
|
||||
expect(await getPlannedWorkouts(LOGIN, day(9))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fixní plán', () => {
|
||||
|
||||
it('navrhuje podle dne v týdnu, ostatní dny jsou volno', async () => {
|
||||
const [plan] = await saveWorkoutTemplate(LOGIN, undefined, 'Týden', undefined, {
|
||||
type: WorkoutTemplateType.FIXNI,
|
||||
startDate: START,
|
||||
days: [
|
||||
{ name: 'Nohy', weekday: 1, items: [cvik('Dřepy', 50, 40)] },
|
||||
{ name: 'Běh', weekday: 3, items: [cvik('Běh', 30, 50)] },
|
||||
],
|
||||
});
|
||||
expect((await proposal(day(0))).dayName).toBe('Nohy');
|
||||
expect((await proposal(day(1))).status).toBe(PlannedWorkoutStatus.VOLNO);
|
||||
expect((await proposal(day(2))).dayName).toBe('Běh');
|
||||
// Pondělí příští týden zase nohy, i když se tento týden necvičilo
|
||||
expect((await proposal(day(7))).dayName).toBe('Nohy');
|
||||
|
||||
await applyWorkoutTemplate(LOGIN, plan.id, day(0), plan.days![0].id);
|
||||
expect((await proposal(day(0))).status).toBe(PlannedWorkoutStatus.SPLNENO);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vynechané cviky a jejich náhrada', () => {
|
||||
|
||||
/** Den 2 potvrzený bez nohou. */
|
||||
async function skipNohy() {
|
||||
const plan = await createFloating();
|
||||
await confirm(plan.id, 0, day(0));
|
||||
await confirm(plan.id, 1, day(1), [0]);
|
||||
return plan;
|
||||
}
|
||||
|
||||
it('potvrzení založí jen odcvičené cviky a vynechaný ukáže jako odchylku', async () => {
|
||||
await skipNohy();
|
||||
expect((await getActivityDay(LOGIN, day(1))).entries.map(entry => entry.name)).toEqual(['Kliky']);
|
||||
|
||||
const stats = await getActivityStats(LOGIN, day(1), day(1));
|
||||
expect(stats.days[0].templates[0]).toEqual(expect.objectContaining({
|
||||
name: 'Plán', dayName: 'Den 2', plannedCalories: 80, actualCalories: 50,
|
||||
deviations: [expect.objectContaining({ kind: PlanDeviationKind.ODEBRANO })],
|
||||
}));
|
||||
});
|
||||
|
||||
it('vynechaný cvik se nabízí k náhradě jen v lhůtě plánu', async () => {
|
||||
await skipNohy();
|
||||
const [pending] = await getPendingReplacements(LOGIN, day(2));
|
||||
expect(pending).toEqual(expect.objectContaining({
|
||||
sourceDate: day(1), templateName: 'Plán', dayName: 'Den 2', deadline: day(3),
|
||||
item: expect.objectContaining({ name: 'Nohy', calories: 30 }),
|
||||
}));
|
||||
expect(await getPendingReplacements(LOGIN, day(3))).toHaveLength(1);
|
||||
expect(await getPendingReplacements(LOGIN, day(4))).toEqual([]);
|
||||
expect(await getPendingReplacements(LOGIN, day(1))).toEqual([]);
|
||||
});
|
||||
|
||||
it('plán bez lhůty náhrady nic nenabízí', async () => {
|
||||
const plan = await createFloating(START, null);
|
||||
await confirm(plan.id, 1, day(0), [0]);
|
||||
expect(await getPendingReplacements(LOGIN, day(1))).toEqual([]);
|
||||
});
|
||||
|
||||
it('smazaný cvik z dne plánu jde nahradit také', async () => {
|
||||
const plan = await createFloating();
|
||||
await confirm(plan.id, 1, day(0));
|
||||
const nohy = (await getActivityDay(LOGIN, day(0))).entries.find(entry => entry.name === 'Nohy')!;
|
||||
await deleteActivity(LOGIN, nohy.id, day(0));
|
||||
expect((await getPendingReplacements(LOGIN, day(1))).map(item => item.item.name)).toEqual(['Nohy']);
|
||||
});
|
||||
|
||||
it('náhrada založí aktivitu, odebere cvik z nabídky a ve statistikách odkáže na den náhrady', async () => {
|
||||
await skipNohy();
|
||||
const [pending] = await getPendingReplacements(LOGIN, day(3));
|
||||
await replaceSkippedExercise(LOGIN, day(3), pending.sourceDate, pending.planId, pending.itemIndex);
|
||||
|
||||
const [entry] = (await getActivityDay(LOGIN, day(3))).entries;
|
||||
expect(entry).toEqual(expect.objectContaining({ name: 'Nohy', calories: 30, replacesDate: day(1) }));
|
||||
expect(await getPendingReplacements(LOGIN, day(3))).toEqual([]);
|
||||
|
||||
const stats = await getActivityStats(LOGIN, day(0), day(3));
|
||||
const source = stats.days.find(item => item.date === day(1))!;
|
||||
expect(source.templates[0].deviations).toEqual([expect.objectContaining({
|
||||
kind: PlanDeviationKind.NAHRAZENO, replacementDate: day(3),
|
||||
})]);
|
||||
const target = stats.days.find(item => item.date === day(3))!;
|
||||
expect(target.replacements).toEqual([expect.objectContaining({
|
||||
name: 'Plán', dayName: 'Den 2', sourceDate: day(1), item: expect.objectContaining({ name: 'Nohy', calories: 30 }),
|
||||
})]);
|
||||
expect(target.totalCalories).toBe(30);
|
||||
expect(stats.templateCalories).toBe(stats.plannedCalories);
|
||||
expect(stats.byTemplate.find(item => item.dayName === 'Den 2')).toEqual(expect.objectContaining({
|
||||
plannedItemCount: 2, doneItemCount: 2, plannedCalories: 80, actualCalories: 80,
|
||||
}));
|
||||
});
|
||||
|
||||
it('náhrada v den volna plovoucí plán neposune', async () => {
|
||||
const plan = await skipNohy();
|
||||
await confirm(plan.id, 2, day(2));
|
||||
const [pending] = await getPendingReplacements(LOGIN, day(3));
|
||||
await replaceSkippedExercise(LOGIN, day(3), pending.sourceDate, pending.planId, pending.itemIndex);
|
||||
expect((await proposal(day(4))).dayName).toBe('Den 1');
|
||||
});
|
||||
|
||||
it('odmítne náhradu po lhůtě, dvojí náhradu i náhradu odcvičeného cviku', async () => {
|
||||
await skipNohy();
|
||||
const [pending] = await getPendingReplacements(LOGIN, day(2));
|
||||
const replace = (date: string, itemIndex = pending.itemIndex) =>
|
||||
replaceSkippedExercise(LOGIN, date, pending.sourceDate, pending.planId, itemIndex);
|
||||
|
||||
await expect(replace(day(4))).rejects.toThrow(/Lhůta/);
|
||||
await expect(replace(day(1))).rejects.toThrow(/až po dni/);
|
||||
await expect(replace(day(2), 0)).rejects.toThrow(/odcvičil/);
|
||||
await replace(day(2));
|
||||
await expect(replace(day(3))).rejects.toThrow(/už je nahrazený/);
|
||||
});
|
||||
|
||||
it('smazáním náhrady se cvik vrátí mezi vynechané, přesunem se změní datum náhrady', async () => {
|
||||
await skipNohy();
|
||||
const [pending] = await getPendingReplacements(LOGIN, day(2));
|
||||
await replaceSkippedExercise(LOGIN, day(2), pending.sourceDate, pending.planId, pending.itemIndex);
|
||||
const [entry] = (await getActivityDay(LOGIN, day(2))).entries;
|
||||
|
||||
await updateActivity(LOGIN, entry.id, { ...entry, date: day(3) });
|
||||
const [plan] = await getWorkoutPlans(LOGIN, day(1));
|
||||
expect(plan.items[1].replacement).toEqual({ date: day(3), entryId: entry.id });
|
||||
|
||||
await deleteActivity(LOGIN, entry.id, day(3));
|
||||
expect((await getWorkoutPlans(LOGIN, day(1)))[0].items[1].replacement).toBeUndefined();
|
||||
expect(await getPendingReplacements(LOGIN, day(3))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('den plánu vynechaný celý zůstane zaznamenaný i po smazání jiné aktivity dne', async () => {
|
||||
const plan = await createFloating();
|
||||
await confirm(plan.id, 0, day(0), []);
|
||||
await addActivity(LOGIN, { date: day(0), name: 'Chůze', unit: ActivityUnit.KROKY, quantity: 5000, caloriesPer100Units: 4 });
|
||||
const [walk] = (await getActivityDay(LOGIN, day(0))).entries;
|
||||
await deleteActivity(LOGIN, walk.id, day(0));
|
||||
|
||||
expect(await getWorkoutPlans(LOGIN, day(0))).toHaveLength(1);
|
||||
expect(await getPendingReplacements(LOGIN, day(1))).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('přehled dne nese návrh plánu i cviky k náhradě', async () => {
|
||||
await skipNohy();
|
||||
const overview = await getDayOverview(LOGIN, day(2));
|
||||
expect(overview.plannedWorkouts).toEqual([expect.objectContaining({ dayName: 'Den 3' })]);
|
||||
expect(overview.pendingReplacements.map(item => item.item.name)).toEqual(['Nohy']);
|
||||
});
|
||||
|
||||
it('volná šablona potřebuje aspoň jeden vybraný cvik', async () => {
|
||||
const [template] = await saveWorkoutTemplate(LOGIN, undefined, 'Volná', [cvik('Kliky', 10, 50)]);
|
||||
await expect(applyWorkoutTemplate(LOGIN, template.id, START, undefined, [])).rejects.toThrow(/žádný cvik/);
|
||||
await expect(applyWorkoutTemplate(LOGIN, template.id, START, undefined, [5])).rejects.toThrow(/neodpovídá/);
|
||||
});
|
||||
|
||||
it('u plánu je den povinný', async () => {
|
||||
const plan = await createFloating();
|
||||
await expect(applyWorkoutTemplate(LOGIN, plan.id, START)).rejects.toThrow(/den plánu/);
|
||||
await expect(applyWorkoutTemplate(LOGIN, plan.id, START, 'neexistuje')).rejects.toThrow(/Den plánu/);
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,31 @@ export function listDatesInRange(from: string, to: string): string[] {
|
||||
return dates;
|
||||
}
|
||||
|
||||
/** Převede datum YYYY-MM-DD na Date v lokální časové zóně. */
|
||||
const parseIsoDate = (date: string): Date => {
|
||||
const [year, month, day] = date.split('-').map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
};
|
||||
|
||||
/**
|
||||
* Posune datum o zadaný počet dní.
|
||||
*
|
||||
* @param date datum ve formátu YYYY-MM-DD
|
||||
* @param days počet dní, záporný posouvá zpět
|
||||
*/
|
||||
export const shiftIsoDate = (date: string, days: number): string => {
|
||||
const result = parseIsoDate(date);
|
||||
result.setDate(result.getDate() + days);
|
||||
return formatDate(result);
|
||||
};
|
||||
|
||||
/** Vrátí počet dní od `from` do `to`; kladný, pokud je `to` později. */
|
||||
export const daysBetween = (from: string, to: string): number =>
|
||||
Math.round((parseIsoDate(to).getTime() - parseIsoDate(from).getTime()) / 86_400_000);
|
||||
|
||||
/** Vrátí den v týdnu podle ISO, 1 = pondělí až 7 = neděle. */
|
||||
export const getIsoWeekday = (date: string): number => parseIsoDate(date).getDay() || 7;
|
||||
|
||||
/** Nejvyšší povolený násobek porce nebo aktivity — pojistka proti překlepu. */
|
||||
const MAX_MULTIPLIER = 100;
|
||||
|
||||
|
||||
+109
-6
@@ -1,22 +1,33 @@
|
||||
import { ActivityEntry, ActivityUnit } from "../../types/gen/types.gen";
|
||||
import getStorage from "./storage";
|
||||
import { NotFoundError } from "./utils";
|
||||
|
||||
const storage = getStorage();
|
||||
|
||||
/** Kde a kterou aktivitou se vynechaný cvik nahradil. */
|
||||
export type WorkoutPlanReplacement = {
|
||||
date: string;
|
||||
entryId: string;
|
||||
};
|
||||
|
||||
/** Jeden cvik plánu — jak ho šablona založila. */
|
||||
export type WorkoutPlanItem = {
|
||||
/**
|
||||
* Aktivita, která z cviku vznikla. Podle ní se pozná, jestli cvik ve dni
|
||||
* zůstal a jestli se upravil — podle názvu by to nešlo, šablona může mít
|
||||
* stejný cvik dvakrát. Chybí jen u plánů dopočtených zpětně z dnešní podoby
|
||||
* šablony, kde se cvik ve dni nenašel.
|
||||
* stejný cvik dvakrát. Chybí u cviku, který se při potvrzení dne plánu
|
||||
* odškrtl jako vynechaný.
|
||||
*/
|
||||
entryId?: string;
|
||||
name: string;
|
||||
unit: ActivityUnit;
|
||||
quantity: number;
|
||||
sets?: number;
|
||||
caloriesPer100Units?: number;
|
||||
extraWeightGrams?: number;
|
||||
calories: number;
|
||||
/** Náhrada vynechaného cviku v některém z dalších dnů. */
|
||||
replacement?: WorkoutPlanReplacement;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -24,12 +35,23 @@ export type WorkoutPlanItem = {
|
||||
*
|
||||
* Bez něj by nešlo poznat odchylku od šablony: smazaná aktivita beze stopy zmizí
|
||||
* a šablona se mezitím mohla upravit, takže porovnání s její současnou podobou
|
||||
* by lhalo.
|
||||
* by lhalo. U plánů (fixních a plovoucích) je to zároveň záznam, že se daný den
|
||||
* plánu odcvičil — podle něj se určuje, co je na řadě dál.
|
||||
*/
|
||||
export type WorkoutPlan = {
|
||||
/**
|
||||
* Identifikátor záznamu. Starší záznamy ho nemají; {@link getWorkoutPlans}
|
||||
* jim místo něj doplní čas použití, který se už nemění.
|
||||
*/
|
||||
id: string;
|
||||
templateId: string;
|
||||
/** Název šablony v době použití — šablona se mohla mezitím přejmenovat nebo smazat. */
|
||||
templateName: string;
|
||||
/** Den plánu; u volné šablony chybí. */
|
||||
planDayId?: string;
|
||||
planDayName?: string;
|
||||
/** Lhůta pro náhradu vynechaných cviků, jak ji plán měl v den použití. */
|
||||
replaceWithinDays?: number;
|
||||
appliedAt: string;
|
||||
items: WorkoutPlanItem[];
|
||||
};
|
||||
@@ -37,6 +59,16 @@ export type WorkoutPlan = {
|
||||
/** Klíč úložiště s plány šablon jednoho dne jednoho uživatele. */
|
||||
const getKey = (login: string, date: string): string => `workoutPlans:${login}:${date}`;
|
||||
|
||||
/** Vrátí cvik s novou náhradou, nebo bez náhrady. */
|
||||
const withReplacement = (item: WorkoutPlanItem, replacement?: WorkoutPlanReplacement): WorkoutPlanItem => {
|
||||
const result = { ...item };
|
||||
delete result.replacement;
|
||||
return replacement ? { ...result, replacement } : result;
|
||||
};
|
||||
|
||||
/** Doplní záznamu identifikátor, pokud ho ze starší verze nemá. */
|
||||
const withId = (plan: WorkoutPlan): WorkoutPlan => ({ ...plan, id: plan.id ?? plan.appliedAt });
|
||||
|
||||
/**
|
||||
* Vrátí plány šablon použitých v daném dni.
|
||||
*
|
||||
@@ -44,7 +76,23 @@ const getKey = (login: string, date: string): string => `workoutPlans:${login}:$
|
||||
* @param date datum ve formátu YYYY-MM-DD
|
||||
*/
|
||||
export const getWorkoutPlans = async (login: string, date: string): Promise<WorkoutPlan[]> =>
|
||||
await storage.getData<WorkoutPlan[]>(getKey(login, date)) ?? [];
|
||||
(await storage.getData<WorkoutPlan[]>(getKey(login, date)) ?? []).map(withId);
|
||||
|
||||
/**
|
||||
* Vrátí data všech dnů, ve kterých má uživatel nějaký plán šablony, vzestupně.
|
||||
*
|
||||
* Výpočet plovoucího plánu prochází historii od začátku platnosti plánu —
|
||||
* dny bez záznamu se tak nemusí načítat jeden po druhém.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
*/
|
||||
export const listWorkoutPlanDates = async (login: string): Promise<string[]> => {
|
||||
const prefix = `workoutPlans:${login}:`;
|
||||
return (await storage.listKeys(prefix))
|
||||
.filter(key => key.startsWith(prefix))
|
||||
.map(key => key.substring(prefix.length))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
};
|
||||
|
||||
/**
|
||||
* Zapamatuje si plán použité šablony.
|
||||
@@ -57,6 +105,56 @@ export const recordWorkoutPlan = async (login: string, date: string, plan: Worko
|
||||
await storage.updateData<WorkoutPlan[]>(getKey(login, date), current => [...(current ?? []), plan]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Zapíše k cviku záznamu, čím se nahradil, nebo náhradu zruší.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param date den záznamu, ve kterém se cvik vynechal
|
||||
* @param planId identifikátor záznamu
|
||||
* @param itemIndex pořadí cviku v záznamu
|
||||
* @param replacement náhrada, nebo nic pro její zrušení
|
||||
*/
|
||||
export const setPlanItemReplacement = async (
|
||||
login: string, date: string, planId: string, itemIndex: number, replacement?: WorkoutPlanReplacement,
|
||||
): Promise<void> => {
|
||||
let found = false;
|
||||
await storage.updateData<WorkoutPlan[]>(getKey(login, date), current => (current ?? []).map(plan => {
|
||||
if (withId(plan).id !== planId || !plan.items[itemIndex]) return plan;
|
||||
found = true;
|
||||
return {
|
||||
...plan,
|
||||
items: plan.items.map((item, index) => (index === itemIndex ? withReplacement(item, replacement) : item)),
|
||||
};
|
||||
}));
|
||||
if (!found) {
|
||||
throw new NotFoundError("Záznam o použití plánu nebyl nalezen");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Upraví vazbu na náhradu, když se náhradní aktivita smaže nebo přesune.
|
||||
*
|
||||
* Smazáním náhrady se cvik v původním dni znovu stane vynechaným (a v lhůtě
|
||||
* se zase nabídne k náhradě); přesunem se jen přepíše datum náhrady.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param sourceDate den, ve kterém se nahrazený cvik vynechal
|
||||
* @param entryId náhradní aktivita
|
||||
* @param newDate nové datum náhradní aktivity, nebo nic, pokud se smazala
|
||||
*/
|
||||
export const updateReplacementLink = async (
|
||||
login: string, sourceDate: string, entryId: string, newDate?: string,
|
||||
): Promise<void> => {
|
||||
const key = getKey(login, sourceDate);
|
||||
if (!await storage.hasData(key)) return;
|
||||
await storage.updateData<WorkoutPlan[]>(key, current => (current ?? []).map(plan => ({
|
||||
...plan,
|
||||
items: plan.items.map(item => (item.replacement?.entryId === entryId
|
||||
? withReplacement(item, newDate ? { entryId, date: newDate } : undefined)
|
||||
: item)),
|
||||
})));
|
||||
};
|
||||
|
||||
/**
|
||||
* Zahodí plány šablon, ze kterých ve dni nezůstala žádná aktivita.
|
||||
*
|
||||
@@ -64,6 +162,10 @@ export const recordWorkoutPlan = async (login: string, date: string, plan: Worko
|
||||
* neodcvičený trénink — jinak by omylem použitá šablona navždy strašila ve
|
||||
* statistikách jako odchylka. Vynechání jen části cviků se jako odchylka počítá.
|
||||
*
|
||||
* Zůstává záznam dne plánu, který se potvrdil celý jako vynechaný (žádný cvik
|
||||
* z něj aktivitu nezaložil), a záznam, jehož některý cvik se už nahradil —
|
||||
* na ten odkazuje náhradní aktivita.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param date datum dne
|
||||
* @param remaining aktivity, které ve dni zůstaly
|
||||
@@ -72,6 +174,7 @@ export const dropOrphanedPlans = async (login: string, date: string, remaining:
|
||||
const key = getKey(login, date);
|
||||
if (!await storage.hasData(key)) return;
|
||||
const ids = new Set(remaining.map(entry => entry.id));
|
||||
await storage.updateData<WorkoutPlan[]>(key,
|
||||
current => (current ?? []).filter(plan => plan.items.some(item => item.entryId && ids.has(item.entryId))));
|
||||
await storage.updateData<WorkoutPlan[]>(key, current => (current ?? []).filter(plan =>
|
||||
plan.items.some(item => (item.entryId && ids.has(item.entryId)) || item.replacement)
|
||||
|| plan.items.every(item => !item.entryId)));
|
||||
};
|
||||
@@ -0,0 +1,264 @@
|
||||
import {
|
||||
PendingReplacement, PlannedWorkout, PlannedWorkoutStatus, WorkoutTemplateType,
|
||||
} from "../../types/gen/types.gen";
|
||||
import { addActivities, getActivityDay } from "./activities";
|
||||
import {
|
||||
MAX_REPLACE_DAYS, StoredPlanDay, StoredTemplate, createActivityFromExercise, expandExercises, getTemplateType,
|
||||
isPlan, loadTemplates, toPlannedItem,
|
||||
} from "./workouts";
|
||||
import { WorkoutPlan, getWorkoutPlans, listWorkoutPlanDates, setPlanItemReplacement } from "./workoutPlans";
|
||||
import {
|
||||
BadRequestError, NotFoundError, daysBetween, formatDate, getIsoWeekday, getToday, isValidIsoDate,
|
||||
listDatesInRange, shiftIsoDate,
|
||||
} from "./utils";
|
||||
|
||||
/**
|
||||
* Kde se plovoucí plán nachází.
|
||||
*
|
||||
* `position` je pořadí dne, který je na řadě; hodnota rovná počtu dnů znamená,
|
||||
* že je na řadě den volna na konci cyklu. `restUsed` říká, že volno tohoto
|
||||
* cyklu už proběhlo dřív (posunuté) — po posledním dni pak cyklus začne hned.
|
||||
*/
|
||||
type CycleState = { position: number, restUsed: boolean };
|
||||
|
||||
/**
|
||||
* Posune plovoucí plán po odcvičeném dni sekvence.
|
||||
*
|
||||
* Návrat na dřívější den (třeba začátek cyklu místo volna) zahájí nový cyklus.
|
||||
* Pokud cyklus skončil a volno už v něm bylo, další den začne rovnou od začátku.
|
||||
*/
|
||||
const advanceDone = (state: CycleState, dayIndex: number, dayCount: number): CycleState => {
|
||||
const restUsed = dayIndex < state.position ? false : state.restUsed;
|
||||
const position = dayIndex + 1;
|
||||
return position === dayCount && restUsed ? { position: 0, restUsed: false } : { position, restUsed };
|
||||
};
|
||||
|
||||
/**
|
||||
* Posune plovoucí plán po dni bez tréninku.
|
||||
*
|
||||
* Když je na řadě volno, proběhlo podle plánu. Uprostřed rozběhnutého cyklu
|
||||
* se den bez tréninku bere jako volno posunuté dopředu, jednou za cyklus.
|
||||
* Jinak (cyklus ještě nezačal, nebo volno už bylo) se trénink jen odsouvá —
|
||||
* plovoucí plán na kalendář navázaný není.
|
||||
*/
|
||||
const advanceGap = (state: CycleState, dayCount: number): CycleState => {
|
||||
if (state.position === dayCount) return { position: 0, restUsed: false };
|
||||
if (state.position > 0 && !state.restUsed) return { ...state, restUsed: true };
|
||||
return state;
|
||||
};
|
||||
|
||||
/** Záznamy použití daného plánu v jednom dni (jen potvrzené dny plánu). */
|
||||
const getPlanRecords = (records: WorkoutPlan[] | undefined, templateId: string): WorkoutPlan[] =>
|
||||
(records ?? []).filter(record => record.templateId === templateId && !!record.planDayId);
|
||||
|
||||
/** Navržený den plánu s rozbalenými cviky. */
|
||||
const proposeDay = (
|
||||
template: StoredTemplate, day: StoredPlanDay, byId: Map<string, StoredTemplate>,
|
||||
): Pick<PlannedWorkout, 'status' | 'dayId' | 'dayName' | 'items'> => ({
|
||||
status: PlannedWorkoutStatus.NAVRH,
|
||||
dayId: day.id,
|
||||
dayName: day.name,
|
||||
items: expandExercises(day.items, byId).map(toPlannedItem),
|
||||
});
|
||||
|
||||
/**
|
||||
* Spočítá, kde je plovoucí plán na začátku zadaného dne.
|
||||
*
|
||||
* Prochází dny od začátku platnosti plánu. Odcvičené dny posouvají sekvenci,
|
||||
* dny bez tréninku se vyhodnotí jako volno, nebo odsunutí. Budoucí dny (od
|
||||
* dneška dál) bez záznamu se berou, jako by se navržený trénink odcvičil —
|
||||
* přehled zítřka tak počítá s tím, že se dnešek splní.
|
||||
*/
|
||||
const getCycleState = (
|
||||
template: StoredTemplate, date: string, recordsByDate: Map<string, WorkoutPlan[]>, today: string,
|
||||
): CycleState => {
|
||||
const days = template.days ?? [];
|
||||
let state: CycleState = { position: 0, restUsed: false };
|
||||
if (template.startDate! >= date) return state;
|
||||
|
||||
for (const current of listDatesInRange(template.startDate!, shiftIsoDate(date, -1))) {
|
||||
const records = getPlanRecords(recordsByDate.get(current), template.id);
|
||||
if (records.length) {
|
||||
for (const record of records) {
|
||||
const index = days.findIndex(day => day.id === record.planDayId);
|
||||
// Den, který se mezitím z plánu odebral, sekvenci neposouvá
|
||||
if (index >= 0) state = advanceDone(state, index, days.length);
|
||||
}
|
||||
} else if (current >= today && state.position < days.length) {
|
||||
state = advanceDone(state, state.position, days.length);
|
||||
} else {
|
||||
state = advanceGap(state, days.length);
|
||||
}
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
/** Sestaví návrh jednoho plánu na zadaný den. */
|
||||
const planWorkout = (
|
||||
template: StoredTemplate,
|
||||
date: string,
|
||||
recordsByDate: Map<string, WorkoutPlan[]>,
|
||||
today: string,
|
||||
byId: Map<string, StoredTemplate>,
|
||||
): PlannedWorkout => {
|
||||
const type = getTemplateType(template);
|
||||
const base = { templateId: template.id, templateName: template.name, type };
|
||||
const days = template.days ?? [];
|
||||
|
||||
const done = getPlanRecords(recordsByDate.get(date), template.id);
|
||||
if (done.length) {
|
||||
return {
|
||||
...base,
|
||||
status: PlannedWorkoutStatus.SPLNENO,
|
||||
dayId: done[0].planDayId,
|
||||
dayName: done.map(record => record.planDayName).filter(name => !!name).join(', '),
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (type === WorkoutTemplateType.FIXNI) {
|
||||
const day = days.find(item => item.weekday === getIsoWeekday(date));
|
||||
return day
|
||||
? { ...base, ...proposeDay(template, day, byId) }
|
||||
: { ...base, status: PlannedWorkoutStatus.VOLNO, items: [] };
|
||||
}
|
||||
|
||||
const state = getCycleState(template, date, recordsByDate, today);
|
||||
if (state.position >= days.length) {
|
||||
return { ...base, status: PlannedWorkoutStatus.VOLNO, items: [] };
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
...proposeDay(template, days[state.position], byId),
|
||||
restAvailable: state.position > 0 && !state.restUsed,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Vrátí, co na zadaný den navrhují plány (fixní a plovoucí šablony), které
|
||||
* už platí. U dne, ve kterém se plán potvrdil, vrátí jeho splnění.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param date datum ve formátu YYYY-MM-DD
|
||||
*/
|
||||
export const getPlannedWorkouts = async (login: string, date: string): Promise<PlannedWorkout[]> => {
|
||||
const all = await loadTemplates(login);
|
||||
const plans = all
|
||||
.filter(template => isPlan(template) && template.startDate! <= date)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
|
||||
if (!plans.length) return [];
|
||||
|
||||
const earliest = plans.reduce((min, plan) => (plan.startDate! < min ? plan.startDate! : min), date);
|
||||
const recordDates = (await listWorkoutPlanDates(login)).filter(day => day >= earliest && day <= date);
|
||||
const recordsByDate = new Map(await Promise.all(
|
||||
recordDates.map(async day => [day, await getWorkoutPlans(login, day)] as const)));
|
||||
const byId = new Map(all.map(template => [template.id, template]));
|
||||
const today = formatDate(getToday());
|
||||
return plans.map(plan => planWorkout(plan, date, recordsByDate, today, byId));
|
||||
};
|
||||
|
||||
/**
|
||||
* Vrátí cviky vynechané v předchozích dnech, které jde v zadaný den ještě nahradit.
|
||||
*
|
||||
* Vynechaný je cvik dne plánu, který se při potvrzení odškrtl, nebo jehož
|
||||
* aktivita se ze dne smazala, a ještě se nenahradil. Lhůta se bere ze
|
||||
* záznamu — jaká platila v den, kdy se cvik vynechal. Nejstarší první, ty mají
|
||||
* lhůtu nejkratší.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param date datum ve formátu YYYY-MM-DD
|
||||
*/
|
||||
export const getPendingReplacements = async (login: string, date: string): Promise<PendingReplacement[]> => {
|
||||
const from = shiftIsoDate(date, -MAX_REPLACE_DAYS);
|
||||
const recordDates = (await listWorkoutPlanDates(login)).filter(day => day >= from && day < date);
|
||||
const result: PendingReplacement[] = [];
|
||||
|
||||
for (const sourceDate of recordDates) {
|
||||
const offset = daysBetween(sourceDate, date);
|
||||
const plans = (await getWorkoutPlans(login, sourceDate))
|
||||
.filter(plan => plan.replaceWithinDays && offset <= plan.replaceWithinDays);
|
||||
if (!plans.length) continue;
|
||||
const entryIds = new Set((await getActivityDay(login, sourceDate)).entries.map(entry => entry.id));
|
||||
|
||||
for (const plan of plans) {
|
||||
plan.items.forEach((item, itemIndex) => {
|
||||
if (item.replacement || (item.entryId && entryIds.has(item.entryId))) return;
|
||||
result.push({
|
||||
sourceDate,
|
||||
planId: plan.id,
|
||||
itemIndex,
|
||||
templateId: plan.templateId,
|
||||
templateName: plan.templateName,
|
||||
...(plan.planDayName ? { dayName: plan.planDayName } : {}),
|
||||
deadline: shiftIsoDate(sourceDate, plan.replaceWithinDays!),
|
||||
item: {
|
||||
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,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Nahradí cvik vynechaný v předchozím dni plánu.
|
||||
*
|
||||
* Do zvoleného dne založí aktivitu podle cviku z plánu (s vazbou na plán
|
||||
* a na nahrazený den) a v záznamu původního dne cvik označí jako nahrazený.
|
||||
* Ve statistikách se pak u původního dne místo vynechání ukáže odkaz na náhradu.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param date den, do kterého se náhrada založí
|
||||
* @param sourceDate den, ve kterém se cvik vynechal
|
||||
* @param planId identifikátor záznamu o použití plánu
|
||||
* @param itemIndex pořadí cviku v záznamu
|
||||
* @returns datum, do kterého se náhrada založila
|
||||
*/
|
||||
export const replaceSkippedExercise = async (
|
||||
login: string, date: unknown, sourceDate: unknown, planId: unknown, itemIndex: unknown,
|
||||
): Promise<string> => {
|
||||
if (!isValidIsoDate(date) || !isValidIsoDate(sourceDate)) {
|
||||
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
|
||||
}
|
||||
if (typeof planId !== 'string' || !planId.length || !Number.isInteger(itemIndex)) {
|
||||
throw new BadRequestError("Nebyl předán nahrazovaný cvik");
|
||||
}
|
||||
const offset = daysBetween(sourceDate, date);
|
||||
if (offset <= 0) {
|
||||
throw new BadRequestError("Náhrada musí být až po dni, ve kterém se cvik vynechal");
|
||||
}
|
||||
|
||||
const plan = (await getWorkoutPlans(login, sourceDate)).find(item => item.id === planId);
|
||||
const item = plan?.items[itemIndex as number];
|
||||
if (!plan || !item) {
|
||||
throw new NotFoundError("Vynechaný cvik nebyl nalezen");
|
||||
}
|
||||
if (!plan.replaceWithinDays) {
|
||||
throw new BadRequestError("Tento plán náhrady vynechaných cviků nenabízí");
|
||||
}
|
||||
if (offset > plan.replaceWithinDays) {
|
||||
throw new BadRequestError(`Lhůta pro náhradu (${plan.replaceWithinDays} dní) už uplynula`);
|
||||
}
|
||||
if (item.replacement) {
|
||||
throw new BadRequestError("Cvik už je nahrazený");
|
||||
}
|
||||
if (item.entryId && (await getActivityDay(login, sourceDate)).entries.some(entry => entry.id === item.entryId)) {
|
||||
throw new BadRequestError("Cvik se v původním dni odcvičil, není co nahrazovat");
|
||||
}
|
||||
|
||||
const entry = createActivityFromExercise(item, date, new Date().toISOString(), {
|
||||
templateId: plan.templateId,
|
||||
...(plan.planDayId ? { planDayId: plan.planDayId } : {}),
|
||||
replacesDate: sourceDate,
|
||||
});
|
||||
await addActivities(login, date, [entry]);
|
||||
await setPlanItemReplacement(login, sourceDate, plan.id, itemIndex as number, { date, entryId: entry.id });
|
||||
return date;
|
||||
};
|
||||
+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