Trip-Creation Flow Redesign

Companion Plan Wizard · India + International · Design approved 2026-06-12 · Implementation not started · Data audit folded in 2026-06-12

Scope: The Agent D companion's chip-driven trip-plan wizard (plan-step-builder.ts and friends). Server-side only — the wizard is fully server-composed and mobile renders cards generically, so the redesign ships on a worker deploy with no app rebuild.

BackgroundContext

Two screenshots (2026-06-12) showed the wizard badly broken for India:

Bug 1. "Plan a trip" → "Within India" → "Where in India?" offers "India" — a nonsense self-loop. Root cause: destination is asked before trip length, so the nearby-getaway branch (gated on num_days <= 4) can't fire, ranked India cities are never loaded for the where step, and the builder falls into a hardcoded {value:'india', label:'India'} fallback (plan-step-builder.ts:863).

Bug 2. "Plan a weekend" → Lepakshi (3 days) → "When for Lepakshi?" offers "Hill stations (Apr-Jun)" → "How should this India trip feel?" offers Golden Triangle (Delhi→Jaipur→Agra→Mumbai). Root causes: picking a nearby getaway doesn't set single_city, so the route step still fires; getRouteOptionsForPlan returns country-level routes unfiltered by duration (prod data: all 14 India routes are 8-14 days); season chips are country-generic regardless of picked destination.

Direction: rethink the chain from first principles — how a person actually creates a trip — and design every path, question, and edge case for both India and international, before any implementation.

Verified prod data grounding this design (audited 2026-06-12 against prod verso-db):

Distance-only ranking audit — the second-order bug this design must also fix. Simulating today's suggestNearbyPlaces (pure distance sort, 1km floor, catch-all-type exclusion only) from the two anchor metros:

Delhi top-4: Sultanpur Bird Sanctuary (37km — a day-trip bird sanctuary), then Barsana, Vrindavan, Govardhan (bare satellite-town rows with zero metadata). Mussoorie is rank ~23; Ranthambore further still. A Delhi weekend card should obviously read Mussoorie / Rishikesh / Jim Corbett / Agra.

Bangalore top-4: Lepakshi (92km, days_needed 1-1 — a day trip, not a weekend stay), Somanathapura (bare), Horsley Hills, Shravanabelagola (bare). Coorg / Mysuru / Ooty — the actual weekend canon — rank below all of them.

"Distance + season-fit + saves" (the naive Mode A spec) does not fix this: Sultanpur is in-season and 37km away; it would still win. The fix is the getaway-grade hard gates + desirability ranking specified in Part 3, Q-WHERE Mode A.

Part 1First principles

How people actually start trips

A trip always starts anchored on one of three things, almost never more than one:

AnchorExampleWhat they need from us
TIME"I need a break this weekend", "Diwali week is free"Destination options that FIT the time window
PLACE"Goa with friends", "Japan in April", "saved 12 Bali reels"Shaping: when exactly, how long, who, budget
NEITHER (open)"Want to go somewhere", "Help me decide"Cheapest narrowing first: time → mood → ranked suggestions

A VIBE-first start ("honeymoon", "beach trip") is a sub-case of NEITHER with mood pre-filled.

The constraint cascade

Each answer constrains the next question's OPTIONS — never ask a question whose options ignore prior answers:

trip length  ->  bounds reachable distance (weekend <= ~6-8h door-to-door; a week opens
                 all-India / short-haul intl; 10+ days opens circuits & long-haul)
origin       ->  bounds "nearby" + flight costs
mood         ->  HARD-filters destination types/vibes (beach mood must never
                 surface desert circuits or hill regions); thin pools degrade
                 honestly, never pad with off-mood chips
season/month ->  ranks destinations (monsoon, winter); never blocks — EXCEPT
                 hard closures (national parks shut in monsoon)
destination  ->  determines when-options (its best window), route eligibility,
                 city list, budget ballparks
length+dest  ->  determines whether a multi-city route makes sense at all

The single biggest structural fix: ask trip length BEFORE destination whenever destination is unknown. Length is a one-tap, zero-thought answer that collapses the destination space enormously.

Question design rules

  1. Never ask what we already know or can infer. Origin from profile/GPS; days from a date range; scope from the entry point; destination from the collection page or typed text. The skip-if-known matrix already exists — keep it.
  2. Options must be valid answers to the question. "India" is never an answer to "Where in India?". A 10-day circuit is never an answer for a 3-day trip. If data fails to load, degrade to a plausible static list (PLAN_SEED top destinations), never to a degenerate option.
  3. Most-constraining cheap question first. Length before place (when place unknown). Mood only on the open branch.
  4. Annotate options with the deciding fact. Nearby: "~1.5h drive · temples". Country: "visa-free · ~4h flight". Season chips: real dates ("This weekend · Jun 13-14").
  5. Hard cap the path: ≤ 7 questions to confirm on the longest path; weekend path ≤ 5. Counter monotonic (index never repeats, total never shrinks).
  6. Escape hatch on every card ("Pick another" → type it). Free text always works mid-flow (existing digression handling stays).
  7. Single source of truth for sequencing — keep the computeStepStates answered/needed discipline; the redesign changes step ORDER and OPTION SOURCES, not the accounting model.
  8. Every chip is a promise: a real trip of the asked length. A chip must be a destination people actually book for num_days — its seeded days_needed_min/max range must overlap the trip length, and it must be metadata-complete (best_months + vibes + days_needed all seeded) so we can annotate and season-rank it. Day-trip rows (days_needed_max = 1) and bare name+coords rows never become chips; both stay reachable by typing.
  9. Mood is a hard filter, not a rank boost. Once the user says "beach", a Rajasthan circuit or a Himachal region chip is a broken promise. geo_mood — whether from the undecided branch or parsed from typed text ("beach trip in October") — filters every WHERE surface: domestic modes, region chips, intl country tiers, the undecided blend, and route/circuit chips. If the filtered pool is thin, widen reachability and say so honestly ("Beaches need a short flight from Delhi - these fit"), never pad with off-mood chips.
  10. Rank by desirability inside feasibility gates — never by raw proximity. Distance/hours decide eligibility; saves, season, mood, and popularity (is_featured, activity_count) decide order. The nearest row is usually a satellite town, not a getaway.

Part 2The flow architecture

Entry points and what they pre-seed

EntryPre-seededFirst question
"Plan a trip" pillnothingQ-SCOPE
"Plan a weekend" pillgeo_scope=domestic, num_days=3Q-WHERE (nearby mode) — origin promoted first if unknown
Typed intent ("5 days in Kerala in Oct with parents")everything parseable (country/region/city/days/month/origin/companions)first unanswered step
Country collection CTA ("Plan a Japan trip")destination_countryQ-LENGTH
Destination/place context (city page)country + city + single_cityQ-LENGTH
Reel-itinerary convertfully formed(bypasses wizard)

Master step order

Recomputed after every answer; skip-if-known throughout.

SCOPE -> LENGTH -> MOOD* -> ORIGIN** -> WHERE -> WHEN -> DATES
      -> ROUTE*** -> CITIES*** -> WHO -> BUDGET -> PACE**** -> CONFIRM

Change from today: LENGTH (num_days) moves from position 6 to position 2 — before WHERE for every branch. Everything else keeps its relative order. This one move is what makes every WHERE card length-aware.

Accelerator — "Use my usual" (recommended, small): once WHERE + WHEN are answered, the trip is essentially decided; WHO/BUDGET/PACE are refinements. Each tail card carries a secondary chip "Finish with my usual" that fills WHO from the user's last trip (else couple), BUDGET = the country's mid tier, PACE = balanced, and jumps straight to CONFIRM — collapsing the tail from 3-4 taps to 1. CONFIRM already renders every field as an editable row, so nothing is hidden or locked in. Net effect: the realistic happy path becomes 3-4 taps total without removing a single question for users who want control.

Part 3Question catalog

Every question with exact copy, option sources, value formats, merge effect, and skip predicate. Value formats matter because the merge layer and the model/free-text relay both parse them.

Q-SCOPE — "Where to?"

Q-LENGTH — "How long are you thinking?"

Q-MOOD — "What are you in the mood for?" (undecided branch only)

Q-ORIGIN — "Where are you based?" / "Where are you flying from?"

Q-WHERE (domestic) — "Where in India?"

The length-aware core. Three modes by num_days:

Mode A — Getaway (≤ 4 days)

Best getaways within reach of origin. suggestNearbyPlaces is rewritten from "nearest rows" to "gates first (hard), desirability second (soft)" — the audit in Context shows distance-sorting alone serves Sultanpur/Barsana to Delhi and Lepakshi to Bangalore.

Hard gates — a candidate must pass ALL of:

  1. Metadata-complete: best_months, vibes, days_needed_min/max all seeded. Removes the 214 bare city rows (Barsana, Somanathapura, Kushalnagar...) — they can't be annotated, season-ranked, or length-checked. Malformed JSON in any of these fields counts as missing (skip the row, never throw).
  2. Length-fit: days_needed_max ≥ 2 AND days_needed_min ≤ num_days. Removes day-trips (Lepakshi 1-1, Sultanpur 1-1, Alwar & Sariska 1-2) from a weekend card, and 5-day-minimum places from a 2-day card.
  3. Reachability in HOURS, not km: when the origin metro is keyed in transport_from_metros (186 rows), use its {mode, hours}: weekend ≤ ~7h door-to-door by drive/train, or ≤ ~2.5h flight; long weekend ≤ ~10h surface or any short flight. Haversine band fallback (≤350km weekend / ≤700km long-weekend) only when tfm or the origin metro is missing — the old 800km cap is a 14-hour Indian drive, not a weekend.
  4. Mood-fit when geo_mood is set (see Mood threading below).
  5. Open in the target window: for national-park / wildlife types with a known target month outside best_months, exclude — these are hard closures (Ranthambore is shut Jul-Sep), not preferences. All other types: season ranks, never blocks.

Rank within survivors (desirability, NOT distance-ascending): user saves (×5) → season-fit (+3) → mood/vibes match (+2) → popularity (is_featured, activity_count) → mild proximity tiebreak. Then a type-diversity pass: max 1 chip per type among the 4 (never three pilgrimage towns in a row), backfilling from the next-ranked other types. Dedupe by name containment, not just exact match ("Mathura & Vrindavan" vs bare "Vrindavan").

Mode B — Week (5-8 days)

Blended "anchor + region" picks, ranked by saves + season + variety-spread:

Mode C — Grand (9+ days)

Mood threading (all domestic modes): when geo_mood is set it is a hard gate, matched via MOOD_TO_TYPES ∪ a new MOOD_TO_VIBES check against the seeded vibes array — vibes catch what type misses (Rishikesh is type city but vibes adventure/wellness). If the gated pool yields < 2 chips, widen in this order: (1) relax reachability one band (weekend hours → long-weekend hours / short flight, chip annotation says so), (2) fall back to the in-season mood-matching slice of INDIA_TOP_DESTINATIONS. Never backfill with off-mood chips; the card copy owns the stretch ("Beaches need a short flight from Delhi - these fit a weekend").

Failure degradation (all modes): if the DB/suggester yields nothing → in-season (and mood-matching, if mood set) slice of INDIA_TOP_DESTINATIONS (PLAN_SEED, static) as text chips. The literal "India" chip is deleted; there is no code path that can emit it.

Q-WHERE (international) — "Which country?"

Length-aware tiers (new), ranked by user saves + season + a new static haul/visa table:

Trip lengthPoolExamples
≤ 5 daysShort-haul (≤ ~5h flight or visa-easy)Sri Lanka, Dubai/UAE, Singapore, Thailand, Nepal, Malaysia, Vietnam, Maldives, Bali
6-9 daysShort + medium-haul+ Japan, Georgia, Turkey, Egypt, Azerbaijan, Oman
10+ daysEverything; saves-first+ Europe, Japan multi-city, Australia, ...

Q-WHERE (undecided) — "Here are a few ideas - where to?"

Q-WHEN — adaptive by length and destination

Weekend (≤ 4 days): "When works?"

A range: pick sets dates + month + days → Q-DATES auto-skips (already true). Mobile needs nothing new. Weekend boundaries computed in IST (the worker runs UTC; mirror trip-live-promotion's IST handling).

Longer, destination/region picked

Chips from the picked destination's best_months (or the region's authored window), phrased as windows: "Oct-Feb · best season" (default) / "Apr-Jun" / next concrete month / "Pick specific dates". Falls back to country seasons → month chips. The "When for Lepakshi? → Hill stations (Apr-Jun)" class disappears: country-generic season labels only ever show when the scope IS the country.

Longer, only country known (international)

Existing country season windows from PLAN_SEED — appropriate at country level. Unchanged.

Question copy: "When for {place}?" stays; for weekend mode: "When works?". Skip: month or dates known.

Q-DATES — "Do you have specific dates?"

Unchanged (skips when a range was picked).

Q-ROUTE — "How should this {country} trip feel?"

New gates (the Golden-Triangle fix), in order:

  1. Skip if single_city (set by getaway/anchor picks, city-states, destination contexts).
  2. Skip if destination_cities non-empty (cities already chosen — typed intent, prior step).
  3. Skip if num_days < 5.
  4. Duration filter: only routes with ideal_duration_days ≤ num_days + 3; rank by |ideal - num_days|, classic first among fits; if zero routes fit → skip to Q-CITIES.
  5. Region-scoped routes when destination_region set; a region with no region routes (today: all of them) skips ROUTE and goes to CITIES scoped to the region — never falls back to country circuits (today's fallback is the bug).

Q-CITIES — "Which cities in {X}?"

Existing behaviour, now also region-scoped via destination_region (plumbing already exists: getCityOptionsForPlan({scopeRegion})). Day-scaled pre-check unchanged. Skipped when single_city or cities set.

Q-WHO / Q-BUDGET / Q-PACE / Q-CONFIRM

Unchanged (budget chips already country+days aware; pace keeps its ≥5-day gate).

Part 4Path walkthroughs (concrete)

P1. Weekend pill, Bangalore profile (today's broken screenshot 2)
[scope skipped] [length skipped: 3d] → WHERE-A: Coorg · 3.4h drive · coffee estates / Mysuru · 2.5h / Ooty · 5h / Chikmagalur · 4.5h → pick Coorg (single_city) → WHEN: This weekend Jun 13-15 / Next weekend / Pick dates → [dates skipped] [route skipped] [cities skipped] → WHO → BUDGET → CONFIRM
5 taps. Today: 8+ taps ending in Golden Triangle garbage. Note: Lepakshi — screenshot 2's actual pick — is days_needed 1-1; it was never a valid 3-day chip and the length-fit gate removes it (still reachable by typing).
P2. "Plan a trip" → Within India, open length (broken screenshot 1)
SCOPE: Within India → LENGTH: About a week → WHERE-B: Goa · beach season / Udaipur / Kerala (region) / Himachal (region) → pick Kerala → WHEN: Oct-Feb · best season / ... → [dates] → [route skipped: no Kerala routes] → CITIES (Kerala-scoped): Munnar / Alleppey / Kochi / Wayanad → WHO → BUDGET → PACE → CONFIRM
No "India" chip anywhere; impossible by construction.
P3. "Plan a trip" → Within India → Weekend
Same as P1 after two taps (scope, length). If origin unknown → ORIGIN promoted before WHERE ("Where are you based?") — now always works because length is known.
P4. India grand trip
SCOPE → LENGTH: 10-14d → WHERE-C: Rajasthan / Ladakh / Northeast / Classic circuits → "Classic circuits" → ROUTE: Golden Triangle & Beyond · ideal 10d / Spice Coast & Backwaters · ideal 10d (duration-fit) → CITIES (route pre-checked) → WHO → BUDGET → PACE → CONFIRM
This is the ONLY path where Golden Triangle appears — a 10-14-day country-wide trip.
P5. International, country open
SCOPE: abroad → LENGTH: Weekend → WHERE-INTL short-haul: Sri Lanka · visa-free · ~1.5h / Dubai / Singapore / Thailand → ... → [route skipped <5d] → CITIES (1 pre-checked) → WHO → BUDGET → CONFIRM
P6. International, country known ("Plan a Japan trip" CTA)
[scope/where skipped] → LENGTH: 10-14 → WHEN: Cherry blossom (Mar-Apr) / Autumn (Oct-Nov) / ... → [dates] → ROUTE (Japan routes filtered to ≤17d, annotated "ideal 12 days") → CITIES → WHO → BUDGET → PACE → CONFIRM
P7. Help me decide
SCOPE: undecided → LENGTH → MOOD → WHERE (mixed dom+intl, length-tiered) → resolves into P1/P2/P5 tail
P8. Typed "weekend in Gokarna with friends"
Parse seeds domestic+3d+Gokarna+friends → WHEN (weekend chips) → BUDGET → CONFIRM
3 taps.
P9. Weekend pill, Delhi profile (the north-India acid test)
[scope/length skipped] → WHERE-A: Mussoorie · 3.7h drive · hills / Rishikesh · ~4.5h · adventure / Jim Corbett · ~4.5h · wildlife / Agra · ~3h · heritage → ... same tail as P1
Type-diverse (hill-station / adventure / wildlife / heritage), all 2-3-day-fit, all inside the hours band. Sultanpur (day-trip) and Barsana/Vrindavan/Govardhan (bare rows) are gated out despite being the literal nearest rows. On a long weekend Ranthambore enters via the train band — unless the target month is Jul-Sep (park closed; gate 5).
P10. Beach mood, Delhi, weekend (thin-pool honesty)
SCOPE: undecided → LENGTH: Weekend → MOOD: beach → WHERE: zero beaches inside Delhi's weekend hours band → widened one band: Goa · 2.5h flight / Gokarna / Sri Lanka (≤1 intl) — copy: "Beaches need a short flight from Delhi - these fit a weekend."
Never Rajasthan, never hill stations, never an off-mood pad chip.

Part 5Edge-case matrix

#CaseBehaviour
1Short domestic, no GPS, no profile cityORIGIN promoted before WHERE (existing mechanism, now reliably triggered since length is known)
2Typed origin not in INDIAN_CITIESWHERE-A degrades to in-season INDIA_TOP_DESTINATIONS slice — never "India", never empty
3Today is Sat/Sun and "this weekend" can't fit num_daysDrop "This weekend", offer next two weekends
4IST vs UTC near midnightWeekend math in IST explicitly
5Weekend + user types a far place ("Leh for the weekend")Accept silently; the wizard never blocks a typed answer
6Off-season pick (Goa in July)Ranked down in options, never blocked once picked
7Date range conflicts with earlier LENGTH answerRange wins (existing applyRangeValue already overwrites num_days)
8Typed multi-city weekend ("Jaipur and Udaipur, 3 days")Cities accepted, ROUTE skipped (cities non-empty), no length policing
9New user, zero savesSeason + popularity ranking only (all rankers already degrade)
10D1/suggester failure on any option loadStatic PLAN_SEED fallbacks per step; degenerate chips ("India") deleted
11Region picked but region has no seeded citiesCITIES falls back to country list (existing fallback chain)
12Region picked, no region routes (true for ALL regions today)ROUTE skipped → region-scoped CITIES; never country-circuit fallback
13No routes fit the duration filterROUTE skipped → CITIES
14ideal_duration_days NULL on a routeTreated as fits-any, ranked after explicit fits
15City-states (Singapore, Monaco)Existing collapse unchanged (single_city)
16Mid-plan side question (digression)Existing handling unchanged — answer, don't mutate draft
17Re-tap on a stale card (screenshot 1's duplicate)Server compares body.step to the conversation's current plan_step; stale-step picks re-merge idempotently and re-emit the current card without inserting a duplicate user+assistant message pair
18Progress counter (1 OF 10 → 2 OF 9 shrink)Root-cause: entry-point BuildStepOptions inconsistency between runStartPlan and /plan-step-pick builds; fix = both paths construct opts identically + a chained regression test asserting the counter is monotonic across every walkthrough path P1-P8
19In-flight drafts at deploy timeSequence is recomputed from the draft each turn; old drafts continue under the new order; counter may shift once — acceptable
20Free-text/model relay pathNew chip values (state:, weekend range: chips, circuits) all flow through existing parse/merge shapes; cards stay within the options ≤ 8 Zod contract
21Undecided + weekendSuggestions lean domestic-nearby; intl limited to visa-easy short-haul
22"Plan a weekend" pill but user changes length later via datesRange-derived days recompute; no stale weekend assumptions persist
23Day-trip rows (days_needed_max=1, 17 rows: Lepakshi, Sultanpur, Alwar & Sariska...)Never chips on any WHERE card (length-fit gate); typed picks still accepted
24Bare rows (214 city rows, no months/vibes/days: Barsana, Somanathapura...)Never chips anywhere (metadata gate); reachable by typing; backfill is a data task, not a launch blocker
25Mood set + zero in-range matches (beach from Delhi, weekend)Widen reachability one band → mood-matching TOP_DESTINATIONS slice; honest copy; never off-mood padding
26National park suggested/picked in closure months (Ranthambore Jul-Sep)Excluded from suggestions when target month known (gate 5); a typed pick is accepted with a one-line caution on CONFIRM
27Heavy saves on a gated row (user saved 6 Lepakshi reels)Gates run BEFORE save-boosting — saves rank survivors, never resurrect gated rows; the saved place still surfaces via saves rails + typed input
28Origin metro not keyed in transport_from_metros (user in Chandigarh)Haversine band fallback (350/700km); chip annotation degrades to "~250km"
29Flight-only destination (Andamans) on a short tripHours gate excludes at ≤3d; eligible again at 4-5d only if days_needed_min fits (it doesn't — 4+ days min)
30Overlapping rows ("Mathura & Vrindavan" pilgrimage + bare "Vrindavan")Chip-build dedupes by case-insensitive name containment, on top of the existing exact-name dedupe
31Top-4 collapse into one type (3 pilgrimage towns near Delhi)Type-diversity pass: max 1 chip per type, backfill from next-ranked other types
32Malformed vibes/best_months/days_needed JSON on a rowTreated as metadata-incomplete (gate 1): row skipped, never thrown
33Mood arrives from typed text ("beach trip in October"), not the MOOD stepParser sets geo_mood outside the undecided branch too; the same hard filter applies on every WHERE surface
34"Use my usual" accelerator with no trip historyWHO defaults to couple, budget mid-tier, pace balanced; CONFIRM rows stay editable so a wrong guess costs one tap

Part 6Data & content work-items (separate from code)

  1. INTL_HAUL static table (~40 countries): flight-hours-band from India + mood tags. Visa class now comes from the existing countries.visa_type column (91/102 populated) — author overrides only where it's stale/missing. Authored once, lives next to PLAN_SEED.
  2. India region table (~10 entries): keyed to existing regions slugs (36 state rows, already linked via destinations.region_id — don't invent parallel slugs): season window, mood tags, anchor cities, min-days. Powers WHERE-B/C region chips and the region mood gate.
  3. MOOD_TO_VIBES mapping (~6 moods × the seeded vibe vocabulary): extends the existing MOOD_TO_TYPES so mood matching sees vibes too (Rishikesh: type city, vibes adventure/wellness — type alone misses it).
  4. Prod destinations audits/backfills (none block the code ship; the gates make missing data safe):
    • Backfill best_months/vibes/days_needed_min/max on the ~50 most chip-worthy of the 214 bare city rows (Dehradun, Coonoor, Sakleshpur...); the rest stay gated out harmlessly.
    • transport_from_metros coverage is 186/477: ensure every Mode-A candidate within haversine range of the 8 keyed metros has tfm, else good getaways silently fall to the conservative km fallback.
    • Audit best_months on all national-park/wildlife rows to correctly encode monsoon closures (Ranthambore, Corbett zones, Kaziranga) — gate 5 trusts this field.
    • Verify all 50 INDIA_TOP_DESTINATIONS slugs resolve to metadata-complete seeded rows — they are the failure fallback, and a fallback that fails the gates defeats the design.
  5. (Later, optional) state-scoped destination_vibes routes for Rajasthan/Kerala/Himachal circuits — unlocks region ROUTE cards; design already accommodates their absence.
  6. (Later, optional) Indian holiday calendar → "long weekend of Aug 15" chips on Q-WHEN.

Part 7Implementation outline (later phase; for sizing only)

All server-side, in workers/api/src/services/agent-d/ (+ destination.service.ts); ships on worker deploy, no app rebuild:

Sign-offAcceptance criteria (when implemented)