Data flow

Tracing data from user input through the UI, into Supabase, and back out to every connected client via realtime.

High-level diagram

┌────────────┐    React Query     ┌───────────────┐
│   Route    │ ─────hooks────────▶│ supabase.from │
│ Component  │◀───cached data─────│   (Postgres)  │
└─────┬──────┘                    └───────┬───────┘
      │ user action                       │
      ▼                                   │
┌────────────┐    .insert/.upsert         │
│  Mutation  │ ──────────────────────────▶│
│ (in comp.) │                            │
└─────┬──────┘                            │
      │ qc.invalidateQueries()            │
      ▼                                   │
   refetch                                │
                                          ▼
                                ┌──────────────────┐
                                │ Realtime channel │
                                │ (all 6 tables)   │
                                └────────┬─────────┘
                                         │
                  invalidates queries on every other client

Authentication flow

  1. User lands on /login or /signup. Email/password or Google OAuth via supabase.auth.
  2. On success, useMeId() reads the session and resolves club_members.id for the current auth.uid().
  3. AppShell checks the member row:
    • No invited flag → redirect to /invite (validates code absolutecinema server-side via invite-gate.ts, sets invited = true).
    • invited but not onboarded → redirect to /onboarding (collects name + preferred theaters, sets onboarded = true).
    • Otherwise → render the requested route.
  4. On every page, useClubRealtime() subscribes to all 6 public tables. Any insert/update/delete invalidates the relevant React Query keys so all clients stay in sync.

Read path (example: Now Playing)

now-playing.tsx
  ├─ useMovies()   → SELECT * FROM movies WHERE currently_playing
  ├─ useVotes()    → SELECT * FROM votes
  └─ useMembers()  → SELECT * FROM club_members
  ↓
  Sort movies by vote count → render <MovieCard /> grid

Write path (example: voting)

User taps <VoteButton />
  ↓
  voted ? supabase.from('votes').delete().match({member_id, movie_id})
        : supabase.from('votes').upsert({member_id, movie_id})
  ↓
  qc.invalidateQueries({ queryKey: ['votes'] })
  ↓
  useVotes() refetches → MovieCard re-renders
  ↓
  Realtime fires INSERT/DELETE on votes
  ↓
  Other connected clients invalidate ['votes'] and refetch

Showtime sync path

POST /api/public/hooks/sync-now-playing
  ↓
  For each AMC theater (Metreon 16, Kabuki 8):
    Firecrawl scrape next 21 days
  ↓
  Upsert movies (currently_playing: true)
  ↓
  Mark stale movies currently_playing: false (preserves votes/logs)
  ↓
  Replace future amc_showtimes for tracked theaters
  ↓
  Realtime fires → all clients refetch

Failure modes

  • RLS denial: any read returning empty when data exists usually means the policy doesn't grant the current role. Check policies on the affected table.
  • Stale UI: a mutation that doesn't invalidate the right query key. Realtime will catch it within a second, but the originating client should also invalidate.
  • Sync silently empty: Firecrawl key missing or AMC HTML changed — check the server-route logs.