f1tness_parser/frontend/js/exercises.js

189 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { alert, api, approachChips, formatDate, h, num, perArmBadge, signed, SOLO, tip } from './common.js';
import { editButton } from './edit-dialog.js';
import { createTable } from './table.js';
const HINTS = {
recordE1rm: 'e1RM — расчётный максимум на один повтор по лучшему подходу тренировки (формула Эпли: вес × (1 + повторы / 30)). Рекорд — наибольшее значение за всю историю.',
lastE1rm: 'e1RM лучшего подхода последней тренировки. Ниже — изменение относительно первой тренировки с этим упражнением.',
recordVolume: 'Тоннаж — суммарная работа за тренировку: сумма вес × повторы по всем подходам с весом. Рекорд — тренировка с наибольшим тоннажем.',
maxWeight: 'Самый тяжёлый вес в одном подходе за всю историю, без учёта повторов.',
bestReps: 'Наибольшее число повторов в одном подходе за всю историю.',
lastBestReps: 'Наибольшее число повторов в одном подходе последней тренировки. Ниже — изменение относительно первой тренировки.',
recordTotalReps: 'Наибольшая сумма повторов по всем подходам за одну тренировку.',
e1rm: 'Расчётный максимум на один повтор по лучшему подходу тренировки (формула Эпли: вес × (1 + повторы / 30)). Подходы без веса не учитываются.',
volume: 'Тоннаж: сумма вес × повторы по подходам с весом за тренировку.',
};
const input = document.getElementById('exercise-input');
const container = document.getElementById('exercise');
const query = (new URLSearchParams(location.search).get('name') || '').trim();
let names = [];
try {
names = await api('/exercises/');
} catch (error) {
container.replaceChildren(alert('danger', `Не удалось загрузить упражнения: ${error.message}`));
}
document.getElementById('exercise-names').replaceChildren(...names.map((item) => h('option', { value: item.name })));
input.value = query;
if (!query) {
input.focus();
}
// Open the history right after a suggestion is picked from the list. Typing, pasting and
// deleting have their own inputType, so a half-typed name that equals a shorter exercise
// name doesn't submit early; Enter/button always work.
const knownNames = new Set(names.map((item) => item.name));
input.addEventListener('input', (event) => {
const picked = !event.inputType || event.inputType === 'insertReplacementText';
if (picked && knownNames.has(input.value)) {
input.form.submit();
}
});
if (query) {
await showProgress();
} else if (!names.length && !container.children.length) {
container.replaceChildren(alert('info',
'В базе пока нет тренировок. Загрузи их из заметок командой:',
h('br'), h('code', {}, 'docker compose exec fastapi-app python -m app.cli import')));
}
async function showProgress() {
let progress;
try {
progress = await api(`/exercises/progress/?name=${encodeURIComponent(query)}`);
} catch (error) {
if (error.status !== 404) {
container.replaceChildren(alert('danger', `Не удалось загрузить историю: ${error.message}`));
return;
}
// Nothing matched exactly: offer the names containing the typed text
const suggestions = names.filter((item) => item.name.toLowerCase().includes(query.toLowerCase()));
container.replaceChildren(
alert('warning', `Упражнение «${query}» не найдено. ${suggestions.length ? 'Возможно, вы искали:' : ''}`),
...(suggestions.length ? [h('div', { class: 'list-group' }, ...suggestions.map((item) =>
h('a', { class: 'list-group-item list-group-item-action d-flex justify-content-between align-items-center', href: `exercises.html?name=${encodeURIComponent(item.name)}` },
item.name, h('span', { class: 'badge text-bg-secondary rounded-pill' }, `${item.count}`))))] : []));
return;
}
input.value = progress.name;
container.replaceChildren(...progressView(progress));
if (progress.rows.length) {
drawChart(progress);
}
}
function progressView(progress) {
const { rows, weighted } = progress;
const first = rows[rows.length - 1];
const last = rows[0];
const change = (unit) => h('span', {},
h('span', { class: progress.primary_change > 0 ? 'text-success' : progress.primary_change < 0 ? 'text-danger' : 'text-muted' },
`${signed(progress.primary_change)} ${unit}`,
progress.primary_change_percent !== null ? ` (${progress.primary_change_percent >= 0 ? '+' : ''}${progress.primary_change_percent.toFixed(1)}%)` : ''),
' к первой тренировке');
const card = (title, value, note, hint) => h('div', { class: 'col' },
h('div', { class: 'card h-100 mb-0' }, h('div', { class: 'card-body' },
h('div', { class: 'text-muted small' }, tip(title, hint)),
h('div', { class: 'fs-4 fw-semibold' }, value),
h('div', { class: 'text-muted small' }, note))));
const cards = weighted ? [
card('Рекорд e1RM', `${num(progress.best_primary.value)} кг`, formatDate(progress.best_primary.date), HINTS.recordE1rm),
card('Последний e1RM', `${num(progress.last_primary)} кг`, rows.length > 1 ? change('кг') : '', HINTS.lastE1rm),
card('Рекорд тоннажа', `${num(progress.best_secondary.value)} кг`, formatDate(progress.best_secondary.date), HINTS.recordVolume),
card('Максимальный вес', `${num(progress.max_weight)} кг`, '', HINTS.maxWeight),
] : [
card('Лучший подход', `${num(progress.best_primary.value)} повт.`, formatDate(progress.best_primary.date), HINTS.bestReps),
card('Лучший подход в последний раз', `${num(progress.last_primary)} повт.`, rows.length > 1 ? change('повт.') : '', HINTS.lastBestReps),
card('Рекорд суммы повторов', num(progress.best_secondary.value), formatDate(progress.best_secondary.date), HINTS.recordTotalReps),
];
const columns = [
{ title: 'Дата', className: 'text-nowrap date', sort: (row) => row.date, cell: (row) => formatDate(row.date) },
{ title: 'Тренер', className: 'text-nowrap', sort: (row) => row.trainer || SOLO, cell: (row) => row.trainer || SOLO },
{ title: 'Подходы (вес × повторения)', cell: (row) => h('span', {}, ...approachChips(row.approaches), row.splitted_weight ? perArmBadge() : null) },
...(weighted ? [
{ title: 'Макс. вес', className: 'text-nowrap text-end', sort: (row) => row.metrics.max_weight, cell: (row) => row.metrics.max_weight ? `${num(row.metrics.max_weight)} кг` : '—' },
{ title: tip('e1RM', HINTS.e1rm), className: 'text-nowrap text-end', sort: (row) => row.metrics.e1rm || 0, cell: (row) => row.metrics.e1rm ? `${num(row.metrics.e1rm)} кг` : '—' },
{ title: tip('Тоннаж', HINTS.volume), className: 'text-nowrap text-end', sort: (row) => row.metrics.volume, cell: (row) => row.metrics.volume ? `${num(row.metrics.volume)} кг` : '—' },
] : [
{ title: 'Лучший подход', className: 'text-nowrap text-end', sort: (row) => row.metrics.best_reps, cell: (row) => `${row.metrics.best_reps}` },
{ title: 'Всего повторов', className: 'text-nowrap text-end', sort: (row) => row.metrics.total_reps, cell: (row) => `${row.metrics.total_reps}` },
]),
{ title: '', cell: (row) => row.training_id ? editButton(row.training_id) : null },
];
return [
h('h2', { class: 'h4' }, progress.name),
h('p', { class: 'text-muted' },
'Тренировок: ', h('strong', {}, `${rows.length}`),
', первая: ', h('strong', {}, formatDate(first.date)),
', последняя: ', h('strong', {}, formatDate(last.date))),
h('div', { class: 'row row-cols-1 row-cols-sm-2 row-cols-xl-4 g-3 mb-4' }, ...cards),
h('div', { class: 'card mb-2' }, h('div', { class: 'card-body' },
h('div', { style: 'height: 320px' }, h('canvas', { id: 'progress-chart' })))),
h('p', { class: 'text-muted small mb-4' }, weighted
? 'e1RM — расчётный максимум на один повтор по формуле Эпли: вес × (1 + повторы / 30), берётся лучший подход тренировки; подходы без веса не учитываются. Тоннаж — сумма вес × повторы по подходам с весом.'
: 'Упражнение выполняется без дополнительного веса, поэтому прогресс считается по повторам.'),
createTable({ id: 'exercise-history-table', columns, rows }),
];
}
/** Two series over time, each on its own y axis. The x axis is linear so gaps between
* trainings are proportional to the real time. */
function drawChart(progress) {
const series = progress.weighted
? [['e1RM, кг', (row) => row.metrics.e1rm], ['Тоннаж, кг', (row) => row.metrics.volume || null]]
: [['Лучший подход, повторы', (row) => row.metrics.best_reps], ['Всего повторов за тренировку', (row) => row.metrics.total_reps]];
const chronological = [...progress.rows].reverse();
const datasets = series.map(([label, metric]) => ({
label,
points: chronological
.map((row) => ({ x: Date.parse(row.date), y: metric(row) })) // "YYYY-MM-DD" parses as UTC
.filter((point) => point.y !== null && point.y !== undefined)
.map((point) => ({ x: point.x, y: Math.round(point.y * 10) / 10 })),
}));
const colors = ['#0d6efd', '#fd7e14'];
const formatTick = (timestamp, options) =>
new Date(timestamp).toLocaleDateString('ru-RU', { ...options, timeZone: 'UTC' });
const scales = {
x: { type: 'linear', ticks: { maxTicksLimit: 8, callback: (value) => formatTick(value, { month: 'short', year: 'numeric' }) } },
};
datasets.forEach((item, index) => {
scales['y' + index] = {
position: index === 0 ? 'left' : 'right',
grid: { drawOnChartArea: index === 0 },
title: { display: true, text: item.label, color: colors[index] },
};
});
new Chart(document.getElementById('progress-chart'), {
type: 'line',
data: {
datasets: datasets.map((item, index) => ({
label: item.label,
data: item.points,
yAxisID: 'y' + index,
borderColor: colors[index],
backgroundColor: colors[index],
borderWidth: 2,
pointRadius: 3,
tension: 0.2,
})),
},
options: {
maintainAspectRatio: false,
interaction: { mode: 'nearest', axis: 'x', intersect: false },
scales,
plugins: { tooltip: { callbacks: { title: (items) => formatTick(items[0].parsed.x, {}) } } },
},
});
}