Коммиты в спортзал: трекер тренировок в 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 × 14Node.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% с оплат звёздами на два уровня |
Языки | Русский и английский, переключатель в настройках |
Вместо заключения: пять выводов
Telegram Mini App — самый дешёвый способ проверить идею потребительского продукта в одиночку. Авторизация, уведомления, оплата и распространение уже есть. Остаётся сделать сам продукт.
Скучный стек — быстрый стек. Три зависимости, SQLite и ванильный JS позволили выкатывать по несколько фич в день. Платой стала одна молчаливая кнопка. Я бы выбрал так же снова — но с линтером с первого дня.
Время и деньги храните как события и отрезки, а не как состояния. «Старт, паузы, шаг» вместо «осталось 30 секунд». «Добавил период с … по …» вместо «продлил подписку». Тогда таймер переживает сон приложения, а возврат снимает ровно то, что нужно.
День ревью нужен раньше, чем кажется. Я провёл его на 13-й день, а стоило — до первой фичи с деньгами.
Меня невероятно воодушевляет та мысль, что можно самому делать гибкий инструмент под себя. Он будет закрывать твои хотелки, как ты хочешь, а ещё им пользуешься не только ты сам.
Кажется, это именно то, что ждёт индустрию в ближайшие пару лет. Никто не читает крупные СМИ , все читают и смотрят блогеров, которые им откликаются, и платят им. И блогером может стать кто угодно.Скоро будет огромное кол-во маленьких приложений под любые нужды, и все будут пользоваться разными, потому что детали и подача будут в огромном ассортименте.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.