What you will find in this article
- "room + members + swipes" model;
- realtime sync of two devices via Supabase;
- matching logic (when a choice "matches");
- packaging web code as an Android app via Capacitor;
- what we would change if starting over.
This is a technical article — a follow-up to how we launched WhatToDo on Google Play.
Stack in one line
Vue 3 + TypeScript + Pinia + Vite on the front. Supabase (Postgres, realtime, auth) on the back. Firebase Analytics + Crashlytics. Capacitor to package for Android.
Data model
Minimal Postgres schema:
- rooms — couple session room.
- room_members — who is in the room (1–2 users).
- ideas — static (at launch) deck of ideas.
- swipes — one row:
room_id,user_id,idea_id,direction (like/skip). - matches — match:
room_id,idea_id,created_at.
We intentionally keep matches as a separate table, not a flag on swipes. That allows:
- instant load of "already matched" when returning to a room;
- realtime subscription only to matches, without noise from all swipes.
Realtime on Postgres
Supabase provides a postgres_changes channel. Pseudo-code for client subscription to room matches:
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();
When the second participant swipes an idea the first already liked, a server function inserts into matches. Both clients get the event and show the "matched" screen.
Server-side matching logic
The matcher lives in a Postgres function so we do not depend on live clients. A trigger on INSERT INTO swipes does:
- finds the "paired" swipe from the other room member on the same idea;
- if found and both
direction = 'like'— insertsmatches; - if already exists — ignores (unique index
(room_id, idea_id)).
That gives two important properties:
- symmetry: match appears the same regardless of who swiped second;
- idempotency: repeat swipe does not create a duplicate.
What lives in Pinia
Client store has three areas:
room— current room, connection status, second participant;deck— local deck (preloaded so swipes do not wait on network);matches— match array with timestamps from the realtime channel.
Local deck is key UX. A swipe must feel instant even on bad network. Server requests run in the background.
Authentication
At launch we support two flows:
- Google Sign-In — fastest entry. Enough for most Android users with one tap.
- Email + confirmation — for pairs where a partner has no Google account or prefers not to link it.
Implementation is standard Supabase Auth. No custom JWT or sessions — saved weeks of work and audit.
Telemetry, crash, analytics
- Firebase Analytics — "room created", "room joined", "swipes per session", "match".
- Firebase Crashlytics — minimum for closed test. Play Console shows crash rate, but Crashlytics gives stack traces.
- Supabase logs — server errors and slow queries.
Events use wtd_ namespace so they do not mix with possible shared 3vstyle.ru analytics later.
Android packaging: Capacitor
Build is straightforward:
npm run build # Vite builds dist/
npx cap sync android # sync dist to android/
cd android
./gradlew bundleRelease
Output — .aab uploaded to Play Console.
Capacitor runs WebView with locally packaged web build. That means:
- no CORS issues — web bundle loads from a
file://-like scheme; - external requests (Supabase, Firebase) go as normal fetch;
- platform features (push, share) — via Capacitor plugins.
What we would do differently
- TypeScript-first from day one. We started mixed (some Vue in JS), then caught up. Should have from the start.
- Supabase Row Level Security immediately. We enabled RLS late. Painful refactor; on a new project RLS is the first thing we turn on.
- Fewer Capacitor plugins. Each plugin = risk something breaks after an Android target update. Fewer externals = calmer life.
- Sentry alongside Crashlytics. Crashlytics is good for native crashes, but Sentry catches web exceptions in WebView more precisely.
If you want something similar
This architecture (web + Supabase + Firebase + packaging) fits many "two-user" products: couple apps, team mini-games, light SaaS. It is what we build for clients as a studio.
Try the app — WhatToDo page. Process chronicle — Telegram channel.