ЦИФРОВОЙ АТЛЕТ

Материалы для учителя · 5 занятий · промпты · код · кейсы · ключи

👥 Роли учеников в команде

Распределение ответственности при работе над проектом

Полный вариант 5 ролей · 5 компьютеров

РольЧто делает
🧬 Аналитик данныхОтвечает за биологические показатели (возраст, ЧСС, зоны), проверяет формулы
💻 Frontend-инженерПишет HTML/CSS/JS код, работает в онлайн-редакторе
🤖 Prompt-инженерСоставляет промпты для ИИ, проверяет результат
🔍 Тестировщик (QA)Запускает Peer Review, проверяет на мобильных, фиксирует баги
🎤 СпикерПрезентует продукт, защищает решения команды

Адаптация для класса с малым числом компьютеров 2–3 ПК

Объединённая рольЧто делает
🧬🔍 Аналитик-тестировщикОтвечает за данные + тестирует продукт на мобильных устройствах
💻 Frontend-Prompt инженерПишет код + составляет промпты для ИИ
🎤 СпикерПрезентует продукт (роль остаётся отдельной)

📋 Справка по проекту «Цифровой атлет»

Полная справка для самостоятельной работы учеников и учителя

1. О проекте

Проект «Цифровой атлет» — веб-приложение для отслеживания физической активности и показателей здоровья пользователя.

Основные компоненты

  • Фронтенд — пользовательский интерфейс (HTML/CSS/JS)
  • Бэкенд — логика работы приложения
  • База данных — хранение данных пользователей
  • Система расчётов — вычисление физиологических показателей

Обязательные модули

  • Регистрация пользователя
  • Профиль с личными данными
  • Календарь тренировок
  • Журнал показателей
  • Система расчётов
  • Отчётность

2. Технические требования

Необходимое ПО

  • Онлайн-редактор кода (CodePen / Replit)
  • Браузер с поддержкой современных стандартов
  • ИИ-чат-бот для генерации кода

Базовые технологии

  • HTML/CSS — создание интерфейса
  • JavaScript — логика работы
  • API — интеграция данных

3. Ключевые расчёты и формулы

Макс. пульс = 220 − возрастМаксимальная частота сердечных сокращений
ИМТ = вес (кг) / рост² (м)Индекс массы тела
Норма ИМТ: 18.5 – 24.9Нормальный диапазон индекса массы тела
Давление: 120/80 мм рт. ст.Систолическое / диастолическое
Пульс в покое: 60–90 уд/минНормальный диапазон

Зоны пульса (от максимального)

50–60% · Разминка 60–70% · Аэробная 70–85% · Анаэробная 85–95% · Максимум

Типы нагрузок

ТипОписаниеПримеры
СиловыеРабота с отягощениямиПодъём штанги, жим, приседания
КардиоАэробные нагрузкиБег, плавание, велосипед
ФункциональныеКомплексные упражненияКроссфит, HIIT, круговая тренировка

4. Ключи к заданиям — правильные ответы только для учителя

ВопросОтвет
Какое нормальное артериальное давление?120/80 мм рт. ст.
Какой нормальный пульс в покое?60–90 уд/мин
Какой диапазон ИМТ считается нормой?18.5–24.9
Как рассчитать максимальный пульс?220 − возраст (лет)
Какие зоны пульса существуют?Разминка (50–60%), Аэробная (60–70%), Анаэробная (70–85%), Максимальная (85–95%)
Какие типы нагрузок бывают?Силовые, кардионагрузки, функциональные
Как рассчитать ИМТ?Вес (кг) / рост² (м)
Какие обязательные модули должны быть в проекте?Регистрация, профиль, календарь тренировок, журнал показателей, система расчётов, отчётность

5. Критерии оценки

Обязательные элементы

  • Корректная работа всех форм
  • Правильные расчёты показателей
  • Функционирующий календарь
  • Система сохранения данных
  • Удобный интерфейс

6. Типичные ошибки

На что обратить внимание:
  • Отсутствие валидации данных
  • Ошибки в математических расчётах
  • Проблемы с сохранением информации
  • Неработающие элементы интерфейса
  • Нарушение логики работы
💡 Совет: перед сдачей проверьте каждый модуль по отдельности, затем — их взаимодействие.

7. Порядок проверки (чек-лист)

Функциональная проверка

  • Тестирование всех форм
  • Проверка расчётов (ИМТ, пульс, зоны)
  • Работа с данными (сохранение/загрузка)
  • Корректность календаря

Интерфейс

  • Удобство использования
  • Адаптивность (мобильные устройства)
  • Эстетичный внешний вид

Документация

  • Наличие инструкции (README)
  • Описание функционала
  • Руководство пользователя

🤖 Эталонные промпты для ИИ

Промпты проверены и дают стабильный результат. Ученики могут использовать их как образец для составления собственных.

💡 Совет учителю: раздайте ученикам эти промпты только после того, как они составят свои. Сравнение «свой промпт vs эталон» — отличная рефлексия для Prompt Engineering.

📄 Промпт 1: HTML-каркас занятие 1

PROMPT
Создай HTML-разметку для электронного дневника тренировок. Используй семантические теги:
- <form> с полями: дата, тип тренировки, длительность (мин), пульс до, пульс после, самочувствие (1-5)
- <table> для истории тренировок (колонки: дата, тип, длительность, пульс, зона)
- <div id="hr-zones"> для вывода расчёта пульсовых зон
- <button> «Рассчитать» и «Добавить тренировку»
Добавь комментарии к каждому блоку кода.

🎨 Промпт 2: CSS-стилизация занятие 2

PROMPT
Оформи HTML-дневник тренировок в спортивном стиле. Цветовая палитра:
- Основной: #10b981 (зелёный, энергия)
- Акцент: #3b82f6 (синий, доверие)
- Фон: #f9fafb (светло-серый)
- Перегрузка: #ef4444 (красный, предупреждение)
Сделай:
- Адаптивную вёрстку (mobile-first)
- Кликабельные кнопки с hover-эффектом
- Читаемый шрифт (system-ui, 16px)
- Таблицу с чередующимися строками

⚙️ Промпт 3: JS-калькулятор ЧСС занятие 3

PROMPT
Напиши JavaScript-функцию calcHR(), которая:
1. Рассчитывает максимальную ЧСС по формуле: maxHR = 220 - age
2. Возвращает объект с 5 пульсовыми зонами (в % от maxHR):
   - Зона 1 (восстановление): 50-60%
   - Зона 2 (жиросжигание): 60-70%
   - Зона 3 (аэробная): 70-80%
   - Зона 4 (анаэробная): 80-90%
   - Зона 5 (красная): 90-100%
3. Выводит результат в формате: «Зона X: от Y до Z уд/мин»
Добавь обработку ошибок: если age < 10 или age > 100, вывести предупреждение.

🧠 Промпт 4: ИИ-тренер занятие 4

PROMPT
Ты — спортивный тренер с медицинским образованием. Составь индивидуальную разминку на 10 минут для пользователя:
- Возраст: [возраст ученика]
- Цель тренировки: [бег / силовая / йога]
- Текущий пульс в покое: [значение] уд/мин
- Уровень подготовки: [начинающий / средний / продвинутый]
Включи: 5 упражнений с описанием, временем и целевым пульсом. Объясни, почему выбрал именно эти упражнения с точки зрения физиологии.

💻 Эталонный код JavaScript

Готовый пример для проверки работ учеников. Можно использовать как «золотой стандарт» при Peer Review.

Функция расчёта пульсовых зон calcHR()

function calcHR() {
  const age = +document.getElementById('age').value;
  const currentPulse = +document.getElementById('pulse').value;

  if (age < 10 || age > 100) {
    alert('Введите возраст от 10 до 100 лет');
    return;
  }

  const maxHR = 220 - age;

  const zones = [
    { name: 'Восстановление', min: 0.50, max: 0.60 },
    { name: 'Жиросжигание',   min: 0.60, max: 0.70 },
    { name: 'Аэробная',       min: 0.70, max: 0.80 },
    { name: 'Анаэробная',     min: 0.80, max: 0.90 },
    { name: 'Красная зона',   min: 0.90, max: 1.00 }
  ];

  let result = `ЧССmax: ${maxHR} уд/мин\n\n`;
  zones.forEach((zone, i) => {
    const from = Math.round(maxHR * zone.min);
    const to = Math.round(maxHR * zone.max);
    result += `Зона ${i+1} (${zone.name}): ${from}–${to} уд/мин\n`;
  });

  const element = document.getElementById('result');
  if (currentPulse > maxHR * 0.9) {
    element.style.background = '#fecaca';
    element.style.color = '#1f2937';
    result += '\n⚠️ ПЕРЕГРУЗКА! Пульс выше 90% от ЧССmax';
  } else {
    element.style.background = '#d1fae5';
  }

  element.textContent = result;
}

🔧 Практический кейс: «Найди 4 ошибки» + Ключ

⚠️ Для занятия 4: скопируйте код ниже и раздайте ученикам. Задание: найти все 4 ошибки, исправить их и объяснить каждую.

📋 «Испорченный» код для учеников раздать ученикам

<!-- Найдите и исправьте 4 ошибки -->
<script>
function calcHR() {
  const age = +document.getElementById('age').value;
  const pulse = +document.getElementById('pulse').value;

  const maxHR = 200 - age; // ошибка здесь?

  let result = `ЧССmax: ${maxHR} уд/мин\n`;

  if (pulse > maxHR * 0.8) {
    document.getElementById('result').style.background = '#fecaca';
    result += '\n⚠️ ПЕРЕГРУЗКА!';
  }

  document.getElementById('result').textContent = result;
}
</script>

<!-- Промпт: "Составь разминку для пользователя. Цель: бег." -->

🔑 Ключ для учителя список ошибок

Ошибка в кодеКак должно бытьПроверяемая компетенция
1В формуле ЧСС стоит 200 - age220 - ageМежпредметная интеграция
2Условие подсветки: pulse > maxHR * 0.8pulse > maxHR * 0.9Алгоритмизация
3В CSS не указан color для текста на красном фонеДобавить color: #1f2937;Качество продукта (UI/UX)
4В промпте ИИ-тренеру не указан возраст пользователяДобавить параметр Возраст: [X] летPrompt Engineering
💡 Как использовать: на занятии 4 (этап «Дебаггинг») ученики работают в парах, фиксируют найденные ошибки в рабочем листе, затем обсуждают решения. Это развивает компетенции «Дебаггинг» и «Межпредметная интеграция» без зависимости от внешних ссылок.

✅ Чек-лист Peer Review (взаимопроверка)

Протестируй сайт одноклассника и оцени по критериям: отмечено 0 из 9

💡 Как использовать: распечатай этот чек-лист или открой на планшете. При взаимопроверке отмечай галочками каждый пункт. Обсуди с одноклассником замечания и предложения по улучшению.

📁 Структура проекта (для работы без интернета)

Полная файловая структура веб-приложения «Цифровой атлет — Твой персональный тренер» с исходными кодами всех модулей.

💡 Как использовать:
  1. Скопируйте приведённые файлы в соответствующую структуру папок.
  2. Откройте index.html в браузере.
  3. Заполните профиль — данные сохранятся в localStorage.
  4. Добавляйте тренировки, записывайте вес, генерируйте планы и смотрите прогресс.

Все модули независимы и легко расширяются. Вы можете заменить Tailwind на свои стили или добавить серверную часть для хранения данных.

Файловая структура

fitcoach/
├── index.html — главная страница (подключение стилей и скриптов)
├── css/
└── style.css — все стили (вынесены из HTML)
├── js/
├── app.js — инициализация приложения, переключение табов
├── storage.js — работа с localStorage
├── profile.js — управление профилем, пульсовые зоны
├── workouts.js — дневник тренировок (CRUD, статистика)
├── weightLog.js — журнал веса (CRUD)
├── aiTrainer.js — генерация плана, сохранение/восстановление
├── chart.js — отрисовка графика (Chart.js)
└── utils.js — вспомогательные функции
└── README.md — описание проекта

1. index.html главная страница

<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>FitCoach — Твой персональный тренер</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link href="https://fonts.googleapis.com/css2?family=Oswald:wght@400;500;600;700&family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
    <link rel="stylesheet" href="css/style.css" />
</head>
<body>
    <header class="hero-header sticky top-0 z-50 shadow-lg">
        <div class="max-w-6xl mx-auto px-4 py-5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 relative z-10">
            <div>
                <h1 class="hero-title">
                    <span>🏋️</span>
                    <span>FitCoach</span>
                </h1>
                <p class="hero-subtitle">Твой путь к лучшей форме</p>
            </div>
            <nav class="flex flex-wrap gap-2">
                <button onclick="App.switchTab('profile')" id="tab-profile" class="tab-btn tab-active px-4 py-2 rounded-xl text-sm">👤 Профиль</button>
                <button onclick="App.switchTab('diary')" id="tab-diary" class="tab-btn tab-inactive px-4 py-2 rounded-xl text-sm">📓 Дневник</button>
                <button onclick="App.switchTab('ai')" id="tab-ai" class="tab-btn tab-inactive px-4 py-2 rounded-xl text-sm">🤖 ИИ-тренер</button>
                <button onclick="App.switchTab('progress')" id="tab-progress" class="tab-btn tab-inactive px-4 py-2 rounded-xl text-sm">📈 Прогресс</button>
            </nav>
        </div>
    </header>

    <main class="max-w-6xl mx-auto px-4 py-8">
        <section id="section-profile" class="space-y-6"></section>
        <section id="section-diary" class="space-y-6 hidden"></section>
        <section id="section-ai" class="space-y-6 hidden"></section>
        <section id="section-progress" class="space-y-6 hidden"></section>
    </main>

    <footer class="max-w-6xl mx-auto px-4 py-8 text-center text-sm text-gray-500">
        <p>Твой персональный тренер · 2026</p>
    </footer>

    <script type="module" src="js/app.js"></script>
</body>
</html>

2. css/style.css основные стили + анимации

/* ====== Глобальные переменные ====== */
:root {
    --primary: #10b981;
    --primary-dark: #059669;
    --accent: #3b82f6;
    --accent-dark: #2563eb;
    --bg: #f9fafb;
    --danger: #ef4444;
    --danger-dark: #dc2626;
}

* { box-sizing: border-box; }

body {
    font-family: 'Inter', system-ui, sans-serif;
    background-color: var(--bg);
    background-image: radial-gradient(circle at 10% 20%, rgba(16, 185, 129, 0.05) 0%, transparent 40%),
                      radial-gradient(circle at 90% 80%, rgba(59, 130, 246, 0.05) 0%, transparent 40%);
    min-height: 100vh;
    color: #1f2937;
}

.font-display { font-family: 'Oswald', sans-serif; letter-spacing: 0.02em; }

/* ====== Шапка ====== */
.hero-header {
    background: linear-gradient(135deg, #10b981 0%, #059669 50%, #3b82f6 100%);
    position: relative;
    overflow: hidden;
}
.hero-title {
    font-family: 'Oswald', sans-serif;
    font-weight: 700;
    font-size: 36px;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: white;
    display: flex;
    align-items: center;
    gap: 12px;
}

/* ====== Кнопки табов, карточки, поля, таблицы, зоны ====== */
.tab-active { background: white; color: var(--primary); box-shadow: 0 4px 15px rgba(0,0,0,0.15); }
.tab-inactive { background: rgba(255,255,255,0.15); color: white; }
.sport-card { background: white; border-radius: 20px; box-shadow: 0 4px 20px rgba(0,0,0,0.06); }
.sport-input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 4px rgba(16,185,129,0.1); }
.btn-primary { background: linear-gradient(135deg, var(--primary), var(--primary-dark)); color: white; }
.btn-accent { background: linear-gradient(135deg, var(--accent), var(--accent-dark)); color: white; }
.stat-green { background: linear-gradient(135deg, #10b981, #059669); }
.stat-blue { background: linear-gradient(135deg, #3b82f6, #2563eb); }
.zone-badge { display: inline-block; padding: 4px 10px; border-radius: 20px; font-size: 11px; font-weight: 700; color: white; }
.hidden { display: none !important; }

3. js/utils.js вспомогательные функции

export function generateId() {
    if (typeof crypto !== 'undefined' && crypto.randomUUID) {
        return crypto.randomUUID();
    }
    return Date.now().toString(36) + Math.random().toString(36).substr(2);
}

export function estimateCalories(duration, weight, hrBefore, hrAfter, type) {
    const hrAvg = (hrBefore + hrAfter) / 2;
    const typeFactors = {
        '🏃 Кардио': 1.1,
        '🏋️ Силовая': 0.9,
        '🧘 Йога': 0.5,
        '👟 Бег': 1.2,
        '🚴 Велосипед': 1.0,
        '🏊 Плавание': 1.15
    };
    const base = duration * (0.035 * weight + (0.029 * hrAvg * weight) / 200);
    return Math.round(base * (typeFactors[type] || 1));
}

export function formatDate(dateStr) {
    const d = new Date(dateStr);
    return d.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short', year: 'numeric' });
}

export function sortByDateDesc(arr, dateKey = 'date') {
    return [...arr].sort((a, b) => b[dateKey].localeCompare(a[dateKey]));
}

export function today() {
    return new Date().toISOString().slice(0, 10);
}

4. js/storage.js localStorage

const PROFILE_KEY = 'fitcoach_profile';
const WORKOUTS_KEY = 'fitcoach_workouts';
const WEIGHT_LOG_KEY = 'fitcoach_weight_log';
const AI_PLAN_KEY = 'fitcoach_ai_plan';

export function loadProfile() {
    try {
        const raw = localStorage.getItem(PROFILE_KEY);
        return raw ? JSON.parse(raw) : null;
    } catch { return null; }
}

export function saveProfile(profile) {
    localStorage.setItem(PROFILE_KEY, JSON.stringify(profile));
}

export function loadWorkouts() {
    try {
        const raw = localStorage.getItem(WORKOUTS_KEY);
        return raw ? JSON.parse(raw) : [];
    } catch { return []; }
}

export function saveWorkouts(workouts) {
    localStorage.setItem(WORKOUTS_KEY, JSON.stringify(workouts));
}

export function loadWeightLog() {
    try {
        const raw = localStorage.getItem(WEIGHT_LOG_KEY);
        return raw ? JSON.parse(raw) : [];
    } catch { return []; }
}

export function saveWeightLog(log) {
    localStorage.setItem(WEIGHT_LOG_KEY, JSON.stringify(log));
}

export function loadAIPlan() {
    try {
        const raw = localStorage.getItem(AI_PLAN_KEY);
        return raw ? JSON.parse(raw) : null;
    } catch { return null; }
}

export function saveAIPlan(planData) {
    localStorage.setItem(AI_PLAN_KEY, JSON.stringify(planData));
}

export function clearAIPlan() {
    localStorage.removeItem(AI_PLAN_KEY);
}

5. js/profile.js модуль профиля

import { loadProfile, saveProfile } from './storage.js';

const defaultProfile = {
    name: '', surname: '', age: 0, height: 0,
    weight: 0, goal: '', level: '', location: ''
};

let profile = loadProfile() || { ...defaultProfile };

export function getProfile() { return profile; }

export function updateProfile(newData) {
    profile = { ...profile, ...newData };
    saveProfile(profile);
    return profile;
}

export function setProfileField(field, value) {
    profile[field] = value;
    saveProfile(profile);
}

export function getZones(age) {
    const max = 220 - age;
    return [
        { name: 'Зона 1', label: 'Восстановительная', min: max * 0.5, max: max * 0.6, color: '#10b981' },
        { name: 'Зона 2', label: 'Жиросжигающая',   min: max * 0.6, max: max * 0.7, color: '#84cc16' },
        { name: 'Зона 3', label: 'Аэробная',        min: max * 0.7, max: max * 0.8, color: '#eab308' },
        { name: 'Зона 4', label: 'Анаэробная',      min: max * 0.8, max: max * 0.9, color: '#f97316' },
        { name: 'Зона 5', label: 'Максимальная',    min: max * 0.9, max: max * 1.0, color: '#ef4444' }
    ];
}

export function getZoneForHR(hr, age) {
    const zones = getZones(age);
    return zones.find(z => hr >= z.min && hr < z.max) || null;
}

6. js/workouts.js дневник тренировок

import { loadWorkouts, saveWorkouts } from './storage.js';
import { getProfile } from './profile.js';
import { generateId, estimateCalories, sortByDateDesc } from './utils.js';

let workouts = loadWorkouts();

export function getWorkouts() { return workouts; }

export function saveWorkout(workout) {
    const existingIndex = workouts.findIndex(w => w.id === workout.id);
    if (existingIndex !== -1) {
        workouts[existingIndex] = workout;
    } else {
        workout.id = workout.id || generateId();
        workouts.unshift(workout);
    }
    saveWorkouts(workouts);
    return workouts;
}

export function deleteWorkout(id) {
    workouts = workouts.filter(w => w.id !== id);
    saveWorkouts(workouts);
    return workouts;
}

export function getStats() {
    const total = workouts.length;
    const minutes = workouts.reduce((s, w) => s + w.duration, 0);
    const avgHR = total ? Math.round(workouts.reduce((s, w) => s + w.hrAfter, 0) / total) : 0;
    const profile = getProfile();
    const kcal = workouts.reduce((s, w) => s + estimateCalories(w.duration, profile.weight, w.hrBefore, w.hrAfter, w.type), 0);
    return { total, minutes, avgHR, kcal };
}

7. js/weightLog.js журнал веса

import { loadWeightLog, saveWeightLog } from './storage.js';
import { getProfile, updateProfile } from './profile.js';

let weightLog = loadWeightLog();

export function getWeightLog() { return weightLog; }

export function saveWeightEntry(date, weight) {
    const existing = weightLog.find(entry => entry.date === date);
    if (existing) {
        existing.weight = weight;
    } else {
        weightLog.push({ date, weight });
    }
    saveWeightLog(weightLog);
    const latest = weightLog.reduce((a, b) => a.date > b.date ? a : b);
    updateProfile({ weight: latest.weight });
    document.getElementById('profile-weight').value = latest.weight;
    return weightLog;
}

export function deleteWeightEntry(date) {
    weightLog = weightLog.filter(w => w.date !== date);
    saveWeightLog(weightLog);
    return weightLog;
}

8. js/aiTrainer.js ИИ-тренер

import { getProfile, getZones } from './profile.js';
import { loadAIPlan, saveAIPlan, clearAIPlan } from './storage.js';

export function generatePlan() {
    const profile = getProfile();
    if (!profile.name || !profile.age) {
        alert('Заполните имя и возраст в профиле.');
        return;
    }

    const levelBase = { 'Новичок': 25, 'Средний': 40, 'Продвинутый': 60 };
    const baseDuration = levelBase[profile.level] || 40;

    const templates = {
        'Похудение': {
            'Новичок': ['Кардио', 'Отдых', 'Кардио', 'Отдых', 'Силовая', 'Кардио', 'Отдых'],
            'Средний': ['Кардио', 'Силовая', 'Кардио', 'Отдых', 'Силовая', 'Бег', 'Отдых'],
            'Продвинутый': ['Кардио', 'Силовая', 'Кардио', 'Силовая', 'Кардио', 'Бег', 'Отдых']
        },
        'Набор массы': {
            'Новичок': ['Силовая', 'Отдых', 'Силовая', 'Отдых', 'Силовая', 'Отдых', 'Отдых'],
            'Средний': ['Силовая', 'Силовая', 'Отдых', 'Силовая', 'Кардио', 'Силовая', 'Отдых'],
            'Продвинутый': ['Силовая', 'Силовая', 'Силовая', 'Отдых', 'Силовая', 'Силовая', 'Отдых']
        },
        'Поддержание формы': {
            'Новичок': ['Кардио', 'Отдых', 'Йога', 'Отдых', 'Кардио', 'Отдых', 'Отдых'],
            'Средний': ['Кардио', 'Силовая', 'Йога', 'Кардио', 'Силовая', 'Бег', 'Отдых'],
            'Продвинутый': ['Кардио', 'Силовая', 'Силовая', 'Кардио', 'Силовая', 'Бег', 'Йога']
        }
    };

    const goalTpl = templates[profile.goal] || templates['Поддержание формы'];
    const weekTypes = goalTpl[profile.level] || goalTpl['Средний'];
    const days = ['Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота', 'Воскресенье'];

    const plan = weekTypes.map((type, i) => {
        const duration = type === 'Отдых' ? 30 : Math.max(20, Math.round(baseDuration + (Math.random() - 0.5) * 10));
        return { day: days[i], type, duration };
    });

    saveAIPlan({
        plan,
        params: { goal: profile.goal, level: profile.level, location: profile.location },
        generatedAt: new Date().toISOString()
    });
}

export function clearPlan() {
    if (confirm('Удалить сохранённый план?')) clearAIPlan();
}

9. js/chart.js график прогресса

import { getWorkouts } from './workouts.js';
import { getWeightLog } from './weightLog.js';

let currentChart = null;

export function renderChart() {
    const container = document.getElementById('chart-container');
    const workouts = getWorkouts();
    const weightLog = getWeightLog();

    const dateSet = new Set();
    workouts.forEach(w => dateSet.add(w.date));
    weightLog.forEach(w => dateSet.add(w.date));

    if (dateSet.size === 0) {
        container.innerHTML = '<div>Добавьте тренировки или записи веса</div>';
        return;
    }

    const sortedDates = Array.from(dateSet).sort();

    const weightData = sortedDates.map(date => {
        const entries = weightLog.filter(w => w.date === date);
        return entries.length ? entries[entries.length - 1].weight : null;
    });

    const hrData = sortedDates.map(date => {
        const dayWorkouts = workouts.filter(w => w.date === date);
        if (dayWorkouts.length === 0) return null;
        return Math.round(dayWorkouts.reduce((s, w) => s + w.hrAfter, 0) / dayWorkouts.length);
    });

    if (currentChart) currentChart.destroy();

    const canvas = document.createElement('canvas');
    container.innerHTML = '';
    container.appendChild(canvas);

    currentChart = new Chart(canvas, {
        type: 'line',
        data: {
            labels: sortedDates.map(d => d.slice(5)),
            datasets: [
                { label: 'Вес (кг)', data: weightData, borderColor: '#14b8a6', yAxisID: 'y', fill: true, tension: 0.4 },
                { label: 'Пульс после (сред.)', data: hrData, borderColor: '#ef4444', yAxisID: 'y1', fill: true, tension: 0.4 }
            ]
        },
        options: {
            responsive: true, maintainAspectRatio: false,
            scales: {
                y: { position: 'left', title: { display: true, text: 'Вес (кг)' } },
                y1: { position: 'right', title: { display: true, text: 'Пульс' }, grid: { drawOnChartArea: false } }
            }
        }
    });
}

10. js/app.js главный модуль

import { initProfileForm } from './profile.js';
import { initWorkoutForm, renderWorkoutsTable, updateStatsUI } from './workouts.js';
import { initWeightForm, renderWeightTable, updateWeightStatsUI } from './weightLog.js';
import { restoreSavedPlan, initAITrainer } from './aiTrainer.js';
import { renderChart } from './chart.js';

const App = {
    currentTab: 'profile',

    switchTab(tabName) {
        document.querySelectorAll('main > section').forEach(s => s.classList.add('hidden'));
        const section = document.getElementById(`section-${tabName}`);
        if (section) section.classList.remove('hidden');

        document.querySelectorAll('nav button').forEach(b => {
            b.classList.remove('tab-active');
            b.classList.add('tab-inactive');
        });
        const tabBtn = document.getElementById(`tab-${tabName}`);
        if (tabBtn) {
            tabBtn.classList.remove('tab-inactive');
            tabBtn.classList.add('tab-active');
        }

        this.currentTab = tabName;
        if (tabName === 'progress') setTimeout(renderChart, 100);
    },

    init() {
        initProfileForm();
        initWorkoutForm();
        initWeightForm();
        initAITrainer();

        renderWorkoutsTable();
        renderWeightTable();
        updateStatsUI();
        updateWeightStatsUI();

        restoreSavedPlan();
        this.switchTab('profile');
    }
};

window.App = App;

document.addEventListener('DOMContentLoaded', () => App.init());