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

—

от автора

Мы делаем «Сбор — спортивные школы» (https://sbor.team) — платформу для детских футбольных школ: расписание, посещаемость, взносы и матч-день в одном приложении для тренера, родителя и директора. На сервере Laravel 12, Livewire 3 и PostgreSQL, мобильное приложение написано на Flutter. Предметная область выглядит простой — календарь и список детей, — но почти каждая задача упирается в людей: родителей у ребёнка двое, детей у родителя трое, а тренер стоит на поле с телефоном в одной руке и мячом в другой. Ниже — пять болей, которые мы услышали от тренеров и родителей, решения, которые из них выросли, и куски настоящего кода.

sbor.team

sbor.team

Неделя обычной детской команды

Типичная команда — 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.

<?phpfinal 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. «Как нам попасть в команду?»

Ссылка-приглашение кажется очевидным решением, но в жизни работает плохо. Её отправляют в чат, она уходит вверх под фотографиями, половина родителей открывает её не на том устройстве, а бабушка не понимает, куда нажимать. На родительском собрании тренеру гораздо проще сказать вслух: «Код команды — такой-то, введите в приложении».

Было: ссылка-приглашение тонет в чате. Стало: код команды с собрания, дальше — заявка, которую одобряет тренер.

Было: ссылка-приглашение тонет в чате. Стало: код команды с собрания, дальше — заявка, которую одобряет тренер.

Так появился постоянный код команды из восьми знаков, который легко продиктовать и записать. Код не зачисляет ребёнка в команду сам: родитель вводит его и подаёт заявку, тренер одобряет. Названный вслух код неизбежно уйдёт за пределы собрания, поэтому защищают не его, а вход: заявка с одобрением и возможность перевыпустить код. Прежнее значение перестаёт работать сразу, потому что больше нигде не хранится.

<?phpfinal 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, кнопка «Все были», код команды вместо ссылки — про то, чтобы убрать лишнее действие у тренера или родителя, а не добавить ещё одно. Цель у всего этого одна: чтобы на рутину уходило меньше времени, а на детей — больше.

ссылка на оригинал статьи https://habr.com/ru/articles/1099268/