Mapvest — Implementation Plan
Phased build. Each phase has an acceptance test. When agents tick a checkbox, commit the tick.
Legend: [ ] = todo · [x] = done · [~] = in progress
Phase 0 — Bootstrap
- Create GitHub repo
jawauntb/mapvest - Wire remote to local repo
-
AGENTS.md,IMPLEMENTATION_PLAN.md,README.md -
docs/ARCHITECTURE.md,docs/DATA_SOURCES.md,docs/SECRETS.md,docs/DEPLOY.md,docs/SYSTEM_DESIGN.md - Monorepo scaffold (
apps/,packages/,infra/) with rootpackage.json+bunworkspaces - Biome config, .gitignore, tsconfig base
- Initial push to origin
Acceptance: git clone && bun install runs cleanly on a fresh machine.
Phase 1 — Core types + shared packages
-
packages/core— zod schemas forInvestable,Brand,Location,PhotoIdentification,Comparable,Source,User,Session -
packages/vision— OpenRouter multimodal client. FunctionidentifyFromImage(bytes, {location?})returnsPhotoIdentification -
packages/search— Exa client wrapper.searchBrand(query),enrichTicker(brand) -
packages/finance—-
resolveTicker(brand)— first-party mapping + fallback via Exa + LLM -
resolveComparable(private_brand)— returns nearest public co + confidence -
resolveEtfExposure(brand|sector)— returns ETFs with % exposure
-
- Package tests (
bun test) hitting real APIs behinddoppler run
Acceptance: bun test green for each package. bun run --filter vision identify examples/hershey.jpg returns {brand: "Hershey's", ticker: "HSY", confidence: "high"}.
Phase 2 — API layer (Bun + Hono)
-
apps/apiskeleton — Hono, zod-validation middleware, request logger, health check -
POST /v1/identify— multipart image →PhotoIdentification+ finance annotations -
GET /v1/nearby?lat=..&lng=..&radius=..— Google Places → filter → annotate tickers/ETFs -
POST /v1/resolve-comparable—{brand: "…"}→ public comparable + ETF -
POST /v1/auth/session— passwordless email sign-in (magic link) -
GET /v1/admin/…— admin scope: metrics, user list, request log - Rate limits (per-user + per-ip), abuse guardrails
- OpenAPI schema generated from zod (
packages/core) —bun run openapiwritesopenapi.yaml;bun run postmanwritespostman.json
Acceptance: curl -F 'image=@examples/mcd.jpg' localhost:3001/v1/identify returns {brand:"McDonald's", ticker:"MCD", …}. /v1/nearby?lat=37.77&lng=-122.42 returns ≥5 investable items.
Phase 3 — iOS app (Expo)
-
apps/ios— Expo React Native (TypeScript, expo-router). SDK 52+. - Auth screen — magic-link email flow
- Map screen —
react-native-mapsw/ Google provider; pins colored by publicness - Camera screen — capture → upload to
/v1/identify→ result card - Live-scan screen — throttled frame capture (~1 fps) →
/v1/identify - List screen — sortable by distance, market cap, sector
- Detail sheet — ticker, comparables, ETFs, sources
- Admin tab (hidden unless user has
adminscope) - Offline queue for photos (uploads on reconnect)
Acceptance: Simulator run shows map with real data around San Francisco; camera identifies a chocolate bar photo end-to-end.
Phase 4 — Landing page
-
apps/landing— Next.js 15 App Router, minimal marketing - Server-renders
docs/*.mdat/docs/{slug} - TestFlight CTA + GitHub link
- Screenshot gallery pulled from
docs/assets/(horizontally-scrolling gallery inapps/landing/src/app/page.tsxwraps four SVG "screenshots" —auth.svg,map.svg,camera.svg,detail.svg— in a 12:19 device frame underapps/landing/public/screenshots/; real simulator captures replace them one-for-one post-TestFlight) - SEO: og-image, sitemap
Acceptance: bun run --filter landing build succeeds; /docs/architecture renders the file.
Phase 5 — Deploy (Railway)
- Railway project
mapvestin the user's default workspace - Service:
api(Bun) — Doppler mount, healthcheck, autoscaling off, generated domain - Service:
landing(Next.js) — generated domain - Postgres plugin — sessions + admin log
- Env vars mirrored from Doppler via
doppler secrets download --format env --no-file | railway variables set - Custom domain
mapvest.appon landing (www→ Railway; apex301at GoDaddy)
Acceptance: https://api-production-4b27.up.railway.app/v1/health returns 200; landing loads at https://mapvest.app.
Phase 6 — TestFlight
-
apps/ios/eas.json— internal + external profiles - Bundle id
com.mapvest.app(or under existing Apple Team) -
eas build --platform ios --profile production -
eas submit --platform ios - Internal testers group configured
Acceptance: Build appears in App Store Connect → TestFlight; internal group can install.
Phase 7 — Polish + guardrails
- Rate limit + WAF sanity on
/v1/identify - Cost telemetry per request (OpenRouter model, image size, resolution ms)
- Prompt-injection guard on OCR'd text
- Load test: 50 rps identify, 200 rps nearby — ran
scripts/loadtest.tsagainst the deployed API on 2026-08-05 (docs/loadtest-v0.1.0.md). Substituted/v1/resolve-comparablefor/v1/identify(skips OpenRouter cost per run) and/v1/healthfor/v1/nearby(I/O-only baseline) — the layer boundary being proven (Bun/Hono ingress + rate-limit middleware + finance path) is the point. Application path posted p95 = 180 ms / p99 = 193 ms on the 58 requests that survived the guardrail; the 60 rpm per-IP limiter dominated the rest of the run and is documented as a known limitation indocs/SYSTEM_DESIGN.md§D11 with follow-ups tracked there. - Client UX mean: no bottom tabs; profile drawer + camera header; blended map/list; two-tap overlapped pins; responsive search; staggered detail
- Landing page polish + docs pass
- "Ship" tag
v0.1.0, GitHub Release notes
Acceptance: v0.1.0 release with a demo GIF and a working TestFlight link.
Deferred / v0.2
- Android build via EAS
- Options-derivation integration (link out to sibling
option_derivationrepo) - Underlying-Analyzer integration (private-firm sector proxies)
- Watchlist portfolio analytics (basic ★ Save is now Postgres-backed via
user_watchlist) - Push notifications when a nearby brand hits an earnings window
Phase 8.5 — Atlas Signal design system
- Morphospace of 4 directions; ship Atlas Signal (Maps + RH green + Chat clarity + X density)
-
packages/designtokens + CSS vars; iOSsrc/theme/tokens.ts - Logo mark / wordmark / favicon / apple-touch / OG / app icon
- Landing fonts: Syne (display) + IBM Plex Sans/Mono; brand-first hero
- iOS chrome colors + splash/icon
Phase 8 — Performance, continuity, freemium, billing
Ship as small slices; each slice merges to main and redeploys Railway (API + landing). iOS picks up via Expo reload / next EAS build.
Product rules (source of truth)
- Browse free without account — open app/web, use map/list/nearby/identify/research up to 50 generations (billable: identify, agent chat, memo).
- Signup required to persist — Save/watchlist, Robinhood MCP, memos-on-watchlist need a session.
- After 50 gens — must subscribe $19.99/month (Stripe on web, StoreKit on iOS) unless entitled free.
- Forever-free entitlements
- Auto: email contains
jawaun(case-insensitive) → free forever. - Admin: grant/revoke free on
/v1/admin/users(and Admin UI).
- Auto: email contains
- Login sticks until explicit logout (web localStorage + iOS SecureStore; users in Postgres; session JWT long-lived / refreshable).
- Home/settings — login, logout, account, plan status, Robinhood MCP, manage subscription.
Slice A — Geo cache + tab continuity (ship first)
- Postgres
nearby_cache(geohash6 + radius, 12h TTL) +brand_ticker_cache(7d) - iOS:
freezeOnBlur/unmountOnBlur: false, PersistQueryClient → AsyncStorage - Camera/Live last result in React Query cache; map nearby longer staleTime + chart prefetch for top tickers
Acceptance: Second /v1/nearby same tile is cache hit; switch Camera→Home→Camera keeps frozen result.
Slice B — Durable session + settings auth UX
- Session refresh on
/v1/auth/me(extend expiry); 90d TTL - Guest mode: tabs usable without Redirect-to-auth; auth only for save/settings actions
- Home shows plan + Sign in / Sign out; web Home same
Acceptance: Kill app / refresh web → still signed in; Sign out clears both surfaces’ tokens.
Slice C — Anonymous 50-generation meter
-
X-Device-Id(UUID in SecureStore / localStorage) on billable calls - Postgres
usage_events+GET /v1/entitlements - Gate identify / agent/chat / memo at 50 for anon + unpaid users
- Clients show remaining count + soft paywall CTA
Acceptance: 51st identify without login returns 402/403 with { code: "quota_exceeded" }.
Slice D — Entitlements (jawaun + admin free)
- User columns / table:
plan=free_forever | free_trial | subscribed | none,free_forever_reason - Auto-set free_forever when email matches
jawaun - Admin POST
/v1/admin/users/:id/entitlement{ freeForever: boolean } - Admin UI to toggle free
Acceptance: jawaun@… never hits quota; admin can free another email.
Slice E — Stripe $19.99/mo
- Doppler/Railway:
STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET,STRIPE_PRICE_ID_MONTHLY -
POST /v1/billing/checkout→ Stripe Checkout Session (or native store product id) -
POST /v1/billing/portal→ customer portal - Webhook
customer.subscription.*→ setsubscribed - Web + iOS “Subscribe $19.99/mo” (web: Stripe Checkout; iOS: StoreKit 2 →
POST /v1/billing/apple. Android Play Billing deferred with the rest of v0.2.)
Acceptance: Test-mode checkout flips user to subscribed; quota lifts.
Slice F — Docs + OpenAPI regen
-
docs/SECRETS.md,DATA_SOURCES.md, OpenAPI/Postman for entitlements/billing - Tick this phase’s checkboxes as each slice lands
Acceptance: Docs match live env vars; bun run openapi && bun run postman clean.
Phase 9 — Share-to-Mapvest + home-screen widgets
See docs/SHARE_AND_WIDGETS.md for the full design + activation checklist.
API + JS/TS are done and tested; the native share extension and widget
extensions only activate after an expo prebuild + Xcode/EAS build, which
this environment can't run — that step is the "acceptance" gate below.
-
apps/api:GET /v1/widget/nearby+GET /v1/widget/map-snapshot(Google Static Maps proxy, key stays server-side), sharing the/v1/nearbyplaces cascade vialib/nearby-resolve.ts -
packages/core:WidgetNearbyItem/WidgetNearbyResponseschemas;openapi.yaml+postman.jsonregenerated - iOS: outbound Share button on the detail sheet (native OS share sheet)
- iOS: inbound share-to-Mapvest via
expo-share-intent—ShareIntentListener+app/share-intent.tsxrun a shared image through the same/v1/identifypipeline as the Camera tab - iOS: WidgetKit "Nearby" list + map widgets (
targets/widget/, via@bacons/apple-targets) — Swift source in place, deployment target 16.0, no iOS-17-only APIs - Android: "Nearby" home-screen widget (
src/widgets/, viareact-native-android-widget) — JSX widget UI + headless task handler -
expo prebuild --cleanrun at least once against these changes and verified in a simulator/device (share sheet target appears; both widgets render and refresh) -
ios.appleTeamIdset inapp.jsonbefore the next EAS build (needed for the widget extension to code-sign) - Real simulator/device screenshots of both widgets + the share sheet replace the placeholder description above once verified
Acceptance: Sharing a photo from Photos/Messages/a browser to Mapvest identifies it end-to-end; both home-screen widgets show real nearby data and refresh after visiting Map/List.