Love Doubles replaces the WhatsApp thread with one simple question per outing: are you playing? Players answer with a tap, the app fills courts four at a time, and nobody hears about anything that doesn't affect them. Everyone is a player, and anyone can organize.
Social tennis runs on group texts. Every “I'm in,” “me too” and thumbs-up pings everyone, and the organizer still has to scroll back to count heads, work out courts, chase people who didn't answer and find a fourth when someone drops.
Love Doubles keeps one status per person per outing. The organizer sees who's in, how many courts that makes and who hasn't replied. Players see whether they're playing, who else is coming and the place on a map. Changes reach only the people they affect.
From the notification or the outing card, for yourself or everyone you sign up.
You're in, a spot opening, a move, a cancellation. No reply-all.
Everyone knows ahead of time whether they're playing, where, and how many courts. Partners are sorted out at the courts.
The same app lets you play in any group and run your own. Starting a group takes a minute.
Full doubles courts always come first. Two left over start on the next court as a practice court, and a pair rotates in and out each set. A single leftover, or a third, waits on the waitlist, so nobody is stuck on a court with nowhere to rotate. Here's a group with a maximum of 3 courts:
Nobody sees partners ahead of time, so nobody picks and nobody feels left out. Before the day, players see only that they're playing, who else is, and how many courts. The organizer chooses, per outing, how teams are made:
Players sort out courts and partners themselves when they arrive, the way many groups already do.
Once everyone is there, the organizer taps Draw teams. The app picks courts, partners and the practice pair at random and shows each player theirs. If someone drops or arrives late, the draw clears and the organizer draws again.
There's no separate organizer app. Everyone has the same four tabs: Tennis (every outing you're in, whoever runs it), Organize, Messages and More. An organizer like Ken answers his own outings and Susan's Saturday outing from the same Tennis tab.
A player's Organize tab invites them to start a group, free like everything else. Margaret can set up “Wednesday Morning Doubles,” pick the club on a map and post her first outing in about a minute. That's how players become organizers, and more organizers means more groups, more players and more reach for a sponsor.
For regular groups like a Thursday morning doubles.
For drop-in formats like live ball.
Any outing can carry a fee note, such as “$10 at the court, cash or Venmo.” Love Doubles shows it but never collects money.
Every group has a usual place and every outing shows its place on a small map with the address and a Directions button. Organizers pick places from a map when they start a group or post an outing.
If the courts change after posting, the organizer taps Change location. Everyone who hasn't said no is told once, the outing is marked “New location,” calendars update, and the 30-minute reminder uses the new address.
One phone can sign up several people: a husband or wife, a grandchild, a friend without a smartphone. Their notifications come to that phone, marked “For Walt:” or “For Tyler:”.
Each outing card offers All of us or Choose who, which lists everyone with In and Out (or Coming, Maybe and No) buttons. The organizer's roster shows them as “Via Margaret,” so nobody looks unreachable.
Only to people who haven't answered, 3 hours before the deadline.
When a court is short, to people who haven't answered or said no. Sent 8 AM to 8 PM, not in the last 2 hours.
If 2 are left over, they hear they'll start on the practice court, and hear again if the court fills.
To everyone playing, with their court and the address, and a summary to the organizer.
Each can be switched off per outing. Players also get a badge on the app icon, a home-screen widget with the next outing, and outings added to their phone's calendar.
Players follow organizers, the way social tennis really works: you go to whatever Eric, Matt or Dennis puts on. Following another organizer is one more scan. Someone who finds the app on their own can browse groups near you. Asking to join, and players inviting friends, both go to the organizer to approve.
Organizers can back up their groups at any time: everything, just the members, or just the outings, as a backup file or a spreadsheet. It's a safety copy, and it means a group never depends on one person's phone.
When an organizer retires or wants help, they add a co-organizer or hand the group over completely. Members, outings and answers carry over, members are told once, and the old organizer stays on as a player. A new organizer can also import a backup someone sends them.
Nobody pays: not players, not organizers, not clubs. Every feature is in for everyone, including repeating outings, the subs list and unlimited groups. A sponsor pays to be the app's presenting partner.
Play in any number of groups, sign up the people you manage, and get every reminder and notice, plus sponsor offers you'll love that never get in the way.
Run as many groups as you like, with repeats, open-spot alerts, Find a sub, announcements, backup and handover. No limits to grow out of.
Becomes the presenting partner: their colors on the app, a “presented by” line on every screen, a member perk with a code, a thank-you for organizers, and their name on the widget and app listing.
What a sponsor never gets: pop-ups, notifications or a spot between outings, players' names or contact details, or any say in how courts fill. The app works the same with or without them.
How it grows: build usage free first. Then a regional partner, like a pro shop or racquet club, once a few thousand players in an area use it. A national equipment brand once there are several regions. Different regions could even have different sponsors.
The perk is the only offer, and it stays quiet: a card under your outings, one line on an outing you're playing, and More → Member perk. Never a notification or pop-up, and players can hide it for a week. Players can also opt in during setup to one offer a week, delivered quietly to Messages. In the prototype, use Sponsor preview (top of the left column) to switch sponsors or type in your own brand; the For Sponsors tab is the pitch.
How to build Love Doubles as native iOS and Android apps in TypeScript: React Native with Expo, a Supabase backend, and push through Expo, Apple and Google. The Prototype tab is the behavioral reference.
October 7, 2026 · Ken · Editable version (Claude Docs)
Love Doubles v1 is a native app for iPhone, Android phones and tablets that organizes social doubles, written entirely in TypeScript: React Native with Expo for the app, Supabase for a backend with no web pages. The marketing website stays separate and only hosts the two files that make invite links open the app.
What v1 must do:
Out of scope for v1: payments, skill ratings, tournament draws, a web app.
The clickable Love Doubles prototype is the behavioral reference. Where it and this spec disagree, log it under Open questions.
Every answer goes from a phone to the backend, which recomputes the lineup with the shared rules and queues notifications. Expo's push service then hands them to Apple and Google, who deliver to phones. While the app is open, live updates arrive directly from the backend.
One GitHub monorepo using pnpm workspaces and Turborepo. The shared rules package is the contract between the two developers: agree its types first, then build app and backend in parallel.
lovedoubles/
apps/mobile/ Expo app (expo-router, TypeScript)
packages/rules/ pure domain logic: courts, waitlist, who gets notified
packages/theme/ design tokens, sponsor theme overrides
supabase/migrations/ SQL migrations (the only way the schema changes)
supabase/functions/ edge functions (Deno, TypeScript)
supabase/tests/ RLS and function tests
supabase/seed.sql demo data: Ken's and Susan's groups from the prototypepackages/rules is plain ESM with no Node- or React-only APIs, so the app (Hermes) and edge functions (Deno) import the same code.supabase gen types) and committed; never hand-edit them.supabase start.The key split is profiles (people who signed in on a phone) versus people (anyone on a roster). Organizers can add Walt before he has an app, and Margaret can manage him without him ever having an account. Times are stored in UTC; each group has an IANA time zone for display and quiet hours.
| Table | Key columns | Notes |
|---|---|---|
| profiles | id (= auth user, anonymous until linked), display_name, organizer_code (unique, resettable), approve_new_names, linked_identity (apple, google, none), text_scale, notif_prefs (jsonb), calendar_sync, deleted_at | One per app user. No phone number or email stored |
| people | id, display_name, profile_id (nullable), manager_profile_id (nullable), same_court | Roster entries. A person's ID is their profile's ID from first run; entries an organizer typed in before someone had the app are linked only by the organizer ("Same person"), never by matching names. display_name is a label; organizers can set their own label per follower |
| groups | id, name, organizer_id, co_organizer_id, timezone, home_location_id, usual_day, usual_start, default_max_courts, listed, level_label | listed = shows in Groups near you |
| follows | profile_id, organizer_id, status (active, pending), created_at | A player following an organizer. Which of that organizer's groups they joined decides which outings they hear about |
| group_members | group_id, person_id, role (player, sub), status (active, pending, removed), added_by, joined_at | Primary key (group_id, person_id) |
| locations | id, name, address, lat, lng, court_count, created_by | Shared, reusable places |
| outings | id, group_id, kind (doubles, open), starts_at, ends_at, location_id, moved_from_location_id, max_courts, practice_court, teams (self, draw), draw_seed, drawn_at, answer_by, fee_note, series_id, status (scheduled, posted, cancelled), auto_remind, open_spot_alerts, start_reminder, reminder_sent_at, start_reminder_sent_at | answer_by null for open sessions |
| series | id, group_id, rule (weekly), post_lead (3 days), post_time, template (jsonb) | Repeating outings |
| answers | outing_id, person_id, status (in, maybe, out), answered_at, answered_by | Primary key (outing_id, person_id). answered_at is set by the server and is the queue order |
| answer_events | id, outing_id, person_id, from_status, to_status, by_profile, at | Audit trail and backup history |
| lineup_snapshots | outing_id, version, lineup (jsonb), computed_at | Last computed lineup, diffed to decide notifications |
| messages | id, organizer_id, player_person_id, from_side, body, created_at, read_at | Private threads only |
| announcements | id, organizer_id, body, created_at; targets (group or outing); reads per profile | One-way |
| devices | id, profile_id, expo_push_token (unique), platform, app_version, last_seen_at, disabled_at | Several per profile allowed |
| notifications | id, recipient_person_id, deliver_to_profile_id, kind, outing_id, body, data, dedupe_key (unique), scheduled_for, sent_at, status, skip_reason, expo_ticket_id | The outbox every push goes through |
| join_requests | id, organizer_id, profile_id, name_typed, possible_same_person_id, group_ids, kind (new_follower, new_phone, ask), decision (same, different, declined), decided_by | Approval queue |
| handovers | id, group_id, from_profile, to_profile, mode (co, full), status | Co-organizer or full handover |
| app_config | key, value (jsonb), region | Sponsor theme, perk text, feature flags |
Every table has row-level security on, and the app reads directly through supabase-js under those rules. Any write with side effects (answers, posting, moving, messages) goes through an edge function, never a raw insert, so notifications can't be skipped.
Two SQL helper functions carry most rules:
my_person_ids(): people rows where profile_id = auth.uid() or manager_profile_id = auth.uid().is_group_organizer(group_id): caller is the organizer or co-organizer.| Table | Read | Write |
|---|---|---|
| groups | Members, organizers; listed groups through a limited view | Create: anyone signed in (becomes organizer). Update: organizers |
| group_members | Members of the same group | Organizers; a player can remove themselves |
| outings, series | Members of the group | Organizers, through edge functions |
| answers | Members of the group | Edge function only: caller must own or manage the person, or organize the group |
| messages | The organizer and the player (or the player's manager) | Edge function only |
| announcements | Their audience | Organizers, through edge function |
| people | Members of the same group see names only | Organizers, or the manager for people they manage |
| profiles | Own row | Own row |
| devices, notifications | Own rows | Devices: own. Notifications: service role only |
Love Doubles stores no phone numbers or email addresses. Managed people have no account and store a name only, which keeps data on minors like Tyler minimal.
The anon key ships in every app, so assume anyone can call the API directly. Row-level security and edge-function checks are the only walls; the app is never trusted. Items marked launch blocker must be done before real users.
| Threat | What could happen | Protection |
|---|---|---|
| Fake accounts (launch blocker) | A script creates thousands of anonymous accounts and floods organizers with follow requests and messages | Anonymous sign-in requires Apple App Attest or Google Play Integrity (CAPTCHA fallback, which Supabase supports for anonymous sign-in). Limits per device and per IP on sign-ups, follows and join requests |
| Guessing organizer codes (launch blocker) | Short or readable codes like /o/ken are guessed to find organizers and spam them | Random codes of at least 10 base-32 characters (about 50 bits), never derived from names. Lookups rate-limited; a wrong code and a reset code get the same response. "Reset my code" revokes the old one |
| Claiming someone else's identity (launch blocker, fixed by design) | Typing "Diane" to become Diane on a list | Identity is the ID made on first run; names are labels. Every follow is a join request, and only the organizer links a follower to an existing roster entry ("Same person"). Organizers are told whenever a follower is linked |
| Reading more than allowed | RLS filters rows, not columns: a player querying people or profiles could see every column | Players read fellow members only through a view (member_names: id, label). Base tables are readable only by their owner, managers and organizers. Column privileges revoke the rest |
| Acting on someone else's things | Calling /answer, PATCH /outings/:id, /export, /handovers/:id/accept or a message thread with another person's IDs | Every edge function calls one shared authorize(caller, action, resource) helper before any write. /export requires organizer or co-organizer of every group requested. Handovers can only be accepted by their recipient. A wrong-user test exists for every endpoint |
| Changing fields you shouldn't | A PATCH body sets organizer_id, status or answered_at | Each endpoint validates input with a Zod schema that lists the editable fields; everything else is rejected. Server-owned fields (times, IDs, status) are never read from the request |
| Push and message spam | Messages, announcements, Find a sub or join requests used to bombard people | Per-user limits enforced in the database (a rate_limits table checked by each function): for example 30 messages an hour, 5 announcements a day, 3 Find a sub per outing, 20 follow requests a day. Block and report on every thread. Organizers can only reach people who follow them |
| Bad text in fields | Huge names, control characters, or a name like =HYPERLINK(…) that runs when an organizer opens the CSV backup | Length caps (name 40, message 1,000, fee note 120, announcement 500). Control characters stripped. CSV cells starting with = + - @ or a tab are prefixed with '. The app renders text only, never HTML |
| Backup import abuse | A huge or malformed file bogs down or corrupts the import | 1 MB cap, schema validation, at most 200 members per import. The importer only creates people inside their own new group |
| Realtime leaks | Subscribing to channels for groups you're not in | Only Postgres-changes subscriptions (which respect RLS) or private channels with Realtime authorization. No public broadcast channels |
| Stolen sessions | A lost phone or copied token keeps working | Tokens in secure storage (Keychain, Android Keystore), short-lived access tokens with refresh rotation. "Same person" on a new phone revokes the old device's sessions and push tokens, and the old device is notified |
| Malicious app updates | A leaked Expo token pushes harmful JavaScript to every phone | EAS Update code signing turned on. EAS, Apple, Google, Supabase and GitHub accounts use two-factor login. Secrets live only in EAS and Supabase secrets |
| Service-role key exposure | Full database access if the key ships in the app or a log | The service-role key is used only inside edge functions and cron, never in the app, the website or CI logs. Secret scanning on the repo |
| Sponsor content | A perk link sends players to a look-alike site | app_config is writable only by the service role. Perk and shop links must be https and on an allow-list of the sponsor's domains |
| Data about minors | Managed players like Tyler (15) have more personal data than needed | Managed people store a label only. Logs and analytics never record names; error reports scrub them |
Checks that run in CI: the Supabase security advisor (fails the build if any table lacks RLS or a view leaks columns), pgTAP wrong-user tests for every table, Deno tests for every endpoint's authorization and rate limits, and dependency and secret scanning.
packages/rules is pure, deterministic TypeScript: no I/O, no Date.now() (the caller passes now). The app runs it to show lineups instantly; edge functions run the same code to decide who gets notified. Start by porting allocate, schedule and afterChange from the prototype.
export type AnswerStatus = 'in' | 'maybe' | 'out' | 'none';
export interface Person { id: string; name: string; managerId?: string; sameCourt?: boolean; isSub?: boolean }
export interface Answer { personId: string; status: AnswerStatus; answeredAt: string } // server time = queue order
export interface OutingRules { kind: 'doubles' | 'open'; maxCourts: number; practiceCourt: boolean; startsAt: string; answerBy?: string; timezone: string }
export interface Lineup {
mode: 'courts' | 'practice' | 'short' | 'open';
courts: string[][]; // 4 person ids each
practice: string[]; // exactly 2 ids, or empty
waitlist: string[]; // in queue order
coming: string[]; maybe: string[]; // open sessions
courtsNeeded: number; need: number; // players needed to open the next court
}
export type Placement = { kind: 'playing' } | { kind: 'court'; court: number; partner: string; opponents: string[] } | { kind: 'practice'; court: number; with: string } | { kind: 'waitlist'; position: number } | { kind: 'open' | 'none' };
export function allocate(o: OutingRules, answers: Answer[], people: Person[]): Lineup;
export function draw(l: Lineup, seed: number, set: number): { courts: string[][]; practice: string[] };
export function placementOf(l: Lineup, personId: string): Placement;
export function diffLineups(before: Lineup, after: Lineup, ctx: ChangeContext): NotificationIntent[];
export function routeIntents(intents: NotificationIntent[], people: Person[], prefs: Prefs): Delivery[];
export function openSpotDecision(o: OutingRules, l: Lineup, now: string): { send: boolean; at?: string; reason?: string };Rules allocate must hold:
answeredAt.short, everyone listed, no court.sameCourt are swapped onto their manager's court when possible.coming and maybe lists only.diffLineups returns intents: promoted, moved to waitlist, draw cleared (players changed after a draw), courts needed changed, open-spot check. It never targets the person who just answered.
Every push goes through one outbox: code inserts rows into notifications with a dedupe_key (for example outing:{id}:start:{person}), and the send-push worker delivers them through Expo's push service, which forwards to Apple (APNs) and Google (FCM).
| Event | Who gets it | Category |
|---|---|---|
| Outing posted (or repeat posted) | Group members except the organizer | outings, with answer buttons |
| Promoted from waitlist | The promoted person | lineup |
| Moved to waitlist | The person moved | lineup |
| Courts needed changed | Organizers | organizer |
| Open spot | Members who haven't answered or said no | outings, with answer buttons |
| Answer reminder | Members who haven't answered | outings, with answer buttons |
| 30 minutes to start | Everyone playing (address, court layout, how teams are made), waitlist, organizer summary | lineup |
| Location changed | Everyone not out | lineup |
| Cancelled | Everyone in or maybe | lineup |
| Teams drawn | Each player, with their court, partner and opponents (or the practice court) | lineup |
| Draw cleared | Organizer, when the players change after a draw | organizer |
| Message, announcement | The other side of the thread; the audience | messages |
| Join request, handover, approval | Organizer or requester | organizer |
Routing rules:
Delivery details:
DeviceNotRegistered, disable that token.The server owns all timing, because outings change after people answer. Jobs run from pg_cron calling edge functions, and every job is idempotent: dedupe keys plus *_sent_at columns mean a rerun never double-sends.
| Job | Runs | What it does |
|---|---|---|
| send-push | Every minute | Sends queued notifications whose scheduled_for has passed |
| answer-reminders | Every 5 minutes | Outings with the reminder on, 3 hours before answer_by: queue reminders to members with no answer |
| start-reminders | Every 5 minutes | Outings starting in 25 to 35 minutes with the reminder on: queue court-and-address reminders plus the organizer summary |
| open-spot | On each answer change, then at send time | Queued when a drop leaves a court short; held until 8 AM if overnight (group time zone); skipped within 2 hours of start; rechecked just before sending in case the spot filled |
| repeat-posting | Hourly | For each weekly series, creates and posts the next outing at its post time, 3 days ahead; copies settings and the organizer's answer |
| receipt-check | Every 15 minutes | Reads Expo receipts and disables dead tokens |
| cleanup | Daily | Purges deleted accounts after 30 days; trims old notifications |
There's no phone number, password or text message. Each organizer has one code, shown as a QR code and a link (https://lovedoubles.app/o/{code}). Joining through it creates the account. Players follow organizers, not groups, and choose which of each organizer's outings they want, which is how people really play: they go to whatever Eric, Matt or Dennis puts on.
Joining, in order:
Accounts:
Organizer codes:
apple-app-site-association (iOS Universal Links) and assetlinks.json (Android App Links); expo-router handles /o/[code]. Not installed: the page sends people to the right store, and onboarding asks them to scan the code again or paste the link. Branch.io for deferred links can come later.Account deletion is in the app (More). It removes the profile and devices, unlinks people rows so organizers keep only the name, and hands managed people back to their organizers.
expo-router file routes mirror the prototype's four tabs. Every screen in the prototype has a route below; build them from it.
app/
(onboarding)/ organizer (pick outings), name-size, managed, notifications, scan-code
(tabs)/
tennis/ index (Coming up), [outingId] (detail, map, lineup), choose-who/[outingId] (modal), find (Groups near you)
organize/ index (outings, requests, scheduled, groups), start (new group), new (new outing),
outing/[id] (Players | Courts | Settings), announce, my-code, backup, import,
location-picker (modal)
messages/ index (Your players / Your organizers), [threadKey]
more/ index (people I manage, organizers I follow, notifications, calendar, about, keep my account safe, delete account)The server is the source of truth; the phone keeps a cached copy so the app opens instantly, even with no signal at the courts.
allocate locally, then the server confirms.answered_at comes from the server clock, never the phone's, so the waitlist is fair.SELECT … FOR UPDATE) while it writes and recomputes. Two people tapping for the last spot are handled in order, and the loser lands on the waitlist and is told.| Feature | Library or approach | Notes |
|---|---|---|
| Maps | react-native-maps (Apple Maps on iOS, Google Maps on Android) | Android needs a Google Maps API key. Organizers pick from saved locations or add one by search; the server geocodes. Directions opens the phone's own Maps app |
| Calendar | expo-calendar | Creates a "Love Doubles" calendar. Upserts outings you're in, maybe or unanswered (unanswered and maybe as tentative); removes ones you skip or that are cancelled. Syncs on app open and after a push |
| Backup and export | Export function returns JSON; the app builds CSV or JSON, writes it with expo-file-system and opens the Share sheet with expo-sharing | Everything, Members or Outings |
| Import | expo-document-picker, then the import function | Validates, creates people rows, makes the importer organizer, notifies members once |
| App-icon badge | Server-set badge in each push, plus expo-notifications locally | Reliable on iOS; varies by Android launcher |
| Home-screen widgets | iOS WidgetKit (Swift) and Android Glance (Kotlin), added through an Expo config plugin | The app writes next-outing data to shared storage; a push refreshes it. Phase 4 |
| Sponsor theme | app_config row fetched at launch and cached | Colors, "presented by" text, perk (headline, details, code, shop link, organizer thank-you, end date); regional. Perk opens and code copies are counted as totals only. No sponsor SDK, no tracking, so no App Tracking Transparency prompt |
| Larger text | In-app multiplier over the OS font scale | See Screens |
Reads go straight through supabase-js and RLS. Writes with side effects go through edge functions, because only TypeScript can run the rules package. Each function checks the caller, opens a transaction, writes, runs allocate before and after, and inserts the resulting notifications in the same transaction.
| Function | Input | Does |
|---|---|---|
| POST /answer | outingId, answers [{personId, status}] | Caller must own or manage each person, or organize. Writes answers, diffs lineups, queues notifications |
| POST /outings | group, kind, start, length, location, max courts, practice, answer-by, fee, reminders, repeat, players ("I'm playing" plus managed) | Creates and posts; queues outing-posted notifications |
| PATCH /outings/:id | max courts, practice, reminders, location, cancel | Location change and cancel queue notifications; court and practice changes rerun the diff |
| POST /outings/:id/draw | none | Random draw of courts, partners and the practice pair; sends each player theirs. DELETE clears it |
| POST /outings/:id/find-sub | none | Asks the group's subs; the first yes gets the spot |
| POST /outings/:id/remind | none | Manual nudge to non-responders |
| POST /announcements | groupIds or outingId, body | One push per phone |
| POST /messages | threadKey, body | Private thread message |
| POST /groups, PATCH /groups/:id | name, home location, usual day and time, listed, level label | Creates or updates |
| POST /follow, PATCH /follow/:organizerId | organizer code, typed name, chosen groups | Follows an organizer and opens a join request (never matches by name); PATCH changes which groups. POST /organizer-code/reset makes a new code |
| POST /join-requests, POST /join-requests/:id/decide | organizer code, typed name, chosen groups; approve or decline | Approval queue |
| POST /handovers, POST /handovers/:id/accept | group, to, mode | Co-organizer or full handover |
| GET /export | groupIds, scope, format | Returns backup JSON or CSV |
| POST /import | backup JSON | Creates the group with the caller as organizer |
| POST /devices, DELETE /devices/:id | Expo push token, platform | Registers or removes a phone |
| POST /account/delete | none | Deletes the account as described in Sign-in |
| Cron functions | none | send-push, answer-reminders, start-reminders, open-spot, repeat-posting, receipt-check, cleanup |
The rules package carries the most tests, because court filling is the product.
| Layer | Tool | What it covers |
|---|---|---|
| Rules | Vitest + fast-check | The prototype's scenarios (8, 9, 10, 11, 12 and 14 players with a max of 3 courts), plus property tests: never more than 4 per court, never more than max courts, practice exactly 2, waitlist order stable, nobody both in and waitlisted |
| Database security | pgTAP | A player can't read another group or answer for someone they don't manage; phones hidden from players |
| Edge functions | Deno test against supabase start with seed.sql | Answer to notifications end to end; the last-spot race; quiet hours |
| Security and abuse | pgTAP, Deno test, Supabase security advisor | A wrong-user test for every table and endpoint, rate limits tripping, PATCH bodies with extra fields rejected, CSV escaping, oversized imports refused |
| App components | Jest + React Native Testing Library | Cards, Choose who, forms |
| App flows | Maestro | Onboarding, All of us, post an outing, change location, backup |
development, preview and production. Preview builds go to TestFlight and Google Play internal testing for your Thursday group.runtimeVersion tied to native changes.Four phases, each ending in something your own groups can use. No dates yet: set them once you've both sized phase 1.
| Phase | Done when | Developer A: app | Developer B: backend and rules |
|---|---|---|---|
| 1. Foundations | Sign-in works on both platforms; rules tests green | Expo app shell, tabs, theme tokens, onboarding screens, push permission | Repo and CI, schema and RLS, anonymous sign-in with App Attest and Play Integrity plus Apple and Google linking, authorize() helper and security advisor in CI, rules package ported from the prototype with tests |
| 2. Core loop | Your Thursday group runs a real week from TestFlight and Play testing | Tennis tab, outing detail, All of us / Choose who, Organize list, New outing, roster, invite QR | Answer and outing functions, notification outbox, send-push, receipts, random organizer codes, join requests and Same person / Different person, rate limits |
| 3. Organizer helpers | Organizers stop using the group chat | Maps and location picker, Messages, Announce, Find a sub, Settings toggles | Answer, open-spot and 30-minute reminders, repeat posting, location change, cancel, subs, messages |
| 4. Polish and launch | Both stores approve | Calendar sync, widgets, tablet layout, Groups near you, backup and import screens, sponsor theme | Export and import, handover, join requests, app_config, store compliance, cleanup jobs |
Swap one task across the line in each phase, so both of you understand both halves.
A mockup of the iPhone App Store page, built from the copy on the right. It's a draft for planning the listing, not Apple's page. The copy fields show Apple's character limits, so it can go straight into App Store Connect.
Love Doubles is free for every player and organizer. One presenting sponsor pays for that, and in return becomes part of how a tennis community plays each week: on every screen, with offers players are glad to get, placed where they never get in the way.
Participation figures: USTA, 2025 participation report (February 2026).
Social doubles players are the steady core of the sport. They play every week, own their racquets, and go through balls, strings, grips and shoes all year. Love Doubles is where they find out whether they're playing, so they open it before every game.
Organizers matter most. Each one runs a group of 12 to 24 players and is the person everyone asks what to buy. Love Doubles turns players into organizers, so your reach grows group by group without paid acquisition.
A sponsor doesn't just pay for Love Doubles. It's the best way to spread it. You already talk to tennis players every day; Love Doubles turns those one-off moments into a weekly habit with your name on it.
A "Find your doubles game" QR code at the register and stringing desk, on receipts, bags and string tags. A player scans it and is following an organizer before they reach the parking lot.
A card in each online order: "Free tennis organizer, presented by you." It's the most useful thing in the box, and it keeps working long after the order arrives.
A newsletter feature and posts pointing to your own co-branded page, so players see the app as a gift from you, not someone else's product.
For the pros, clubs and leagues you already know: a weatherproof court-fence sign with the organizer's code and a starter can of balls. One kit can bring in a whole group.
From one email to every game. A marketing email reaches a customer once. Love Doubles puts your name in front of them two to four times a week: when an outing is posted, when they say they're in, when a spot opens, and 30 minutes before they play. Those are the moments they're already thinking about tennis, and the app is making their week easier.
It compounds. Each organizer your marketing brings in arrives with 12 to 24 players, and some of those players start groups of their own. Your co-branded links count how many joined through you, as totals only. As an illustration, one shop sign that starts a 16-player group adds up to about 2,500 moments with your name each year (16 players, 3 times a week, 52 weeks).
A thin bar with your name under every screen's header, plus credit on the home-screen widget, the App Store listing and the website.
Headers and courts take your brand color. Status colors (green for playing) stay fixed, so nobody misreads their game.
A quiet card under each player's outings, a perk screen with your code, and one line on outings they're playing in.
A separate perk for the people who run groups, on their Organize tab. The cheapest way to win the people others listen to.
One perk at a time, changed as often as you like. It never arrives as a notification or pop-up, and players can hide it for a week, which is exactly why they don't mind it.
Weekly offer, opt-in only. During setup, players can choose to get one offer a week from you in their Messages. It's off unless they turn it on, never sends a notification, and they can stop it in one tap. Players who said yes asked to hear from you.
A sponsor report counts totals only. These numbers are illustrative, for one region after a season.
That restraint is the product. Players see you as the reason their app is free, not as something in their way.
A pro shop, club or regional retailer presents Love Doubles to every player in its metro area, for a yearly fee.
An online retailer or equipment brand presents it in every region, for a yearly fee, a revenue share on code sales, or both.
Terms to be agreed. A share of sales through a members' code works like the official-retailer deals many tennis associations already run.
Say you're in with one tap. Love Doubles fills courts by four and tells you only what changes.
Full doubles courts come first. Two left over share a practice court and rotate in each set. A single extra, or a third, waits on the waitlist and moves up when a spot opens.
A spot opening up, a change of courts, a rain-out. No reply-all, no group chat.
Answer for everyone you play with, from one phone.
Larger text with one tap.
Thirty minutes ahead, with the address and directions.
Outings appear in your phone's calendar and on a home-screen widget.
Every outing you're in, whoever runs it.
Anyone can start a group. Post an outing, and Love Doubles does the rest.
Start your groupNo subscription. Our sponsor keeps it free with offers you'll love, placed where they never get in the way. They never see your information and never change how courts fill.
Presented by Northcourt Racquet Co. (sample sponsor)
No password, no phone number, no email. Scan your organizer's code or tap their link, type your name, and pick which of their outings you want.
Yes. Add him under People I manage. You answer for both of you, and his updates come to your phone.
Not ahead of time. Your group either pairs up at the courts, or the organizer draws random teams there and the app shows you yours.
Never. Everything comes as app notifications, and only when something changes for you.
iPhone, Android phones, and tablets.
No. Love Doubles never asks for it. Other players see your name only, and sponsors see nothing.
No. You can message your organizer privately, and organizers send one-way announcements. That's the point.
Scan it or open the link on your phone. It takes you straight to their outings, or to the store if you don't have Love Doubles yet.