Skip to content

release: auto-share rules + share / trip-list / auth polish#247

Merged
justmarks merged 18 commits intomainfrom
preview
May 10, 2026
Merged

release: auto-share rules + share / trip-list / auth polish#247
justmarks merged 18 commits intomainfrom
preview

Conversation

@justmarks
Copy link
Copy Markdown
Owner

Merges 18 commits accumulated on preview since the last main release. Shipped behind the same UI surface; no migrations required.

Headlines

Auto-share rules (PRs #242, #243, #244)

  • Shared types: TripShareRule, originRuleId on TripShare (packages/shared)
  • Server: /api/v1/share-rules CRUD + backfill + cascade, fan-out on trip create, Drive persistence (share-rules.json), in-memory + Drive storage methods, applyShareToTrip extraction
  • Web: typed api-client methods + React Query hooks, desktop user-menu panel, mobile bottom sheet, per-trip share dialog "advertise auto-share" prompt with email pre-fill handoff
  • Demo: MockApiClient overrides so the demo experience covers rules end-to-end
  • Docs: README + welcome page + intro tour copy

Service worker — /auth/* bypass

  • The SW was re-issuing /auth/callback navigations via fetch(req) and caching the URL containing the single-use OAuth code. Vercel's DDoS mitigation flagged the cross-site Google → callback round-trip as suspicious, denying with x-vercel-mitigated: deny.
  • New v8 SW returns early for same-origin /auth/* so OAuth navigations hit the network as plain browser nav. Eviction also drops any cached callback HTML carrying a stale code.

Trip list parity (desktop ↔ mobile)

  • Extracted bucketing helpers to apps/web/src/lib/trip-buckets.ts — Now / Upcoming / Past, today-vs-startDate/endDate, ascending for current/upcoming, descending for past.
  • Desktop My Trips now uses the same three sections (Past collapsed by default), matching mobile.
  • Dropped the "Show completed trips (N)" toggle on desktop — the collapsed Past section is sufficient. Mobile never had this filter; both surfaces now share one mental model.

Share / leave-trip UX fixes

  • Tapping the per-trip share dialog's auto-share advertisement now opens the auto-share sheet immediately instead of "tap → nothing → tap share again to see it." Cause: the auto-share sheet was rendered as a child of MobileBottomSheet, which returns null when closed and unmounted the sibling sheet that just opened.
  • Leaving an auto-shared trip is now instant — useDeleteShare optimistically drops the trip from queryKeys.trips (matched by sharedShareId), restores on error. Previously felt like a no-op until the next stale refetch.
  • Margin between the share-sheet toggles block and the auto-share ad.
  • "with someone" copy removed from auto-share dialog/sheet (recipient is implicit in the rule).

Auth + login polish

  • Logout clears the React Query cache + persisted localStorage snapshot. Catches both manual logout and refresh-token failure. Previously, a different user signing in would briefly see the prior account's trips on first paint.
  • Mobile login wordmark + buttons block is vertically centered; "Use desktop site instead" anchored at the bottom.

Docs

  • New docs/vercel-setup.md section: Stable preview alias — DNS CNAME, Vercel branch-domain assignment to preview, OAuth-relay regex extension, Railway preview-env CORS, optional Google direct registration.
  • .env.example + README.md env table: NEXT_PUBLIC_PREVIEW_ORIGIN_PATTERN example shows alternation pattern (per-deploy hostnames OR stable alias).
  • CLAUDE.md parity rule cites lib/trip-buckets.ts alongside useTripPermission / useShareLinkOwnerRedirect as a fresh shared-helper example.

Test plan

  • CI green on every preview commit (latest: run on 9639288, 52cf94f)
  • Build succeeds locally (pnpm --filter @travel-app/web build)
  • Lint clean (pnpm --filter @travel-app/web lint)
  • Mobile preview verified: leave-trip removes the trip immediately; auto-share ad opens auto-share sheet on first tap; share-sheet margin visible; auto-share copy reads "Auto-share every trip (existing and future)."
  • Desktop preview verified: My Trips renders UPCOMING / PAST headings, Past has Show / Hide toggle, no "Show completed trips" footer
  • Mobile login screenshot shows centered layout

Reviewer notes

  • The apps/web/src/lib/trip-buckets.ts extraction is the canonical place to change bucketing logic — both surfaces consume it.
  • Cache-clear-on-logout effect lives in ApiProviderSwitcher (providers.tsx) since AuthProvider itself sits outside QueryClientProvider. It watches accessToken for truthy → null transition.
  • No package.json / lockfile changes; if Vercel reports the lstat .pnpm/node_modules/react ENOENT seen on a recent preview deploy, redeploy with build cache disabled — CI install reproduces clean every run.

🤖 Generated with Claude Code

claude and others added 18 commits May 9, 2026 20:14
Introduces the type system for owner-scoped auto-share rules. A rule
captures "share every trip I have (and future trips) with X at
permission Y." Each share spawned by a rule carries the rule's id via
the new optional originRuleId field, so the server can later cascade
revoke / cascade update those shares without disturbing manually
created shares for the same recipient.

Adds Zod schemas tripShareRuleSchema, createShareRuleSchema, and
updateShareRuleSchema (all required fields validated; updates require
at least one mutating field). originRuleId is optional on
tripShareSchema so already-persisted shares parse cleanly.
Adds list/get/save/deleteShareRule to the StorageProvider interface,
backed by a Map in InMemoryStorage (with clear() resetting it for
tests). DriveStorage stubs throw "not implemented" — Drive
persistence for share-rules.json will land alongside the rest of
DriveStorage; until then dev / tests use the in-memory path and
production trips don't reach this code.

Storage returns deep-cloned rules to keep callers from mutating
in-place, matching the Trip / settings handling.
Pulls the share-creation side effects (push to trip.shares, register in
ShareRegistry, store unfurl snapshot, record share.create history,
fire push notification) out of POST /trips/:tripId/share into a
reusable helper at services/share-fanout.ts.

Same behaviour as before — all 486 server tests still pass — but
upcoming auto-share rule paths (rule create backfill, trip-create
fan-out) now have a single seam to plug into. The helper accepts a
suppressNotification flag so rule paths can fire one consolidated
push for the whole rule application instead of N per-trip pushes,
plus an originRuleId pass-through for cascade-revoke.
Adds the four share-rule endpoints:
- GET /share-rules — owner's rules
- POST /share-rules — creates rule, backfills shares across existing
  trips. Conflict policy is "upgrade only if stricter": a manual
  view-share for the recipient gets upgraded to edit when the rule
  grants edit, but never downgraded. Equal permission is skipped.
  Self-share (owner email == recipient) rejected 400; duplicate rule
  for the same recipient rejected 409.
- PUT /share-rules/:ruleId — edits cascade onto every share with
  originRuleId === ruleId. Manual shares for the same recipient
  (no originRuleId) are left untouched.
- DELETE /share-rules/:ruleId?cascade=true|false — cascade is required
  (400 if missing). cascade=true revokes only originRuleId-matching
  shares; cascade=false deletes the rule and leaves shares as orphans.

Notifications collapse to one push per rule application: a single
"now auto-shares with you" / "updated your access" / "stopped
auto-sharing" push instead of N per-trip pushes — avoids spamming the
recipient on the first activation against a busy account.

Wired in app.ts behind requireAuth in drive mode. 18 new tests cover
the conflict matrix, cascade cases, and ownership boundary.
POST /trips and POST /trips/import-xlsx now iterate the owner's active
rules after the initial saveTrip, spawn a TripShare per rule (skipping
recipients that already have a share on the trip), and re-save once.
Each spawned share carries originRuleId so cascade revoke / cascade
update can find it later.

Behavior decisions baked in:
- Per-share push fires here (not the rule-level collapse) — a single
  new trip shared with N recipients via N rules is N legitimate "trip
  shared with you" pushes to N different people.
- No suppression of recipients who previously left an earlier trip;
  the rule means "always share with X", and a fresh trip is a fresh
  decision. Test locks this in.
Adds listShareRules / createShareRule / updateShareRule /
deleteShareRule on ApiClient, returning the typed envelopes the
server emits (rule + spawnedShareCount, rule + updatedShareCount,
revokedShareCount).

React Query layer:
- queryKeys.shareRules
- useShareRules() — list query
- useCreateShareRule() — invalidates rules + trips after spawn
- useUpdateShareRule() — optimistic patch of the rules cache (per
  CLAUDE.md), invalidates trips after cascade
- useDeleteShareRule() — optimistic remove; only invalidates trips
  when cascade=true (no need to refetch shares when shares are
  intentionally left behind).
Demo mode (?demo=true) now mirrors the real backend's share-rule
behaviour:
- listShareRules / createShareRule / updateShareRule /
  deleteShareRule overrides keep state in a per-MockApiClient Map
- createShareRule backfills across the demo's owned trips (skips
  trips already flagged via SHARED_DEMO_OVERRIDES — those are
  "shared with you" trips the demo user doesn't own)
- The conflict policy matches the server: edit upgrades view,
  view-on-edit is skipped, equal permission left alone
- Cascade=true on delete removes only originRuleId-tagged shares
- createTrip spawns shares for every active rule so the "create a
  new trip with rules active" demo flow lands the same way it
  would in production.
Adds AutoShareRulesPanel — a card on the home dashboard with:
- "Add" → CreateRuleDialog (email + permission radio + showCosts /
  showTodos toggles, success toast reports the spawned/upgraded
  count)
- One row per rule (recipient + permission + an inline toggle to
  flip view↔edit; cascades to existing spawned shares via
  useUpdateShareRule's optimistic update)
- Trash → DeleteRuleDialog with two destructive options: "Keep
  existing shares" (cascade=false) vs. "Also revoke from existing
  trips" (cascade=true). Required cascade query param matches the
  server contract.

share-trip-dialog gets a small "Via auto-share rule" pill on every
share row whose originRuleId is set, so the owner can see at a
glance which shares come from a rule and would be cascade-affected.

useCreateShareRule / useUpdateShareRule / useDeleteShareRule
exported from @travel-app/api-client (was missing — caught the
build).
Mirrors the desktop auto-share UI on the mobile shell:
- MobileAutoShareSheet — full bottom sheet with rule list, inline
  permission toggle (view↔edit cascades to existing spawned shares),
  and an in-sheet create form (no nested dialog — the form expands
  inside the sheet so the user stays in flow). The destructive
  delete confirmation also lives inline as a panel that replaces the
  rule list, with the same "Keep" / "Also revoke" choice as desktop.
- Entry from MobileUserMenu via a new "Auto-share with people"
  item; the trigger only renders when the parent passes
  onAutoShare, so trip-detail menus that reuse MobileUserMenu don't
  surface it.
- mobile-share-sheet share-row gets the same "Via auto-share rule"
  pill as desktop (using --status-info-* tokens + text-kicker).

Per CLAUDE.md desktop+mobile parity, every affordance added to the
home dashboard now lands on /m too.
- README: extend the Sharing one-liner, add the four /share-rules
  endpoints to the API table, and append a new "Auto-share rules"
  bullet under the Sharing section that captures the conflict
  policy, cascade behaviour, and surfaces (panel on desktop, sheet
  on mobile, pill on the per-trip share dialog).
- Welcome page: rename the share card to "Share — view, edit, or
  auto-share" and add a sentence about pinning a person to every
  trip in one tap.
- Intro tour dialog: extend the share step with a sentence about
  auto-share rules so first-run users discover the feature.
- Convert AutoShareRulesPanel to AutoShareRulesDialog and remove it
  from the dashboard so it doesn't take prime real-estate from the
  trip list. Open it via a new "Auto-share…" item in the desktop
  user menu — same affordance pattern as the mobile shell.
- Rename the mobile menu item to "Auto-share…" and drop the "With
  people" subtitle from the mobile sheet header — title is just
  "Auto-share" now.
- Drop "in one tap" from the empty-state copy on both surfaces; it
  was inaccurate (creating a rule is several taps).
- Welcome share card: replace "Auto-share rules pin a person to every
  existing trip and every new one you create, in one tap." with
  "Setup Auto-share rules for all existing and future trips."
- Intro tour share step: replace the auto-share sentence with "Add
  an auto-share rule to share all existing and future trips with
  someone." and call out that share links can be read-only or
  editable so the choice is visible to first-run users.
feat: auto-share rules — share every (existing & future) trip with someone
)

* feat(server): persist auto-share rules to Drive

Replaces the four DriveStorage share-rule stubs with a real
implementation that reads and writes a single share-rules.json file
in the user's Itinly app folder, alongside settings.json and
processed-emails.json. Rules are owner-scoped and low-cardinality —
always read and written together — so a single-file array is the
right granularity (no per-rule file like trips).

- listShareRules: reads share-rules.json, returns [] when the file
  doesn't exist (back-compat with users who never created a rule).
- getShareRule: list + find by id.
- saveShareRule: list, replace-or-append by id, write back. The
  upsert means re-saving an edited rule never duplicates.
- deleteShareRule: list, filter, write back; returns false (without
  writing) when the rule was already absent so the caller can 404.

Drops the "not implemented" comments and the throw stubs. The
share-rule routes work unchanged — they only see StorageProvider.

Adds 9 new drive-storage.test.ts cases covering empty file, list +
get, create vs. update branching, replace-vs-append, and the no-op
delete path. Total server test count: 516.

* chore(ci): run CI on the preview branch too

Adds `preview` to the CI workflow's pull_request and push branch
filters so PRs targeting preview (and pushes after merge) get the
same test + lint + typecheck pass that PRs targeting main get. Until
now the green PR check on a preview-targeted PR was Vercel-only;
this closes that gap so I can't merge a broken preview branch.

version-bump stays main-only — it's tied to release tagging.

---------

Co-authored-by: Claude <noreply@anthropic.com>
* feat(web): advertise auto-share inside the per-trip share dialog

Adds a small CTA banner at the bottom of ShareTripDialog (desktop)
and MobileShareSheet (mobile). Tapping it closes the share UI and
opens the auto-share dialog/sheet with the recipient email the user
already typed pre-filled into the create form — so they don't have
to retype the address.

Threads an `initialEmail?: string` prop through:
- AutoShareRulesDialog → CreateRuleDialog (auto-opens the inner
  create dialog when set, useEffect picks up fresh values on each
  open transition)
- MobileAutoShareSheet → CreateForm

Banner uses the --status-info-* tokens (cyan info hue) so it reads
as a discovery hint, not a warning. Hidden in the desktop dialog's
"share link ready" success state to keep that moment focused.

* fix(web): disable auto-share submit + delete buttons while pending

Rule create + delete are not optimistic (they need server-assigned
ids / a confirmed cascade count), so a user double-tapping the CTAs
fires N parallel requests against the same rule. Result: rule create
hits 409 on the second attempt; rule delete double-cascades / 404s.

Disable both flows while the underlying mutation is in flight and
swap the label to "Creating…" / "Removing…" so the action feels
acknowledged. Applies to both desktop dialogs and the mobile sheet:

- desktop: AutoShareRulesPanel CreateRuleDialog "Auto-share" button,
  DeleteRuleDialog "Keep" + "Also revoke" buttons
- mobile: MobileAutoShareSheet CreateForm "Auto-share" button,
  DeleteRulePanel "Keep" + "Also revoke" + "Cancel" buttons

* fix(web): close user menu when opening Auto-share dialog

The DropdownMenuItem onSelect handler called event.preventDefault()
which blocked Radix's default menu-close behaviour, leaving the
dropdown visible behind the dialog. Other items in the same menu
(Use mobile site, Sign out) use plain onClick and close cleanly —
match their pattern. preventDefault is only useful for items that
need to keep the menu state alive across an async UI step (e.g. iOS
PWA install prompts), which doesn't apply here.

* perf(server): parallelize Drive reads + share-rule fan-out / cascade

The auto-share rule create + delete cascade were hitting Drive
sequentially: ~3N round-trips for backfill (N trips), all serial.
With N=15 and ~300ms per Drive op, that's the ~15-second wait we
were seeing on preview.

Switch to bounded-concurrency parallel writes/reads via a small
mapWithConcurrency helper (cap=6, comfortably under Drive's per-user
quota even when chained writes follow):

- DriveStorage.listTrips: N sequential file reads → ceil(N/6)
  round-trips of 6-wide parallel reads
- POST /share-rules backfill loop: per-trip work runs in parallel
  pool of 6 instead of one-at-a-time
- PUT /share-rules cascade-update loop: same
- DELETE /share-rules?cascade=true revoke loop: same

For a 15-trip user this drops the create/revoke wait from ~15s to
~2-3s. No behaviour change — each trip remains an independent
saveTrip and the registry/snapshot side effects are atomic
in-memory ops.

Also fix two double-tap-vulnerable buttons surfaced in the audit:
- email-scan-dialog "Start Scan" — now disables on
  scanEmails.isPending and labels "Scanning…". Prevents accidental
  re-fires that burn Anthropic quota on a slow scan.
- html-import-dialog "Parse" — now disables on
  importMutation.isPending and labels "Parsing…". Same rationale.

---------

Co-authored-by: Claude <noreply@anthropic.com>
The service worker was intercepting /auth/callback navigations and
re-issuing them via fetch(req), which (a) cached URLs containing the
single-use OAuth `code` and (b) made the cross-site Google → callback
request look like an SW retry pattern to Vercel's DDoS mitigation,
contributing to firewall denials. Bumped SW_VERSION to v8 to evict any
previously-cached callback HTML carrying a stale code.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(mobile): tapping auto-share ad opens the auto-share sheet immediately

The auto-share advertisement inside the mobile share sheet was rendered
as a child of MobileBottomSheet. Tapping the ad called both
setAutoShareEmail("") and onClose(); the latter flips the bottom sheet
to open=false, which makes MobileBottomSheet return null and unmount
all its children — including the just-opened MobileAutoShareSheet. The
auto-share sheet's state was preserved on the parent component, so the
*next* time the share sheet opened, the auto-share sheet would mount
already-open, producing the reported "click ad → nothing → click share
→ Auto-share appears" sequence.

Move MobileAutoShareSheet to be a sibling of MobileBottomSheet so the
two sheets mount independently. Also reset autoShareEmail in the
on-open effect to defend against any future state leak.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(share): leave-trip is instant, margin under share-sheet ad, drop "with someone"

Three issues reported on the auto-share / share flow:

1. **Leaving an auto-shared trip looked like a no-op.** `useDeleteShare`
   only optimistically removed from the trip's `shares` list (the
   owner's view) and only invalidated that key. The recipient leaving
   from their trip card got no immediate UI change — the list query
   stayed stale until the next refetch, with no toast, looking like
   nothing happened. Extend the hook to also drop any TripSummary with
   a matching `sharedShareId` from the trips-list cache on mutate,
   restore on error, and invalidate `queryKeys.trips` on settled.

2. **No spacing between the share-sheet body and the auto-share ad.**
   The ad button sat flush against the toggles. Add `mt-5` for breathing
   room.

3. **"with someone" is unnecessary.** It's already implicit in the rule
   (the recipient is configured when you create the rule). Drop it
   from both the desktop dialog title/description and the mobile sheet
   empty state.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): mirror mobile trip groupings, center mobile login, clear cache on logout

Three parity / hygiene issues that landed together:

- **Desktop My Trips: Now / Upcoming / Past sections.** The desktop list
  was a flat grid sorted by start date; mobile already buckets into Now,
  Upcoming, and Past with the past collapsed by default. Extract the
  bucketing helpers to `lib/trip-buckets.ts` so both surfaces share one
  rule (today comparison against startDate/endDate, ascending for now /
  upcoming, descending for past). Mobile now imports the shared helpers
  instead of duplicating them.

- **Mobile login is vertically centered.** Previous layout pinned the
  wordmark to `pt-16` and pushed the desktop-site link to the bottom
  with `mt-auto`, leaving the buttons block in a top-anchored gap.
  Adding `mt-auto` to the wordmark group splits the leftover space
  evenly with the link's `mt-auto`, centering the (wordmark + buttons)
  block while keeping the link at the bottom.

- **Sign-out clears cached trips.** `logout()` only reset auth state;
  the React Query in-memory cache and the persisted localStorage
  snapshot survived, so the next user (or the same user re-signing in)
  briefly saw the previous account's trips on first paint until the
  refetch completed. Add an effect in `ApiProviderSwitcher` that
  watches `accessToken` and on truthy→null transition runs
  `queryClient.clear()` + removes `CACHE_STORAGE_KEY` from localStorage.
  Catches both manual logout and refresh-token failure.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs: stable preview alias setup + cite trip-buckets in parity rule

- vercel-setup.md: new "Stable preview alias" section walks through DNS,
  Vercel branch-domain assignment, OAuth-relay regex extension, and the
  Railway preview env CORS update needed to back a stable
  preview.itinly.app URL with the `preview` branch.
- .env.example + README env table: example regex now shows the
  alternation pattern (per-deploy hostnames OR a stable alias) so
  future readers don't have to reverse-engineer the right shape.
- CLAUDE.md parity rule: cite `lib/trip-buckets.ts` alongside the
  `useTripPermission` / `useShareLinkOwnerRedirect` hook examples — a
  pure helper module also keeps desktop + mobile in sync.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): import TripSummary from api-client, not shared

`TripSummary` lives in `@travel-app/api-client`, not
`@travel-app/shared`, so the production build failed type-check with
"Module has no exported member 'TripSummary'". Other consumers
(`m/page.tsx`, `trip-list.tsx`) already use the api-client path —
match them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…apse (#246)

Desktop trip list still had the legacy status-based filter (`status ===
"completed" || "cancelled"`) hiding trips behind a "Show completed
trips (N)" footer button. Mobile already shows every trip in its
date-bucket regardless of status — the collapsed Past section is
enough to keep finished trips out of the way without a separate
toggle. Drop the filter, the toggle, and the empty-state hint about
hidden completed trips so the two surfaces match.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented May 10, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
itinly Ready Ready Preview, Comment May 10, 2026 0:24am

@justmarks justmarks merged commit bcb24c3 into main May 10, 2026
5 checks passed
@justmarks justmarks deleted the preview branch May 10, 2026 00:25
justmarks added a commit that referenced this pull request May 10, 2026
…etachars (#248)

The "Determine version bump type" step interpolated the commit message
via `${{ github.event.head_commit.message }}` directly inside the run:
script. GitHub substitutes that as raw text BEFORE bash parses, so any
commit body containing parens, quotes, or backticks (e.g. a squash-
merge body listing "(#246)" PR refs, like the one for PR #247) crashed
the step with "syntax error near unexpected token '('".

Pass the value via `env:` instead. Env vars route through getenv so
bash never re-parses them — `"$COMMIT_MSG"` expands cleanly even when
the body contains arbitrary characters.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants