Skip to content
Christopher Diuyan

Viahe

Travel together, not just to the same destination.

  • In progress
  • Personal project
  • Solo, AI-assisted
Challenge
Navigate a convoy as one group rather than several vehicles that only share a destination.
Ownership
Solo product, architecture, mobile, backend, and deployment.
Key constraint
A live-looking surface must not imply that the group can see a traveler when it cannot.
Current result
Planning, invitations, live positions, guidance, session enforcement, and QA distribution are working.
Mobile
React Native, Expo, Expo Router, NativeWind, Zustand, TanStack Query, Mapbox
Backend
Hono, Node.js, Drizzle ORM, Zod
Data
PostgreSQL, PostGIS, Supabase Auth
Platform
pnpm, Turborepo, TypeScript (strict), Biome, Vitest, Maestro
Operations
Render, GitHub Actions, Sentry, Pino

01

The problem

Conventional navigation solves for one vehicle reaching one destination. It has no concept of a group, so a convoy traveling together is really several independent navigations that happen to share an endpoint, and the moment one car stops, takes a wrong turn, or falls behind, nothing in the system notices or cares.

Viahe treats the group as the thing being navigated. That changes what the software has to know: not just where you are and where you're going, but who else is on this journey, whether they can still be seen, and what it means when one of them goes quiet.

Almost every interesting problem in the project came from that shift. Once a screen shows other people's positions, the screen is making a promise about them, and most of the hard engineering was in making sure that promise stays true, or is visibly withdrawn when it can't be.

02

The system

A monorepo with eight packages, one application and one API, and a dependency rule that a package manager enforces rather than a code review.

Applications

  • apps/mobile
  • packages/backend

Orchestration, presentation, infrastructure integration

Shared

  • contracts
  • validation
  • database
  • auth
  • security
  • logging

Transport shapes, schemas, persistence, adapters

Domain

  • core

Business models, enums, domain types. No dependencies at all.

The mobile app does not declare database, auth, security or logging as dependencies, so those imports cannot resolve. The package manager enforces the boundary, not a code review.

Fig. 1Dependencies point inward, toward the domain. Nothing points back out.
  • Eight packages, one direction

    `core` is the only package with no dependencies; `backend` is the only one that depends on all the others. No cycles.

  • The mobile app cannot reach the database

    It depends on `contracts`, `core` and `validation`, and not on `database`, `auth`, `security` or `logging`. The import doesn't resolve, so the rule is enforced by the package manager rather than by review.

  • Strict beyond the default

    `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes` and `verbatimModuleSyntax` are on across every package: the settings that catch the errors ordinary strict mode lets through.

  • Every vendor SDK enters through an adapter

    Supabase, Drizzle and Pino are each reached through a port the application layer owns, so the application never depends on a vendor type directly.

03

Navigation & Focus Mode

The guidance engine is a set of pure functions. No React, no React Native, no Expo, no clock, no network. It takes a route and a coordinate and answers questions about them.

That constraint is what makes the navigation logic testable at all. Locating a rider on a route, deciding which turn instruction is due, and detecting that someone has left the route are all decisions that depend only on geometry and the session's own memory of what it has already said. Pushing the clock and the network outside that boundary means the hard parts can be tested without a device, a map, or a running server.

Only one piece is stateful: a single journey's guidance memory (the current step, how long the rider has been off-route, which cues have already been spoken, whether they have arrived). Keeping that in exactly one place is what stops the same instruction being announced twice from two different code paths.

Focus Mode

Focus Mode is a product decision expressed as a lifecycle rule.

Guidance runs only when the rider explicitly opened Focus Mode, the app is foregrounded, the member session is active, and the journey has no outcome yet. All four conditions, or nothing happens. It would have been easier to start guidance whenever a journey was active, and that would mean a phone in someone's pocket announcing turns to nobody, on a journey they had already finished.

04

Voice guidance

Voice guidance lives outside the guidance engine, and the dependency runs one way only.

Application: pure, no device, no clock

Location fixNavigationFix
Guidance enginepure functions
Due announcementDueAnnouncement

Infrastructure: device state lives here

Speech adapterreceives a SpokenCue
Already said? Foregrounded?device questions
Platform text-to-speechexpo-speech

Which instruction is due is a navigation question, answerable from geometry. Whether to speak it right now is a device question. Fusing them would put device state inside the pure engine and make the navigation logic untestable, in exchange for nothing.

Fig. 2The guidance engine decides what is due. The speech adapter decides whether to say it. Nothing crosses back.

The engine decides that an announcement is due. It does not know that speech exists. The speech adapter receives a cue (a plain shape the engine's announcement happens to satisfy) and decides whether and how to utter it. Nothing in the speech layer reaches back into the engine.

The reason for the separation is that these are two different kinds of decision. Which instruction is due is a navigation question, answerable from geometry and the route. Whether to speak it right now is a device question: is the app in the foreground, has this already been said, is something else talking. Fusing them would put device state inside the pure engine and make the navigation logic untestable, in exchange for nothing.

It is also deliberately one-directional: the outbound speech adapter is forbidden from growing a microphone. Speech recognition exists in the project, but only for searching places by voice: a different feature, a different adapter, and no path between them.

05

Sessions & device safety

Two rules pointing in opposite directions: one user to one device, and one device to one traveler. Neither implies the other.

Sign in on a new device
Previous session revokedserver-side
Old device signed outloses API access
Stops sharing location

Journey state

Untouched. A device switch never ends a journey, creates a second membership, or changes a role. Only the session moves.

Why not read-only and stale

A traveler glancing at a device showing a live-looking journey will assume the convoy can see them. Signing out is blunt, but it cannot be misread.

Fig. 4One session per user, settled at the session boundary rather than re-checked at every endpoint that accepts a position.

Transitions: a refusal works here

  • Confirm readiness
  • Start journey
  • Redeem invite code
  • Report position

Is the device free?

no → 409 DEVICE_IN_USE

yes → stamp device on membership

The path a refusal cannot reach

A traveler who is already traveling confirms nothing, starts nothing and redeems nothing. The only gate left is the position write, and its refusal is swallowed by the delivery queue, which drops the batch, reports it, and returns.

So the journey read carries the conflict instead. Collection stops, and the driving surfaces refuse to open.

Fig. 3Four checks guard the transitions. The fifth path (a traveler already traveling) has no transition to guard, so the read discloses instead.

06

Data, caching & freshness

Caching in this system is a question about authority, not about speed.

A journey's canonical route is an origin, a destination and ordered stops. The polyline drawn on the map is generated from those by a routing provider, and it is never stored, because a stored polyline is a second copy of something reproducible, and it goes stale the instant a stop is edited.

Caches are permitted anywhere they help. The route the planning wizard is working on is cached for the duration of the wizard. Static map images lean on the image cache. What no cache is ever allowed to be is the thing the system believes when it disagrees with the canonical record.

The one place durability genuinely matters is the opposite direction: positions waiting to be sent. Those are queued on disk rather than in memory, because a backgrounded app can be killed by the operating system at any moment, and a position that never arrives is a traveler who disappears from the convoy.

07

Engineering decisions

Taken from the project's architecture decision records. The rejected options are included because the rejected options are the part that shows the reasoning.

08

How AI fits the work

AI accelerates exploration. It doesn't get to decide what's correct. I define the requirements, challenge the assumptions, review what comes back, test the behavior, and approve what enters the project.

  1. 01Mine

    Idea

    What the product should do, and for whom.

  2. 03AI-led

    Exploration

    The one step where AI leads: options, drafts, unfamiliar APIs, and the shape of an approach.

  3. 04Mine

    Review

    Read against the standards. Plausible is not the same as correct, and generated code is confident either way.

  4. 07Mine

    Verification

    On a real device. The most costly bug in this project was invisible to every test that existed.

  5. 08Mine

    Decision

    What enters the project, and what gets written down as a decision record.

The constraints are written down before the code is generated

The project carries an engineering standards document whose final section sets what generated code must satisfy: TypeScript-first, no unnecessary `any`, thin controllers, business logic in application services, repository pattern, dependency inversion, structured logging, centralized error handling, and explicitly, avoid premature optimization and over-engineering. Generated code that ignores those is rejected, not adapted to.

The output gets overruled, and the overruling is recorded

The device-conflict rule shipped with four working enforcement checks and still let a traveler reach a live convoy screen they should not have reached. It was found on a physical device, not by a test. The first fix (a warning strip above the map) was then rejected as wrong in the same direction as the bug it was fixing, and replaced with refusing to open the surface at all. That judgment is not something the tooling arrived at.

Documentation drift is exactly the failure mode to watch for

The repository's own agent-facing notes list a real-time voice library in the stack table. Nothing in the project installs or imports it. Voice guidance is platform text-to-speech, and speech recognition is used only for place search. Generated documentation describing an intended architecture rather than the built one is the quiet version of this failure, and it is why the source of truth for a technical claim is `package.json`, not prose.

09

Testing & validation

The parts that are hard to get right are the parts written to be testable.

The guidance engine's purity is what makes its unit tests meaningful: geometry, announcement selection, deviation detection and the Focus Mode lifecycle rule are all covered without a device in the loop. Where a rule could have lived in a React hook, it was deliberately placed in a plain module instead, because a rule that lives in a hook is a rule nothing proves.

Continuous integration runs lint, typecheck, build and tests on every pull request, and the production deployment waits for that check to pass rather than racing it, so a commit that fails tests never reaches the deployed backend.

Stated accurately

Stated accurately rather than favorably: end-to-end tests exist and are driven by Maestro, but they run locally and are not part of continuous integration. Three of the eight packages have no test script at all. And the bug that produced the project's most interesting design decision was found on a physical device, by hand, not by any of it.

10

Current state

Viahe is in progress. What that means, precisely:

Working

  • Journey planning, invitation and admission
  • Convoy view with live member positions
  • Turn-by-turn guidance with Focus Mode and spoken cues
  • Authentication with single-session enforcement
  • One-active-journey-per-device integrity rule
  • Backend deployed continuously, gated on CI passing
  • QA builds distributed to testers

Partial

  • End-to-end tests are written but run locally, not in CI
  • Rate limiting counts per process, so it needs a shared store before running more than one instance
  • A landing page is planned but not yet built

Deferred

  • Scheduled journey settlement runs on read rather than on a timer. The job is written and tested, but the hosting plan has no free scheduler. The repository records this as a deployment gap rather than missing code.

11

What I learned

  1. 01

    A rule that only refuses transitions can't reach someone who has none left

    Four enforcement checks, all working, and a traveler still reached a screen they shouldn't have, because they had already passed every gate and had nothing left to request. Constraints need to cover states, not only the moves between them.

  2. 02

    A screen that opens is making a claim

    Showing a live-looking map is a promise that the group can see you. If that promise isn't true, the answer isn't a warning on the map: it's not opening the map.

  3. 03

    Which way a design fails matters more than whether it fails

    Several options here were rejected not because they broke more often, but because they broke in the direction where someone is misled into relying on something that isn't working.

  4. 04

    Write the decision down while the reasoning is still available

    The alternatives that were considered and rejected are the part that disappears fastest. A record written afterward remembers the choice and forgets the argument.