Cory Copeland
Back to work

SetOff

Active

A unified trip dashboard for friend groups and families — itinerary building, expense splitting, and group decisions in one product, rather than four single-purpose tools and a group chat.

  • Travel
  • Social
  • Supabase
  • Self-hosted
  • Settlement algorithm

Updated 2026-05-21

SetOff

Overview

SetOff is a unified trip dashboard for friend groups and families. One product that combines itinerary building, expense splitting, and group decisions — replacing the four-tab combo most groups actually use today (Wanderlog + Splitwise + Troupe + iMessage group chat).

Phase 1 is shipped and live, running on self-hosted Supabase. The interesting parts are below the product surface: an anonymous-guest auth model that fits RLS cleanly, an expense settlement engine that learned the hard way to compute in integer cents, and a multi-phase trip-deletion state machine that prevents the social blow-up where one member nukes everyone's plans.

The problem

Trip planning is one of the most reliably scattered workflows in software:

  • Wanderlog owns the itinerary.
  • Splitwise owns the expenses.
  • Troupe owns the group vote.
  • The iMessage / WhatsApp group chat owns everything in between by default — and absorbs the load whenever the other tools are too much friction.

The real competition for a unified product isn't any of those apps. It's the group chat. A new product has to be easier to open than scrolling up through two weeks of "wait, what time was the dinner reservation?"

Audience

  • Friend groups and families coordinating a shared trip — the kind where four to ten people each have opinions and nobody wants to be the spreadsheet person.
  • The organizer specifically, who today has to babysit a Google Doc and chase down Venmo payments.
  • Guests who don't want to sign up for one more app just to RSVP to a beach house weekend. SetOff lets them join via invite link with no account.

What I built

Phase 1 MVP — eight panels on a single shareable trip page:

  • Trip header — destination, dates, cover, shareable invite link.
  • Who's Going — member list with RSVP, anonymous guest join via invite link.
  • Itinerary builder — day-by-day, time-aware ordering, categories, per-item reorder.
  • Quick polls — inline vote, live results.
  • Budget tracker — even splits, minimum-transfer settlement summary.
  • Shared notes.
  • Multi-phase trip deletion — claim/vote/cancel state machine driven by pg_cron, so one member can't unilaterally delete a trip everyone else is attached to.
  • Anonymous guest auth — guests get a real auth.users row with is_anonymous = true so RLS treats them exactly like full accounts.

Done criteria from the product blueprint, verbatim: "A group of 5 people can create a trip, share the link, build an itinerary, run a poll, log expenses, and see who owes who — all from a single URL." Met.

Product decisions

The decisions that shaped the rest of the app:

  • Anonymous guest sign-ins, not cookie tokens. The original guest-token cookie approach kept tripping over Supabase's managed PostgREST stripping the cookie-setting plumbing. Migrating to auth.signInAnonymously() collapsed every RLS predicate from user_id = auth.uid() OR guest_token = current_setting(...) to just user_id = auth.uid(). Same RLS works for full accounts and anonymous accounts identically.
  • Trip deletion is a state machine, not a button. A multi-phase claim/vote/cancel flow with timed transitions driven by pg_cron. The trade-off chosen consciously: more code, but no social blow-up from one person nuking everyone's plans.
  • Settle in integer cents, not floats. A user reported the budget panel's settlement summary didn't match what they'd paid in. Diagnosis: IEEE-754 drift across many small expenses. Fix: every balance and split runs in integer cents through a largest-remainder method, then formats to dollars for display.
  • Phase 1 is the right scope. Real-time sync, drag-and-drop, AI suggestions, photo sharing, mobile card-stack UI — all deferred. The bet: a single shareable URL that's slightly better than the group chat earns the right to add the polish; building the polish first earns nothing.
  • Self-host before scale. Same call as KinGrove. Real Supabase compose, real backups, real RLS, fronted by Cloudflare Tunnel. Operational discipline before users.

Technical architecture

  • Frontend: React SPA, deployed to LXC 208 on deployment-pve, served behind Cloudflare Tunnel at the production hostname.
  • Backend: the full official Supabase docker-compose on LXC 205, fronted by its own Cloudflare Tunnel. The SPA talks to it directly — no app server in front.
  • RLS model: every public table is row-level-secured. Three predicates cover the surface: is_trip_member(trip_id), is_trip_organizer(trip_id), and a using (true) public-lookup for the invite-link path. Defined as security definer SQL functions in migration 001 (and patched in 003).
  • Settlement engine: evenSplitShares and calculateSettlements in app/src/lib/utils.ts. Even splits use the largest-remainder method so $10.00 ÷ 3 produces [$3.34, $3.33, $3.33] deterministically. Settlements use greedy match on net balances in integer cents — at most n − 1 transfers for n non-zero balances, which is the theoretical minimum on transfer count. Covered by app/src/lib/utils.test.ts.
  • Trip deletion: a multi-phase state machine with timed transitions; the pg_cron driver advances trips through claim and vote windows before permanent deletion. See the wiki's trip-deletion-flow.md for the full state diagram.
  • Anonymous auth setup: Auth → Providers → Anonymous Sign-ins must be enabled on Supabase. The upstream self-host compose ships with ENABLE_ANONYMOUS_USERS=false; flipping it on was part of the cutover runbook so the trip-join flow doesn't render empty dashboards with no error.

Design and brand

SetOff's identity is warm and quietly competent — Plan together. Go together. The visual system is a deliberate dual-font pairing: Plus Jakarta Sans for display (trip names, section titles), DM Sans for body and UI (labels, buttons, metadata). The pairing was reconciled in blueprint v2 after v1's typography table left it ambiguous.

Current status

  • Phase 1 MVP shipped, self-hosted on deployment-pve (LXC 208 web + LXC 205 Supabase), all eight panels functional.
  • Time-aware itinerary ordering shipped 2026-05-11 (PR #26).
  • Phase 1.5 holes identified and tracked: edit/delete on itinerary items and notes, soft-delete with undo, cover-image producer. None block the shipped experience for the primary flow.
  • Phase 2 (polish & real-time) designed, not started.

What I would do next

Phase 1.5 follow-ups before Phase 2 polish:

  • Edit/delete UI for itinerary items and notes (today: append-only).
  • Soft delete with a 10-second undo toast and an organizer-visible "Recently deleted" tray.
  • Wire a paste-URL cover-image input (cheap) instead of waiting for the Phase 3 Supabase Storage rollout.

Then Phase 2:

  • Real-time sync via Supabase subscriptions (presence + live polls).
  • Drag-and-drop itinerary reordering with dnd-kit.
  • Activity feed ("Mike added Sunset Dinner · 2h ago").
  • Map view with Mapbox pins driven by itinerary items.
  • Uneven expense splits — the schema already supports split_type='custom'; the UI is hard-coded to even.

Proof

  • Live site: setoff.corycopeland.dev
  • Screenshots and a settlement-flow worked example are still being prepared for this page; they will land here without changing the status above.