Status: family side shipped (2026-04-18) · companion side deliberately deferred (the existing home-page search feed already covers it passably). Date: 2026-04-18 Context: freezes the shape of the match-discovery surface so whoever picks up the implementation has something to point at.
/trip/[id]/matches— curated shortlist page, up to 5 companion cards with evidence badge, review summary, verified-channel icons, bio snippet.components/matches/—companion-card.tsx,evidence-badge.tsx,intro-modal.tsx.components/ui/dialog.tsx— shadcn-style wrapper over@radix-ui/react-dialog.- Dashboard entry point — "See companions →" button on every open request trip.
- Intro modal reuses the existing
sendMatchRequestAction(same moderation + same(trip, requester)dedupe constraint).
- Companion side — the spec's
/browsemirror. The home page/already has FlightComposer + leg-based search + cards, which is functionally "flights that need help" — not as tight as the spec, but good enough pre-launch. Revisit after real user feedback rather than up-front. - Per-companion help_categories — the intro modal currently shows the family's help_categories read-only. Letting the family send a tailored subset per companion would need a
match_requests.help_categoriescolumn. Out of scope here. - Chain-of-companions (BFS over trip_legs for pairs of helpers) — see bugs/ALGORITHM.md upgrade #1. Supply-dependent; not a V1 concern.
Curated shortlist, not a swipe feed. Both sides see a small, ranked set of candidates chosen for a specific flight. Rich profile content per card. One clear CTA. No infinite scroll, no gestures that make a high-stakes decision feel casual.
The same card DNA renders on both sides — only the subject changes.
Three properties of Saathi break the Tinder model:
- Asymmetric stakes. A family is entrusting a stranger with someone they love — an elderly parent, a pregnant sibling, a first-time flyer. A companion is volunteering a few hours of airport time. Treating these as symmetric swipes misreads the emotional weight on the family side.
- Sparse supply. Tinder works because supply is abundant — swipe fatigue is the point. On a given flight on a given day, Saathi might have 0-3 candidates. A deck of 2 cards isn't a deck.
- Time-bound subjects. A trip expires the moment the flight lands. Profiles aren't evergreen; they're tied to one QR540 on one date. Matching needs to be "best options for this flight," not "people you might like."
Swiping also gamifies a decision that benefits from friction. The user should think before sending a match request, not flick one off.
- Evidence > vibes. Every card leads with why this person — the specific overlap (same plane, same route, shared language), not a photo-first aesthetic.
- Shortlist > feed. 3-5 cards above the fold, collapse the rest. Pressure to decide carefully beats pressure to keep swiping.
- One CTA, one intent. Each card funnels to a single action with friction (intro message + needs) — not instant chat, not silent match.
- Mirror, don't clone. Both sides use the same card shape. The subject flips (companion ↔ family), the CTA verb flips, the trust rationale stays the same.
Entry point from the trip dashboard: "3 companions are on your
flight — see who they are →". Route to /trip/:id/matches.
┌─────────────────────────────────────────────────────┐
│ Your trip · CCU → DOH → AMS · Apr 20 │
│ QR540 + QR23 · Mom (70-80) · Bengali, Hindi │
│ │
│ We found 3 companions on your flight. │
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ [photo] Priya S. ★ 4.9/12 │
│ │
│ ● Also on QR540 + QR23 (same plane, same day) │
│ Languages: Bengali · Hindi · English │
│ "TU Delft grad student. Flew home with my │
│ grandma last month — happy to help." │
│ ✓ LinkedIn ✓ Email ✓ WhatsApp │
│ │
│ [ Send a message → ] │
└─────────────────────────────────────────────────────┘
┌── Aditya M. ─ Also on QR23 · Bengali · ✓✓ ─────────┐
┌── Sara K. ─ Similar route Apr 21 · Hindi · ✓ ──────┐
— 2 more available — (collapse)
No companions on your flight yet.
We'll email you the moment someone posts.
In the meantime, you can:
· Share your trip with your network [copy link]
· Loosen the window to ±3 days [see nearby]
If nobody scores past the "on your actual flight" bar, show a clear downgrade rather than pretending:
No one is on QR540 + QR23 yet.
Here are 2 people on your route a day either side —
they're not on the same plane, but they might be able to help.
Tap Send a message on a card → intro modal:
┌─ Message Priya ─────────────────────────────────────┐
│ Hi Priya, │
│ │
│ My mom Rina (72) is flying CCU → DOH → AMS on │
│ Apr 20 on QR540 + QR23 — same flight as you. │
│ She speaks Bengali and Hindi. │
│ │
│ [ ] │ ← editable
│ │
│ She'll need help with: │
│ ☑ Wheelchair at transfers │
│ ☐ Translation with airline staff │
│ ☑ Finding her gate at DOH │
│ ☐ A meal │
│ │
│ [ Cancel ] [ Send request → ] │
└─────────────────────────────────────────────────────┘
- Intro is pre-filled with the concrete facts (flight, relative's name, language) — editable but useful even if the user doesn't edit.
- Needs checkboxes drive the
help_categoriesfield — gives the companion something specific to agree to, not an open-ended "help." - One match_request per (companion, trip) — the DB already enforces this; the UI shows "Pending · sent 10 min ago" on the card after send.
Order the shortlist by the existing scorer (lib/matching.ts), cut at 5. The badge on each card translates the score into plain English — the user should never see the score itself:
| Score band | Badge text |
|---|---|
| flight_number match | "Also on QR540 + QR23" |
| same route, ±1 day | "Same route, 1 day apart" |
| same route, same dy | "Also going CCU → AMS on Apr 20" |
| one-leg overlap | "Can help with the AMS leg" |
Cards below the "flight_number match" band collapse by default with
the — N more available — expander.
Entry point from the dashboard: "4 families near you need a
companion →". Route to /browse (the directory already exists).
Same card shape, mirrored content. The subject is the flight, not the family; the traveller's PII stays behind the match-gate.
┌─────────────────────────────────────────────────────┐
│ Flights that need help │
│ │
│ Filters: [ from: CCU ▾ ] [ to: AMS ▾ ] [ date ▾ ] │
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ QR540 + QR23 · CCU → DOH → AMS · Apr 20 │
│ │
│ ● This is your flight │
│ Rina's daughter · speaks Bengali, Hindi │
│ Helping with: wheelchair, gate navigation │
│ One traveller, age 70-80 │
│ "My mom has done the trip before but gets anxious │
│ at transfers. Grateful for company at DOH." │
│ │
│ [ Offer to help → ] │
└─────────────────────────────────────────────────────┘
┌── Amsterdam · Apr 21 · Same route, 1 day later ────┐
┌── Mumbai hop · Apr 19 · Different plane, similar ──┐
| Field | Family card shows… | Companion card shows… |
|---|---|---|
| Primary subject | Companion name + photo | Flight number + route |
| Identity reveal | Companion first name | Family first name only |
| Traveller info | (n/a — their own relative) | "One traveller, age 70-80" |
| No traveller photos | Not shown at all pre-match | |
| Language line | Companion's languages | Family's languages |
| Needs | (composed in modal) | Pre-written help_categories |
| CTA | "Send a message" | "Offer to help" |
The companion never sees the traveller's name, photo, or private notes
until the family accepts. trip_travellers RLS enforces this at the DB
layer (once bugs/01-trip-travellers-rls.md
lands).
No flights matching your filters.
[ Show flights on your route, any date → ]
[ Show flights on any route, your date range → ]
Tap Offer to help → reverse of the family-side modal:
┌─ Offer to help Rina's family ───────────────────────┐
│ Hi, │
│ │
│ I'm Priya, also on QR540 + QR23 on Apr 20. │
│ I speak Bengali, Hindi, and English. I'm happy │
│ to help with wheelchair handoff and finding the │
│ gate at DOH. │
│ │
│ [ ] │ ← editable
│ │
│ I can help with: │
│ ☑ Wheelchair at transfers │
│ ☐ Translation with airline staff │
│ ☑ Finding her gate at DOH │
│ ☐ A meal │
│ │
│ [ Cancel ] [ Send offer → ] │
└─────────────────────────────────────────────────────┘
The checkboxes echo the family's requested help_categories —
companion opts into a subset. Creates a concrete agreement the family
can accept on, not "yeah I'll help with stuff."
Both sides render the same component with different content. Worth building once.
┌── <subject line> ──────────── <reputation> ──┐
│ │
│ ● <evidence badge — why this match> │
│ <language line, matches bolded> │
│ <one-sentence bio or ask, in their voice> │
│ <verified-channel icons> │
│ │
│ [ <one CTA> ] │
└───────────────────────────────────────────────┘
- Subject line. Companion name (family side) or flight+route (companion side). First name only pre-match.
- Reputation.
★ avg / countifprofile_review_statshas data, omit otherwise — don't show "0 reviews," that reads as distrust. - Evidence badge. The single strongest overlap signal, written in English. Colour-coded: green for flight-number match, blue for route match, grey for "similar."
- Language line. All languages, matching ones bolded so the viewer sees overlap at a glance.
- Bio/ask. Free-text from the poster, 200 chars visible, expand on tap. The part that lets them sound like a person, not a row.
- Verified-channel icons. Icons, not a count (avoids "5 verified"
where 4 are Google).
bugs/LOW.md L01notes the deduplication work to do on the data side. - CTA. One button, opens the intro modal. Never "Match" or "Accept" — those words belong to the family-accepts-a-request step later in the funnel.
● Also on <FLIGHT+FLIGHT>— same flight number(s), same date. Green.● Also going <O> → <D> on <DATE>— same route, same date. Blue.● Same route, N day(s) apart— same route, within window. Blue.● Can help with the <D> leg— partial overlap. Grey.● Nearby route— fallback. Grey, collapsed by default.
(Once bugs/ALGORITHM.md lands, the badge is
computed from trip_legs not route[]. Bands 1 and 4 become trivially
derivable.)
Same component, same shape, different pre-fill strategy:
- Family → pre-fills traveller's first name + needs. Companion checkboxes are the family's requested help categories.
- Companion → pre-fills companion's own name + languages. Checkboxes are the companion's offered subset of the family's requested set.
- Full name. First-name-only until the match is accepted.
- Traveller photo. Never pre-match, never anon-visible.
- Phone number / WhatsApp / email. Unlocked post-match on
/match/:id. - Review text (only ★ avg + count). Full reviews on the profile page.
- Exact age. Age bands only.
Flag these explicitly so the builder doesn't pick silently:
- How many cards? 3, 5, or show-all-with-collapse? Probably 5 above the fold, rest collapsed. Test with real data.
- Does the family see "Priya has already matched 2 other trips this
month"? Probably yes — it's a trust signal. Needs a count field
on
public_profilesderived frommatches. - What happens when a companion's trip is itself a request? Users can post both kinds. Today nothing stops it. If A's request matches B's offer, and A has also posted an offer that B could take, the UI should probably not show them both at once. Defer.
- What's the right "loosen window" affordance? Family has a tight window that found nothing — one tap to widen to ±3 days, or one tap to see "different plane, same route"? Probably both, labelled as downgrades not expansions.
- What happens after "Send request" — stay on page, or navigate? Recommend: stay, toast the success, flip the card to a pending state. Let the family keep scanning other candidates.
- The match page itself (
/match/:id). Out of scope — already partially built and has its own bugs (see bugs/01-trip-travellers-rls.md). - Post-wizard UX (how users create a trip). Out of scope.
- Notifications. See bugs/MEDIUM.md M03, M04.
- Admin / moderation. Out of scope.