Коммиты в спортзал: трекер тренировок в Telegram в стиле GitHub

—

от автора

Все мы знаем как выглядит сетка в профиле на GitHub.
53 недели, 7 дней, по квадратику на день.

У меня довольно скромный профиль, но всегда тащился по закрашиванию дней.

Как-то на тренировке я одновременно листал своё приложение для тренировок и зачем-то залез в приложение гитхаба.

Этим же вечером появились первые наброски Fitcommit

Telegram Mini App, в котором год тренировок выглядит как граф контрибуций. Квадрат — день, цвет — вид спорта, две тренировки в один день делят клетку по диагонали. Вечером бот спрашивает, была ли сегодня тренировка.

Механика намеренно простая:

  • Сетка из 53 колонок-недель и 7 строк (пн–вс) сама прокручивается до сегодняшнего дня.

  • Тап по дню открывает шторку: вид тренировки и необязательное время, с быстрыми кнопками 30/45/60/90 минут.

  • Виды тренировок и их цвета пользователь задаёт сам. Две тренировки за день делят квадрат по диагонали, три и больше — на диагональные полосы.

  • Статистика за год: дни с тренировками и без, процент года, часы по видам, среднее время на тренировку.

  • Каждый вечер в 23:00 по местному времени бот спрашивает: «Была ли сегодня тренировка?» «Да» открывает приложение сразу с формой на сегодня, «Нет» — день отдыха. Если отметка уже есть, бот молчит.

  • Год можно выгрузить картинкой 1080×1080 и выложить в сторис прямо из Telegram.

Почему Telegram Mini App, а не обычное приложение

Трекером пользуются, только если он открывается за секунду. Telegram и так открыт весь день, а Mini App сразу даёт то, что в мобильном приложении пришлось бы строить неделями:

  • Без магазинов приложений. Ни ревью в App Store, ни сборок под две платформы. Обновление — это деплой веб-страницы.

  • Без регистрации. Telegram сам подписывает данные пользователя (initData), сервер проверяет подпись. Логин и пароль не нужны.

  • Уведомления без push-сервиса. Вечерний вопрос — обычное сообщение от бота.

  • Оплата встроена. Telegram Stars принимаются через инвойс бота , платёжный провайдер не нужен.

Технические решения и почему они такие

Клиент Telegram                              Telegram Bot API  WebView: index.html + app.js                    ▲  Authorization: tma <initData>                   │ long polling        │                                         │        ▼                                         │  Caddy (TLS, CSP, общий для всех сайтов)         │        │  docker-сеть edge                       │        ▼                                         │  Контейнер fitcommit (256 МБ, 0.5 CPU)    ───────┘    ├─ Express 5: статика + REST API    ├─ grammY: бот, платежи, напоминания, таймеры    └─ better-sqlite3 → /data/tracker.db (WAL)                        └─ backup/tracker-ГГГГММДД.db.gz × 14

Node.js 22 и Express 5. API — обычный REST: виды тренировок, дневники, группы, платежи. Express 5 понимает async-обработчики без обёрток, большего не нужно. Node 22 — ради встроенных process.loadEnvFile и node:test: .env и тесты без лишних пакетов.

grammY. Бот и мини-апп живут в одном процессе и делят одну базу. У grammY есть трансформеры API: через них проходит каждый вызов Telegram — троттлинг до ~25 сообщений в секунду и автоповтор на 429. Бот работает на long polling: не нужен вебхук и публичный адрес под него.

SQLite через better-sqlite3. Для трекера на сотни пользователей отдельный Postgres — ещё один сервис, который надо бэкапить, обновлять и мониторить. SQLite — один файл. better-sqlite3 синхронный, и для денег это плюс: транзакция — просто функция db.transaction(fn), внутри нет await и некуда вклиниться другому запросу.

Фронтенд без фреймворка и без сборки. index.html, style.css, app.js и по файлу на каждый дневник. Модули делят общие помощники через один объект window.FC. Мини-апп грузится мгновенно даже на плохом мобильном интернете, а конфига бандлера просто нет.

Docker, GHCR, GitHub Actions, Caddy. Один VPS. Контейнер не публикует ни одного порта: только expose внутри docker-сети.

Вход без логина: проверка initData

Когда Telegram открывает мини-апп, он передаёт строку initData: данные пользователя, время авторизации и HMAC-подпись, построенную на токене бота. Клиент отправляет её в каждом запросе в заголовке Authorization: tma <initData>, сервер проверяет подпись и только потом доверяет user.id. Своих сессий, паролей и JWT в проекте нет вообще.

function validateInitData(initData, botToken, maxAgeSec) {  const params = new URLSearchParams(initData);  const hash = params.get('hash');  if (!hash) return null;  params.delete('hash');  const dataCheckString = [...params.entries()]    .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) // байтовый порядок, не localeCompare    .map(([k, v]) => `${k}=${v}`)    .join('\n');  const secret = crypto.createHmac('sha256', 'WebAppData').update(botToken).digest();  const calc = crypto.createHmac('sha256', secret).update(dataCheckString).digest('hex');  const a = Buffer.from(calc, 'hex');  const b = Buffer.from(hash, 'hex');  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;  const authDate = Number(params.get('auth_date') || 0);  if (maxAgeSec && Date.now() / 1000 - authDate > maxAgeSec) return null;  return JSON.parse(params.get('user'));}

Бот и API в одном процессе

Express и grammY запущены в одном процессе. Это упрощает всё: общая база, общий конфиг, бот может отправить сообщение прямо из обработчика API. Но у long polling жёсткое правило: один токен — один процесс, читающий обновления. Второй получает 409 Conflict, и ошибками сыплют оба.

Отсюда три следствия:

  • Локально — только с отдельным тестовым ботом.

  • В GitHub Actions — concurrency без отмены: два деплоя, а значит и два бота, одновременно не идут.

  • При остановке важен порядок: сначала bot.stop(), чтобы освободить long polling для нового контейнера, потом дождаться HTTP-запросов, потом закрыть базу. В compose — init: true, чтобы SIGTERM вообще доходил до Node, и stop_grace_period: 30s.

async function shutdown(code = 0) {  if (stopping) return;  stopping = true;  const force = setTimeout(() => process.exit(code || 1), 20_000);  force.unref();  try { if (bot?.isRunning()) await bot.stop(); } catch (e) { console.error('bot stop:', e.message); }  await new Promise((r) => server.close(() => r()));  try { db.close(); } catch (_) { /* уже закрыта */ }  process.exit(code);}process.once('SIGTERM', () => shutdown(0));

SQLite в продакшене

Четыре прагмы на старте закрывают большую часть претензий к SQLite «не для прода»:

db.pragma('journal_mode = WAL');    // читатели не ждут писателяdb.pragma('synchronous = NORMAL');  // с WAL безопасно: при сбое питания теряются последние коммиты, файл не бьётсяdb.pragma('busy_timeout = 5000');   // подождать блокировку (например, ночного бэкапа), а не упастьdb.pragma('foreign_keys = ON');

Миграции — без фреймворка. Схема создаётся через CREATE TABLE IF NOT EXISTS, а новая колонка добавляется, если PRAGMA table_info её не видит. Когда схема меняется несколько раз в неделю, это удобнее нумерованных миграций: файл схемы читается сверху вниз как история проекта.

const userCols = db.prepare('PRAGMA table_info(users)').all().map((c) => c.name);const addUserCol = (name, def) => {  if (!userCols.includes(name)) db.exec(`ALTER TABLE users ADD COLUMN ${name} ${def}`);};addUserCol('referrer_id', 'INTEGER');   // кто пригласил (1-й уровень)addUserCol('referrer2_id', 'INTEGER');  // кто пригласил пригласившего (2-й уровень)

Бэкап делается штатным online backup API SQLite каждую ночь. Копия проверяется quick_check, сжимается и хранится 14 ночей, восстановление расписано в README по шагам. Честное слабое место: копии лежат на том же диске, что и база. Умрёт диск — умрут и бэкапы. Следующий шаг — Litestream или S3.

Время — самая коварная сущность в трекере

Трекер тренировок почти целиком состоит из дат и таймеров. И почти каждая проблема здесь начинается со слов «а если у пользователя…».

Дата тренировки — локальная. В таблице workouts дата хранится строкой YYYY-MM-DD в часовом поясе человека, а не в UTC. Тренировка в 23:30 в Бангкоке должна попасть в бангкокское «сегодня». Часовой пояс приложение берёт с устройства, и при переезде он меняется сам.

Напоминания. Раз в 30 секунд сервер проходит по пользователям с включённым напоминанием и проверяет, наступило ли у них по местному времени remind_time. Создать Intl.DateTimeFormat дорого, поэтому форматтеры кешируются по часовому поясу:

const fmtCache = new Map();function zoneFormatter(tz) {  let f = fmtCache.get(tz);  if (!f) {    f = new Intl.DateTimeFormat('en-CA', {      timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit',      hour: '2-digit', minute: '2-digit', hourCycle: 'h23',    }); // на неизвестной зоне бросает исключение — тогда не кешируем    if (fmtCache.size > 1000) fmtCache.clear();    fmtCache.set(tz, f);  }  return f;}

Таймеры на серверных отметках. Плеер практики йоги и таймер отдыха в зале нельзя считать на клиенте. Веб-вью Telegram засыпает, когда приложение свёрнуто или экран заблокирован, и setInterval просто перестаёт тикать. Поэтому сервер хранит не «сколько осталось», а моменты: старт, паузы, начало текущего шага. Клиент только рисует разницу между ними и текущим временем. Закрыл приложение посреди позы, открыл через пять минут — таймер показывает правду. Забытую практику бот напомнит через 2 часа и закроет через 6.

Офлайн-очередь. В залах и подвальных студиях связи часто нет. Действия плеера — пауза, следующая поза, выполненный подход — складываются в localStorage вместе с моментом нажатия. Экран меняется сразу (оптимистично), а на сервер действия уходят, когда сеть вернулась. Сервер применяет их со временем нажатия, а не доставки.

// действия хранят время нажатия и уходят на сервер, когда появилась сетьasync function flushQueue() {  while (Y.queue.length) {    const a = Y.queue[0];    try { setSession(await api('POST', `/yoga/sessions/${a.id}/${a.action}`, { at: a.at, step: a.step })); }    catch (e) { if (e instanceof TypeError) return; /* всё ещё офлайн */ }    Y.queue.shift(); saveQueue();  }}setInterval(() => { if (Y.queue.length) flushQueue(); }, 4000);

Гонка двойного тапа. Первая версия плеера иногда перепрыгивала позу. Двойной тап по «Далее» или тап, совпавший с автопереходом, отправлял два next подряд. Теперь каждое перемещение несёт номер шага, на котором его нажали (поле step выше). Сервер игнорирует устаревшее: next с шага 4, пришедший, когда практика уже на шаге 5, ничего не делает. Классическая идемпотентность из финтеха, только вместо платежа — асана.

Когда нужен бот, а не таймер. Серверные отметки не помогут, если экран заблокирован и человек просто не слышит, что отдых закончился. Для этого есть опция: бот присылает «Отдых окончен» обычным уведомлением Telegram. Проверка идёт каждые 5 секунд, на один отдых — одно сообщение, а предыдущее удаляется, чтобы не засорять чат.

Безопасность и деплой глазами QA

День ревью я провёл так, как провёл бы аудит чужого сервиса: куда попадают данные пользователя, что будет при флуде, что утечёт в ошибках. Вот что нашлось в моём же коде:

  • Stored XSS через имя в Telegram. Имя участника группы попадало в SVG как разметка. Имя в Telegram может содержать что угодно, включая угловые скобки. Теперь оно идёт в значение атрибута, а не в разметку.

  • CSV-инъекция в админке. Выгрузка /users открывается в Excel, а имя, начинающееся с =, Excel исполнит как формулу. Теперь такие ячейки получают апостроф впереди.

  • Загрузка картинок. Принимается только настоящий PNG: сигнатура файла и размер из заголовка IHDR, до 2 МБ, не больше 10 в час. Файлы удаляются через сутки.

  • Бот как инструмент спама. Приглашение в группу — это сообщение от бота незнакомому человеку. Лимиты: 20 приглашений в сутки, одно сообщение от одного приглашающего одному человеку в сутки. Названия групп, похожие на ссылки, в текст приглашения не попадают, иначе группа с именем-ссылкой превращает моего бота в рассыльщика фишинга.

  • Общий лимит — 240 запросов в минуту на пользователя, скользящее окно в памяти. Для одного процесса Redis не нужен.

  • Дев-режим. Локально DEV_USER_ID позволяет работать без подписи Telegram. В продакшене приложение с ним просто не запустится, а деплой откажется ехать. Два ремня вместо одного.

CSP без пустого экрана. Политика строгая: скрипты только свои и telegram.org, никаких сторонних запросов. Исключение — frame-ancestors, то есть кто может встраивать приложение во фрейм. Он в режиме Report-Only: если какой-то клиент Telegram встраивает мини-аппы с неизвестного мне домена, пользователь увидел бы пустой экран. Нарушения пишутся в лог через /csp-report. Неделя тишины в логе — и правило переезжает в боевой заголовок.

Кэш Telegram. Веб-вью любит показывать старую версию после деплоя. Решение классическое: index.html не кэшируется, а скрипты и стили подключаются с ?v=<хеш содержимого> и кэшируются на год. Без сборки хеши считает сам сервер на старте.

Удаление данных — тоже фича с граничными случаями. Правила Telegram для ботов требуют политику конфиденциальности и удаление данных по просьбе. На деле это семь вопросов вместо одного DELETE:

  • Всё удаляется одной транзакцией: профиль, сетка, дневники, группы.

  • Свои группы не исчезают, а переходят самому раннему участнику.

  • Записи об оплатах остаются без имени: они нужны для учёта и возвратов.

  • «Надгробие» deleted_users хранит только id, дату и факт использованной бесплатной недели групп. Иначе «удалился и вернулся» стало бы способом получать новый триал.

  • 10 минут после удаления API отвечает 410 Gone. Без этого запросы, которые ещё летят из открытого приложения, тут же создали бы аккаунт заново.

Конвейер деплоя. Каждый push в main проходит четыре шага, и любой из них останавливает всё:

push в main └─ test:   npm test (65 тестов, база в памяти) + npm audit --omit=dev --audit-level=high     └─ image:  docker build → ghcr.io/<owner>/fitcommit:<sha> (linux/amd64)         └─ deploy: scp → docker compose up -d → caddy validate → reload (при ошибке — откат конфига)             └─ check:  curl https://fitcommit.menanshin.com/health, 30 попыток по 5 с

Вокруг — гигиена цепочки поставок. Actions закреплены по SHA, а не по тегу, базовый образ Node — по digest, обновления предлагает Dependabot. Ключ сервера задан в переменной DEPLOY_KNOWN_HOSTS, так что подменённый сервер деплой заметит. У workflow по умолчанию только contents: read, право писать в реестр образов есть только у шага сборки

Что умеет FitCommit сегодня

За три недели сетка обросла дневниками, группами и подпиской

Раздел

Что делает

Сетка года

Отметки тренировок, свои виды и цвета, любая прошедшая дата

Статистика

Дни и процент года, часы по видам, среднее время

Картинка года

1080×1080, сторис, отправка в чат, скачивание

Вечерний вопрос

Бот пишет в 23:00 по местному времени, время настраивается

Дневник йоги

263 асаны на 5 уровнях, комплексы и связки, плеер практики с таймером

Тренажёрный зал

577 упражнений плюс кардио, разминка и растяжка; комплексы с плановыми весами в кг, lb или % от 1ПМ; суперсеты, таймер отдыха, рекорды

Группы

Приглашение по нику, лента сеток участников, общая статистика, чат группы через бота

Тренеры

Каталог тренеров и спортивных групп с фильтром по видам спорта

Пригласить друга

Личная ссылка, 20% и 10% с оплат звёздами на два уровня

Языки

Русский и английский, переключатель в настройках

Вместо заключения: пять выводов

  1. Telegram Mini App — самый дешёвый способ проверить идею потребительского продукта в одиночку. Авторизация, уведомления, оплата и распространение уже есть. Остаётся сделать сам продукт.

  2. Скучный стек — быстрый стек. Три зависимости, SQLite и ванильный JS позволили выкатывать по несколько фич в день. Платой стала одна молчаливая кнопка. Я бы выбрал так же снова — но с линтером с первого дня.

  3. Время и деньги храните как события и отрезки, а не как состояния. «Старт, паузы, шаг» вместо «осталось 30 секунд». «Добавил период с … по …» вместо «продлил подписку». Тогда таймер переживает сон приложения, а возврат снимает ровно то, что нужно.

  4. День ревью нужен раньше, чем кажется. Я провёл его на 13-й день, а стоило — до первой фичи с деньгами.

  5. Меня невероятно воодушевляет та мысль, что можно самому делать гибкий инструмент под себя. Он будет закрывать твои хотелки, как ты хочешь, а ещё им пользуешься не только ты сам.
    Кажется, это именно то, что ждёт индустрию в ближайшие пару лет. Никто не читает крупные СМИ , все читают и смотрят блогеров, которые им откликаются, и платят им. И блогером может стать кто угодно.Скоро будет огромное кол-во маленьких приложений под любые нужды, и все будут пользоваться разными, потому что детали и подача будут в огромном ассортименте.

ссылка на оригинал статьи https://habr.com/ru/articles/1104806/