← Все статьи

Свайпы вместо споров: архитектура WhatToDo на Vue 3 + Supabase realtime

Как устроен «тиндер для идей»: модель данных в Postgres, realtime-синхронизация двух свайпающих, упаковка веб-приложения под Android через Capacitor.

whattodo разработка vue supabase

Что вы найдёте в этой статье

  • модель «комната + участники + свайпы»;
  • realtime-синхронизация двух устройств через Supabase;
  • логика матчинга (когда выбор «совпал»);
  • упаковка веб-кода в Android-приложение через Capacitor;
  • что бы изменили, если бы стартовали заново.

Это техническая статья — продолжение рассказа о том, как мы запустили WhatToDo в Google Play.

Стек одной строкой

Vue 3 + TypeScript + Pinia + Vite на фронте. Supabase (Postgres, realtime, auth) на бэкенде. Firebase Analytics + Crashlytics. Capacitor для упаковки в Android.

Модель данных

Базовая схема в Postgres минимальна:

  • rooms — комната пары на сессию.
  • room_members — кто в комнате (1–2 пользователя).
  • ideas — статичная (на старте) колода идей.
  • swipes — отдельная запись: room_id, user_id, idea_id, direction (like/skip).
  • matches — совпадение: room_id, idea_id, created_at.

matches мы намеренно делаем отдельной таблицей, а не флагом на swipes. Это позволяет:

  • мгновенно подтянуть «уже совпавшие» при возврате в комнату;
  • подписаться realtime-каналом только на матчи, без шума всех свайпов.

Realtime поверх Postgres

Supabase даёт postgres_changes-канал. Псевдо-код подписки клиента на матчи комнаты:

const channel = supabase
  .channel(`room:${roomId}`)
  .on(
    'postgres_changes',
    {
      event: 'INSERT',
      schema: 'public',
      table: 'matches',
      filter: `room_id=eq.${roomId}`,
    },
    payload => onNewMatch(payload.new),
  )
  .subscribe();

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

Логика матчинга на сервере

Решение matchera — в Postgres-функции, чтобы не зависеть от живых клиентов. Триггер на INSERT INTO swipes делает следующее:

  1. ищет «парный» свайп второго участника той же комнаты по той же идее;
  2. если найдено и оба direction = 'like' — вставляет matches;
  3. если уже было — игнорирует (уникальный индекс (room_id, idea_id)).

Это даёт два важных свойства:

  • симметричность: матч появится одинаково, кто бы ни свайпнул вторым;
  • идемпотентность: повторный свайп не создаёт дубль.

Что хранится в Pinia

В сторе клиента — три области:

  • room — текущая комната, статус подключения, второй участник;
  • deck — локальная колода (заранее загружена, чтобы свайпы не ждали сети);
  • matches — массив матчей с timestamp, на основе realtime-канала.

Локальная колода — ключевой UX. Свайп должен быть мгновенным даже при плохой сети. Запросы к серверу идут в фоне.

Авторизация

На старте поддерживаем два сценария:

  • Google Sign-In — самый быстрый вход. Для большинства Android-пользователей хватает одного тапа.
  • Email + подтверждение — для пар, где у партнёра нет аккаунта Google или он не хочет привязываться.

Реализация — стандартная Supabase Auth. Никаких своих JWT и кастомных сессий — это нам сэкономило недели работы и аудита.

Telemetry, crash и аналитика

  • Firebase Analytics — события «комната создана», «комната присоединена», «свайпов за сессию», «матч».
  • Firebase Crashlytics — обязательный минимум на этапе closed test. Play Console показывает crash rate, но Crashlytics даёт стек.
  • Supabase logs — серверные ошибки и slow queries.

Метрики обмазаны namespace wtd_ чтобы потом не смешивать с возможной общей аналитикой 3vstyle.ru.

Упаковка под Android: Capacitor

Сборка простая:

npm run build         # Vite собирает dist/
npx cap sync android  # синхронизирует dist в android/
cd android
./gradlew bundleRelease

На выходе — .aab, который мы заливаем в Play Console.

Capacitor запускает WebView с локально упакованной web-сборкой. Это значит:

  • нет CORS-проблем — web-bundle грузится с file://-like схемы;
  • любые внешние запросы (Supabase, Firebase) идут как обычный fetch;
  • platform-specific фичи (push, share) — через Capacitor-плагины.

Что бы сделали иначе

  • TypeScript-first с первого дня. Мы стартовали смешанно (часть Vue в JS), потом дотягивали. Стоило сразу.
  • Supabase Row Level Security сразу. Мы поздно подключили RLS. Это болезненный рефакторинг; на новом проекте RLS — первое, что включаем.
  • Меньше плагинов Capacitor. Каждый плагин = риск, что после обновления Android-таргета что-то отвалится. Чем меньше внешних — тем спокойнее жизнь.
  • Sentry рядом с Crashlytics. Crashlytics хорош для нативных крэшей, но web-исключения в WebView Sentry ловит точнее.

Если хотите такое же

Подобная архитектура (web + Supabase + Firebase + упаковка) подходит для много какого «двух-пользовательского» продукта: парные приложения, командные мини-игры, лёгкие SaaS. Это то, что мы делаем под клиентов как студия.

Самому посмотреть приложение в работе — страница WhatToDo. Хроника процесса — Telegram‑канал.

Что почитать дальше