Easy-to-manage touch screens
Free, open-source touch screens. Guided USB setup, a drag-and-drop editor and local control. Made for Home Assistant.
A road trip planner for iPhone. Penny lays your trip out day by day, and Finn finds fuel along the route before your tank runs low.
LifestyleTypeScript · JavaScriptGitHub @samuelshirleyOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Feral Travels Trip Planning App
Translation pending
Taken from the repository README
Loading the full README. The saved excerpt is shown below.
AI trip planner for overlanders. You tell Penny (a Claude tool-use agent) where you're going and how far you want to drive each day; she builds a dated, day-by-day itinerary with routes and rest days, and Finn (a deterministic fuel engine) finds gas stations your vehicle can actually reach along each day's route. You then edit the plan entirely by chatting — the itinerary list and map are read views anchored to your live GPS position.
Live at feraltravels.com (web + PWA). A native iOS client lives in mobile/ (Expo / React Native, in TestFlight ahead of App Store submission).Read the full README ↗AI trip planner for overlanders. You tell Penny (a Claude tool-use agent) where you're going and how far you want to drive each day; she builds a dated, day-by-day itinerary with routes and rest days, and Finn (a deterministic fuel engine) finds gas stations your vehicle can actually reach along each day's route. You then edit the plan entirely by chatting — the itinerary list and map are read views anchored to your live GPS position.
Live at feraltravels.com (web + PWA). A native iOS client lives in mobile/ (Expo / React Native, in TestFlight ahead of App Store submission).
Deep reference:
CLAUDE.mdis the authoritative map of the codebase — architecture, schema, Penny tools, invariants, and the history behind every non-obvious decision. This README is the short version.
fuel (found automatically by Finn) and other (a place the user adds by pasting a Google/Apple Maps link, an address, or a place name). Penny does not discover campgrounds, groceries, etc.range_km, carries tank state across days, and attaches a one-line reason to every forced stop. Fuel is sourced lazily when a day is opened and cached 48 h.report_position re-anchors the trip to where the driver actually is; declare_fuel_state records "I only have 150 km in the tank"; the itinerary collapses days behind you./admin (hardcoded allowlist): users, trips, chat volume, per-request AI cost, Google usage.| Layer | Choice |
|---|---|
| Web | Next.js 14 (App Router), React 18, TypeScript, CSS Modules, PWA service worker |
| iOS | Expo SDK 54 / React Native, expo-router, react-native-maps — pure client of the same API (mobile/) |
| API | 61 REST routes; every route accepts one Zod-validated payload; all DB access through src/server/repos/* |
| DB | Neon Postgres via Drizzle ORM (33 tables, migrations in drizzle/) |
| Auth | NextAuth v5 — email OTP, Google OAuth, and Sign in with Apple; mobile uses the same flows and gets a bearer token stored in the iOS Keychain |
| AI | Anthropic SDK, tool use, 21 Penny tools in src/lib/penny/tools/; model IDs in src/lib/models.ts. Jev (TypeSafe, typed decisions) as an optional first opinion on the message gate, off by default (src/server/jev/) |
| Maps | Google Maps Platform — one key (NEXT_PUBLIC_GOOGLE_MAPS_API_KEY) for the browser SDK, server Directions, and Places (New) search-along-route / text search |
| Resend | |
| Payments | Apple IAP via RevenueCat; entitlement lives server-side in src/server/payments/ |
| Tests | Vitest (1,955 tests in 163 spec files), Playwright (web e2e), Maestro (iOS e2e on a simulator) |
| Hosting | Vercel + Neon branches; GitHub Actions CI — merge to main deploys |
npm install
cp .env.example .env # fill in the vars below
npm run db:migrate # apply Drizzle migrations to your Neon DB
npm run dev # http://localhost:3000
Required env (see .env.example for comments):
DATABASE_URL Neon pooled connection string
AUTH_SECRET openssl rand -base64 32
AUTH_URL http://localhost:3000 locally
AUTH_GOOGLE_ID / AUTH_GOOGLE_SECRET
AUTH_RESEND_KEY / AUTH_EMAIL_FROM
ANTHROPIC_API_KEY
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY enable Maps JS, Directions, and "Places API (New)" on it
For the iOS app, payments and Apple sign-in:
REVENUECAT_WEBHOOK_SECRET shared secret for POST /api/webhooks/revenuecat; unset = the route answers 503
AUTH_APPLE_ID / AUTH_APPLE_SECRET web Sign in with Apple; the button renders only when both are set
(the secret is a JWT that expires — regenerate with scripts/generate-apple-client-secret.ts)
APPLE_APP_BUNDLE_ID audience for native Apple identity tokens (defaults to com.feraltravels.ios)
AUTH_GOOGLE_IOS_CLIENT_ID audience for native Google identity tokens
NEXT_PUBLIC_APP_STORE_URL the web soft block's "Continue on iPhone"; falls back to an App Store search
Optional: ADMIN_EMAILS (can only restrict the hardcoded allowlist), REPLAN_REQUESTS_PER_HOUR, REPLAN_USD_CAP_PER_DAY, DELETED_USER_ENC_KEY (makes deleted accounts' emails readable in /admin/deleted), NOMINATIM_USER_AGENT (reverse geocoding), the GOOGLE_PLACES_FREE_CALLS_* allowances (admin cost display), SUBSCRIPTION_TESTING=1 (trial-state test accounts; ignored in production), ANTHROPIC_ADMIN_KEY (only for scripts/anthropic-usage-report.ts), E2E_INBOX_DOMAIN (the one e2e spec that reads a real inbox). APPLE_REVIEW_SIGNIN exists for the App Store review queue only and is unset on production.
The iOS app's own build-time vars (EXPO_PUBLIC_*) live in mobile/eas.json; see mobile/README.md.
npm run dev # dev server
npm run build # production build
npm run test # vitest, whole suite — run after EVERY code change
npm run test:unit # logic specs only (node)
npm run test:components # component specs only (jsdom)
npm run e2e # playwright (starts the app itself; see e2e/TESTING-MODES.md)
npm run e2e:smoke # critical subset
npm run db:generate # generate a migration from schema.ts
npm run db:migrate # apply migrations
npm run db:push # push schema.ts straight to a FRESH database (see below)
npm run db:studio # Drizzle Studio
npm run seed # (re)build the public demo trip users can clone
npm run sync-shared # regenerate mobile/shared from src/shared — after touching either
npm run check:shared # fail if mobile/shared has drifted
npm run trial-account # print, age or reset a paywall test account (dry run; refuses prod)
npm run test-user # one disposable paywall test account (needs SUBSCRIPTION_TESTING=1)
npm run prune-branches # delete branches already in main (dry run; --apply to act)
Schema change workflow: edit src/server/db/schema.ts → npm run db:generate → npm run db:migrate. Keep migrations additive. The migration chain cannot be replayed from an empty database: a fresh DB uses npm run db:push + scripts/seed-migration-journal.ts.
src/
app/ pages (trips, trips/[tripId], login, settings, vehicle-setup, admin) + api/ routes
components/ TripWorkspace pieces: ChatPanel, Itinerary/LegCard, TripMap, StopsSection, DeviceLocationContext …
lib/
penny/ Penny context, schedule/continuity repair, contiguity gate, leg placement, tool registry
finn/ fuel engine: range math, route projection, station filter, greedy placement
google/ server-side Directions, Places (search-along-route), geocode
claude.ts Penny system prompt + turn loop
server/
onboarding.ts deterministic form-in-chat state machine (runs before the LLM)
fuel.ts lazy per-leg fuel sourcing + cache + Finn wiring
repos/ the only place SQL happens
auth/ NextAuth config, guards (cookie + bearer), admin allowlist, OTP
db/ schema.ts + Neon client
drizzle/ generated migrations
e2e/ Playwright specs + fixtures (real OTP sign-in, no auth bypass)
mobile/ Expo iOS app (shares DOM-free logic via mobile/shared, regenerated by npm run sync-shared)
resolve_place), plan numbers (derived from the DB), or fuel-range safety numbers (Settings only).Every chat turn is a durable penny_turns row with an idempotency key. A partial unique index enforces one running turn per trip at the DB level; extra sends queue and drain in-request. A phone that backgrounds mid-stream re-attaches to the durable record instead of showing a false "something went wrong". See docs/design/penny-turn-resilience.md.
Before Penny runs, every chat message passes the message gate, which decides whether it is a trip request (T1), harmless off-topic (T2) or junk/abuse (T3). Jev is a typed-decision model from TypeSafe: it answers a fixed-choice question with a probability per answer, in about 0.25 s, and it cannot write text. It sits in front of Haiku on the one step where a fixed answer is all that is needed:
message ─▶ free rules (src/lib/pennyGate.ts) ── settled? ──▶ T1 → Penny / T2·T3 → refused
│ not settled
▼
Jev (src/server/jev/) ── T1 with p ≥ JEV_T1_MIN (0.85)? ──▶ T1 → Penny (Haiku not called)
│ anything else, error or timeout
▼
Haiku classifier (src/server/pennyClassifier.ts) ──▶ final tier
/admin with no redeploy: off (Haiku only, no Jev call), compare (Haiku decides; Jev's answer is only logged beside it), and on (Jev first, as above). The global switch is the app_meta.jev_mode row. A per-account override, users.jev_mode, wins over it. A missing or unreadable value means off.JEV_BASE_URL, JEV_API_KEY, JEV_MODEL, JEV_TIMEOUT_MS (default 800) and JEV_T1_MIN (default 0.85). Production needs https and a key. Any bad or missing config counts as "not configured": the gate goes to Haiku and /admin shows why. For TypeSafe's hosted Jev, set JEV_BASE_URL=https://api.typesafe.ai and pin JEV_MODEL=jev-1.13.0, because the code's default model id (typed-decisions) names the local open-source stand-in. The Vercel Preview environment has these set. Production has none of them, so Jev is off there whatever the switch says.usage_events row (provider: 'jev') with its choice, top probability, latency and whether it settled the message. It never stores user text, and it counts toward no spend cap. /admin shows Jev-first and compare stats, including "Jev would have passed, Haiku refused".messageGate.ts, repos/jev.ts, api/admin/jev/** and scripts/ may import src/server/jev/, which jevBoundaryGuard.test.ts enforces. claude.ts (Penny) may not.Measured on 2026-10-02 (hosted jev-1.13.0 against Haiku 4.5; 397 synthetic messages plus 39 real ones from three users):
| Jev | Haiku classifier | |
|---|---|---|
| Gate accuracy, messages the free rules can't settle | 94% | 85% |
| Real requests wrongly refused (synthetic set) | 5 | 30 |
| Real users' messages, gate | 39/39 | 38/39 |
| Latency (median) | ~0.23 s | 0.7–1 s |
| Cost per gate decision | ~$0.00004 (third-party price; TypeSafe publishes none) | $0.0014 |
What it does not do: save much money yet. A gate call is about 2% of a Penny turn, so Jev's real gain is faster, more accurate gate decisions. Making Jev route simple requests ("fuel on day 3") to code without a Penny turn is future work. Before real users: TypeSafe must be named on the privacy page as a processor of chat messages.
Full design, modes and ledger: docs/design/jev.md.
Production has no real users yet, but it is live infrastructure. Never run tests, seeds, or migrations against prod from a laptop.
Merging is deploying. main moves only via pull requests — branch protection on main enforces it: a PR is required, Decide scope and Unit tests must pass on an up-to-date branch, and force-pushes and deletions are blocked. enforce_admins is off, so a repo admin can bypass it; the deploy gate is the backstop for that (see docs/design/deploy-pipeline.md). A merged PR is live on production a few minutes later. There is no ship script and no button to press.
Six workflows, one per trigger — there is no single pipeline.yml.
Open a PR into main → .github/workflows/ci.yml (workflow CI), re-run on every push to the PR. On a docs-only PR, the preview and both E2E jobs are skipped:
preview/pr-<N> (copy-on-write clone of prod data), migrates it, and deploys a Vercel preview pointed at it. The URL is posted as a sticky PR comment and pinned to the top of the PR description. Prod's DB is never touched, and this run is the rehearsal for the prod migration./api/test/* fixture endpoints are data-only, hard-off on production, and locked with a per-run HMAC secret.Only Decide scope and Unit tests are required by branch protection — the two jobs that run on every PR. E2E and the simulator are enforced by the deploy gate instead, which reads the CI run's overall conclusion. If you rename either required job in ci.yml, update the protection rule in the same PR.
Merge the PR → .github/workflows/deploy-production.yml fires on the push to main: it re-checks that the PR behind the merge commit had a green CI run, applies pending migrations to the prod DB, then builds and deploys that commit. Vercel's own git auto-deploy is disabled in vercel.json, so this workflow is the only path to prod.
PR closes → .github/workflows/pr-cleanup.yml deletes the PR's Neon branch, so a clone of real user data isn't left behind a public URL.
Merge touching mobile/ → .github/workflows/mobile.yml publishes an OTA update or cuts a TestFlight build (see iOS app); on a PR it only posts a comment forecasting which.
Every PR not docs-only → .github/workflows/docs-drift.yml has a model review the diff against CLAUDE.md and docs/decisions.md and comment on stale docs; it never edits them.
An admin push that bypasses protection has no CI run behind it, so the deploy refuses it — push through a PR.
Rolling back: merge a revert PR (it goes through the same gate), or, for an instant fix, re-promote the previous production deployment from the Vercel dashboard. Neither undoes a migration — which is why migrations stay additive (add → backfill → switch code → drop later).
See mobile/README.md and docs/design/ios-app-plan.md. Bundle com.feraltravels.ios; EAS build profiles development / preview / production; eas submit → TestFlight. Sign-in is email OTP, Google, or Apple (EXPO_PUBLIC_ENABLE_APPLE_SIGNIN=1, set in mobile/eas.json). The road to the App Store is tracked in docs/design/launch-checklist.md.
Subscriptions. Seven days free from sign-up, capped at $1 of Anthropic spend — whichever runs out first. Then one of two auto-renewing products: com.feraltravels.ios.monthly at $2/month or com.feraltravels.ios.annual at $20/year, bought through RevenueCat (mobile/lib/purchases.ts), which shows the store's localized price.
POST /api/webhooks/revenuecat (authenticated by REVENUECAT_WEBHOOK_SECRET) records it. The app then polls /api/me/entitlement.app_meta.paywall_enabled, flipped from /admin (or scripts/set-paywall-flag.mjs), so turning it off needs no redeploy. It fails closed to OFF; with it off, nothing blocks while /admin still reports who would be. PAYWALL_ENABLED has not been read since 2026-09-02.src/server/payments/ is a bounded module: index.ts is its only public surface. Why, and the full account-state machine: docs/design/subscriptions.md. Store setup, in order: docs/design/iap-setup.md.Sign in with Apple survives Apple's JWKS 404s. Apple's key endpoint 404'd about one request in five on 2026-09-21. The server now fetches the keys before reading the token — retry, then a persisted last-known-good set up to 72 h old (oauth_provider_keys) — and with no keys at all answers 503 ProviderUnavailable, which the app retries silently, never 401 InvalidToken. See src/server/auth/jwksSource.ts and docs/design/conventions.md.
Shipping a build — OTA vs native. A merge to main touching mobile/ runs mobile.yml, and scripts/decide-mobile-release.mjs picks one outcome:
eas update publishes an OTA to the production channel, but only after Deploy to production has succeeded for the same commit, so a bundle never reaches phones before its API. Installed builds pick it up on next launch; no new binary is built.eas build + submit to TestFlight automatically, likewise only once production serves that commit.mobile/ changed → no mobile release at all.A fresh install gets the binary's bundled JS until its first launch, and App Review sees the binary. So when a JS-only change must be in the binary itself — before an App Store submission, say — cut the build by hand: Actions → Mobile → Run workflow, which always builds. Details: docs/design/mobile-release.md.
docs/design/mvp-scope.md — what the MVP is, and what was cutdocs/design/stack.md — why each provider, and what each costsdocs/design/finn-fuel-agent.md — Finn design + Google-only cutoverdocs/design/penny-turn-resilience.md — durable turns / idempotencydocs/design/subscriptions.md — pricing, account states, spend defencedocs/design/deploy-pipeline.md — the workflows and their sharp edgesdocs/plans/google-only-teardown.md — why OSM/OSRM and fuel pricing were removeddocs/decisions.md — the decision registerEvery 15 minutes → .github/workflows/oauth-provider-probe.yml probes Apple's and Google's JWKS endpoints and the production OAuth exchange, and opens an issue when one breaks. It deploys and gates nothing.