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, {}) } } }, }, }); }