chore: graf db

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

No files matched your search

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