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. Now extended with Skipper, an in-app AI co-planner that proposes itinerary items and polls for the crew to approve, plus a full desktop/mobile redesign and real Pro billing.
- Travel
- Social
- Supabase
- Self-hosted
- Settlement algorithm
- AI
- Monetization
Updated 2026-07-03

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 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.
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.
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. - 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).
- 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.