Реализация системы ежедневных стриков (Daily Streaks) в мобильном приложении
Стрики — один из самых мощных механизмов retention в мобильных приложениях. Потерять серию из 30 дней болезненно. Именно это удерживает пользователей даже тогда, когда прямая ценность продукта снизилась. Но реализация стриков полна неочевидных багов, которые ломают всё.
Главная техническая сложность — время и часовые пояса
Вопрос, который обязательно нужно решить в начале: по какому времени считать «день»? Варианты:
Локальное время пользователя. Стрик не ломается, если пользователь в Токио, а сервер в UTC. Требует хранения timezone пользователя и пересчёта «сегодня» при каждой проверке. При смене часового пояса (перелёт) — потенциальные артефакты.
UTC-полночь. Проще технически, но несправедливо для пользователей в UTC-5 — их «вчера» заканчивается в 7 утра UTC, а не в полночь по ихнему времени.
Скользящее окно 24 часа. Не «сегодня», а «в течение последних 24 часов от последнего действия». Самый лояльный подход, но ломает интуицию «ежедневного» стрика.
На практике большинство успешных приложений (Duolingo, Headspace) используют локальное время с хранением user_timezone. При первом запуске определяем TimeZone.current и сохраняем на сервер.
Модель данных
user_streak:
user_id UUID
current_streak INT
longest_streak INT
last_activity DATE -- хранить DATE, не TIMESTAMP
updated_at TIMESTAMP
last_activity — дата в часовом поясе пользователя, не UTC timestamp. Это ключевое. При проверке стрика:
today = current_date_in_user_timezone(user.timezone)
days_since = today - last_activity
if days_since == 0: стрик активен, ничего не делаем (уже отмечен сегодня)
if days_since == 1: стрик продолжается, current_streak += 1
if days_since > 1: стрик сломан, current_streak = 1
Атомарное обновление через SQL с RETURNING — защита от конкурентных запросов.
Freeze и восстановление стрика
Потеря стрика — болезненное событие. Некоторые приложения дают «заморозки» (streak freeze): пользователь может пропустить день без потери серии. Это увеличивает retention при пропущенных днях.
streak_freeze — отдельный ресурс, который пользователь получает как награду или покупает. При сломанном стрике проверяем: есть ли активная заморозка на вчерашний день. Если да — не ломаем стрик, списываем заморозку.
Восстановление стрика (платная фича некоторых приложений) — технически проще, этически спорнее. Если реализуем: streak_restore_purchase, сохраняем новый last_activity = yesterday, current_streak = pre_break_value.
Уведомления
Reminder перед полуночью (например, в 21:00 по локальному времени) — «Вы ещё не выполнили задание сегодня, стрик X дней под угрозой». Эффективность этих уведомлений высокая, но нужна персонализация времени: пользователь, который всегда активен в 8 утра, не должен получать reminder в 21:00.
На iOS: UNUserNotificationCenter с UNCalendarNotificationTrigger. Время рассчитываем в часовом поясе пользователя. При смене активности обновляем trigger — если пользователь уже отметился сегодня, отменяем сегодняшний reminder.
Визуализация
Flame icon с числом — стандарт. На Flutter: AnimatedFlipCounter для плавного увеличения счётчика. Еженедельная сетка дней (как в GitHub contribution graph) — показывает историю последних 7/30 дней. Это мощно: пустые ячейки визуально «зовут» их заполнить.
Milestone-уведомления
7 дней, 30 дней, 100 дней — специальные события с анимацией. Интегрируем с системой достижений: milestone стрика = автоматически разблокированное достижение.
Ориентиры по срокам
Базовая система со стриком, уведомлениями и milestone — 1–2 дня (клиент) + 2–3 дня (бэкенд). С заморозками, восстановлением, персонализированными reminders и интеграцией с достижениями — 1–2 недели. Стоимость рассчитывается индивидуально.







