SetOff
ActiveA 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. Carries Skipper, an in-app AI co-planner that drafts itinerary items and polls for the crew to approve, and now a complete crew lifecycle: a member can leave and come back, an organizer can hand the trip over, and a guest who signs up keeps everything they already planned.
- Travel
- Social
- Supabase
- Self-hosted
- Settlement algorithm
- AI
- Monetization
Updated 2026-08-31

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 shipped first as a deliberately narrow MVP. Since then the product has grown into what the original blueprint called Phase 2: a full desktop and mobile redesign, a real payment rail, push-style in-app notifications, a cinematic trip-recap video export, and Skipper — an in-app AI co-planner that proposes itinerary items, polls, and notes for the crew to approve, never auto-committing anything on its own. Underneath all of it, the same engineering discipline from Phase 1 still holds: an anonymous-guest auth model that fits row-level security cleanly, a 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 work since has been less glamorous and more revealing: a complete crew lifecycle — leaving, rejoining, being removed, handing the trip over, converting from guest to real account without losing anything — and the row-level-security work that turned out to be hiding underneath it.
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
The Phase 1 core — still the spine of the product:
- Trip header, itinerary, polls, budget, notes, and crew — day-by-day itinerary with time-aware ordering, inline live-result polls, an even-split budget tracker with a who-owes-who settlement summary, shared notes, and a member list with anonymous guest join via invite link.
- 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.usersrow withis_anonymous = trueso RLS treats them exactly like full accounts.
Everything built since (Phase 2, now shipped and live):
- Skipper, an AI crew member.
@skipperin any chat thread answers trip-aware questions (dinner spots, packing, logistics); an "Autopilot" button on Itinerary/Polls/Notes generates draft items the crew explicitly approves or dismisses — nothing lands on the trip without a tap. - SetOff Pro billing, a real payment rail via LemonSqueezy hosted checkout: a free tier (10 Skipper actions/user/month), a Pro tier (~$7/mo, 300 actions/month), and pay-as-you-go top-ups. One Pro "sponsor" funds Skipper usage for the whole crew, not per-user seats.
- Journey, a cinematic trip recap built from a trip's Gallery photos and video — six visual modes, a generative soundtrack, and a map-route flythrough, playable in-browser with a Pro-gated video export.
- In-app notifications — a bell + dropdown covering poll activity, crew
and trip events,
@skipperreplies, journey-ready, deletion claims, and timezone-aware itinerary reminders viapg_cron. - A full desktop redesign — a dark app-bar shell, a flat hero band with a
countdown ring, a ⌘K command palette, and a shared
Panelprimitive that every board (Overview, Itinerary, Polls, Budget, Crew, Notes, Gallery, Chat) now inherits. - A full mobile redesign — compact header, bottom tab bar, bottom-sheet primitives, swipe-to-delete, and dedicated mobile screens for chat, notes, command search, and trip-switching, built as a pure presentation layer over the same server actions as desktop.
- Password auth alongside magic link and anonymous guest join — any account can set a password in Settings.
- A rebuilt onboarding funnel — signup, magic-link callback, a reskinned invite-arrival page showing real trip details, and a "Where to first?" empty state for new logged-in users.
- A rebuilt marketing site at setoff.corycopeland.dev that shows the real product UI in the hero and feature sections instead of hand-built replicas.
And since — the crew lifecycle, which turned out to be where the interesting bugs lived:
- Leaving and coming back. A member can leave a trip and rejoin later through the same invite, with their history intact. Planning the feature surfaced an RLS hole underneath it, which is the real story of this arc.
- Organizer handover. An organizer can promote a member and hand the trip over, so a trip is no longer permanently hostage to whoever created it.
- Member removal, with the deletion state machine taught to ignore removed members — and an organizer explicitly barred from ejecting someone who has an active deletion claim, which was a way to quietly win the vote.
- Guest-to-account conversion that keeps the trip. Anonymous guests were stranding their own data on sign-out: they would plan a whole trip, make an account, and arrive logged in and empty-handed. A guest who signs up now carries everything across.
- Invite enforcement — closed invites are refused in
joinTripwith a message that says why, rather than failing opaquely, and a removed member gets told the real reason they cannot re-join.
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, and since extended well past it.
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 fromuser_id = auth.uid() OR guest_token = current_setting(...)to justuser_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 — but it's a floor, not a ceiling. Real-time sync, AI suggestions, and mobile-native UI were deferred out of the MVP on purpose, then built once the single-URL bet proved out. Building the polish first would have earned nothing.
-
Self-host before scale. Same call as KinGrove. Real Supabase compose, real backups, real RLS, fronted by Cloudflare Tunnel. Operational discipline before users.
-
Skipper proposes, the crew disposes. Every AI-generated itinerary item, poll, or note is a draft row a human approves — never an autonomous write. Draft approval itself is atomic at the SQL layer (a Postgres function, not a shared TS insert helper) specifically so two crew members tapping "approve" at once can't double-commit the same suggestion.
-
AI usage is metered like a real cost center, because it is one. Skipper's entitlement layer does atomic reserve → commit → refund accounting instead of a soft counter, so a failed model call refunds the user's quota rather than silently burning it. One Pro sponsor funding the whole crew's usage — not per-seat billing — matches how a trip actually gets paid for in real life.
-
LemonSqueezy over raw Stripe. A Merchant-of-Record absorbs global VAT and sales-tax compliance for a solo operator — worth the extra per-transaction cost to not become a part-time tax accountant.
-
Prove client-side capture before building a render farm. Journey's video export records the in-browser player itself via
MediaRecorderrather than standing up a server render worker. Thejourney_renderstable is shaped so a real worker could swap in later without changing the UX — the cheap version first, the scalable version only if it's earned. -
Ship the redesign in independently-releasable phases. The desktop rebuild landed as eight separate phase PRs, one per board, with mobile held byte-identical throughout; the mobile rebuild then landed as its own pass reusing the same server actions. Neither redesign risked regressing the other.
-
Membership is a lifecycle, not a boolean. The original schema treated crew membership as a row that either existed or didn't. Every feature in this arc — leaving, rejoining, removal, handover — is really the same admission that people move through a trip over time, and that the states in between (removed but remembered, left but welcome back, claiming a deletion while being ejected) each need a defined answer. Most of the bugs were states nobody had named yet.
-
Measure RLS, don't reason about it. Planning leave-trip turned up a policy hole that reading the migration had not:
trip_membersaccepted an INSERT it shouldn't, and separately a member could UPDATE their own row's role and promote themselves to organizer. That second one was live in production. The fix was migrations 026 and 031, but the durable change was the method — every RLS claim now gets a probe that performs the action as the wrong user and asserts the specific refusal reason. Two of those probes initially passed because they were never actually removing anyone first; a policy test that cannot fail is worse than no test, because it reads as evidence.
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 ausing (true)public-lookup for the invite-link path. Defined assecurity definerSQL functions in migration 001 (and patched in 003). - Settlement engine:
evenSplitSharesandcalculateSettlementsinapp/src/lib/utils.ts. Even splits use the largest-remainder method so$10.00 ÷ 3produces[$3.34, $3.33, $3.33]deterministically. Settlements use greedy match on net balances in integer cents — at mostn − 1transfers fornnon-zero balances, which is the theoretical minimum on transfer count. Covered byapp/src/lib/utils.test.ts. - Trip deletion: a multi-phase state machine with timed transitions; the
pg_crondriver advances trips through claim and vote windows before permanent deletion. See the wiki'strip-deletion-flow.mdfor the full state diagram. - Anonymous auth setup:
Auth → Providers → Anonymous Sign-insmust be enabled on Supabase. The upstream self-host compose ships withENABLE_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. - Skipper: runs as a Next.js server action calling Claude Sonnet 4.6, not
a Supabase edge function — deliberately kept inside the app's existing
trust domain rather than standing up a second deployment target.
Privileged writes (committing an approved draft) go through a narrow
SECURITY DEFINERRPC instead of the service-role key. - Billing: LemonSqueezy hosted checkout and customer portal. LemonSqueezy is the source of truth; Supabase mirrors subscription and entitlement state via a signature-verified webhook, with a reconcile path for drift.
- Journey export: captures the recap player itself via the browser
MediaRecorder/screen-capture APIs — no server-side render step. Tested end-to-end (upload, RPC, realtime, download) against the real deployed app by stubbing the untestablegetDisplayMediacall withcanvas.captureStream()in Playwright, catching two real production bugs before they shipped. - Notifications: in-app only today — bell + dropdown, unread badges,
deep-links,
pg_cron-driven timezone-aware itinerary reminders. OS-level push (Web Push/FCM) was scoped as a later phase and hasn't been built.
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.
The Phase 2 desktop redesign layered a dark deep-teal app-bar, a flat hero band with a countdown ring, and a ⌘K command palette onto that same type system — still the same brand voice, now with the visual weight of a daily-use dashboard instead of an MVP.
Current status
- Phase 1 core shipped and stable: itinerary, polls, budget, notes, crew,
trip deletion, anonymous guest auth — self-hosted on
deployment-pve(LXC 208 web + LXC 205 Supabase). - Phase 2 shipped and live: Skipper AI co-planner, SetOff Pro billing via
LemonSqueezy, Journey recap + video export, in-app notifications, a full
desktop redesign (8 boards), a full mobile redesign, password auth, and a
rebuilt onboarding funnel — all merged to
mainand running in production. - Phase 3 in flight, unreleased: the full crew lifecycle — leave and
rejoin, organizer handover, member removal, guest-to-account conversion,
invite enforcement — merged to
mainand running in production, but not yet cut as a tag.v0.3.0still marks the Skipper release. - Infrastructure: the Supabase host was being starved by its own gateway — Kong was running 24 nginx worker processes on a 2-core box. Corrected, along with a migration script that had been quietly planning to delete six earlier migrations, and a CD pipeline that would pass a run in which the deploy had silently gone stale.
- Known gaps: OS-level push notifications (in-app only so far), and
uneven expense splits (schema supports
split_type='custom', UI is still hard-coded to even splits).
What I would do next
- Ship OS-level Web Push/FCM delivery — the in-app notification center already has the event model, it just doesn't reach a closed tab or phone lock screen yet.
- Build the uneven-split UI — the last real Phase-1 gap, schema-ready but unbuilt.
- Turn on real LemonSqueezy billing (today's setup is fully wired but worth a deliberate go-live checklist before the first real charge).
- Cut the Phase 3 tag. The crew-lifecycle work is live but unreleased, and the RLS probes that came with it deserve to gate the release rather than trail it.
- Consider a server-side render worker for Journey exports once volume
justifies it — the
journey_rendersschema was built to make that swap a non-event.
Proof
- Live site: setoff.corycopeland.dev
- The hero image on this page is a live capture from the marketing site — the real itinerary and live-poll UI, not a mockup.