Цифровой аватар спортсмена: как я собрал работающий прототип на 3000 строк ванильного JS
- понедельник, 2 ноября 2026 г. в 00:00:09

Есть жанр проектов, где самое интересное — не то, что сделано, а то, что сознательно не сделано. Этот как раз такой.
Речь о системе «Цифровой аватар спортсмена» (ЦАС): верифицированный профиль спортсмена, методическое ядро тренера, аренда методик с роялти, кабинеты спонсора и федерации. Звучит как очередная «экосистема», и на этапе презентации обычно всё заканчивается слайдами. Мне было интересно другое: собрать работающий прототип, в котором каждое заявленное утверждение можно проверить двумя кликами, а не поверить на слово.
Ограничения были заданы заранее и жёстко:
ни сервера, ни базы данных, ни сборки — статические файлы;
никаких внешних запросов и CDN, всё локально;
персональных данных нет вообще, все данные синтетические;
юридическая рамка первична: сначала выясняем, что делать нельзя, и только потом проектируем, как сделать полезное.
Получилось 15 экранов, 14 функциональных модулей, 4 демонстрационные роли, 20 файлов и около 370 КБ без единой зависимости. Ниже — инженерная часть.
Главная ошибка в таких системах — одна таблица «спортсмен» со всеми полями, к которой потом аккуратно раздают доступы. Проблема в том, что «аккуратно» не работает: рано или поздно тренер видит медицинские отметки, а спонсор — паспортные данные.
Здесь данные разделены на три плоскости с разными правилами:
Плоскость | Что внутри | Кто видит |
|---|---|---|
P1. Витрина | псевдоним, регион, дисциплина, разряд, результаты турниров | все, включая публичный каталог |
P2. Методическое ядро | методики тренера, деревья решений, версии | автор, арендаторы по договору, наследник по завещанию |
P3. Закрытое хранилище | сон, ЧСС, признаки усталости, заметки тренера | сам спортсмен, законный представитель, тренер — только при живом согласии |
Ключевое: P3 недоступна не потому, что в интерфейсе спрятана кнопка, а потому что проверка зашита в модель прав. Арендатор методики физически не может получить P3 — у него нет основания обработки, а не «нет кнопки».
function canAccessPlane3(state, actorId, subjectAvatarId) { var actor = find(state.accounts, actorId); var avatar = find(state.avatars, subjectAvatarId); if (!actor || !avatar) return { allow: false, reason: 'Объект не найден' }; if (avatar.ownerId === actorId) return { allow: true, reason: 'Владелец аватара' }; var link = find(state.accounts, actorId); if (link && link.wardId && avatar.ownerId === link.wardId) return { allow: true, reason: 'Законный представитель' }; if (actor.role === 'coach') { var consent = state.consents.some(function (c) { return c.accountId === avatar.ownerId && c.type === 'coach' && !c.revokedAt; }); return consent ? { allow: true, reason: 'Согласие спортсмена на передачу тренировочных данных' } : { allow: false, reason: 'Согласие отозвано или отсутствует' }; } if (actor.role === 'federation') return { allow: false, reason: 'Федерация видит только паспортные и соревновательные данные' }; return { allow: false, reason: 'Нет основания обработки' }; }
Отзыв согласия — это не «пометка в базе», а немедленное изменение результата функции. В демо это проверяется в один клик: отзываем согласие на передачу данных тренеру — и следующий запрос тренера получает отказ с причиной. Причина показывается человеку, а не только пишется в лог: «403. Нет основания обработки» понятнее, чем пустой экран.

Вторая часть, где обычно начинается магия на словах. Методика тренера — это объект, который можно арендовать, и у которого есть «родословная»: если я взял чужую методику и адаптировал под свою дисциплину, мой вариант помнит, откуда вырос.
При каждом платеже сумма делится на три части: комиссия платформы, роялти по цепочке наследования и остаток автору.
Правила простые:
комиссия — 7 % от суммы договора;
роялти первой линии — 15 % от пула (пул = сумма минус комиссия);
второй линии — 10 %, третьей — 5 %;
суммарный потолок роялти — 30 % пула;
остальное — автору блока.
function calcSplit(state, blockId, priceRub, commissionPct) { var base = C.round2(priceRub); var commission = C.round2(base * (commissionPct == null ? 7 : commissionPct) / 100); var pool = C.round2(base - commission); var chain = lineageChain(state, blockId); // от блока вверх по derivedFrom var royalties = [], used = 0; chain.forEach(function (item, i) { var percent = RULES.cascade[i]; // [15, 10, 5] if (percent == null) return; if (used + percent > RULES.cascadeCap) return; // потолок 30 % used += percent; royalties.push({ level: item.level, blockId: item.block.id, beneficiaryAvatarId: item.block.ownerAvatarId, percent: percent, amount: C.round2(pool * percent / 100) }); }); var ownerShare = C.round2(pool - royalties.reduce(function (s, r) { return s + r.amount; }, 0)); var check = C.round2(commission + royalties.reduce(function (s, r) { return s + r.amount; }, 0) + ownerShare); return { base: base, commission: commission, pool: pool, royalties: royalties, ownerShare: ownerShare, balanced: check === base, check: check }; }
Реальный расчёт из демо для блока третьего уровня (цена 1 100 ₽ за месяц):
Строка | Сумма |
|---|---|
Цена договора | 1 100,00 ₽ |
Комиссия платформы, 7 % | −77,00 ₽ |
Пул к распределению | 1 023,00 ₽ |
Роялти 1-й линии, 15 % (автор родительского блока) | 153,45 ₽ |
Роялти 2-й линии, 10 % (автор корневого блока) | 102,30 ₽ |
Автору блока | 767,25 ₽ |
Сходимость | 1 100,00 ₽ — до копейки |

Что здесь важнее самой арифметики — инвариант balanced. Каждый расчёт возвращает флаг сходимости, и интерфейс его показывает. Если завтра кто-то поменяет правило или добавит четвёртую линию, расхождение вылезет сразу, а не через полгода в бухгалтерии.
Отдельно про деньги: внутри системы нет «валюты». Есть неконвертируемые баллы (за вклад в сообщество, ими нельзя торговать и нельзя вывести) и рубли по договору — выплаты идут через платёжного партнёра, с НДФЛ. Баллы и деньги разведены на уровне модели, а не на уровне подписи в интерфейсе: одна функция не может вернуть баллы там, где ожидаются рубли.
Все значимые действия — оплата договора, начисление роялти, выдача рекомендации, отзыв согласия, верификация — пишутся в append-only журнал. Каждая запись содержит хеш предыдущей:
function ledgerAppend(state, type, payload) { var prev = state.ledger.length ? state.ledger[state.ledger.length - 1].hash : '0'.repeat(64); var seq = state.ledger.length + 1; var body = { seq: seq, type: type, at: new Date().toISOString(), payload: payload, prevHash: prev }; body.hash = C.sha256(JSON.stringify(body)); state.ledger.push(body); return body; } function ledgerVerify(state) { var prev = '0'.repeat(64); for (var i = 0; i < state.ledger.length; i++) { var e = state.ledger[i]; if (e.prevHash !== prev) return { ok: false, brokenAt: e.seq }; var body = { seq: e.seq, type: e.type, at: e.at, payload: e.payload, prevHash: e.prevHash }; if (C.sha256(JSON.stringify(body)) !== e.hash) return { ok: false, brokenAt: e.seq }; prev = e.hash; } return { ok: true, count: state.ledger.length, brokenAt: null }; }
Почему не блокчейн: для задачи «доказать, что журнал не переписывали» достаточно хеш-цепочки, а любой распределённый реестр добавляет стоимость, юридическую неопределённость и вопросы к хранению персональных данных. Я сознательно не использую это слово в интерфейсе — там написано «журнал с хеш-цепочкой».
Честная оговорка: хеш-цепочка защищает от правки отдельной записи, но не от подмены всего журнала целиком. Настоящая гарантия появляется, когда хеш головы журнала периодически «якорится» во внешней системе (нотариальное депонирование, независимый сервис меток времени). В прототипе этого шага нет, и в интерфейсе он помечен как отложенный — это принципиально: лучше показать дырку, чем сделать вид, что её нет.
Ещё одна деталь: SHA-256 реализован на чистом JS, без crypto.subtle. Причина простая — при открытии index.html двойным щелчком (протокол file:) crypto.subtle недоступен, а стенд должен работать и так. Проверено на векторах RFC: sha256("abc") = ba7816bf…f20015ad, sha256("") = e3b0c442…7852b855.

Ещё одно место, где можно было «прикрутить LLM» и получить красивую демонстрацию с непредсказуемым поведением. Вместо этого — движок на правилах и статистике, который объясняет каждый вывод списком факторов с весами.
Логика: считаем признаки накопленной усталости (тренд нагрузки, дефицит сна, рост времени реакции), сравниваем с порогами, формируем рекомендацию. У каждой рекомендации есть:
список факторов с числами — «сон 5,8 ч против 7,2 ч на прошлой неделе»;
уверенность и порог, выше которого решение уходит тренеру;
версия модели и запись в журнал — что именно было предложено и как отреагировал человек.
Это HITL (human-in-the-loop) не как модное слово, а как архитектурное решение: при высоком уровне сигнала система не меняет план тренировок, а создаёт задачу тренеру.
Что ИИ делает | Что ИИ не делает никогда |
|---|---|
считает признаки усталости по нагрузке, сну и реакции | не ставит диагнозы и не назначает лечение |
предлагает микроцикл из существующих блоков методики | не делает выводов о допинге |
объясняет каждый вывод факторами с весами | не даёт финансовых и инвестиционных советов |
передаёт спорные решения тренеру | не прогнозирует доход и не оценивает «профпригодность» |

Исходная концепция была написана «как продукт» — без оглядки на 152-ФЗ, 323-ФЗ и здравый смысл в части персональных данных несовершеннолетних. Задача стояла не «сделать юридическое заключение», а переписать механику так, чтобы она осталась полезной, но перестала быть противоправной.
Что изменилось (в интерфейсе есть отдельный экран, где это показано таблицей — 11 учтённых норм и 7 замен):
Было в концепции | Стало в системе | Причина |
|---|---|---|
биометрический контроль состояния и «антидопинговые выводы» | импорт официального статуса РУСАДА; система не делает собственных выводов | биометрия — спецкатегория ПД, выводы о допинге — компетенция аккредитованных организаций |
внутренняя конвертируемая валюта спортсмена | неконвертируемые баллы + рубли по договору через платёжного партнёра | нельзя создавать денежный суррогат |
собственная «касса» и выплаты внутри системы | выплаты через НПО (платёжного партнёра), с налоговой отчётностью | расчёты — лицензируемая деятельность |
наследование аккаунта с персональными данными | наследование методики и права на роялти, ПД не передаются | ПД умершего не наследуются в общем порядке |
«инвестиции в спортсмена» с доходностью | лицензионные договоры и дарение | инвестиционный продукт — иная регуляторная рамка |
трансграничная передача данных | обезличенная методика без ПД | 152-ФЗ, требования к локализации |
ИИ-советы по финансам | только аналитика и отчётность | финансовые рекомендации — регулируемая деятельность |
Отдельно про несовершеннолетних: для профиля 16-летнего спортсмена согласия даёт законный представитель, экономика юниора скрыта, публичная витрина не содержит ни почты, ни точной даты рождения.
Именно эта часть, а не красивый дашборд, оказалась главной в проекте. Когда показываешь чиновнику систему, где заранее вырезаны все спорные механики и на каждое ограничение есть ответ «почему именно так», разговор идёт совсем иначе.

Прототип должен открываться с флешки, работать в браузере на слабом ноутбуке в переговорной и не требовать интернета. Отсюда решения:
Ванильный JS, ES5-совместимый синтаксис. Никакой сборки: открыл index.html — работает. Не нужен Node, не нужен npm install за пять минут до презентации.
Один localStorage как хранилище состояния. Есть кнопка «Сбросить демо» — состояние возвращается к исходному, включая журнал.
Ноль внешних запросов. Шрифт PT Sans лежит четырьмя .woff2-файлами рядом с кодом, favicon — SVG. При проверке в DevTools вкладка Network показывает только запросы к своему же домену.
Модули разбиты по доменам, а не по типам файлов: screens-sport.js, screens-method.js, screens-admin.js. Для прототипа на три тысячи строк этого достаточно, и навигация по коду остаётся предсказуемой.
Экранирование всего пользовательского ввода. Есть функция esc() и правило: любые данные, попавшие в состояние (название блока, ФИО, комментарий), проходят через неё при рендере. Проверял внедрением <img src=x> в название методики — не срабатывает.
Чего я сознательно не делал: не тащил фреймворк ради «правильной архитектуры». Для демонстрационного контура, который живёт без бэкенда, React добавил бы 150 КБ, сборку и цепочку инструментов — и всё это ровно для того, чтобы получилось то же самое.

Честный список ограничений — потому что прототип, у которого нет ограничений, обычно просто не запускали.
Нет бэкенда и базы. Всё состояние — в браузере посетителя. Два человека не увидят одни и те же данные.
Нет реальных интеграций. ЕСИА, ГИС ФКиС, ЕКП, носимые устройства, ЕБС — не подключены. В интерфейсе они помечены как имитация, а не как «интеграция в разработке».
Платежи и выплаты имитируются. Никаких платёжных шлюзов, только запись в журнал и демонстрационный номер платежа.
k-анонимность для спонсорских отчётов работает на синтетике. Порог k ≥ 10 реализован, но на реальных малых данных потребуется отдельная политика подавления.
Хеш-цепочка без внешнего якоря не защищает от подмены журнала целиком (см. выше).
Нет многопользовательской работы и ролей на сервере. Переключатель ролей — демонстрационный: он меняет точку зрения, а не права доступа.
Нет медицинской валидации. Признаки усталости — правило на трёх метриках, а не клинически проверенная модель.
Все эти пункты выписаны и в README, и в интерфейсе на экране «О системе». Когда ограничения заявлены самим автором, их невозможно предъявить как обман.
15 экранов, 14 модулей, 4 демонстрационные роли;
20 файлов, около 370 КБ, 0 зависимостей, 0 внешних запросов;
60 комбинаций «экран × роль» — 0 ошибок JS;
ширины от 320 px до 2 560 px — горизонтальной прокрутки нет;
расчёты роялти сходятся до копейки на всех проверенных сценариях.
Исходники: https://github.com/Mabus46/cas-mvp
Лицензия: проприетарная, все права принадлежат автору; условия — в файле LICENSE в репозитории. Демонстрационное ознакомление и цитирование со ссылкой разрешены.
Если интересно, в следующей статье разберу подробнее граф методик: как хранится «родословная» блока, как считается глубина наследования и почему каскад роялти ограничен потолком в 30 % — там больше математики, чем кажется.