Детский футбол без чатов: что болит у тренеров и родителей и как мы закрываем это кодом

Мы делаем «Сбор - спортивные школы» (https://sbor.team) — платформу для детских футбольных школ: расписание, посещаемость, взносы и матч-день в одном приложении для тренера, родителя и директора. На сервере Laravel 12, Livewire 3 и PostgreSQL, мобильное приложение написано на Flutter. Предметная область выглядит простой — календарь и список детей, — но почти каждая задача упирается в людей: родителей у ребёнка двое, детей у родителя трое, а тренер стоит на поле с телефоном в одной руке и мячом в другой. Ниже — пять болей, которые мы услышали от тренеров и родителей, решения, которые из них выросли, и куски настоящего кода.
Неделя обычной детской команды
Типичная команда — 15–20 детей одного года рождения, две-три тренировки в будни и игра на выходных. Вся коммуникация живёт в родительском чате (порой даже не в одном): расписание, переносы, «кто едет», сбор денег, фото после игры. Тренер ведёт посещаемость в тетради или в голове, а состав на субботу — плюсиками в чате. У директора школы на руках таблица, которую раз в месяц сводит администратор, и вопрос, сколько тренировок на самом деле провёл каждый тренер.
Ругать чат при этом не за что: он бесплатный, он есть у всех, в нём живые люди. Проблема в другом — чат устроен как лента, а у команды есть состояния: кто ответил, кто был, кто заплатил. Ленту приходится каждый раз пересчитывать руками, и делает это тренер, у которого и так двадцать детей и если у него одна команда, а если он ведет несколько? Да еще и разного возраста? Наша задача — превратить эти состояния в данные и при этом не сделать из тренера оператора CRM.
Боль 1. «Кто придёт?»
Тренер пишет в чат: «Завтра тренировка в 18:00, отметьтесь». Половина ответит в первый час, часть — к вечеру, остальные промолчат. Утром тренер листает ленту, пересчитывает плюсики и пишет лично тем, кто молчит. С матчем всё то же самое, только цена ошибки выше: если состав не набирается, нужно успеть позвать ребят из другой группы.

У каждого события у нас один вопрос и три состояния: «Будет», «Не будет», «Не ответили». Родитель отвечает кнопкой в приложении, без похода в чат, а напоминание уходит только тем, кто ещё не ответил — остальных мы не дёргаем. Самое удобное место для ответа — само уведомление: у push есть кнопки «Будет» и «Не будет», и ответить можно прямо из уведомления.
Кнопки в push — место, где легко ошибиться молча. Категория действий регистрируется в трёх местах: на сервере, во Flutter-коде и в нативном AppDelegate для iOS. Если идентификатор разъедется хотя бы в одном из них, уведомление придёт без кнопок, и ни сервер, ни клиент не покажут ошибку. Поэтому на сервере у этих строк один источник правды, а в комментарии прямо перечислено, где лежат их двойники:
<?php
/**
* Идентификаторы ОБЯЗАНЫ дословно совпадать с
* mobile/lib/core/services/push_local_notifications.dart (Flutter)
* и mobile/ios/Runner/AppDelegate.swift (категория iOS).
* Расхождение даёт уведомление без кнопок и никакой ошибки.
*/
final class PushActionCategory
{
public const CATEGORY_ID = 'EVENT_RSVP_ACTIONS';
public const ACTION_ATTEND = 'RSVP_ATTEND';
public const ACTION_DECLINE = 'RSVP_DECLINE';
public static function actionLabels(): array
{
return [
self::ACTION_ATTEND => 'Будет',
self::ACTION_DECLINE => 'Не будет',
];
}
public static function typesWithButtons(): array
{
return [
'event_rsvp_reminder',
'event_rsvp_reminder_wave_day_before',
'event_rsvp_reminder_wave_morning_of',
];
}
}Вторая задача — когда напоминать. Тренировки в детском футболе бывают и в 9 утра в субботу, и в 19:30 во вторник, а будить родителей сообщением в шесть утра нельзя. Мы пришли к одному правилу на всё. Событие раньше 13:00 считается утренним, и напоминание уходит накануне в 19:00. Событие с 13:00 — вечернее, напоминание уходит в тот же день в 8:00. Тихие часы у уведомлений по умолчанию — с 21:00 до 8:00, поэтому вечерняя волна обязана уйти до 21:00, а утренняя не может начаться раньше 8:00.
<?php
final class RsvpActionWindow
{
private const EVENING_START_HOUR = 13; // 13:00 — уже вечер
public const HOUR_DAY_BEFORE = 19; // раньше начала тихих часов в 21:00
public const HOUR_MORNING_OF = 8; // ровно в момент их окончания
public static function resolveWave(DateTimeImmutable $eventStartWall): string
{
return ((int) $eventStartWall->format('G')) >= self::EVENING_START_HOUR
? self::WINDOW_MORNING_OF
: self::WINDOW_DAY_BEFORE;
}
public static function opensAtWall(DateTimeImmutable $eventStartWall): DateTimeImmutable
{
$window = self::resolveWave($eventStartWall);
$base = $window === self::WINDOW_DAY_BEFORE
? $eventStartWall->modify('-1 day')
: $eventStartWall;
return $base->setTime(self::waveHour($window), 0, 0);
}
}У этого правила есть свойство, ради которого его стоило формализовать: окно ответа всегда открывается раньше начала события, и минимум за пять часов. Для утреннего события разрыв — от 5 до 17 часов, для вечернего — от 5 до 15, а ровно пять часов получается на границах, у событий в 00:00 и в 13:00. Раньше правило жило в двух местах: в блоке «Требует действия» на главном экране родителя и в сервисе напоминаний. Две копии одного правила рано или поздно дают ситуацию «push пришёл, а карточки на экране нет», поэтому теперь оба места спрашивают один класс — чистую функцию от времени, которая не ходит ни в базу, ни в конфиг.
Боль 2. Журнал посещаемости по вечерам
Посещаемость нужна всем. Директору — чтобы платить тренеру за проведённые занятия и видеть, кто перестал ходить. Родителю — чтобы понимать, за что он платит. Тренеру — чтобы не спорить на парковке. При этом вести её неудобно: тетрадь осталась дома, таблица на телефоне неудобна на поле, а вечером уже не вспомнить, был ли Миша во вторник.

Поэтому тренер у нас отмечает только исключения. Ответы родителей уже есть, и лист посещаемости открывается предзаполненным: ответившие «Будет» стоят присутствующими, ответившие «Не будет» — отсутствующими. Тренеру остаётся пройти по тем, кто не ответил, и поправить тех, кто обещал, но не пришёл. Кнопка «Все были» отмечает разом всех оставшихся и не трогает тех, кто уже стоит как «Не был».
На сервере это выглядит так. Ответ родителя и отметка тренера живут в разных таблицах, и их нужно свести в момент подтверждения. При подтверждении тренировки неразмеченные строки посещаемости подтягиваются из ответов, ручные отметки тренера не перезаписываются, а подтвердить тренировку можно, только если неразмеченных строк не осталось:
<?php
// TrainingService, сокращено
private function resolvePendingFromRsvp(Training $training, User $confirmedBy): void
{
// Только неразмеченные строки — ручные отметки тренера не трогаем
$pendingRows = $training->attendance()
->where('attendance_status', 'pending')
->get();
$rsvpByUserId = EventResponse::forEvent('training', $training->id)
->whereIn('user_id', $pendingRows->pluck('player_user_id'))
->get()
->keyBy('user_id');
foreach ($pendingRows as $row) {
$resolved = match ($rsvpByUserId->get($row->player_user_id)?->status) {
'yes' => 'present',
'no' => 'absent',
default => null, // «может быть» и молчание решает тренер
};
if ($resolved === null) {
continue;
}
$row->update([
'attendance_status' => $resolved,
'marked_by_user_id' => $confirmedBy->id,
'absence_kind' => $resolved === 'absent'
? TrainingAttendance::ABSENCE_KIND_REPORTED
: null,
]);
}
}
// в confirmTraining() после синка — инвариант: ни одной неразмеченной строки
if ($training->attendance()->where('attendance_status', 'pending')->exists()) {
throw new \RuntimeException('Есть неразмеченные игроки');
}Если подтверждение в клубе не обязательно, тренировка, которую тренер не закрыл, закрывается сама после окончания — по ответам родителей. Здесь мы различаем два вида пропуска. «Предупредил» — родитель заранее ответил «Не будет». «Не ответил» — ответа не было вовсе или был «может быть». Для директора это разные истории: первое — обычная жизнь, второе — повод позвонить семье. Различие хранится в отдельном поле absence_kind, а свободный текст причины система не заполняет никогда.
Боль 3. «Как нам попасть в команду?»
Ссылка-приглашение кажется очевидным решением, но в жизни работает плохо. Её отправляют в чат, она уходит вверх под фотографиями, половина родителей открывает её не на том устройстве, а бабушка не понимает, куда нажимать. На родительском собрании тренеру гораздо проще сказать вслух: «Код команды — такой-то, введите в приложении».

Так появился постоянный код команды из восьми знаков, который легко продиктовать и записать. Код не зачисляет ребёнка в команду сам: родитель вводит его и подаёт заявку, тренер одобряет. Названный вслух код неизбежно уйдёт за пределы собрания, поэтому защищают не его, а вход: заявка с одобрением и возможность перевыпустить код. Прежнее значение перестаёт работать сразу, потому что больше нигде не хранится.
<?php
final class TeamJoinCodeService
{
public const LENGTH = 8;
// Цифры и латиница без I, L, O, U: они путаются с 1, 0 и V
// и на глаз, и на слух. 32^8 ≈ 2^40 вариантов.
public const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
private const MAX_ISSUE_ATTEMPTS = 20;
public function normalize(string $input): string
{
$value = mb_strtoupper(trim($input), 'UTF-8');
$value = str_replace([' ', '-'], '', $value);
// Похожие латинские знаки приводим к каноническим.
// Кириллические двойники НЕ трогаем: пусть получат понятный отказ.
return strtr($value, ['I' => '1', 'L' => '1', 'O' => '0', 'U' => 'V']);
}
}За путаницу I и 1 мы родителя не наказываем и молча приводим похожие знаки к каноническим. А вот кириллические «А», «В», «С» сознательно не конвертируем: они должны дойти до проверки формы и получить сообщение «в коде недопустимый знак», а не превратиться в латиницу без ведома человека. Ошибка в знаке и ошибка в длине — разные сообщения, и знак проверяется первым. Несуществующий код и код, отозванный перевыпуском, дают одинаковый ответ: по реакции системы нельзя понять, что код когда-то был.
Боль 4. Деньги и скриншоты
Типичная оплата в детской секции — перевод по номеру телефона и скриншот в общий чат. Скриншот доказывает, что перевод отправили, но не то, что он пришёл. Платит бабушка с другой фамилией, одна сумма приходит сразу за двоих детей, а в конце месяца администратор сверяет выписку со скриншотами весь вечер.

Первое решение здесь было не техническим: деньги родителей через платформу мы не принимаем. Это агентская схема с отдельными требованиями платёжного провайдера и налоговыми последствиями, а школам она не нужна — способы получать оплату у них уже есть. Через платформу проходят только оплаты тарифов самими школами. Для взносов мы ведём учёт: абонемент команды настраивается один раз, счета по нему выставляются вручную или автоматически в выбранный день месяца, родитель видит сумму и реквизиты, а после перевода нажимает «Я оплатил» и прикладывает скриншот или фото квитанции.
Дальше счёт уходит в статус «На проверке». Тренер или администратор сверяет поступление, подтверждает оплату и указывает способ — наличные или перевод; если заплатили не всё, отмечает частичную оплату. Скриншот остаётся привязан к конкретному счёту, а не тонет в ленте. Клуб, которому сверка не нужна, может включить автоподтверждение, и отметка родителя сразу закрывает счёт.
Нетривиальной частью оказались семьи. У ребёнка может быть два родителя в системе, оба видят его счета, но отметить оплату может только тот, на кого счёт оформлен, — иначе на один перевод появляются две отметки. У родителя может быть несколько детей с разными тарифами: счета видны по всем детям сразу, а выбор ребёнка сужает список. Если ребёнок ушёл из команды, его счета не исчезают: тренер их больше не видит, и долг переходит к администратору клуба.
Боль 5. «Когда следующая игра и какой был счёт?»
Матчи у детской команды идут почти каждые выходные, часто в двух-трёх турнирах сразу. Расписание приходит в чат фотографией таблицы от организатора, место и время уточняют отдельным сообщением, а счёт прошлых игр помнит только тренер. Поэтому в чате по кругу одни и те же вопросы: когда следующая игра, где играем и во сколько, кто едет, какой был счёт в прошлый раз, сколько игр уже сыграли и где мы в таблице турнира.

Матч-день у нас — это журнал игр команды. У каждого матча есть дата, время, площадка, соперник и вид — товарищеский или турнирный. Родители отвечают на приглашение так же, как на тренировку, а тренер собирает из ответов состав на игру. В разделе «Игры» родитель видит предстоящие и сыгранные матчи со счётом, а по каждому турниру — таблицу, расписание и результаты матчей.
Счёт в журнал попадает не из чата, а из протокола. Во время игры тренер отмечает с телефона голы с передачами, замены и карточки, минута гола подставляется со счётчика матча, а гол соперника вносится отдельной кнопкой — игроков соперника в системе нет. На каждый гол - Push родителям которых нет на матче. Так-же можем делать интеграцию с сайтами самих турниров на случай если тренера не было данные тура будут загружены автоматически с сайта турнира. Когда тренер завершает матч, он сразу оказывается в списке сыгранных со счётом. За сезон из этого складывается история команды: сколько игр, с каким результатом и как часто команда играет.
Начать можно бесплатно
Мы хотим, чтобы школа могла уйти из чата, не начиная со счёта и договора. Поэтому для клуба до 100 активных игроков «Сбор» бесплатен без ограничения срока — это не пробный период, который закончится через две недели. На бесплатном тарифе есть всё, о чём шла речь в болях 1–3 и 5: расписание и посещаемость, ответы родителей с push-напоминаниями, вход в команду по коду, матч-день с журналом игр, чат клуба и команды, мобильные приложения и выгрузка данных в CSV и Excel. Тренер в маленькой школе — тот же тренер, что и в большой академии, рутина у него та же, поэтому его инструменты мы не урезаем.
Платные тарифы — для тех, кому нужно больше: больше 100 игроков, несколько филиалов, учёт денег. Туда ушли функции директора — взносы из четвёртой боли, финансовая сводка, создание турниров, филиалы.
Вместо заключения
Детский футбол оказался хорошим полигоном для простых, но строгих правил: у каждого состояния — один источник правды, у каждого напоминания — адресат, который ещё не ответил, у каждой строки, на которую опирается интерфейс, — одна копия. Если вы тренер или директор школы и узнали себя в чате из начала статьи, платформа называется «Сбор» (sbor.team), до 100 игроков она бесплатна. А если вы решали похожие задачи — про напоминания, часовые пояса или семьи из двух родителей и трёх детей, — расскажите в комментариях, как это устроено у вас.
Мы продолжаем развивать систему: добавляем новые функции и улучшаем удобство тех, что уже есть. На странице тарифов часть строк отмечена «скоро» — например, отчёты по посещаемости и страница клуба в каталоге школ. Дальше на очереди всё то, что сегодня тренер и директор делают руками в таблицах и мессенджерах.
Самое сложное здесь — сделать решение одновременно удобным и функциональным. Простая CRM для спортивной школы решает в основном задачу контроля: кто пришёл, кто заплатил, кто должен. Тренеру и родителю от такого контроля легче не становится — наоборот, им приходится вносить данные, которые нужны кому-то другому. Поэтому почти каждое решение из этой статьи — ответ прямо из push, кнопка «Все были», код команды вместо ссылки — про то, чтобы убрать лишнее действие у тренера или родителя, а не добавить ещё одно. Цель у всего этого одна: чтобы на рутину уходило меньше времени, а на детей — больше.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.