Все мы знаем как выглядит сетка в профиле на 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% с оплат звёздами на два уровня |
|
Языки |
Русский и английский, переключатель в настройках |
Вместо заключения: пять выводов
-
Telegram Mini App — самый дешёвый способ проверить идею потребительского продукта в одиночку. Авторизация, уведомления, оплата и распространение уже есть. Остаётся сделать сам продукт.
-
Скучный стек — быстрый стек. Три зависимости, SQLite и ванильный JS позволили выкатывать по несколько фич в день. Платой стала одна молчаливая кнопка. Я бы выбрал так же снова — но с линтером с первого дня.
-
Время и деньги храните как события и отрезки, а не как состояния. «Старт, паузы, шаг» вместо «осталось 30 секунд». «Добавил период с … по …» вместо «продлил подписку». Тогда таймер переживает сон приложения, а возврат снимает ровно то, что нужно.
-
День ревью нужен раньше, чем кажется. Я провёл его на 13-й день, а стоило — до первой фичи с деньгами.
-
Меня невероятно воодушевляет та мысль, что можно самому делать гибкий инструмент под себя. Он будет закрывать твои хотелки, как ты хочешь, а ещё им пользуешься не только ты сам.
Кажется, это именно то, что ждёт индустрию в ближайшие пару лет. Никто не читает крупные СМИ , все читают и смотрят блогеров, которые им откликаются, и платят им. И блогером может стать кто угодно.Скоро будет огромное кол-во маленьких приложений под любые нужды, и все будут пользоваться разными, потому что детали и подача будут в огромном ассортименте.
ссылка на оригинал статьи https://habr.com/ru/articles/1104806/