Как я перестал измерять MVP количеством функций: история создания Prosnix

В первом приложении мне было легко понять, чем заняться дальше. Всегда находилась ещё одна функция, которую можно добавить: новый экран, аналитика, AI, оплата. Каждая закрытая задача давала понятное ощущение прогресса. Продукт становился больше, код — сложнее, релизы — заметнее.
После большого релиза я задал себе неприятный вопрос: что именно я доказал? Что могу довести приложение до работающего состояния — да. Что людям нужна каждая из реализованных функций и они готовы возвращаться — уже не факт.
Со вторым проектом я решил поменять порядок работы. Не начинать с большого roadmap и каталога возможностей, а сначала сформулировать одно повторяемое действие, которое можно проверить. Так появился Prosnix — Telegram Mini App для личных экспериментов после пробуждения.
Это не история успешного запуска. Пользователей пилота и подтверждённых результатов пока нет. Это история о том, как попытка сделать более узкий MVP изменила мои технические решения — и почему даже одна простая продуктовая метрика потребовала state machine, нескольких версий и отдельного отношения к исходным данным.
Первый продукт научил меня выпускать функции
До предыдущего приложения у меня не было опыта создания полноценных продуктов. Большая часть работы закономерно ушла на техническую сторону: связать frontend с backend, хранить данные, настроить авторизацию, подключить AI, разобраться с оплатой, выпустить всё в production и чинить проблемы, которые проявляются только после релиза.
Этот опыт оказался полезным. Без него второй проект я собирал бы намного дольше. Ошибка была не в количестве сделанного, а в том, что готовность функций постепенно стала для меня главным способом измерять движение.
У такого подхода есть удобство: результат виден сразу. Появился экран статистики — задача закрыта. Подключилась оплата — ещё один большой пункт готов. Но работающая оплата сама по себе не доказывает готовность людей платить, а красивый график не доказывает, что его вывод влияет на решения пользователя.
Большой релиз доказывает прежде всего то, что разработчик смог выпустить большой релиз. Он не отвечает на вопрос, какую регулярную задачу человек доверит продукту и почему откроет его снова.
В Prosnix я хотел встретиться с этим вопросом раньше, чем успею построить ещё один большой продукт вокруг неподтверждённой идеи.
Откуда появился Prosnix
Идея выросла из моей собственной проблемы. После сна мне бывает сложно перейти от состояния «я уже проснулся» к состоянию «я действительно встал и начал что‑то делать». Иногда я откладываю подъём, иногда беру телефон на минуту и надолго остаюсь в новостях или коротких видео.
У знакомых находились похожие истории. Этого хватило, чтобы проверить идею, но недостаточно, чтобы объявить найденный спрос. Фраза «у меня тоже так» ничего не говорит о том, откроет ли человек приложение сразу после сна, закончит ли сценарий и захочет ли повторить его через день.
Поэтому задача первой версии получилась уже, чем первоначальная проблема. Prosnix не заменяет системный будильник, не определяет фазы сна и не обещает устранить сонливость. Он предлагает пройти короткую последовательность заданий, оценить состояние до и после, а затем посмотреть, какие варианты чаще совпадали с лучшим результатом у конкретного человека.
Формат Telegram Mini App позволил оставить один web‑клиент и не начинать второй продукт с отдельных приложений для iOS и Android. При этом Telegram WebView сразу добавил ограничения, которые обычный браузер показывает не всегда: приложение могут закрыть посреди сценария, старая вкладка может пережить deployment, а мобильная сеть — повторить запрос.
Одна гипотеза вместо большого roadmap
Главный вопрос первой версии звучит так: вернётся ли человек ко второй завершённой сессии в течение семи дней после первой?
Я специально выбрал повторное действие, а не регистрацию, открытие Mini App или положительный ответ на вопрос «нравится ли вам идея». Первый запуск легко пройти из любопытства или желания поддержать автора. Возвращение требует, чтобы человек вспомнил о продукте в подходящий момент и снова увидел в нём смысл.
Сама сессия устроена как небольшой личный эксперимент. Пользователь выбирает контекст — ночной сон, короткий или долгий дневной сон либо ситуацию, когда нужно взбодриться без сна. Затем указывает ориентир длительности: 2, 5 или 10 минут, оценивает состояние, выполняет последовательность доступных заданий и ставит вторую оценку. Примерно через 15 минут приложение просит пройти follow‑up.
Все экраны ниже сняты в demo‑режиме. Значения на них синтетические и не являются результатами пилота.
Скриншоты из приложения
Повторная оценка появилась не ради ещё одного экрана. Состояние сразу после движения, яркого света или когнитивного задания может измениться, а затем вернуться к исходному. Follow‑up позволяет сохранить ещё одно наблюдение, но не превращает эксперимент в доказательство причины: за пределами приложения остаются продолжительность сна, время суток, настроение, кофе и другие факторы.
Режимы 2, 5 и 10 минут тоже не являются точным таймером. Это бюджет нагрузки. Быстрый пользователь может закончить пятиминутный вариант раньше. Пока неизвестно, будет ли это восприниматься как удобная свобода или как несоответствие ожиданиям. Ответить на этот вопрос может только пилот.
Узкий сценарий не означает простые данные
В начале казалось, что для проверки гипотезы достаточно двух событий: session_started и session_completed. Затем стало понятно, что клиентское событие ещё не является фактом завершённой сессии.
Пользователь может закрыть Mini App на третьем задании. Один запрос может уйти дважды. Старая версия frontend может отправить команду, которая уже не соответствует правилам нового API. Наконец, интерфейс способен показать успешный переход раньше, чем сервер сохранил результат.
Если просто собирать события с клиента, все эти случаи начнут выглядеть как нормальное поведение. Воронка получится, но я не смогу уверенно ответить, какие сессии действительно завершились и из каких наблюдений сложилась метрика.
Другой быстрый вариант — хранить у пользователя только готовую сводку: среднее изменение, количество запусков и лучший вариант. Такая запись компактна, но ошибка в формуле становится почти необратимой. После изменения алгоритма уже нельзя восстановить, какие сессии попали в старый результат.
Можно было уйти в противоположную крайность и сразу добавить event bus, отдельный warehouse и воркеры. До пилота это создало бы больше точек отказа, чем достоверности. Я остановился на модульном монолите: React/Vite, Fastify API, PostgreSQL и отдельный domain package с чистыми функциями.
Telegram WebView
↓
React/Vite ──HTTP──> Fastify API ──repositories──> PostgreSQL
│
├──> domain: state machine, selector, analytics
│
└──> AI provider — только для объясненияЭта схема не рассчитана на воображаемую нагрузку через пять лет. Её преимущество для текущего этапа проще: переход сессии и запись наблюдения остаются в одной серверной транзакции, а основные правила можно тестировать без браузера и базы.
Экраны превратились в state machine
Сначала пользовательский путь существовал у меня как последовательность экранов. Но экран ничего не гарантирует: его можно закрыть, перезагрузить или открыть из кэша. Поэтому источником истины стала серверная сессия с явными состояниями:
assigned → in_progress → protocol_completed → follow-up observation
└────→ abandonedBaseline принимается один раз и переводит сессию из assigned в in_progress. Результат допустим только для текущего шага. Post‑rating нельзя сохранить, пока не завершены все назначенные задания. Follow‑up принимается только после завершения протокола.
На каждый переход клиент отправляет Idempotency-Key. Для существующей сессии он также передаёт ожидаемую версию в If-Match.
Эти механизмы решают разные задачи. Idempotency key позволяет узнать, повторно ли пришла та же операция. Версия показывает, строит ли клиент команду из актуального состояния. Один и тот же запрос можно безопасно повторить, но команда из старой версии сессии всё равно должна быть отклонена.
Ключевая проверка при приёме результата задания выглядит так:
export function acceptTaskResult(session, command) {
assertVersion(session, command.expectedVersion);
assertActive(session);
const expectedStep = session.assignment.steps[session.currentStepIndex];
if (
expectedStep === undefined ||
command.stepIndex !== session.currentStepIndex ||
command.taskId !== expectedStep.taskId
) {
throw new SessionCommandError(
"unexpected_step",
`Expected step ${session.currentStepIndex}`,
);
}
const target = taskSuccessTarget(
command.taskId,
session.durationMinutes,
);
if (
session.assignment.protocolVersion >=
STRICT_TASK_PROTOCOL_VERSION &&
command.correct < target
) {
throw new SessionCommandError(
"invalid_task_result",
`Task requires ${target} successful results before completion`,
);
}
return {
...session,
currentStepIndex: session.currentStepIndex + 1,
version: session.version + 1,
};
}В полной функции есть дополнительные проверки количества попыток, длительности и уровня сложности. Для этой статьи важен сам принцип: сервер знает, какой шаг сейчас ожидается и какой результат достаточен для назначенной версии протокола. Сообщения клиента «задание завершено» недостаточно.
Если If-Match не совпадает, API отвечает 409 и возвращает canonicalSession. Клиент принимает состояние сервера и решает, какой экран показать. Пользователю не нужно выбирать, какая из двух копий сессии настоящая.
Наблюдения пришлось отделить от выводов
В PostgreSQL отдельно хранятся назначения, сессии, оценки, результаты заданий и follow‑up. Назначение фиксирует версию протокола, версию стратегии, фазу эксперимента, гипотезу и ограничения пользователя на момент выбора.
Аналитический профиль — производная запись. Его можно пересчитать из завершённых наблюдений. Каждая метрика хранится вместе с размером выборки и ссылками на сессии, из которых она получена:
function metric(key, value, evidence, confidenceCount = evidence.length) {
return {
key,
value,
evidenceCount: confidenceCount,
evidenceIds: evidence.map(({ sessionId }) => sessionId),
confidence: confidenceFor(confidenceCount),
};
}evidenceIds используются только для трассировки на сервере. Они не уходят в интерфейс и не передаются внешнему AI provider. Если формула изменится, аналитика получит новую версию, а исходные оценки останутся прежними.
Смешанные протоколы создают отдельную ловушку. Допустим, сессия включала математику, ходьбу и яркий свет, после чего оценка выросла на два пункта. Приписать +2 каждому из трёх факторов было бы удобно, но неправильно. Общий результат можно связать с сессией или точной последовательностью. Эффект отдельного фактора требует сопоставимых групп with и without.
Контекст и бюджет времени тоже нельзя смешивать бездумно. Результат короткого протокола после дневного сна не становится evidence для десятиминутного протокола после ночного. Из‑за этого выводы появляются медленнее, зато интерфейс не создаёт уверенность только потому, что объединил несопоставимые наблюдения.
Одной версии API оказалось мало
По мере разработки версии появились на четырёх уровнях:
session.versionзащищает конкретный переход;protocolVersionфиксирует правила назначенной сессии;strategyVersionобъясняет выбор последовательности;methodVersionфиксирует формулу аналитики.
Старый протокол может оставаться проходимым после изменения требований к заданиям. Новые назначения при этом уже используют другую стратегию, а аналитику можно пересчитать ещё раз, не меняя сохранённые наблюдения.
Для нового назначения selector сначала применяет профиль возможностей и бюджет времени. Затем одинаковые фактические последовательности схлопываются, а непосредственный повтор исключается, если остаётся другой допустимый вариант.
Сейчас первые семь сопоставимых сессий используются для калибровки: система по возможности назначает разные последовательности. После этого она может повторно проверить перспективный вариант. Score намеренно остаётся простой и детерминированной эвристикой: средняя разница post - baseline плюс небольшая поправка по follow‑up.
Это не научная оценка эффективности. На текущем этапе важнее, чтобы один и тот же fixture всегда давал один и тот же выбор и объяснение.
Даже retention потребовал договориться о времени
Главная продуктовая метрика отвечает на простой вопрос: завершил ли человек вторую сессию не позднее семи суток после первой?
Наивный расчёт вернувшиеся / все с первой сессией занижает показатель в реальном времени. Пользователь, завершивший первую сессию вчера, ещё имеет шесть дней на возвращение. Он пока не returned, но ещё и не отрицательный исход.
Поэтому агрегат различает четыре значения:
cohort— все пользователи с первой завершённой сессией в периоде;returned— вторая сессия завершена внутри окна;eligible— результат окна уже известен;pending— окно открыто, а второй сессии пока нет.
Rate считается как returned / eligible. Pending показываются отдельно и не попадают в знаменатель.
В PostgreSQL завершённые сессии сначала нумеруются внутри каждого пользователя. Затем запрос берёт первые две и проверяет временную границу:
with ranked as (
select
user_id,
protocol_completed_at,
row_number() over (
partition by user_id
order by protocol_completed_at, id
) as session_number
from wake_sessions
where protocol_completed_at is not null
), first_two as (...), cohort as (...)
select
count(*) as cohort,
count(*) filter (
where returned_in_window
or first_completed_at <= :seven_day_cutoff
) as eligible,
count(*) filter (where returned_in_window) as returned,
count(*) filter (
where not returned_in_window
and first_completed_at > :seven_day_cutoff
) as pending
from cohort;У формулы есть integration fixture для возврата ровно на границе семи суток, слишком поздней второй сессии и ещё не закрытого окна. Но это проверка вычисления, а не демонстрация реального retention. Пользователей пилота пока нет, поэтому показывать продуктовый график с цифрами было бы нечестно.
Где в этой схеме находится AI
AI появился в Prosnix не как источник метрик, а как необязательный слой объяснения. Модель не записывает наблюдения, не меняет state machine, не выбирает доступ и не решает, какой протокол назначить. Запрос отправляется только по действию пользователя.
Provider получает агрегаты без Telegram ID, внутренних UUID, cookie, evidence IDs и полной истории. Ответ валидируется и кэшируется. Если внешний API недоступен или возвращает неподходящую структуру, сервер использует детерминированный fallback.
Такой подход ограничивает возможности модели: она видит меньше контекста и не может свободно искать закономерности во всех ответах. Зато её сбой ухудшает только объяснение. Исходные записи и числовой профиль продолжают работать.
Что удалось проверить до пилота
Тесты разделены по границам ответственности. Domain unit покрывают state machine, selector, формулы аналитики и изоляцию контекста. API contract проверяют заголовки, схемы и конфликты. PostgreSQL integration отвечает за транзакции, уникальные ограничения и временные границы. Компонентные тесты ловят повторную отправку и восстановление из canonical state. Mobile Chromium E2E проходит основной сценарий целиком.
Но у тестирования есть более важная граница. Оно подтверждает, что система выполняет записанные правила. Оно не подтверждает, что людям нужен этот сценарий, что задания не раздражают сразу после сна и что персональный вывод даёт причину вернуться.
Что в итоге изменилось в моём подходе
Раньше я мог долго двигаться внутри технически понятной зоны: добавлять функции, улучшать интерфейс и считать релизы. В Prosnix я сначала выбрал вопрос, на который продукт должен ответить, а затем строил только тот контур, без которого ответу нельзя доверять.
Это не сделало разработку маленькой. Появились state machine, idempotency keys, optimistic concurrency, несколько независимых версий, пересчитываемые проекции и ручные release‑проверки. Зато у каждого механизма есть связь с исходной гипотезой.
Сейчас Prosnix остаётся технически подготовленным MVP перед пилотом. Я могу проверить, что принятая сессия действительно завершена, восстановить её историю и воспроизвести расчёт. Я пока не могу сказать, захотят ли люди пройти этот путь второй раз.
В этом и состоит главное отличие от моего первого подхода. Рабочий продукт, проверенный разработчиком сценарий и подтверждённый спрос — три разных состояния. Теперь я стараюсь не называть одно другим и не строить следующий большой roadmap, пока не получил ответ от предыдущего маленького эксперимента.
А если вы создавали второй продукт после первого, какой урок сильнее всего изменил ваш подход к разработке?
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.