Product: CatchUp, a group scheduling app for people who never installed it
Role: Sole designer and developer. Product, design system, mobile, web, backend.
Duration: December 2025 to May 2026
Status: Working prototype. Never shipped to a public userbase, and no usage metrics exist.
Context
CatchUp is a group scheduling product built as a client prototype. An organizer picks the people they want to see, chooses a format, and the app does the rest: it texts everyone an invitation, collects when they can actually make it, and converges the group on a time.
The load-bearing fact about this product is that most of the people in it never asked for it. The organizer wanted this. Everybody else got a text message. Almost every decision below follows from taking that seriously.
The organizer app, live in the browser demo. The only person here who asked for a scheduling app.
Problem
Scheduling anything with more than two people dies in the group chat. Somebody proposes Thursday, two people answer, one answers on Saturday, and the plan expires without anyone deciding to cancel it.
The conventional fix is a shared calendar product, which asks six people to install an app, create an account, and learn an interface in order to answer a question a text message already asked.
The other conventional fix is an availability grid, which hands back the square where the most people are free. That is often not the time the group wants. Being free and being willing are different states, and a grid cannot tell them apart.
Texting people who never signed up for anything carries real obligations. A link sent that way has to expire, has to be revocable after it has been forwarded to someone it was not meant for, and has to arrive with the compliance surface a carrier review expects.
Process
1. Decide who is allowed to be inconvenienced
The first decision was a rule about people rather than a feature. The organizer can be asked to install an app, because the organizer wants the thing to happen. The invitee can be asked for a tap and an answer, and nothing else. No install, no account, no password, no app store.
That rule produced the architecture. Two separate frontends over one backend. The organizer runs a React Native app built in Expo, 34 screens, carrying the whole product: contacts, groups, the create flow, the invitation preview, the negotiation view, the calendar. The invitee opens a link in whichever browser is already on their phone and lands in a small React web app that exists to do one job.
Both frontends share a design system and a schema, so the two experiences agree about what a catch-up is without the guest ever meeting the product that produced it.
One rule, drawn: the organizer installs an app, the invitee taps a link, both hit the same backend.
The create flow branches by meet type, so an in-person plan and a video call ask different questions.
2. Make a link that is safe to text a stranger
Every invitation is a signed token in a URL, and every property of that token exists because of how text messages actually travel. People forward them. People screenshot them. People open them three weeks later.
The token is an HS256 JWT carrying only the event and the participant, and it expires in seven days. The backend never stores the token itself. It stores a SHA-256 hash of it, so the invite table is not a list of working keys to somebody else's plans. Each token carries a revocation flag and a usage counter, so a link that reached the wrong person can be killed without cancelling the event, and the organizer can see that an invitation was opened four times and never answered.
A second and separate system handles shareable group links, with its own tokens and its own JTI tracking, because a link posted into a group chat carries a different threat model than a link texted to one person. The guest app ships the compliance surface that path requires: consent language, message frequency and rate disclosure, STOP and HELP handling, privacy and terms pages that a carrier review can read.
What the invitee sees. One page, one job, no account.
Mint, hash, text, open, revoke. The invite table stores hashes, so it is not a list of working keys.
3. Score the times instead of counting them
The convergence is not an availability heatmap with the darkest cell highlighted. It is a five-stage scoring pipeline that runs over every candidate slot.
Stage one is the availability percentage. Stage two weights it, because a plan that works for everyone except the one person it was for is not a working plan, so invitees carry an organizer-set priority and the score is computed against total weight rather than a headcount. Stage three applies per-person time-of-day preference multipliers, which is the difference between free at 8am and willing at 8am. Stage four applies contextual penalties. Stage five validates quorum against the event's own rules and zeroes any slot that fails, then the best slot is the highest score that actually cleared it, with an explicit fallback when nothing does.
Stage four is worth being precise about, because it is implemented and deliberately not switched on. Two penalty inputs were built: a live OpenWeatherMap forecast that downweights a slot for severe conditions, and a Haversine great-circle distance penalty of 0.9x for each invitee more than ten miles from the venue, stacking multiplicatively. Neither one feeds the live path today. Fetching a forecast requires an action context that the scoring mutation does not have, and contacts carry no home location yet, so the pipeline passes a constant 1.0 and a null coordinate rather than inventing a plausible one.
Three stages run. Two are wired and waiting on inputs that do not exist yet, and that is what the code says out loud instead of quietly rounding up.
Five stages. Three run today, and the two that do not are marked rather than faked.
The pipeline verbatim, including the constant that stands in for a forecast nobody is fetching yet.
Back on the organizer side: a scored overlap, who has answered, and a negotiation with stages rather than a thread.
4. Treat the event as a state machine, because a group will break anything else
An event is not a row with a status string. It is a six-state machine: draft, sent, confirmed, finalized, completed, cancelled. Forward transitions are enumerated, cancellation is legal from any non-terminal state, completed and cancelled are terminal, and a same-state transition is rejected rather than absorbed. Every accepted transition writes a row to an audit table with the actor that caused it.
That matters because the scheduling itself is a negotiation. Each event carries a round counter, and people counter-propose. A five-minute cron sweeps events with auto-confirmation enabled and settles the ones whose conditions are met, skipping anything whose deadline expired more than thirty days ago. Without a validated state machine underneath, a late reply arriving after a cancellation is a data corruption bug that shows up as a friend receiving a confirmation for a dinner nobody is attending.
Six states, enumerated transitions, and an audit row for every one that gets accepted.
5. Route by who the recipient is
Notifications route through a policy function rather than a send call. It takes the recipient's actual reachability, push token, phone, email, and their stated preferred channel, and returns an ordered decision: which channels, which provider, and the reason each one was chosen. Unreachable returns a skip with a reason instead of a failure.
Two rules carry the weight. A recipient's preferred channel goes first when it is reachable, so the person who lives in text messages is not chased through email. Anything marked critical adds an email backup even when a faster channel was already selected, because the cost of a duplicate is annoyance and the cost of a miss is somebody standing outside a restaurant. Delivery is written to a log keyed by an idempotency key, so a retried job cannot text the same person twice.
Reachability in, an ordered decision out. Critical messages get an email backup they did not ask for.
6. Prove the accessibility instead of intending it
Accessibility is a test file. The harness wraps axe-core and adds the two things axe cannot do on this stack. It implements the WCAG relative luminance formula from scratch with the ITU-R BT.709 coefficients, so contrast is asserted against the real 4.5:1 and 3:1 AA thresholds rather than eyeballed against a palette. It adds a React Native accessibility label validator that fails any interactive component shipping without one, which is the gap axe leaves on React Native.
333 lines, and it runs against the components rather than a checklist. A design system that has to pass a contrast assertion in CI is a different object than one with a note in its documentation saying it meets AA.
Contrast computed from the BT.709 coefficients, asserted at 4.5:1, in a test that can fail.
Solution & Outcome
A working cross-platform prototype, live at catchup-prototype.vercel.app as a phone-framed demo of the organizer app.
Two frontends on one Convex backend: a 34-screen Expo React Native app and a zero-install React guest app, over a 24-table schema and 55 backend function modules.
A five-stage weighted scheduling engine, three stages live and two implemented and honestly gated.
A six-state event machine with an audit trail, an iterative negotiation loop, and a five-minute auto-confirmation sweep.
Magic links that expire, hash at rest, revoke individually, and count their own use, with the SMS compliance surface the channel requires.
A channel-selection policy with a critical-priority email backup and idempotent delivery.
An accessibility harness that computes WCAG contrast from first principles and fails a React Native component with no accessibility label.
159 test and spec files across Jest, Vitest with convex-test, and cross-browser Playwright.
What this is not: a shipped app. There are no users, no retention numbers, and no revenue. The public URL runs the organizer app with the live backend disconnected, which is the honest way to show a prototype and the reason every number above is a build fact rather than a result.
Close
The interesting constraint in most products is not technical. It is deciding whose time you are allowed to spend. Getting that wrong is why so many coordination tools ask five people to do work so that one person can avoid it.
Thanks for reading this far. If this is the kind of work you need, I would like to hear what you are building.
A cross-platform scheduling prototype: a React Native app for the organizer, a zero-install web app for everyone else, one real-time backend under both.