massive frontend refactoring (works good)

This commit is contained in:
2026-09-30 01:54:17 +03:00
parent 9203884f53
commit 93ca76c6e1
36 changed files with 1608 additions and 2069 deletions
+51
View File
@@ -0,0 +1,51 @@
import { loadState, saveState, state, updateSyncStatus } from './state.js';
import { renderMainScreen } from './render/main-screen.js';
import { sync } from './sync.js';
// Подключаем logic-модули (они регистрируются в window)
import './logic/workout.js';
import './logic/exercise.js';
import './logic/set.js';
import './logic/cardio.js';
import './logic/metrics.js';
import './logic/settings.js';
// Подключаем модалки
import './modals/modal-base.js';
import './modals/exercise-modal.js';
import './modals/set-modal.js';
import './modals/cardio-modal.js';
import './modals/metrics-modal.js';
import './modals/edit-exercise-modal.js';
import './modals/edit-workout-date-modal.js';
function renderHeader() {
document.getElementById('app-header').innerHTML = `
<div>
<h2>Gym Tracker</h2>
<div id="sync-status" class="sync-status">● Локально</div>
</div>
<div class="header-actions">
<button class="icon-btn header-btn" onclick="ML.showBodyMetrics()" title="Замеры">📏</button>
<button class="icon-btn header-btn" onclick="SettingsLogic.show()" title="Настройки">⚙️</button>
<button class="btn btn-small btn-secondary" onclick="AppSync.sync()" title="Синхронизация">🔄</button>
</div>
`;
}
function init() {
loadState();
renderHeader();
renderMainScreen();
updateSyncStatus();
}
// Глобальные хелперы для inline-обработчиков
window.App = {
getWorkout: (id) => state.workouts.find(w => w.id === id),
saveAndRender: () => { saveState(); /* перерисовка не нужна — select не требует */ },
};
window.AppSync = { sync };
window.App.backToMain = () => renderMainScreen();
document.addEventListener('DOMContentLoaded', init);
+35
View File
@@ -0,0 +1,35 @@
import { state, saveState, genId } from '../state.js';
import { renderMainScreen } from '../render/main-screen.js';
import { showCardioDialog } from '../modals/cardio-modal.js';
export function addCardio(workoutId) {
showCardioDialog(null, (cardio) => {
if (!cardio) return;
const w = state.workouts.find(x => x.id === workoutId);
cardio.id = genId();
w.cardio.push(cardio);
saveState();
renderMainScreen();
});
}
export function editCardio(workoutId, cardioId) {
const w = state.workouts.find(x => x.id === workoutId);
const cardio = w.cardio.find(c => c.id === cardioId);
showCardioDialog(cardio, (newData) => {
if (!newData) return;
Object.assign(cardio, newData);
saveState();
renderMainScreen();
});
}
export function deleteCardio(workoutId, cardioId) {
if (!confirm('Удалить кардио?')) return;
const w = state.workouts.find(x => x.id === workoutId);
w.cardio = w.cardio.filter(c => c.id !== cardioId);
saveState();
renderMainScreen();
}
window.CL = { addCardio, editCardio, deleteCardio };
+62
View File
@@ -0,0 +1,62 @@
import { state, saveState, genId } from '../state.js';
import { renderMainScreen } from '../render/main-screen.js';
import { showExerciseDialog } from '../modals/exercise-modal.js';
export function addExercise(workoutId) {
const w = state.workouts.find(x => x.id === workoutId);
showExerciseDialog(w.type, (name) => {
if (!name) return;
const ex = { id: genId(), name, sets: [] };
w.exercises.push(ex);
addToRef(name, w.type);
state.expandedExerciseId = ex.id;
saveState();
renderMainScreen();
});
}
export function addToRef(name, workoutType = '') {
const normalizedName = name.trim().toLowerCase();
const exists = state.exerciseRef.find(
e => e.name.trim().toLowerCase() === normalizedName
);
if (!exists) {
state.exerciseRef.push({
id: genId(),
name: name.trim(),
workoutType,
});
} else if (!exists.workoutType && workoutType) {
exists.workoutType = workoutType;
}
}
export function toggleExercise(exerciseId) {
state.expandedExerciseId =
state.expandedExerciseId === exerciseId ? null : exerciseId;
renderMainScreen();
}
export function deleteExercise(workoutId, exerciseId) {
if (!confirm('Удалить упражнение?')) return;
const w = state.workouts.find(x => x.id === workoutId);
w.exercises = w.exercises.filter(e => e.id !== exerciseId);
saveState();
renderMainScreen();
}
export function getExerciseHistory(exerciseName) {
const normalizedName = exerciseName.trim().toLowerCase();
const history = [];
for (const w of state.workouts) {
for (const ex of w.exercises) {
if (ex.name.trim().toLowerCase() === normalizedName && ex.sets.length > 0) {
history.push({ date: w.startTime, sets: ex.sets });
}
}
}
history.sort((a, b) => new Date(b.date) - new Date(a.date));
return history.slice(0, 3);
}
window.EL = { addExercise, toggleExercise, deleteExercise };
+47
View File
@@ -0,0 +1,47 @@
import { state, saveState, genId } from '../state.js';
import { renderMainScreen } from '../render/main-screen.js';
import { showBodyMetricsDialog } from '../modals/metrics-modal.js';
export function showBodyMetrics() {
showBodyMetricsDialog(state.bodyMetrics, (newMetric) => {
if (!newMetric) return;
newMetric.id = genId();
state.bodyMetrics.unshift(newMetric);
state.bodyMetrics.sort((a, b) => new Date(b.date) - new Date(a.date));
saveState();
renderMainScreen();
alert('Замеры сохранены!');
});
}
export function editBodyMetric(metricId) {
const metric = state.bodyMetrics.find(m => m.id === metricId);
if (!metric) return;
showBodyMetricsDialog(state.bodyMetrics, (updatedData) => {
if (!updatedData) return;
Object.assign(metric, updatedData);
state.bodyMetrics.sort((a, b) => new Date(b.date) - new Date(a.date));
saveState();
// Если сейчас на экране настроек — перерисуем список
const list = document.getElementById('body-metrics-list');
if (list && typeof window.SettingsLogic !== 'undefined') {
window.SettingsLogic._renderBodyMetricsList();
} else {
renderMainScreen();
}
}, metric);
}
export function deleteMetric(id) {
if (!confirm('Удалить замер?')) return;
state.bodyMetrics = state.bodyMetrics.filter(m => m.id !== id);
saveState();
const list = document.getElementById('body-metrics-list');
if (list && typeof window.SettingsLogic !== 'undefined') {
window.SettingsLogic._renderBodyMetricsList();
} else {
renderMainScreen();
}
}
window.ML = { showBodyMetrics, editBodyMetric, deleteMetric };
+45
View File
@@ -0,0 +1,45 @@
import { state, saveState, genId } from '../state.js';
import { renderMainScreen } from '../render/main-screen.js';
import { showSetDialog } from '../modals/set-modal.js';
import { getExerciseHistory } from './exercise.js';
export function addSet(workoutId, exerciseId) {
const w = state.workouts.find(x => x.id === workoutId);
const ex = w.exercises.find(e => e.id === exerciseId);
const lastSet = ex.sets[ex.sets.length - 1];
const history = getExerciseHistory(ex.name);
showSetDialog(lastSet, history, (setData) => {
if (!setData) return;
setData.id = genId();
setData.timestamp = new Date().toISOString();
ex.sets.push(setData);
saveState();
renderMainScreen();
});
}
export function editSet(workoutId, exerciseId, setId) {
const w = state.workouts.find(x => x.id === workoutId);
const ex = w.exercises.find(e => e.id === exerciseId);
const set = ex.sets.find(s => s.id === setId);
const history = getExerciseHistory(ex.name);
showSetDialog(set, history, (setData) => {
if (!setData) return;
Object.assign(set, setData);
saveState();
renderMainScreen();
});
}
export function deleteSet(workoutId, exerciseId, setId) {
if (!confirm('Удалить подход?')) return;
const w = state.workouts.find(x => x.id === workoutId);
const ex = w.exercises.find(e => e.id === exerciseId);
ex.sets = ex.sets.filter(s => s.id !== setId);
saveState();
renderMainScreen();
}
window.SL = { addSet, editSet, deleteSet };
+185
View File
@@ -0,0 +1,185 @@
import { state, saveState } from '../state.js';
import { showEditExerciseDialog } from '../modals/edit-exercise-modal.js';
export function showSettings() {
const app = document.getElementById('app');
app.innerHTML = '';
const screen = document.createElement('div');
screen.className = 'settings-screen';
screen.innerHTML = `
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
<h2>⚙️ Настройки</h2>
<button class="btn btn-small btn-secondary" onclick="App.backToMain()">← Назад</button>
</div>
<div class="settings-section">
<h3>📋 Справочник упражнений</h3>
<p class="text-muted text-small mb-10">Управляйте списком упражнений и привязывайте их к типам тренировок.</p>
<div class="field-group">
<div style="display:flex; gap:6px;">
<input type="text" id="new-ref-exercise" placeholder="Новое упражнение...">
<select id="new-ref-type" style="width: auto; min-width: 120px;">
<option value="">тип...</option>
<option value="грудь-руки">грудь-руки</option>
<option value="ноги">ноги</option>
<option value="спина-плечи">спина-плечи</option>
<option value="круговая">круговая</option>
<option value="силовая">силовая</option>
<option value="многоповторная">многоповторная</option>
</select>
<button class="btn btn-small" style="width:auto; padding: 0 20px;" onclick="SettingsLogic.addRefExercise()">+</button>
</div>
</div>
<div id="ref-exercises-list"></div>
</div>
<div class="settings-section">
<h3>📏 Замеры тела</h3>
<div id="body-metrics-list"></div>
</div>
<div class="settings-section">
<h3>🗑 Данные</h3>
<button class="btn btn-danger" onclick="SettingsLogic.clearAllData()">Очистить все данные</button>
</div>
`;
app.appendChild(screen);
_renderRefExercises();
_renderBodyMetricsList();
}
export function addRefExercise() {
const input = document.getElementById('new-ref-exercise');
const typeSelect = document.getElementById('new-ref-type');
const name = input.value.trim();
if (!name) return;
const normalizedName = name.trim().toLowerCase();
const exists = state.exerciseRef.find(
e => e.name.trim().toLowerCase() === normalizedName
);
if (!exists) {
state.exerciseRef.push({
id: (state.nextId++).toString(),
name: name.trim(),
workoutType: typeSelect.value,
});
}
saveState();
input.value = '';
typeSelect.value = '';
_renderRefExercises();
}
export function deleteRefExercise(id) {
if (!confirm('Удалить из справочника? (в тренировках останется)')) return;
state.exerciseRef = state.exerciseRef.filter(e => e.id !== id);
saveState();
_renderRefExercises();
}
export function updateRefExerciseType(id, newType) {
const ex = state.exerciseRef.find(e => e.id === id);
if (ex) {
ex.workoutType = newType;
saveState();
}
}
export function editRefExercise(id) {
const ex = state.exerciseRef.find(e => e.id === id);
if (!ex) return;
showEditExerciseDialog(ex, (updatedData) => {
if (!updatedData) return;
Object.assign(ex, updatedData);
saveState();
_renderRefExercises();
});
}
export function clearAllData() {
if (!confirm('Удалить ВСЕ данные? Это действие необратимо!')) return;
if (!confirm('Точно уверены?')) return;
localStorage.clear();
location.reload();
}
export function _renderRefExercises() {
const list = document.getElementById('ref-exercises-list');
if (!list) return;
if (state.exerciseRef.length === 0) {
list.innerHTML = '<p class="text-muted text-small">Справочник пуст</p>';
return;
}
const sorted = [...state.exerciseRef].sort((a, b) => {
if (a.workoutType !== b.workoutType) return (a.workoutType || '').localeCompare(b.workoutType || '');
return a.name.localeCompare(b.name);
});
list.innerHTML = sorted.map(ex => {
let count = 0;
state.workouts.forEach(w => {
w.exercises.forEach(e => {
if (e.name.trim().toLowerCase() === ex.name.trim().toLowerCase()) count++;
});
});
return `
<div class="exercise-ref-item">
<div style="flex:1;">
<span class="name">${ex.name}</span>
<span class="count">(${count}×)</span>
${ex.workoutType ? `<span class="type-badge">${ex.workoutType}</span>` : ''}
</div>
<div style="display:flex; gap:4px; align-items:center;">
<select onchange="SettingsLogic.updateRefExerciseType('${ex.id}', this.value)"
style="width:auto; min-width:100px; padding:6px; font-size:0.8rem;">
<option value="">тип...</option>
<option value="грудь-руки" ${ex.workoutType==='грудь-руки'?'selected':''}>грудь-руки</option>
<option value="ноги" ${ex.workoutType==='ноги'?'selected':''}>ноги</option>
<option value="спина-плечи" ${ex.workoutType==='спина-плечи'?'selected':''}>спина-плечи</option>
<option value="круговая" ${ex.workoutType==='круговая'?'selected':''}>круговая</option>
<option value="силовая" ${ex.workoutType==='силовая'?'selected':''}>силовая</option>
<option value="многоповторная" ${ex.workoutType==='многоповторная'?'selected':''}>многоповторная</option>
</select>
<button class="icon-btn" onclick="SettingsLogic.editRefExercise('${ex.id}')">✎</button>
<button class="icon-btn danger" onclick="SettingsLogic.deleteRefExercise('${ex.id}')">✕</button>
</div>
</div>
`;
}).join('');
}
export function _renderBodyMetricsList() {
const list = document.getElementById('body-metrics-list');
if (!list) return;
if (state.bodyMetrics.length === 0) {
list.innerHTML = '<p class="text-muted text-small">Нет замеров</p>';
return;
}
list.innerHTML = state.bodyMetrics.map(m => {
const date = new Date(m.date);
const dateStr = date.toLocaleDateString('ru-RU') + ' ' + date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
const parts = [];
if (m.weight) parts.push(`⚖️ ${m.weight} кг`);
if (m.chest) parts.push(`📏 ${m.chest} см`);
if (m.waist) parts.push(`📏 ${m.waist} см`);
if (m.hips) parts.push(`📏 ${m.hips} см`);
if (m.arms) parts.push(`💪 ${m.arms} см`);
if (m.legs) parts.push(`🦵 ${m.legs} см`);
return `
<div class="exercise-ref-item">
<div>
<div style="font-weight:500;">${dateStr}</div>
<div class="text-small text-muted">${parts.join(' • ') || '—'}</div>
</div>
<div style="display:flex; gap:4px;">
<button class="icon-btn" onclick="ML.editBodyMetric('${m.id}')">✎</button>
<button class="icon-btn danger" onclick="ML.deleteMetric('${m.id}')">✕</button>
</div>
</div>
`;
}).join('');
}
window.SettingsLogic = {
show: showSettings,
addRefExercise, deleteRefExercise, updateRefExerciseType,
editRefExercise, clearAllData,
_renderRefExercises, _renderBodyMetricsList,
};
+66
View File
@@ -0,0 +1,66 @@
import { state, saveState, genId } from '../state.js';
import { renderMainScreen } from '../render/main-screen.js';
import { showEditWorkoutDateDialog } from '../modals/edit-workout-date-modal.js';
export function startWorkout() {
const w = {
id: genId(),
startTime: new Date().toISOString(),
endTime: null,
type: '',
exercises: [],
cardio: [],
notes: '',
synced: false,
};
state.workouts.unshift(w);
state.expandedWorkoutId = w.id;
saveState();
renderMainScreen();
}
export function finishWorkout(workoutId) {
if (!confirm('Завершить тренировку?')) return;
const w = getWorkout(workoutId);
w.endTime = new Date().toISOString();
saveState();
renderMainScreen();
}
export function deleteWorkout(workoutId) {
if (!confirm('Удалить тренировку полностью?')) return;
state.workouts = state.workouts.filter(w => w.id !== workoutId);
saveState();
renderMainScreen();
}
export function toggleWorkout(workoutId) {
state.expandedWorkoutId =
state.expandedWorkoutId === workoutId ? null : workoutId;
renderMainScreen();
}
export function getWorkout(id) {
return state.workouts.find(w => w.id === id);
}
export function getActiveWorkout() {
return state.workouts.find(w => !w.endTime);
}
export function editWorkoutDate(workoutId) {
const w = getWorkout(workoutId);
if (!w) return;
showEditWorkoutDateDialog(w, (newDates) => {
if (!newDates) return;
w.startTime = newDates.startTime;
w.endTime = newDates.endTime;
saveState();
renderMainScreen();
});
}
window.WL = {
startWorkout, finishWorkout, deleteWorkout,
toggleWorkout, editWorkoutDate,
};
+57
View File
@@ -0,0 +1,57 @@
import { showModal, closeModal, stepperHtml } from './modal-base.js';
let _cb = null;
export function showCardioDialog(existing, callback) {
_cb = callback;
const e = existing || {};
showModal(`<div class="modal-content">
<h3 class="modal-title">${existing ? 'Редактировать' : 'Новое'} кардио</h3>
<div class="field-row">
<div class="field-group"><label>Тип</label><select id="m-ct">
<option value="вело" ${e.type==='вело'?'selected':''}>🚴 Вело</option>
<option value="ходьба" ${e.type==='ходьба'?'selected':''}>🚶 Ходьба</option>
<option value="бег" ${e.type==='бег'?'selected':''}>🏃 Бег</option>
<option value="эллипс" ${e.type==='эллипс'?'selected':''}>⚡ Эллипс</option>
<option value="гребля" ${e.type==='гребля'?'selected':''}>🚣 Гребля</option>
</select></div>
<div class="field-group"><label>Назначение</label><select id="m-cp">
<option value="warmup" ${e.purpose==='warmup'?'selected':''}>🔥 Разминка</option>
<option value="cooldown" ${e.purpose==='cooldown'?'selected':''}>🧘 Заминка</option>
<option value="separate" ${e.purpose==='separate'?'selected':''}>➕ Отдельная</option>
</select></div></div>
<div class="field-row-3">
<div class="field-group"><label>Минут</label>${stepperHtml('m-cd', e.duration??'', 1, 'мин', 'numeric')}</div>
<div class="field-group"><label>Дист.</label>${stepperHtml('m-cdi', e.distance??'', 0.1, 'км')}</div>
<div class="field-group"><label>Скор.</label>${stepperHtml('m-cs', e.speed??'', 0.5, 'км/ч')}</div></div>
<div class="field-row">
<div class="field-group"><label>Наклон</label>${stepperHtml('m-ci', e.incline??'', 0.5, '—')}</div>
<div class="field-group"><label>Сопр.</label>${stepperHtml('m-cr', e.resistance??'', 1, '—', 'numeric')}</div></div>
<div class="field-group"><label>Заметка</label><input type="text" id="m-cn" value="${e.notes||''}"></div>
<div class="btn-row">
<button class="btn btn-secondary" onclick="ModalBase.closeOnBg({target:document.querySelector('.modal-overlay')})">Отмена</button>
<button class="btn btn-success" onclick="CardioModal.submit()">Сохранить</button></div>
</div>`);
}
export function submit() {
// 1. Сначала считываем все значения, пока модалка ещё в DOM
const result = {
type: document.getElementById('m-ct').value,
purpose: document.getElementById('m-cp').value,
duration: parseInt(document.getElementById('m-cd').value) || 0,
distance: parseFloat(document.getElementById('m-cdi').value) || 0,
speed: parseFloat(document.getElementById('m-cs').value) || 0,
incline: parseFloat(document.getElementById('m-ci').value) || 0,
resistance: parseInt(document.getElementById('m-cr').value) || 0,
notes: document.getElementById('m-cn').value.trim(),
};
// 2. Только теперь закрываем модалку
closeModal();
// 3. Отдаём результат
_cb?.(result);
}
window.CardioModal = { submit };
+42
View File
@@ -0,0 +1,42 @@
import { showModal, closeModal } from './modal-base.js';
let _cb = null;
export function showEditExerciseDialog(exercise, callback) {
_cb = callback;
showModal(`
<div class="modal-content">
<h3 class="modal-title">Редактировать упражнение</h3>
<div class="field-group">
<label>Название</label>
<input type="text" id="m-edit-ex-name" value="${exercise.name}">
</div>
<div class="field-group">
<label>Тип тренировки</label>
<select id="m-edit-ex-type">
<option value="">тип не указан</option>
<option value="грудь-руки" ${exercise.workoutType==='грудь-руки'?'selected':''}>грудь-руки</option>
<option value="ноги" ${exercise.workoutType==='ноги'?'selected':''}>ноги</option>
<option value="спина-плечи" ${exercise.workoutType==='спина-плечи'?'selected':''}>спина-плечи</option>
<option value="круговая" ${exercise.workoutType==='круговая'?'selected':''}>круговая</option>
<option value="силовая" ${exercise.workoutType==='силовая'?'selected':''}>силовая</option>
<option value="многоповторная" ${exercise.workoutType==='многоповторная'?'selected':''}>многоповторная</option>
</select>
</div>
<div class="btn-row">
<button class="btn btn-secondary" onclick="ModalBase.close()">Отмена</button>
<button class="btn btn-success" onclick="EditExModal.submit()">Сохранить</button>
</div>
</div>
`);
}
export function submit() {
const name = document.getElementById('m-edit-ex-name').value.trim();
const type = document.getElementById('m-edit-ex-type').value;
if (!name) { alert('Название не может быть пустым'); return; }
closeModal();
if (_cb) _cb({ name, workoutType: type });
}
window.EditExModal = { submit };
@@ -0,0 +1,41 @@
import { showModal, closeModal } from './modal-base.js';
import { toDatetimeLocal } from '../utils.js';
let _cb = null;
export function showEditWorkoutDateDialog(workout, callback) {
_cb = callback;
const startValue = toDatetimeLocal(workout.startTime);
const endValue = workout.endTime ? toDatetimeLocal(workout.endTime) : '';
showModal(`
<div class="modal-content">
<h3 class="modal-title">📅 Дата тренировки</h3>
<div class="field-group">
<label>Начало</label>
<input type="datetime-local" id="m-w-start" value="${startValue}">
</div>
<div class="field-group">
<label>Окончание</label>
<input type="datetime-local" id="m-w-end" value="${endValue}">
</div>
<div class="btn-row">
<button class="btn btn-secondary" onclick="ModalBase.close()">Отмена</button>
<button class="btn btn-success" onclick="EditDateModal.submit()">Сохранить</button>
</div>
</div>
`);
}
export function submit() {
const startStr = document.getElementById('m-w-start').value;
const endStr = document.getElementById('m-w-end').value;
if (!startStr) { alert('Укажите дату начала'); return; }
closeModal();
if (_cb) _cb({
startTime: new Date(startStr).toISOString(),
endTime: endStr ? new Date(endStr).toISOString() : null,
});
}
window.EditDateModal = { submit };
+60
View File
@@ -0,0 +1,60 @@
import { showModal, closeModal } from './modal-base.js';
import { state } from '../state.js';
let _cb = null;
let _names = [];
export function showExerciseDialog(workoutType, callback) {
_cb = callback;
const nameCount = {};
state.exerciseRef.forEach(ex => {
const bonus = (workoutType && ex.workoutType === workoutType) ? 100 : 10;
nameCount[ex.name] = (nameCount[ex.name] || 0) + bonus;
});
state.workouts.forEach(w => w.exercises.forEach(ex => {
nameCount[ex.name] = (nameCount[ex.name] || 0) + 1;
}));
_names = Object.keys(nameCount).sort((a, b) => {
const d = nameCount[b] - nameCount[a];
return d !== 0 ? d : a.localeCompare(b);
});
showModal(`<div class="modal-content">
<h3 class="modal-title">Добавить упражнение</h3>
<div class="field-group">
<label>Название</label>
<input type="text" id="m-ex-name" placeholder="Начните вводить..." oninput="ExerciseModal.filter()" autocomplete="off">
<div id="m-ex-autocomplete" class="autocomplete-list"></div>
</div>
<div class="btn-row">
<button class="btn btn-secondary" onclick="ModalBase.closeOnBg({target:document.querySelector('.modal-overlay')})">Отмена</button>
<button class="btn" onclick="ExerciseModal.submit()">Добавить</button>
</div>
</div>`);
setTimeout(() => { document.getElementById('m-ex-name').focus(); filter(); }, 50);
}
export function filter() {
const q = document.getElementById('m-ex-name').value.trim().toLowerCase();
const list = document.getElementById('m-ex-autocomplete');
let f = _names;
if (q) f = f.filter(n => n.toLowerCase().includes(q));
if (!f.length) { list.innerHTML = '<div class="autocomplete-item text-muted">Нет совпадений</div>'; return; }
list.innerHTML = f.slice(0, 15).map(name => {
const count = state.workouts.reduce((s, w) => s + w.exercises.filter(e => e.name === name).length, 0);
return `<div class="autocomplete-item" onclick="ExerciseModal.select('${name.replace(/'/g, "\\'")}')">${name}${count ? `<span class="freq">${count}×</span>` : ''}</div>`;
}).join('');
}
export function select(name) {
document.getElementById('m-ex-name').value = name;
document.getElementById('m-ex-autocomplete').innerHTML = '';
}
export function submit() {
const name = document.getElementById('m-ex-name').value.trim();
closeModal();
if (name && _cb) _cb(name);
}
window.ExerciseModal = { filter, select, submit };
+44
View File
@@ -0,0 +1,44 @@
import { showModal, closeModal, stepperHtml } from './modal-base.js';
import { toDatetimeLocal } from '../utils.js';
let _cb = null;
export function showBodyMetricsDialog(metrics, callback, editing = null) {
_cb = callback;
const src = editing || metrics[0] || {};
const isEdit = !!editing;
const dateVal = src.date ? toDatetimeLocal(src.date) : toDatetimeLocal(new Date().toISOString());
showModal(`<div class="modal-content">
<h3 class="modal-title">📏 Замеры ${isEdit ? '(ред.)' : ''}</h3>
<div class="field-group"><label>📅 Дата</label><input type="datetime-local" id="m-md" value="${dateVal}"></div>
<div class="field-row">
<div class="field-group"><label>⚖️ Вес</label>${stepperHtml('m-mw', src.weight??'', 0.1, 'кг')}</div>
<div class="field-group"><label>📏 Грудь</label>${stepperHtml('m-mc', src.chest??'', 1, 'см', 'numeric')}</div></div>
<div class="field-row">
<div class="field-group"><label>📏 Талия</label>${stepperHtml('m-mwa', src.waist??'', 1, 'см', 'numeric')}</div>
<div class="field-group"><label>📏 Бёдра</label>${stepperHtml('m-mh', src.hips??'', 1, 'см', 'numeric')}</div></div>
<div class="field-row">
<div class="field-group"><label>💪 Руки</label>${stepperHtml('m-ma', src.arms??'', 0.5, 'см')}</div>
<div class="field-group"><label>🦵 Ноги</label>${stepperHtml('m-ml', src.legs??'', 0.5, 'см')}</div></div>
<div class="btn-row">
<button class="btn btn-secondary" onclick="ModalBase.closeOnBg({target:document.querySelector('.modal-overlay')})">Отмена</button>
<button class="btn btn-success" onclick="MetricsModal.submit(${isEdit})">Сохранить</button></div>
</div>`);
}
export function submit(isEdit) {
const r = {
date: new Date(document.getElementById('m-md').value).toISOString(),
weight: parseFloat(document.getElementById('m-mw').value)||null,
chest: parseInt(document.getElementById('m-mc').value)||null,
waist: parseInt(document.getElementById('m-mwa').value)||null,
hips: parseInt(document.getElementById('m-mh').value)||null,
arms: parseFloat(document.getElementById('m-ma').value)||null,
legs: parseFloat(document.getElementById('m-ml').value)||null,
};
if (!Object.values(r).some((v,i) => i>0 && v!==null)) { alert('Заполните поле'); return; }
closeModal(); _cb?.(r, isEdit);
}
window.MetricsModal = { submit };
+33
View File
@@ -0,0 +1,33 @@
const container = () => document.getElementById('modal-container');
export function showModal(html) {
container().innerHTML = `<div class="modal-overlay" onclick="ModalBase.closeOnBg(event)">${html}</div>`;
}
export function closeModal() {
container().innerHTML = '';
}
export function closeOnBg(e) {
if (e.target.classList.contains('modal-overlay')) closeModal();
}
export function stepperHtml(id, value, step, placeholder, inputmode = 'decimal') {
const v = value !== '' && value != null ? value : '';
return `<div class="stepper-field">
<button type="button" class="stepper-btn" onclick="ModalBase.stepValue('${id}', -${step})">−</button>
<input type="number" id="${id}" step="${step}" value="${v}" placeholder="${placeholder}" inputmode="${inputmode}">
<button type="button" class="stepper-btn" onclick="ModalBase.stepValue('${id}', ${step})">+</button>
</div>`;
}
export function stepValue(id, delta) {
const input = document.getElementById(id);
if (!input) return;
const current = parseFloat(input.value) || 0;
const step = parseFloat(input.step) || 1;
const newVal = Math.max(0, current + delta);
input.value = parseFloat((Math.round(newVal / step) * step).toFixed(4));
}
window.ModalBase = { closeOnBg, closeModal, stepValue };
+127
View File
@@ -0,0 +1,127 @@
import { showModal, closeModal, stepperHtml } from './modal-base.js';
import { formatShortDate, formatSet } from '../utils.js';
let _data = {};
let _cb = null;
export function showSetDialog(existingSet, history, callback) {
_cb = callback;
const isEdit = !!existingSet;
const isDropset = existingSet?.isDropset || false;
let historyHtml = '';
if (history?.length) {
const rows = history.map(h => `<div class="history-hint-row">
<span class="history-hint-date">${formatShortDate(h.date)}</span>
<span class="history-hint-sets">${h.sets.map(formatSet).join(', ')}</span>
</div>`).join('');
historyHtml = `<div class="history-hint"><div class="history-hint-title">📊 Прошлые тренировки</div>${rows}</div>`;
}
showModal(`<div class="modal-content">
<h3 class="modal-title">${isEdit ? 'Редактировать' : 'Новый'} подход</h3>
${historyHtml}
<div class="field-group"><label style="display:flex;align-items:center;gap:8px;">
<input type="checkbox" id="m-set-dropset" ${isDropset ? 'checked' : ''} onchange="SetModal.toggleDropset()" style="width:auto;">
Это дроп-сет</label></div>
<div id="m-set-normal" class="${isDropset ? 'hidden' : ''}">
<div class="field-row">
<div class="field-group"><label>Вес (кг)</label>${stepperHtml('m-set-weight', existingSet?.weight ?? '', 0.5, 'Вес')}</div>
<div class="field-group"><label>Повторения</label>${stepperHtml('m-set-reps', existingSet?.reps ?? '', 1, 'Повт.', 'numeric')}</div>
</div></div>
<div id="m-set-dropset-block" class="${isDropset ? '' : 'hidden'}">
<div id="m-subsets-list"></div>
<button class="btn btn-ghost mt-10" style="width:100%;" onclick="SetModal.addSubset()">+ Сбрасывание</button></div>
<div class="field-group"><label>Оценка (1-5)</label>
<div class="rating-grid" id="m-rating-grid">
${[1,2,3,4,5].map(r => `<button class="rating-btn ${existingSet?.rating===r?'active':''}" data-rating="${r}" onclick="SetModal.rating(${r})">${r}</button>`).join('')}
</div></div>
<div class="btn-row">
<button class="btn btn-secondary" onclick="ModalBase.closeOnBg({target:document.querySelector('.modal-overlay')})">Отмена</button>
<button class="btn btn-success" onclick="SetModal.submit()">Сохранить</button></div>
</div>`);
_data = {
isDropset, rating: existingSet?.rating || 3,
subSets: existingSet?.subSets ? JSON.parse(JSON.stringify(existingSet.subSets)) : [],
};
if (isDropset) { if (!_data.subSets.length) addSubset(); else renderSubsets(); }
rating(_data.rating);
}
export function toggleDropset() {
_data.isDropset = document.getElementById('m-set-dropset').checked;
document.getElementById('m-set-normal').classList.toggle('hidden', _data.isDropset);
document.getElementById('m-set-dropset-block').classList.toggle('hidden', !_data.isDropset);
if (_data.isDropset && !_data.subSets.length) addSubset();
else if (_data.isDropset) renderSubsets();
}
export function addSubset() {
const last = _data.subSets[_data.subSets.length - 1];
_data.subSets.push({ weight: last?.weight || '', reps: last?.reps || '' });
renderSubsets();
}
export function renderSubsets() {
document.getElementById('m-subsets-list').innerHTML = _data.subSets.map((s, i) => `
<div class="stepper-row">
<div class="stepper-field">
<button type="button" class="stepper-btn" onclick="SetModal.stepSubset(${i}, 'weight', -0.5)">−</button>
<input type="number" id="m-sw-${i}" step="0.5" value="${s.weight || ''}" placeholder="Вес" inputmode="decimal" oninput="SetModal.updateSubset(${i}, 'weight', this.value)">
<button type="button" class="stepper-btn" onclick="SetModal.stepSubset(${i}, 'weight', 0.5)">+</button>
</div>
<div class="stepper-field">
<button type="button" class="stepper-btn" onclick="SetModal.stepSubset(${i}, 'reps', -1)">−</button>
<input type="number" id="m-sr-${i}" step="1" value="${s.reps || ''}" placeholder="Повт." inputmode="numeric" oninput="SetModal.updateSubset(${i}, 'reps', this.value)">
<button type="button" class="stepper-btn" onclick="SetModal.stepSubset(${i}, 'reps', 1)">+</button>
</div>
<button class="icon-btn danger" onclick="SetModal.removeSubset(${i})">✕</button>
</div>
`).join('');
}
export function updateSubset(i, field, value) {
_data.subSets[i][field] = value;
}
export function stepSubset(i, field, delta) {
const current = parseFloat(_data.subSets[i][field]) || 0;
const step = field === 'weight' ? 0.5 : 1;
const newVal = Math.max(0, current + delta);
_data.subSets[i][field] = parseFloat((Math.round(newVal / step) * step).toFixed(4));
renderSubsets(); // Перерисовываем, чтобы обновить значение в input
}
export function removeSubset(i) {
_data.subSets.splice(i, 1);
renderSubsets();
}
export function rating(r) {
_data.rating = r;
document.querySelectorAll('#m-rating-grid .rating-btn').forEach(b => b.classList.toggle('active', +b.dataset.rating === r));
}
export function submit() {
if (_data.isDropset) {
_data.subSets.forEach((s, i) => {
const w = document.getElementById(`m-sw-${i}`);
const r = document.getElementById(`m-sr-${i}`);
if (w) s.weight = w.value; if (r) s.reps = r.value;
});
if (!_data.subSets.length) { alert('Добавьте сбрасывание'); return; }
closeModal(); _cb?.({ isDropset: true, subSets: _data.subSets.map(s => ({ weight: parseFloat(s.weight)||0, reps: parseInt(s.reps)||0 })), rating: _data.rating });
} else {
const w = parseFloat(document.getElementById('m-set-weight').value);
const r = parseInt(document.getElementById('m-set-reps').value);
if (!w && !r) { alert('Укажите вес и повторения'); return; }
closeModal(); _cb?.({ isDropset: false, weight: w||0, reps: r||0, rating: _data.rating });
}
}
// Обновляем экспорт в window
window.SetModal = {
toggleDropset, addSubset, removeSubset, rating, submit,
updateSubset, stepSubset
};
+30
View File
@@ -0,0 +1,30 @@
export function renderCardioItem(workoutId, c, isReadOnly = false) {
const card = document.createElement('div');
card.className = 'card';
const purposeLabels = { warmup: '🔥 Разминка', cooldown: '🧘 Заминка', separate: '➕ Отдельная' };
const metrics = [];
if (c.duration) metrics.push(`${c.duration} мин`);
if (c.distance) metrics.push(`${c.distance} км`);
if (c.speed) metrics.push(`${c.speed} км/ч`);
if (c.incline) metrics.push(`наклон ${c.incline}`);
if (c.resistance) metrics.push(`сопр. ${c.resistance}`);
const actionsHtml = isReadOnly ? '' : `
<div class="set-actions">
<button class="icon-btn" onclick="CL.editCardio('${workoutId}', '${c.id}')">✎</button>
<button class="icon-btn danger" onclick="CL.deleteCardio('${workoutId}', '${c.id}')">✕</button>
</div>
`;
card.innerHTML = `
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
<div>
<div style="font-weight:600;">${c.type || '—'} ${purposeLabels[c.purpose] || ''}</div>
<div class="text-small text-muted">${metrics.join(' • ') || '—'}</div>
${c.notes ? `<div class="text-small" style="margin-top:4px;">"${c.notes}"</div>` : ''}
</div>
${actionsHtml}
</div>
`;
return card;
}
+60
View File
@@ -0,0 +1,60 @@
import { state } from '../state.js';
import { renderSetRow } from './set-row.js';
export function renderExercise(w, ex, isReadOnly = false) {
const isExpanded = state.expandedExerciseId === ex.id;
const totalSets = ex.sets.length;
const summary = totalSets > 0 ? `${totalSets} подх.` : '';
const card = document.createElement('div');
card.className = 'exercise-card';
card.innerHTML = `
<div class="exercise-header ${isExpanded ? 'expanded' : ''}" onclick="EL.toggleExercise('${ex.id}')">
<div>
<span class="exercise-name">${ex.name}</span>
<span class="exercise-summary">${summary}</span>
</div>
<span class="chevron ${isExpanded ? 'open' : ''}">›</span>
</div>
`;
const body = document.createElement('div');
body.className = `exercise-body ${isExpanded ? 'expanded' : ''}`;
if (isExpanded) {
if (ex.sets.length > 0) {
const table = document.createElement('div');
table.className = 'sets-table';
[...ex.sets].reverse().forEach((set, idx) => {
const realIdx = ex.sets.length - 1 - idx;
table.appendChild(renderSetRow(w.id, ex.id, set, realIdx, isReadOnly));
});
body.appendChild(table);
} else {
body.innerHTML += `<p class="text-muted text-small">Пока нет подходов</p>`;
}
if (!isReadOnly) {
if (!w.endTime) {
const addBtn = document.createElement('button');
addBtn.className = 'btn mt-10';
addBtn.textContent = '+ Добавить подход';
addBtn.onclick = () => window.SL.addSet(w.id, ex.id);
body.appendChild(addBtn);
}
const delBtn = document.createElement('button');
delBtn.className = 'btn btn-ghost mt-10';
delBtn.style.width = '100%';
delBtn.textContent = 'Удалить упражнение';
delBtn.onclick = (e) => {
e.stopPropagation();
window.EL.deleteExercise(w.id, ex.id);
};
body.appendChild(delBtn);
}
}
card.appendChild(body);
return card;
}
+43
View File
@@ -0,0 +1,43 @@
// Рендеринг главного экрана: список тренировок.
// Получает данные из state, возвращает DOM.
import { state } from '../state.js';
import { renderWorkoutCard } from './workout-card.js';
import { startWorkout } from '../logic/workout.js';
export function renderMainScreen() {
const app = document.getElementById('app');
app.innerHTML = '';
if (state.workouts.length === 0) {
app.innerHTML = `
<div class="empty-state">
<h3>🏋️ Нет тренировок</h3>
<p>Начните первую тренировку</p>
<button class="btn" id="btn-start-first">▶ Начать тренировку</button>
</div>
`;
document.getElementById('btn-start-first').onclick = startWorkout;
return;
}
// Сортировка: активные сверху, завершённые снизу
const sorted = [...state.workouts].sort((a, b) => {
if (!a.endTime && b.endTime) return -1;
if (a.endTime && !b.endTime) return 1;
return new Date(b.startTime) - new Date(a.startTime);
});
const hasActive = sorted.some(w => !w.endTime);
if (!hasActive) {
const btn = document.createElement('button');
btn.className = 'btn mb-10';
btn.textContent = '▶ Начать новую тренировку';
btn.onclick = startWorkout;
app.appendChild(btn);
}
sorted.forEach(w => {
app.appendChild(renderWorkoutCard(w));
});
}
+35
View File
@@ -0,0 +1,35 @@
export function renderSetRow(workoutId, exerciseId, set, index, isReadOnly = false) {
const row = document.createElement('div');
const actionsHtml = isReadOnly ? '' : `
<div class="set-actions">
<button class="icon-btn" onclick="SL.editSet('${workoutId}', '${exerciseId}', '${set.id}')">✎</button>
<button class="icon-btn danger" onclick="SL.deleteSet('${workoutId}', '${exerciseId}', '${set.id}')">✕</button>
</div>
`;
if (set.isDropset && set.subSets && set.subSets.length > 0) {
row.className = 'dropset-group';
const subsText = set.subSets.map(s => `${s.weight}×${s.reps}`).join(' → ');
const ratingStars = '★'.repeat(set.rating || 3);
row.innerHTML = `
<div class="set-row" style="grid-template-columns: 30px 1fr auto ${isReadOnly ? '0' : 'auto'};">
<div class="set-number">${index + 1}</div>
<div class="set-cell dropset">🔥 ${subsText}</div>
<div class="set-rating">${ratingStars}</div>
${actionsHtml}
</div>
`;
} else {
row.innerHTML = `
<div class="set-row">
<div class="set-number">${index + 1}</div>
<div class="set-cell">${set.weight || '—'} кг</div>
<div class="set-cell">${set.reps || '—'}</div>
<div class="set-rating">${'★'.repeat(set.rating || 3)}</div>
${actionsHtml}
</div>
`;
}
return row;
}
+169
View File
@@ -0,0 +1,169 @@
import { state } from '../state.js';
import { renderExercise } from './exercise-card.js';
import { renderCardioItem } from './cardio-card.js';
import { formatDate } from '../utils.js';
export function renderWorkoutCard(w) {
const isActive = !w.endTime;
const isExpanded = state.expandedWorkoutId === w.id;
const isReadOnly = w.synced && w.endTime;
const div = document.createElement('div');
div.className = 'workout-accordion';
const headerClass = `workout-header ${isActive ? 'active' : 'completed'}`;
const dateStr = formatDate(w.startTime);
const typeStr = w.type || 'тип не выбран';
const exCount = w.exercises.length;
const cardioCount = w.cardio.length;
let statusBadge;
if (isReadOnly) statusBadge = '<span class="workout-badge synced-badge">✓ архив</span>';
else if (w.synced) statusBadge = '<span class="workout-badge synced-badge">✓</span>';
else statusBadge = '<span class="workout-badge dirty">● не сохранено</span>';
const editDateBtn = !isReadOnly
? `<span class="editable-date" onclick="event.stopPropagation(); WL.editWorkoutDate('${w.id}')">
<span class="edit-icon">✎</span>
</span>`
: '';
div.innerHTML = `
<div class="${headerClass}" onclick="WL.toggleWorkout('${w.id}')">
<div>
<div style="font-weight:600;">${isActive ? '🔥 Тренировка' : '✅ Завершена'}</div>
<div class="workout-meta">${dateStr} ${editDateBtn} • ${typeStr}</div>
<div class="workout-meta">Упр: ${exCount} • Кардио: ${cardioCount}</div>
</div>
<div>${statusBadge} <span class="chevron ${isExpanded ? 'open' : ''}">›</span></div>
</div>
`;
const body = document.createElement('div');
body.className = `workout-body ${isExpanded ? 'expanded' : ''}`;
if (isExpanded) {
body.appendChild(renderWorkoutDetails(w));
}
div.appendChild(body);
return div;
}
export function renderWorkoutDetails(w) {
const wrap = document.createElement('div');
const isActive = !w.endTime;
const isReadOnly = w.synced && w.endTime;
if (isReadOnly) {
const banner = document.createElement('div');
banner.className = 'read-only-banner';
banner.textContent = '🔒 Тренировка в архиве — только просмотр';
wrap.appendChild(banner);
}
// Тип тренировки
const typeCard = document.createElement('div');
typeCard.className = 'card';
if (isReadOnly) {
typeCard.innerHTML = `
<div class="field-group" style="margin:0;">
<span class="text-muted text-small">Тип тренировки</span>
<div style="font-weight:600; margin-top:4px;">${w.type || '—'}</div>
</div>`;
} else {
typeCard.innerHTML = `
<label class="field-group" style="margin:0;">
<span>Тип тренировки</span>
<select onchange="App.getWorkout('${w.id}').type = this.value; App.saveAndRender();">
<option value="">— выберите —</option>
<option ${w.type==='грудь-руки'?'selected':''}>грудь-руки</option>
<option ${w.type==='ноги'?'selected':''}>ноги</option>
<option ${w.type==='спина-плечи'?'selected':''}>спина-плечи</option>
<option ${w.type==='круговая'?'selected':''}>круговая</option>
<option ${w.type==='силовая'?'selected':''}>силовая</option>
<option ${w.type==='многоповторная'?'selected':''}>многоповторная</option>
</select>
</label>`;
}
wrap.appendChild(typeCard);
// Кнопки добавления
if (isActive && !isReadOnly) {
const addSection = document.createElement('div');
addSection.className = 'add-section';
addSection.innerHTML = `
<button class="btn btn-secondary" onclick="EL.addExercise('${w.id}')">+ Упражнение</button>
<button class="btn btn-secondary" onclick="CL.addCardio('${w.id}')">+ Кардио</button>
`;
wrap.appendChild(addSection);
}
// Кардио: разминка сверху
const cardioWarmup = w.cardio.filter(c => c.purpose === 'warmup' || c.purpose === 'separate');
const cardioCooldown = w.cardio.filter(c => c.purpose === 'cooldown');
if (cardioWarmup.length > 0) {
const title = document.createElement('h4');
title.style.margin = '16px 0 8px';
title.textContent = '🔥 Разминка / Кардио';
wrap.appendChild(title);
cardioWarmup.forEach(c => wrap.appendChild(renderCardioItem(w.id, c, isReadOnly)));
}
// Упражнения
[...w.exercises].reverse().forEach(ex => {
wrap.appendChild(renderExercise(w, ex, isReadOnly));
});
// Заминка внизу
if (cardioCooldown.length > 0) {
const title = document.createElement('h4');
title.style.margin = '16px 0 8px';
title.textContent = '🧘 Заминка';
wrap.appendChild(title);
cardioCooldown.forEach(c => wrap.appendChild(renderCardioItem(w.id, c, isReadOnly)));
}
// Комментарий
const notesCard = document.createElement('div');
notesCard.className = 'card mt-10';
if (isReadOnly) {
notesCard.innerHTML = `
<div class="field-group" style="margin:0;">
<span class="text-muted text-small">Комментарий</span>
<div style="margin-top:4px; white-space:pre-wrap;">${w.notes || '—'}</div>
</div>`;
} else {
notesCard.innerHTML = `
<label class="field-group" style="margin:0;">
<span>Комментарий к тренировке</span>
<textarea placeholder="Как прошла тренировка...">${w.notes || ''}</textarea>
</label>`;
notesCard.querySelector('textarea').oninput = (e) => {
w.notes = e.target.value;
window.App.saveAndRender();
};
}
wrap.appendChild(notesCard);
// Блок завершения
const finishBlock = document.createElement('div');
finishBlock.className = 'finish-block';
if (isReadOnly) {
finishBlock.innerHTML = `
<h3>🔒 Архив</h3>
<p class="text-muted text-small">Тренировка синхронизирована и завершена. Редактирование недоступно.</p>`;
} else if (isActive) {
finishBlock.innerHTML = `
<h3>Завершение тренировки</h3>
<p class="text-muted text-small">После завершения данные останутся локально до синхронизации</p>
<button class="btn btn-danger" onclick="WL.finishWorkout('${w.id}')">⏹ Закончить тренировку</button>`;
} else {
finishBlock.innerHTML = `
<h3>Тренировка завершена</h3>
<p class="text-muted text-small">До синхронизации можно редактировать</p>
<button class="btn btn-danger" onclick="WL.deleteWorkout('${w.id}')">🗑 Удалить</button>`;
}
wrap.appendChild(finishBlock);
return wrap;
}
+46
View File
@@ -0,0 +1,46 @@
const STORAGE_KEY = 'gym_tracker_v5';
export const state = {
workouts: [],
exerciseRef: [],
bodyMetrics: [],
expandedWorkoutId: null,
expandedExerciseId: null,
nextId: 1,
};
export function loadState() {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
Object.assign(state, parsed);
if (!state.exerciseRef) state.exerciseRef = [];
if (!state.bodyMetrics) state.bodyMetrics = [];
} catch (e) {
console.error('Failed to load state', e);
}
}
}
export function saveState() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
updateSyncStatus();
}
export function genId() {
return (state.nextId++).toString();
}
export function updateSyncStatus() {
const dirty = state.workouts.filter(w => !w.synced);
const el = document.getElementById('sync-status');
if (!el) return;
if (dirty.length === 0) {
el.textContent = '● Всё синхронизировано';
el.className = 'sync-status synced';
} else {
el.textContent = `● ${dirty.length} не сохран.`;
el.className = 'sync-status pending';
}
}
+18
View File
@@ -0,0 +1,18 @@
import { state, saveState } from './state.js';
export function getDirtyWorkouts() {
return state.workouts.filter(w => !w.synced);
}
export async function sync() {
const dirty = getDirtyWorkouts();
if (dirty.length === 0) {
alert('Нет данных для синхронизации');
return;
}
// TODO: заменить на fetch('/api/sync', ...)
console.log('Syncing:', JSON.stringify(dirty, null, 2));
dirty.forEach(w => w.synced = true);
saveState();
alert(`Отправлено тренировок: ${dirty.length}`);
}
+28
View File
@@ -0,0 +1,28 @@
export function formatDate(isoString) {
const d = new Date(isoString);
return d.toLocaleDateString('ru-RU') + ' ' +
d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
}
export function formatShortDate(isoString) {
return new Date(isoString).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' });
}
export function formatSet(set) {
if (set.isDropset && set.subSets) {
return set.subSets.map(s => `${s.weight}×${s.reps}`).join('-');
}
return `${set.weight}×${set.reps}`;
}
export function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
export function toDatetimeLocal(isoString) {
const d = new Date(isoString);
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}