Auto-generate structured travel itineraries from email confirmations. Sign in with Google, and your trip data lives in your own Google Drive — no third-party database, no monthly hosting costs.
Live app: itinly.app · Demo (no sign-in): itinly.app/?demo=true · Marketing: /welcome · Legal: /privacy · /terms
- Day-by-day itinerary view — inline segment cards with editing, per-segment costs, and confirmation codes
- Timeline view — Hipmunk-style Gantt grid; toggle between swimlane rows (Transport / Hotel / Activities / Dining) and a single chronological row; hotel stays render as spanning bars; prints cleanly
- Map view — plot hotels, dining, activities, and transport endpoints as pins on an interactive Google Map; KML export for sharing with Google My Maps
- Flight endpoints by IATA code — flights store 3-letter airport codes (e.g.
JFK,NRT) and render consistently asCity (CODE)everywhere; the segment editor has a typeahead that searches 1,178 commercial airports by code, city, airport name, or alias (so "Tokyo" finds NRT) and the title auto-fills toDEP → ARR (Carrier RouteCode)once both endpoints are set - Google OAuth — sign in with your Google account; no separate credentials needed
- Google Drive storage — trip data stored as JSON in your own Drive (you own your data)
- Inline editing — rename trips, add/edit/delete segments, manage TODOs and costs; one-click "Confirm all" for a whole batch of auto-parsed segments
- Embedded costs — each segment card shows cost and booking details inline, with a dedicated Costs tab
- TODO tracking — categorized checklist for meals, activities, research, and logistics
- Sharing — generate per-trip share links with configurable visibility (costs, TODOs), or set up an auto-share rule that shares every existing and future trip with someone in one tap
- Trip history (audit log) — every change to a trip (segments added / edited / deleted, todos checked, dates rolled, day cities edited, segment field-level diffs, shares created or revoked, XLSX or email-scan imports) is recorded in an append-only History tab showing what changed, who did it, and when. Reverse-chrono, grouped by day, capped at the most recent 500 entries per trip. Available to owners and contributors alike — particularly useful on shared edit-trips with multiple collaborators
- Export — download itineraries as Markdown, OneNote-compatible HTML, PDF, or iCal (.ics); the iCal file is named after the trip, includes VTIMEZONE blocks for correct DST display in Outlook, and handles overnight flights (arrival date advances to the next calendar day when the flight crosses midnight UTC)
- Google Calendar sync — push all trip segments to any of your writable Google Calendars with one click; hotels and car rentals become all-day events; flights and trains carry the correct IANA time zone per city so events land at the right wall-clock time regardless of your device zone; re-sync updates existing events; unsync removes them (with a choice to delete the events from Google or just unlink); a calendar picker lets you choose which calendar before each sync; available on both desktop (trip-header overflow menu) and mobile (
/moverflow menu) - Dark mode — Light / Dark / System theme toggle in the user menu (desktop + mobile); follows your OS preference by default and persists your choice across sessions
- Installable PWA + offline trips — add the mobile site to your home screen (Android / iOS), launch into a standalone window, and read previously-loaded trips with no signal — critical for day-of airport use. The mobile trip list dims trips that haven't been loaded on this device when you're offline (with an "Offline — not loaded" badge) so you know at a glance what's safe to open before takeoff
- Demo mode — try the app with sample data via
?demo=true(no sign-in required) - Email parsing — auto-extract flights, hotels, restaurants from Gmail confirmations using Claude AI
- Email file import — paste or upload a saved
.htmlor.emlmessage and run it through the same Claude parser (unblocks non-Gmail users) - XLSX import — one-shot import a complete trip from a OneNote-exported workbook; auto-detects column layouts (B=date vs. C=date) and parses the Costs sheet, attaching costs to matching lodging segments
| Layer | Technology |
|---|---|
| Frontend | Next.js 15 · React 19 · TailwindCSS 4 · ShadCN UI (mobile served as a PWA at /m) |
| Backend | Express 5 · TypeScript · Google Drive API · Gmail API |
| AI | Claude API (Anthropic) for email parsing |
| Shared packages | Zod validators · TanStack React Query · typed API client |
| Auth | Google OAuth (auth-code flow + PKCE for native) |
| Monorepo | pnpm 10 workspaces · Turborepo |
| CI/CD | GitHub Actions · auto version bumping · Vercel deploy |
| Hosting | Vercel (web, SSR + Edge runtime for share unfurls) · Railway (API) · Upstash Redis (share-token persistence) — all free tier |
itinly/
├── apps/
│ └── web/ # Next.js 15 frontend (App Router); mobile is the `/m` route as a PWA
├── packages/
│ ├── shared/ # Types, Zod schemas, utilities (framework-agnostic)
│ └── api-client/ # Typed fetch client + React Query hooks
├── server/ # Express 5 REST API
│ ├── src/
│ │ │ ├── routes/ # trips, auth, shared, emails
│ │ ├── services/ # Google Drive, Gmail scanner, email parser, token store
│ │ └── middleware/ # Auth
│ └── __tests__/
├── .github/workflows/ # CI + auto version bump (Vercel handles deploys)
├── turbo.json # Build pipeline
└── pnpm-workspace.yaml # Workspace config
- Node.js ≥ 20
- pnpm 10.33.0 — enable via corepack:
corepack enable corepack prepare pnpm@10.33.0 --activate
git clone https://github.com/justmarks/itinly.git
cd itinly
pnpm install
# Configure environment
cp server/.env.example server/.env
# Edit server/.env with your Google OAuth credentials# Start everything (frontend + backend + shared packages)
pnpm dev
# Or run individually:
cd server && pnpm dev # Express API → http://localhost:3001
cd apps/web && pnpm dev # Next.js → http://localhost:3000The backend runs in memory mode during development — no Google Drive credentials needed. Data resets on server restart.
pnpm build # Builds all packages in dependency order via Turborepopnpm test # Run all tests across the monorepo
# Run specific packages:
cd server && pnpm test
cd packages/shared && pnpm test
# Run a single test file:
cd server && pnpm test -- --testPathPattern="trips.test"Current coverage: 647 tests across 40 test suites.
| Package | Tests | What's tested |
|---|---|---|
packages/shared |
231 | Validators (incl. html / eml import schema branch and XLSX import schema), date utils, currency formatting (including USD FX conversion), markdown + OneNote export, iCal export (VCALENDAR wrapper, TZID on flights, all-day hotels/car-rentals, VTIMEZONE DST offsets, overnight flight date advancement, floating datetimes, line folding, escaping), ID generation, segment label formatting, IATA airport lookup (code/city/name/keyword search, timezone resolution, normalisation), overlap detection, segment matching, meal suggestions, primary-location detection (bookend exclusion, asymmetric transfer days), trip schema migrations (incl. v1 → v2 history backfill), append-only trip-history helper (append, trim at 500, immutability) |
server |
416 | Trip + segment + todo CRUD, sharing (incl. recipient self-leave: success path, cross-recipient denial, owner-revoke path unchanged), costs, export (markdown + OneNote + PDF + iCal), trip history audit log across all mutation paths (segment / todo / trip / share / day-city / bulk-import; field-level diffs; no-op writes record nothing), email scanning + match detection, HTML + EML import pipeline, EmailParser.htmlToText + emlToEmail, XLSX trip importer (B/C column-layout auto-detection, day-of-month carry-forward, year-hint inference + year shift, Costs sheet → lodging attachment, import route), Google Calendar sync + unsync (all-day events for hotels/car rentals, timed events with TZID for flights), auth routes, shared route, contributor edit flow (resolveTripAccess + sharedWithEmail index), requireAuth middleware (silenced 401 from Google for expired tokens), CORS origin allow-list + preview-pattern matching, rate limiting on /emails/scan, EmailParser (time normalisation, cost/URL sanitisation, hotel defaults, cruise portsOfCall), DriveStorage, TokenStore, refresh-token AES-256-GCM encryption, ShareRegistry, ShareSnapshotStore (Edge-runtime unfurl previews) |
-
Go to the Google Cloud Console
-
Create a project (or select existing)
-
Enable Google Drive API and Gmail API
-
Go to APIs & Services → Credentials → Create OAuth 2.0 Client ID
-
Add authorized JavaScript origins:
http://localhost:3000(local dev)- Your Vercel origin (e.g.,
https://project-yhbyn.vercel.appor your custom domain)
-
Add authorized redirect URIs (Google bounces the user back here after consent):
http://localhost:3000/auth/callbackhttps://project-yhbyn.vercel.app/auth/callback(or your production origin)
You don't need to register Vercel preview URLs — they relay through production. See docs/vercel-setup.md.
-
Copy credentials into
server/.env:GOOGLE_CLIENT_ID=your-client-id GOOGLE_CLIENT_SECRET=your-client-secret -
Set the frontend env var in
apps/web/.env.local:NEXT_PUBLIC_GOOGLE_CLIENT_ID=your-client-id
| Variable | Where | Description |
|---|---|---|
PORT |
server | Express port (default: 3001) |
NODE_ENV |
server | development / production / test |
CORS_ORIGIN |
server | Comma-separated list of allowed origins (default: http://localhost:3000) |
CORS_ORIGIN_PATTERN |
server | Optional regex (as a string) for dynamic origins. A request is allowed if its Origin matches any literal in CORS_ORIGIN or this pattern. Used to accept Vercel per-deploy preview URLs without re-listing every hash. Example: ^https://itinly-[a-z0-9-]+-justmarks-projects\.vercel\.app$. Leave unset in dev. |
GOOGLE_CLIENT_ID |
server | Google OAuth client ID |
GOOGLE_CLIENT_SECRET |
server | Google OAuth client secret |
ANTHROPIC_API_KEY |
server | For Claude AI email parsing |
UPSTASH_REDIS_REST_URL |
server and apps/web | Upstash Redis REST URL. Server uses it for token/share registry persistence; web reads it on the Edge runtime to render share unfurl previews. |
UPSTASH_REDIS_REST_TOKEN |
server and apps/web | Upstash Redis REST token (server-only on web — set as a non-public Vercel env var). |
TOKEN_ENCRYPTION_KEY |
server | Hex-encoded 32-byte key (64 hex chars) for AES-256-GCM encryption of refresh tokens at rest. Generate with openssl rand -hex 32. Unset = plaintext storage (fine for dev/tests, not recommended in production). See docs/redis-persistence.md for the rotation story. |
NEXT_PUBLIC_API_URL |
apps/web | Backend URL (default: http://localhost:3001/api/v1) |
NEXT_PUBLIC_SITE_URL |
apps/web | Origin used by metadataBase for absolute OG image URLs. Set to the deployed origin (e.g. https://itinly.vercel.app or https://itinly.app). |
NEXT_PUBLIC_GOOGLE_CLIENT_ID |
apps/web | Google OAuth client ID for frontend |
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY |
apps/web | Google Maps API key (enables Map tab) |
NEXT_PUBLIC_GOOGLE_MAPS_MAP_ID |
apps/web | Cloud map ID for styled markers (optional; defaults to demo ID) |
NEXT_PUBLIC_PROD_ORIGIN |
apps/web | Production origin (e.g. https://itinly.vercel.app). Set in Vercel for both Production and Preview. Drives the OAuth preview-relay: previews send Google's redirect_uri here (the only value Google has registered) and are bounced back via state.origin. Leave unset locally. See docs/vercel-setup.md. |
NEXT_PUBLIC_PREVIEW_ORIGIN_PATTERN |
apps/web | Anchored regex matching allowed preview origins for the OAuth relay. Set on Production only — that's where the relay validates the state.origin before bouncing the OAuth code. Mirrors the server's CORS_ORIGIN_PATTERN. Include both per-deploy hostnames and any stable preview alias in the alternation. Example: ^(https://itinly-[a-z0-9-]+-justmarks-projects\.vercel\.app|https://preview\.itinly\.app)$. |
Base URL: /api/v1
| Method | Endpoint | Description |
|---|---|---|
POST |
/auth/google |
Exchange Google auth code for tokens |
GET |
/trips |
List all trips |
POST |
/trips |
Create a new trip |
POST |
/trips/import-xlsx |
One-shot import a full trip from a OneNote-exported XLSX workbook |
GET |
/trips/:id |
Get trip with days and segments |
PUT |
/trips/:id |
Update trip metadata |
DELETE |
/trips/:id |
Delete a trip |
POST |
/trips/:id/segments |
Add a segment to a day |
PUT |
/trips/:id/segments/:segId |
Update a segment |
DELETE |
/trips/:id/segments/:segId |
Delete a segment |
POST |
/trips/:id/segments/:segId/confirm |
Mark an auto-parsed segment as reviewed |
POST |
/trips/:id/segments/confirm-all |
Confirm all pending auto-parsed segments on a trip in one call |
GET |
/trips/:id/costs |
Aggregated cost summary (USD-normalized) |
POST |
/trips/:id/todos |
Add a TODO |
PUT |
/trips/:id/todos/:todoId |
Update a TODO |
DELETE |
/trips/:id/todos/:todoId |
Delete a TODO |
POST |
/trips/:id/share |
Create a share link |
GET |
/trips/:id/shares |
List share links for a trip |
DELETE |
/trips/:id/shares/:shareId |
Revoke a share link |
GET |
/shared/:token |
View a shared trip (public) |
GET |
/share-rules |
List the owner's auto-share rules |
POST |
/share-rules |
Create an auto-share rule (backfills shares across existing trips) |
PUT |
/share-rules/:ruleId |
Edit a rule; cascades permission / visibility changes onto every share the rule spawned |
DELETE |
/share-rules/:ruleId?cascade=true|false |
Remove a rule; cascade=true also revokes every share the rule spawned, cascade=false leaves them in place |
GET |
/trips/:id/export/markdown |
Export as Markdown |
GET |
/trips/:id/export/onenote |
Export as OneNote HTML |
GET |
/trips/:id/export/pdf |
Export as PDF (pdfkit; day-by-day + costs summary) |
GET |
/trips/:id/export/ical |
Export as iCal (.ics) with VTIMEZONE blocks and correct DST offsets |
GET |
/trips/calendar/list |
List the user's writable Google Calendars |
POST |
/trips/:id/calendar/sync |
Push all segments to a Google Calendar; pass ?calendarId= to target a specific calendar |
DELETE |
/trips/:id/calendar/sync |
Remove all previously synced events; uses the stored calendarId by default |
GET |
/emails/labels |
List Gmail labels for scan filtering |
POST |
/emails/scan |
Scan Gmail and parse with Claude AI |
POST |
/emails/import-html |
Parse a raw HTML email through the same Claude pipeline |
GET |
/emails/pending |
Return pending parse results from the last scan |
POST |
/emails/apply |
Add parsed segments to a trip |
GET |
/emails/processed |
List previously processed emails |
POST |
/emails/dismiss/:emailId |
Dismiss an email (skip on re-scan) |
The app scans your Gmail for travel confirmation emails and uses Claude AI to extract structured itinerary data.
How it works:
- Click "Scan Emails" from the dashboard or a trip detail page
- Optionally select a Gmail label to filter (e.g., "Travel") or scan all mail
- The backend searches Gmail for travel-related keywords (confirmation, booking, reservation, itinerary, e-ticket)
- Each email body is sent to Claude AI, which extracts segments (flights, hotels, restaurants, activities, etc.) as structured JSON
- Extracted segments are validated with Zod and auto-matched to existing trips by date
- Each parsed segment is compared against the existing itinerary and classified as new, duplicate, enrichment (fills empty fields), or conflict (disagrees on a non-empty field) so you can review and decide what to apply
- You review the results, select which segments to add, and assign them to trips
- Added segments appear with a yellow "Review" badge — click the green checkmark to confirm
Requirements:
- Gmail API enabled in your Google Cloud project
ANTHROPIC_API_KEYset inserver/.env(Claude API key from Anthropic)- User must grant Gmail read permission during OAuth sign-in
Deduplication and recovery:
- Processed email IDs are tracked, so re-scanning skips already-handled emails
- Dismissed emails are also skipped on subsequent scans
- Emails that previously failed Zod validation are auto-retried on the next scan
- A "Re-parse previously processed emails" toggle in the scan dialog forces a full re-scan (except for emails already applied to a trip)
The app supports a runtime demo mode for trying it without Google credentials. Append ?demo=true to any URL:
- Live demo: itinly.app/?demo=true
- Local: http://localhost:3000/?demo=true
Demo mode uses a mock API client with sample trip data. No backend required. The demo and real login flow are served from the same build — toggle via the URL parameter.
Times are wall-clock local to the segment's city. A 09:00 flight out of Tokyo and a 09:00 dinner in Paris are both stored and displayed as 09:00 — the app does not display, convert, or annotate time zones in the UI, even on multi-country trips. This keeps the day-by-day view simple: the time you read is the time you'll see on the clock when you're there.
The one place times are zone-aware is calendar export. Both Google Calendar sync and iCal export attach the correct IANA time zone per segment so events land at the right wall-clock time regardless of the user's device zone. For flights, the timezone is derived from the departure / arrival IATA airport code via the static airport-lookup table; non-flight segments fall back to the city-name lookup. iCal files include VTIMEZONE blocks (with DST transition dates computed via Intl) so Outlook and other clients that look up timezone offsets from their own database get the correct summer/winter offset rather than always using standard time. Overnight flights (e.g. LA → Paris) have their DTEND advanced to the next calendar day when the arrival UTC time is earlier in the day than the departure UTC time.
All changes go through pull requests — no direct commits to main.
Use conventional commits:
feat:— new feature (bumps minor version)fix:— bug fix (bumps patch version)feat!:orBREAKING CHANGE— breaking change (bumps major version)
Version is auto-incremented on merge to main via GitHub Actions. vercel.json at the repo root carries an ignoreCommand so Vercel skips the no-op build for the auto-generated chore: bump version ... [skip ci] commits — only real merges trigger a production deploy.
Foundation (shipped):
- Phase 1 — Foundation: monorepo, types, Zod schemas, Express API, tests
- Phase 2 — Core UI: Next.js web app, itinerary table, segment cards, inline editing
- Phase 3 — Google OAuth: sign-in flow, auth middleware, protected routes
- Phase 4 — Google Drive storage: per-user Drive persistence, token store, share registry
- Phase 5 — Email processing: Gmail scanning + Claude AI parsing, segment match detection, USD cost normalization
- Phase 6 — UX & export: PDF export (pdfkit), Timeline tab (Hipmunk/Gantt with grouped + chronological views, print-ready), Map tab (Google Maps pins + KML export)
- Email file import — paste or upload a saved
.htmlor.emlmessage and run it through the sameEmailParserpipeline (unblocks non-Gmail users) - Multi-layout XLSX import — auto-detects column layouts (B=date vs. C=date, with day-of-month carry-forward for week-grouped workbooks)
- Debt payoff batch — Gmail scanner label resolution + body extraction tests,
schemaVersionon trip JSON, Sentry error tracking, rate limiting on/emails/scan - Google Calendar sync — push trip segments to any writable Google Calendar; hotels and car rentals as all-day events; flights/trains carry the correct IANA time zone per city; re-sync updates existing events; unsync removes them; calendar picker lets you choose the target calendar;
calendarIdstored on trip so unsync always hits the right calendar - iCal export — download trip as a
.icsfile named after the trip; VTIMEZONE blocks with DST-aware transitions for Outlook compatibility; overnight flightDTENDadvanced to next day when UTC arrival precedes UTC departure - IATA airport codes for flights — flight segments carry
departureAirport/arrivalAirport(3-letter IATA) backed by a 1,178-entry shared lookup; render asCity (CODE)everywhere; segment editor exposes a keyboard-navigable typeahead (search by code, city, airport name, or alias); title auto-fills toDEP → ARR (Carrier RouteCode)once both endpoints are set; calendar sync derives the per-leg IANA timezone from the airport code with city-name fallback for legacy data and other transport - Trip history audit log — every mutation to a trip records an append-only entry showing what changed, who, and when. Covers segment create / update (with field-level diff) / delete / confirm, todo lifecycle, share create / revoke, day-city edits, trip-metadata edits, and bulk imports (XLSX + email-apply roll up to one summary entry rather than one per row). Surfaced as a desktop "History" tab and a
/moverflow-menu bottom sheet. Append-only with a 500-entry cap per trip; visible to owners and contributors; deliberately excluded from the public/shared/:tokenpayload so unauthenticated viewers don't see actor emails
Mobile companion (web PWA at /m):
A mobile-first parallel experience focused on consuming a planned trip rather than re-creating the desktop authoring UX. Lives under /m/* in the same Next.js bundle and is auto-served when the viewer hits the desktop URL on a phone.
- Phase 1 — Foundation —
MobileFrame(430px max-width on desktop preview),/m/loginand/mtrip list with hero images + country flags + grouped current/upcoming/past sections,/m/tripdetail with day carousel + segment detail bottom sheet, mobile-aware redirect, share button entry point, mobile user menu with "Use desktop site" override - Phase 2 — Costs and Todos — bottom-sheet for costs (USD-normalised, totals by category) and todos (full CRUD with drag-aware dismissal); pills on the trip header replace a discoverability-poor footer
- Phase 3 — Offline / PWA — installable web app (manifest with
start_url: /m, theme color, iOS standalone meta), hand-rolled service worker that precaches the/mshell + Next static chunks (cache-first), runtime-caches trip JSON (network-first → cache fallback) and Wikipedia city images (stale-while-revalidate), React Query cache restored fromlocalStoragevia<PersistQueryClientProvider>so queries don't fire (and fail) before hydration completes — cached trips render immediately on cold offline launch. "Add to Home Screen" entry in the mobile user menu (with iOS-Safari Share-sheet hint as fallback), offline banner inMobileFramedriven bynavigator.onLine. The SW precache usesfetch+cache.put(notcache.add) so redirected responses are re-wrapped without theredirectedflag — Chromium would otherwise refuse to serve them to a navigation request and fall through to the browser's default offline page. Navigation fallback layers — exact runtime cache → loose match (ignoreSearch: true) so query-string variants reuse a cached entry → precached/mshell → branded synthetic offline HTML — guarantee the SW always returns a realResponse, so Chrome's default "You're offline" screen never fires. Next App Router RSC payload fetches (the SPA-nav data layer triggered by<Link>) are also intercepted: detected via thetext/x-componentAccept header or?_rsc=query param, cached network-first, with the_rsc=deploy-hash stripped from the cache key so a redeploy doesn't invalidate previously-visited routes. Without this, an offline tap on a previously-viewed trip would silently fail (the SPA fetch can't intercept; Next falls back to a hard nav and the user sees the trip list "refresh"). The trip list reads the React Query cache viauseCachedTripIds(auseSyncExternalStoresubscription with a microtask-deferred listener and ref-stable snapshot, so childuseQueryregistrations don't trigger render-during-render warnings) and dims trips that aren't available on this device when offline (with an "Offline — not loaded" badge); tapping one toasts instead of navigating, so the Past section's expanded state survives. The trip detail page detects offline + uncached and renders a dedicated "Not available offline" view with Back + Retry, instead of the generic load error. - Phase 4 — Authoring on mobile — add, edit, and delete segments end-to-end on
/m. A bottom-sheet form (MobileSegmentFormSheet) reuses the desktopSegmentFormFieldscomponent so the field set, type-specific behaviour, and validation stay identical across surfaces. Per-day "Add" buttons appear on the day-strip carousel and inside each day's section in the All view; the segment detail sheet exposes an "Edit" footer action that hands off to the form sheet (delete lives inside the form sheet's footer). All affordances are gated byuseTripPermission— owners and shared-edit contributors see them, view-only contributors and public link viewers don't - Phase 5 — End-to-end planning on mobile — trip creation, trip metadata editing, and per-day city editing all live on
/m.MobileCreateTripSheet(title + date range, with overlap-error handling) is reachable from a "+" button in the trip-list header and from the empty-state CTA.MobileEditTripSheet(title / start / end / status pills) opens from the trip-detail overflow menu.MobileEditableCityis a tap-to-edit affordance shown next to each day's city in both the carousel's active-day header and the All view's per-day sticky headers. Combined with the auto-derive on segment add/edit (city flows from segment → day when sensible), a phone-only user can plan a trip end-to-end without bouncing to desktop - Phase 6 — Map and timeline views on
/m—MobileFullMapSheetoverlays a full-screen Google Map with day-filter pills, tap-pin info windows, and an expand button on the existing per-day map preview.MobileTimelineViewis a Hipmunk/Gantt grid (sticky day-header row + label column, swimlane / chrono toggle, hotel multi-day spans) reachable via a?v=timelineURL toggle in the overflow menu. The timeline dropsMobileFrame's 430px cap when the device rotates so a landscape phone fits 6+ day columns; portrait fits 3.extractHotels/sortByTimeplusSEGMENT_CONFIG/fmt12hare extracted to shared utils so desktop and mobile timelines / cards share one source of truth - Phase 7 — Email scan + confirm-segment shortcuts —
MobileEmailScanSheetruns the Gmail → Claude → review → apply flow as a multi-step bottom sheet (config / scanning / review / done). Reachable from the trip-detail overflow menu (per-trip, pre-filtered to segments matched here) and the mobile user menu (account-level, with a per-row trip selector). The review step renders each parsed segment with a classification badge (new / enrichment / conflict / duplicate), a cycle-through action button (Add / Merge / Replace / Skip), and a default action keyed off the match status. After segments are added withneedsReview: true, a "N pending" pill in the trip-detail header opensMobileReviewPendingSheet— per-row green-check confirm plus a "Confirm all" footer (the first UI surface for the existinguseConfirmAllSegmentshook). The Review badge itself becomes tappable on each segment card and inside the detail sheet, so single segments can be cleared without going through Edit → Save - Phase 7 polish — Gmail OAuth integration + parity passes — gates the scan path on
hasGmailLink(mirroring desktop's split-OAuth flow from PR #202): averifyingstep probes the labels + pending queries before showing config so a stalehasGmailLink: truecache doesn't flash a config screen before bouncing to "Connect Gmail." Mid-flight 403GMAIL_SCOPE_REQUIREDbounces back the same way. Diff'd against the desktop dialog and closed seven parity gaps: pending-results restoration on open, 402 / 503 partial-results banner, default-skip low-confidence segments, server-response apply count, "Scan more" footer button, narrowedexistingSegmentIdto merge / replace, labels-fetch error hint. Empty-trip-target warning + Apply-button gating cover the case where a user with zero trips tries an account-level scan; the all-skip terminal screen distinguishes "dismissed N emails" from "nothing to add" - Phase 7 polish — picker + naming UX — Gmail label picker becomes a dropdown tree on both surfaces (mobile native
<select>, desktop ShadCNSelect) with hierarchical indent for nested labels (Travel/Hotels/Confirmedreads as a tree). Trip-picker dropdown showsTitle (date range)so phone-only users can distinguish two trips with similar names - Phase 7 polish — new-trip proposals — when an account-level scan parses segments that don't match any existing trip, cluster them by date proximity (gap > 14 days = separate trip; hotels / cruises bridge the gap via
endDate) and propose one new trip per cluster. Default name<Most-common destination> <Month> <Year>("Maui April 2026") with a fall-back to "Trip " when no city is detectable. Picker shows proposals under a "Create new trip" optgroup; on apply,useCreateTripruns first (sequential, with date-range expansion to cover any manually-rebucketed segment) and the sentinel ids swap for real trip ids beforeapplyParsedSegments. Shared utilproposeNewTripsin@travel-app/sharedso the same clustering can be reused on desktop later - Phase 7 polish — server-side deprecation watch — Anthropic returns model-deprecation warnings via response headers (
anthropic-deprecation-warningand the standardWarning: 299 ...). The parser now surfaces those to Sentry aswarning-level events with the model tagged, deduped per-process so a deprecated model doesn't generate one event per parsed email. Default model bumped fromclaude-sonnet-4-20250514(the one Anthropic flagged) toclaude-sonnet-4-6 - Phase 8 — Google Calendar sync on mobile —
MobileCalendarSyncSheetreachable from the trip-detail overflow menu mirrors the desktop dropdown's four states (connect-Calendar prompt, calendar picker, synced-info with refresh / remove, and a delete-from-Google vs unlink choice). Shares theuseCalendarSynchook with desktop so toast copy and behavior stay identical, and the menu label flips to "Calendar synced (N)" once any segment carries acalendarEventId
Remaining mobile work:
- Segment reorder — drag-to-reorder within a day. Desktop has it via the segment row; mobile needs a long-press-to-grab gesture or a dedicated reorder mode
- Suggest meals dialog — the AI meal-suggestion flow that exists on desktop
Sharing:
A trip's owner can publish a read-only or contributor-edit link; recipients open it without signing in (view) or sign in to edit (contributor flow). Backed by a Redis-persisted share registry so links survive server restarts and Railway sleep cycles.
- Viewer + share creation — desktop share dialog (
ShareTripDialog) and mobile share sheet (MobileShareSheet); mobile usesnavigator.shareso the OS picks the channel (Messages, Mail, AirDrop, …); recipient lands at/shared/[token](desktop) or/m/shared(mobile); per-share toggles for showing costs and todos - Server hardening —
ShareRegistryself-heal on registry miss (rebuilds from the owner's Drive once any owner logs back in); Upstash Redis persistence forTokenStore+ShareRegistryso refresh tokens and share-token mappings survive process restarts - Cross-browser demo shares — demo-mode share tokens are self-describing (
demo:tripId:perm:costs:todos:nonce) so a recipient on any other browser running?demo=truecan resolve them from their local sample trips - Per-trip unfurl previews —
ShareSnapshotStorewrites a tiny title/dates snapshot to Redis on share creation; the public/shared/[token]page reads it on the Vercel Edge runtime ingenerateMetadataand renders a per-trip Open Graph card - Contributor edit flow — shared trips with
permission: "edit"show up in the recipient's own trip list with a "shared with you" badge; the recipient can open and edit them in place (writes go back to the owner's Drive); read/write access is gated by aresolveTripAccess(req, tripId, requiredPermission)helper that checks owner-or-shared-with-edit-permission;ShareRegistrykeeps an email index keyed onsharedWithEmailfor fast lookup - Recipient self-leave — a share recipient can remove themselves from a trip without waiting for the owner. Leave action lives on both the trip card (dashboard) and the trip detail page on desktop and mobile. Reuses the existing
DELETE /trips/:id/shares/:shareIdendpoint with the access gate relaxed: owners can revoke any share; non-owners can revoke a share row whosesharedWithEmailmatches their authenticated email. Writes ashare.leaveaudit entry (distinct from owner-initiatedshare.revoke) and pushes a notification to the owner ("X left your trip") rather than to the leaver. Anonymous link shares stay owner-only since there's no recipient identity to match - Auto-share rules — owner-scoped rule that auto-shares every existing trip plus every future trip with a given recipient. On rule create, backfills a
TripShareper existing trip; on trip create, fans out a share to every active rule. Each spawned share carriesoriginRuleIdso rule edits cascade onto its shares (and only its shares — manual shares to the same recipient stay untouched), and rule delete prompts the owner to choose between keeping existing shares or cascade-revoking them. Conflict policy on backfill is "upgrade only if stricter" (a rule will upgrade view → edit on a manual share, but never downgrade edit → view). One rule per(owner, recipient); self-share rejected. Rule-level activity (create / edit / cascade-delete) collapses into a single push to the recipient instead of N per-share pushes. Surfaced as a dashboard panel on desktop (AutoShareRulesPanel) and a bottom sheet on mobile (MobileAutoShareSheet); shares originating from a rule render a "Via auto-share rule" pill in the per-trip share dialog so the owner can see at a glance which shares cascade-affect
Potential ideas for the future:
- Email invites + notifications — Resend-powered email when a share is created; notifications when a shared trip is updated
- Android native — Expo SDK 55 + React Native; scaffold + Google auth shipped, offline/cached active trip view in progress (no push notifications in v1)
MIT