BBC NewsWatch: Match of the DayESPN DeportesClaves de la victoria del Real Madrid vs Barcelona en el ClásicoESPNAntonelli wins US GP but made to wait for titleDaily MaverickJSC’s October 2026 interviews reveal leadership and judicial appointment challengesInquirerEco concerns raised over land conversion in Tarlac Aeta communitiesZDF heuteEntdecken Sie das ZDF-NachrichtenstudioColliderBruce Campbell's 27-Episode Sci-Fi Western Deserves a Second ChanceFootball ItaliaPulisic ‘job’ when Modric has the ball, Hutchinson gift after ‘massive’ first Milan goalRai NewsSerie A, Serie A, Udinese-Milan 0-2: i rossoneri agganciano la terza piazza e la RomaCNN بالعربيةالكلاسيكو.. فينيسيوس جونيور يعلق بسخرية من خسارة برشلونة أمام ريال مدريدNOS SportVerstappen kan niet genieten van derde plek in Texas: 'Ging weinig goed'EngadgetDoes TSA actually allow AirTags in your luggage? Here's what the rules say
The Daily Newsstand · Free, Always
Sunday, October 25, 2026

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

Translate

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

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.