Архитектура
React 18 + TypeScript + Vite. Из runtime-зависимостей — только
react,react-domиmodern-screenshotв панели отладки (см. «Единственное исключение»). Ни роутера, ни менеджера состояния, ни библиотеки графиков, ни библиотеки перетаскивания.
Структура
src/
i18n/ строки интерфейса и правила множественного числа
domain/ типы, палитра, даты, наборы, агрегация, наблюдения
store/ локальное хранилище, прежняя схема, стор с отложенной записью
sync/ журнал операций, метки, обмен с сервером, разовый перенос
platform/ сессия и то, что различается между браузером и обёрткой
telegram/ обёртка над WebApp и хранилище «ключ — значение»
components/ батарея, строка приоритета, шторка, графики, перетаскивание
demo/ профили «показать другу» и их генератор
screens/ десять экранов
skills/ лестница ступеней, подсчёт часов, строка и шторка навыка
achievements/ реестр, предпосчёт, выдача, карточка для «поделиться»
wallpaper/ canvas-рендер и сохранение
devkit/ переносимая панель отладки: кадр, разметка, тикет
brandkit/ справочник по системе стилей, открывается по `?brand`
styles/ токены, общий слой, каркасstyles/ — три файла и они читаются подряд: tokens.css (что вообще есть в системе), ui.css (общие рецепты: кнопка, поле, полоса, строка-переход) и global.css (сброс и каркас экрана). В таком же порядке они и подключаются, см. src/main.tsx: общий слой раньше стилей экранов, каркас — последним.
brandkit/ — единственный экран, который не про приоритеты. Он показывает систему стилей: рендерит настоящие компоненты и разбирает tokens.css и все стили приложения, а не пересказывает их. Поэтому разойтись с кодом он не может: удалили токен — исчезла плашка, появился новый размер мимо шкалы — появилась строка в исключениях. Открывается адресом ?brand в любой сборке; строка в настройках есть только при npm run dev.
Рядом с src/ лежат ещё четыре самостоятельные части, каждая со своим package.json и своим деплоем: worker/ (сервер синхронизации), docs/ (этот сайт), landing/ (публичная страница) и tools/shots/ (Playwright — кадры документации и иконки приложения). См. «Сборка и публикация».
Навигация
Роутера нет. Вместо него в src/App.tsx — две переменные состояния:
tab: 'home' | 'charge' | 'skills' | 'stats' | 'settings'
overlay: 'edit' | 'presets' | 'achievements' | 'demo' | 'brand' | nullПорядок вкладок — порядок внимания: сначала то, что отмечают каждый день. Вкладка «Навыки» появляется и исчезает вместе с модулем.
Вложенный экран (overlay) закрывает вкладки целиком и перехватывает системную кнопку «назад», чтобы она возвращала к вкладкам, а не закрывала мини-приложение. Шторки делают то же самое, но живут в состоянии своего экрана, а не в App.
Адресов у экранов нет. Ни ссылок, ни истории браузера, ни глубоких ссылок: внутри Telegram адресная строка недоступна, а вторая система навигации ради несуществующего сценария не окупается.
Состояние
Один useReducer в контексте — src/store/useStore.tsx. Ни Redux, ни Zustand.
Состояние навыков и достижений держится в главном сторе, а не в собственных провайдерах, намеренно: сброс, экспорт, импорт и отложенная запись обязаны быть согласованы между собой. Это вопрос сохранности данных, а не раскладки по файлам.
Запись оптимистичная: интерфейс меняется сразу, хранилище догоняет с задержкой. Подробности — в «Данных».
Модули подключены тремя точками
«Навыки» и «Достижения» лежат отдельными папками и связаны с остальным приложением ровно тремя вещами:
- флагом в
Settings.modules, - вкладкой или строкой в
App.tsx, - полями состояния в сторе.
Что вынесено из компонентов
| Что | Куда |
|---|---|
| Весь текст интерфейса | src/i18n/ru.ts — см. «Строки интерфейса» |
| Вся арифметика статистики | src/domain/stats.ts |
| Пороги лестницы | src/skills/levels.ts |
| Условия достижений | src/achievements/registry.ts — декларативный реестр |
| Предпосчёт для условий | src/achievements/derive.ts — один проход по истории |
| Прежняя схема хранения, только чтение | src/store/legacy/persistence.ts |
| Проверка данных | src/domain/settings.ts, src/domain/battery.ts, src/skills/types.ts, src/achievements/types.ts |
| Формат копии данных | src/domain/snapshot.ts |
| Локальная копия на устройстве | src/store/local/db.ts |
| Журнал операций и слияние | src/sync/ |
| Демо-профили и их генератор | src/demo/ — см. «Демо-режим» |
Достижение — это условие и две строки, а не код. Реестр рассчитан на то, чтобы однажды стать настраиваемым.
Почему без библиотек
Не из принципа, а по расчёту: каждая зависимость — это килобайты в бандле, который грузится в вебвью на телефоне, и ещё одна вещь, которая может сломаться при обновлении клиента Telegram.
- Перетаскивание — свой хук на Pointer Events (
useReorder.ts), ~120 строк. Список из десяти строк одинаковой высоты не требует большего. - Графики — голый SVG. Два графика с известной формой дешевле любой универсальной библиотеки.
- Даты — свои функции в
domain/date.ts. Все операции локальные, и их меньше двадцати.addDaysидёт черезsetDate, а не через прибавление 86 400 000 мс, иначе переход на летнее время съедал бы день.
Единственное исключение
modern-screenshot в панели отладки — единственная библиотека сверх React. Причина в том, что здесь расчёт даёт другой ответ: превратить живой DOM в растр — это foreignObject, встраивание шрифтов, кросс-доменные картинки и десятилетие причуд WebKit. Своя реализация заняла бы больше времени, чем весь остальной модуль, и ломалась бы ровно там, где её нельзя проверить, — в вебвью чужого телефона.
Исключение ограничено тремя способами, и каждый проверяем:
- динамический
import()— библиотека лежит отдельным куском и грузится в тот момент, когда панель впервые открыли. В основной бандл она не попадает; посетитель, никогда не делавший жест, не скачивает ни байта; - отказ не ломает ничего — не догрузилось, не сняло, не закодировало: тикет уходит текстом, а причина едет отдельным полем;
- сторож в тестах —
tools/deps.test.tsсверяет список зависимостей и требует, чтобы исключение было названо и здесь, и в README.
Типы, которые ловят ошибки за компилятор
AchievementIdвыводится из ключейru.ts. Опечатка в идентификаторе или забытое название — ошибка компиляции, а не пустая карточка на экране.Settings.modulesобъявлено обязательным. Забытый модуль в новой конструкции настроек не соберётся.labelKeyвместо строки в описании периодов: подпись резолвится при отрисовке, а не застывает на языке до первого рендера.StringKeyв ошибках разбора копии, а не готовый текст: сообщение видит пользователь, значит переводит его тот, кто рисует.
Строгость включена вся: strict, noUnusedLocals, noUnusedParameters, noFallthroughCasesInSwitch, noUncheckedIndexedAccess.
Тесты
Только чистая логика (environment: 'node'), без рендера компонентов:
| Файл | Что сторожит |
|---|---|
store/legacy/persistence.test.ts | круговые рейсы через формат CloudStorage, худший месяц в 4096 байт, слияние двух копий |
store/local/db.test.ts | выбор хранилища на устройстве, перенос старой копии, отказ вместо устаревших данных |
domain/settings.test.ts | разбор настроек, генерация идентификаторов, разворачивание наборов |
domain/snapshot.test.ts | копия данных: круговой рейс, отказ на чужом файле, мусор внутри |
skills/sanitize.test.ts | инварианты каталога навыков |
sync/project.test.ts | коммутативность и идемпотентность журнала, снятие блока, барьер стирания |
sync/hlc.test.ts | монотонность метки при сбитых часах |
sync/ops.test.ts | разбор операции, пришедшей извне |
domain/stats.test.ts | окна периодов, нормализация заливки, переоценка блоков, серии, интервалы заряда через полночь и переход на летнее время |
domain/insights.test.ts | пороги и формулировки наблюдений |
demo/profiles.test.ts | валидность и детерминированность демо-профилей, полнота реестра у «Максимума» |
achievements/derive.test.ts | предпосчёт по истории |
skills/levels.test.ts | форма лестницы, точность границ в минутах, прогресс по рангу |
skills/total.test.ts | аддитивность, обратимость отвязки, маршрутизация «+» |
achievements/evaluate.test.ts | несъёмность автоматических, точность порогов, выключенный модуль |
achievements/registry.test.ts | уникальность идентификаторов, соответствие auto ⇄ test, весь реестр в пределе хранилища |
devkit/access.test.ts | когда панель отладки видна: своя машина, параметр в адресе, демо на проде |
devkit/geometry.test.ts | выделение в любую сторону, обрезка по краям, пересчёт выреза при плотности 1, 2 и 3, конечность лестницы ужатия |
devkit/redact.test.ts | вычистка личного: длинные строки, почта и JWT по форме, глубина, циклы |
devkit/context.test.ts | сборка тикета, упавший вызов приложения вместо исключения |
devkit/breadcrumbs.test.ts | кольцо ошибок, повторная установка перехвата, возврат управления настоящей консоли |
devkit/strokes.test.ts | отмена штриха, порядок точек и масштаб при отрисовке |
devkit/outbox.test.ts | очередь черновиков: вытеснение, протухание, отступы между попытками |
| devkit/invite.test.ts | ключ приглашения: разбор ссылки, память на вкладку, переход между страницами |
Плюс четыре сторожа в tools/: devkit.test.ts — копии панели на документации и лендинге и вес её входного файла; docs.test.ts — сама документация (страницы экранов, кадры, якорные ссылки), deps.test.ts — список зависимостей и переносимость src/devkit/, tickets.test.ts — разметка тикета для командной строки. Сервер проверяется отдельно, в worker/test/, включая разбор белого списка и двери командной строки (devkit.test.ts).
Рядом
Локальный запуск · Данные и синхронизация · Строки интерфейса · Про эту документацию