191 lines
12 KiB
JavaScript
191 lines
12 KiB
JavaScript
import './page.js';
|
||
import { alert, api, approachChips, formatDate, h, num, perArmBadge, signed, SOLO, tip } from './common.js';
|
||
import { editButton } from './edit-dialog.js';
|
||
import { canEditTraining } from './page.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 && canEditTraining(row.trainee_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, {}) } } },
|
||
},
|
||
});
|
||
}
|