Справочник · learn-se · живой кейс

Кейс: как мы строили чек-ины в setlog

Реальная фича, разрезанная на срезы. Каждый концепт курса — с конкретным примером из этого кода, чтобы вернуться и вспомнить «как это выглядело вживую».

0 · От предложения к спеке (делегирование)

Старт — одно предложение: «клиент раз в неделю шлёт тренеру чек-ин (вес, фото, коммент), тренер видит». Развернули в черновик спеки, ты как редактор закрыл 6 вопросов (LWW, окно, минимум одно поле, 4 фото, scope). Урок: автор черновика — модель, суждение — твоё.

1 · Vertical slices и walking skeleton

Фичу не строили целиком. Нарезали на срезы, каждый — сквозь все слои (БД → логика → UI), виден и проверяем:

СрезЧто проходит
1 — скелеттаблица + клиент отправляет + видит у себя ✅
2 — тренер видиттренерский доступ к чек-инам клиента ✅
3a — idempotentодин-в-неделю, дубль не создаётся ✅
3b — фотодо 4 фото + приватный бакет/signed URL ✅
4 — расписаниедень чек-ина (LWW), неделя привязана ко дню, гейт формы ✅ (деплой)
3c — офлайночередь + синк по возврату связи (eventual consistency) ✅
5 — уведомленияпуш тренеру о чек-ине, оповещение о смене дня, крон-напоминание ✅

Фича закрыта целиком — 6 срезов от пустой таблицы до задеплоенного продукта с пушами, каждый прошёл вживую с негативными контролями.

Walking skeleton — срез 1: тончайшая вертикаль «отправил → увидел», нарочно без фото, гейтинга, офлайна. Дальше каждый срез навешивает один пласт на живой скелет.

Re-slicing: «Срез 3» оказался тремя фичами (фото/офлайн/дубль) — разрезали на 3a/3b/3c. Когда срез разросся — режь дальше, не тащи всё разом.

2 · Чтение диффа — карта «что и где»

После каждого среза — карта на уровне масштаба, не построчно. Пример (Срез 1):

ФайлЧто и где
migration …check_ins.sqlновая таблица + правило доступа — новый слой данных
hooks/checkIns.tsвся логика чтения/записи — новый слой логики
CheckInCard.tsxновый блок UI
HomeScreen.tsx+4 строки — точка подключения

Что карта говорит инженеру: 3 новых файла = 3 слоя среза (форма среза видна в структуре); единственный тронутый чужой файл — +4 строки (узкие границы = сигнал качества; если бы +200 или 15 файлов — повод спросить «почему так широко»); i18n-строки — шум, не логика, скользи мимо.

3 · Чтение кода — узнавать форму

Не читать сверху вниз, а найти форму. В файле данных она всегда: один хук читает, другой пишет. Узнал эти два — понял файл на 80%.

Форма optimistic-мутации — четыре части (hooks/checkIns.ts):

mutationFn  — настоящая запись на сервер (медленно)
onMutate    — optimistic: показать СРАЗУ + сохранить previous (для отката)
onError     — откат к previous, если сервер отклонил
onSuccess   — сверить с правдой сервера (перечитать)

Чек-лист узнавания бага: нет previous → нечем откатывать; нет onError → «покажет, но не откатит»; нет onSuccess → останется фейковый id. Теперь ты ловишь пропуск, не написав ни строки.

4 · Верификация запуском (и качество evidence)

Что поймал драйв, а tsc — нет Срез 1 прошёл типизацию, но запись молча отваливалась: браузерный Supabase-клиент не залогинен → RLS отклонял insert. Типы не знают про RLS/auth/паттерн проекта. Починка: перешли на паттерн setlog — API-роут + verifyUser + серверный клиент.
Ловушка качества evidence Первый «успешный» скриншот показывал кнопку «Отправка…» — то есть optimistic-состояние в полёте, не подтверждённую запись. Optimistic делает «выглядит готовым» и «сохранено» визуально неотличимыми.
Строгое доказательство Не снимок формы, а отдельный read-back запрос после записи. Срез 2 проверен верно: POST → независимый GET клиента → GET тренера — три чтения сошлись по одному id.
Негативный контроль Чужая связь (bogus relationship) → 404. Позитив показывает «работает», негатив — «не работает там, где не должно». Требовать оба.

5 · Idempotent — реализованный (Срез 3a)

Твой критерий «двойное нажатие → один чек-ин». Реализация — учебниковый паттерн:

-- БД: физически не может быть двух строк на клиента в неделю
CREATE UNIQUE INDEX uniq_check_ins_client_week ON check_ins(client_id, week_start);

-- API: повтор попадает в ту же строку, а не плодит дубль
.upsert({ client_id, week_start, weight, comment },
        { onConflict: 'client_id,week_start' })

Unique key + upsert = idempotent by construction. Проверено: два POST → один id, latest wins. Связка с race condition: наивный «проверь-потом-вставь» содержит гонку (два запроса оба увидят «нет» и вставят), а unique+upsert разруливает её в БД. Два выученных слова в одном решении.

6.5 · Приватность фото — три слоя (харденинг 3b)

Фото прогресса — чувствительнее аватара. Публичный бакет (даже с неугадываемым URL) не годится. Решение — приватный бакет + signed URLs + путь в БД, доступ стерегут API-роуты (автор и его тренер).

СлойЧто защищаетКонтроль
приватный бакетнельзя достать файл публичной ссылкойпубличный URL → 400
signed URL из APIдоступ только тем, кому роут его выдалsigned URL → 200
проверка владения путёмклиент не подсунет чужой путьчужой путь → 400
// не доверяй ссылке от клиента — привяжи к текущему юзеру
if (!photos.every(p => p.startsWith(`${me.id}/`)))
  return 400   // нельзя присвоить чужое фото

Урок: любую ссылку на ресурс, пришедшую от клиента, сервер обязан привязать к текущему пользователю. И для приватности недостаточно позитивного теста — нужны негативные контроли: позитив показывает «авторизованные видят», негативы — «никто больше».

6.7 · Расписание, LWW и согласование срезов (Срез 4)

День чек-ина ставят и клиент, и тренер — конфликт разрешает last-writer-wins (кто записал последним), updated_by хранит автора. LWW здесь безопасен: данные — один скаляр (день), перезапись ничего не теряет.

Согласование срезов: срез 4 заставил уточнить определение «недели» из среза 3a. Было — календарный понедельник; стало — неделя, привязанная ко дню чек-ина. Поздний срез правит раннее допущение — это нормально, если предупреждён заранее. Расчёт вынесен в изоморфный lib/checkInWeek.ts — им пользуются и сервер (POST), и клиент (гейт формы): одна правда о «неделе» в одном месте, не продублирована.

6.8 · Верификация ловит саму себя — сверка со вторым источником

Дважды за сборку «выглядело как баг» оказывалось артефактом снимка:

Правило: не доверяй одному снимку — сверь со вторым источником. Read-back вместо снимка формы; негативный контроль вместо одного позитивного; API-значение против UI-значения. Один снимок лжёт легко; два независимых источника, сойдясь, — почти нет. И: «выглядит как баг» разводится сверкой, а не переписыванием кода.

6.9 · Срезы компаундятся (3c и 5)

Два поздних среза окупили ранние решения — без единой строки лишнего кода:

Правило: хорошие инварианты платят вниз по течению. Решение в раннем срезе (idempotent) сделало поздний (офлайн) тривиальным. Вкладывайся в правильные свойства рано.

6.95 · Приёмка результата на второй фиче (цели БЖУ)

После чек-инов те же навыки отработаны с другой стороны — не строя, а принимая результат (walking skeleton «цели по БЖУ в профиле»). Проверка приёмки, которую стоит гонять на каждом срезе:

ОсьВопрос приёмщикаЗдесь
Картачто форма диффа говорит до кода?0 миграций (профиль = metadata) → таблица ради 3 скаляров была бы избыточна на скелете
Доказательствоread-back, не снимок формы?независимый GET /api/auth/me сошёлся с введённым; негатив protein=5000 → 400; reload подтвердил гидрацию
Компаундингчто удешевит след. срез, а что упрётся?metadata НЕ переносится на дневной учёт (много строк → нужна таблица); а idempotent-паттерн unique+upsert из 3a переносится один-в-один

Слепок: хорошая приёмка различает не «что пригодится», а что именно пригодится, а что упрётся в границу. И «optimistic рисует до подтверждения сервера» — поэтому правду говорит только независимый источник, не форма.

6.97 · Дневной учёт БЖУ: прогноз сбылся + 2 бага, что поймал только запуск

Третья фича практикума — ручной дневной учёт БЖУ. Здесь сбылся прогноз с приёмки целей (раздел 6.95): metadata не тянет историю по дням → впервые настоящая таблица nutrition_log с UNIQUE(user_id, log_date) + upsert — идемпотентный паттерн из чек-инов (Срез 3a) перенесён один-в-один. Хорошая приёмка предсказала форму следующей фичи.

И два бага, которых tsc не видел, а живой драйв поймал — оба стейтовые:

Баг 1 · useState-инициализатор один раз После перезагрузки карточка пустая, хотя день уже залогирован. useState(() => из todayEntry) отработал до загрузки async-лога → поля инициализировались пустыми и данные, пришедшие позже, их не заполнили.
Баг 2 · гонка «загрузка после ввода» Фикс №1 (эффект заполняет поля при загрузке) стал затирать ввод: пользователь начал печатать 150, лог догрузился и перезаписал на старое 160 → POST ушёл со 160. Драйв это показал: введено 150, отправлено 160.
Правильный фикс Гидрировать из сервера один раз и только если пользователь ещё не печатал (hydratedRef + dirtyRef). На reload — заполняет; во время набора — не трогает.

Слепок: зелёный tsc ≠ работает — интеграция состояния (async-загрузка × локальный ввод) типами не проверяется, только запуском. И «фикс» способен породить новый баг: перепроверяй драйвом после починки, а не только до.

7 · Граница доступа — одно место (Срез 2)

Весь тренерский доступ к данным клиента — в одном роуте, за проверкой связи:

// «это точно мой клиент?» — до чтения его данных
.from('trainer_clients').select('client_id')
  .eq('id', relationshipId).eq('trainer_id', me.id).single()
if (!rel) return 404

Читая карту диффа, ищи: где живёт правило доступа? Если этой проверки нет — тренер читал бы любого. Граница безопасности должна быть в одном узнаваемом месте.