Price rides with rate cards, surge and driver share, by market

Dispatch now prices rides with a basic model inspired by public
information on Uber and Lyft (docs/pricing.md): base + per mile + per
minute, times a per-zone surge multiplier from recent requests against
available drivers, plus a booking fee, with a minimum. Riders get an
upfront quote per ride type and book against it; drivers see and earn
a configurable share (75%) of the fare.

Rate cards are per market, chosen by pickup location: San Francisco
(third-party 2026 rates) and Fairbanks, AK (its own UberX and UberXL
rates, Comfort from Anchorage, no Black). Everything is configurable
via PRICING_FILE or POST /test/reset.

Tests use a simple test card by default so auction scenarios keep
their numbers; new scenarios cover SF rates, Fairbanks rates and surge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Philip Peterson 2026-09-30 08:47:10 -08:00
parent da9172e594
commit d0fb299f13
27 changed files with 793 additions and 98 deletions

View file

@ -0,0 +1,4 @@
import scenario from '../scenarios/base-rates';
import { runScenario } from '../support/pace';
runScenario(scenario);

View file

@ -0,0 +1,4 @@
import scenario from '../scenarios/fairbanks-rates';
import { runScenario } from '../support/pace';
runScenario(scenario);

View file

@ -0,0 +1,4 @@
import scenario from '../../scenarios/base-rates';
import { runScenario } from '../../support/pace';
runScenario(scenario, 'slow');

View file

@ -0,0 +1,4 @@
import scenario from '../../scenarios/fairbanks-rates';
import { runScenario } from '../../support/pace';
runScenario(scenario, 'slow');

View file

@ -0,0 +1,4 @@
import scenario from '../../scenarios/surge-pricing';
import { runScenario } from '../../support/pace';
runScenario(scenario, 'slow');

View file

@ -0,0 +1,4 @@
import scenario from '../scenarios/surge-pricing';
import { runScenario } from '../support/pace';
runScenario(scenario);

View file

@ -0,0 +1,16 @@
{
"features": [
{
"geometry": { "coordinates": [-147.7747, 64.8387] },
"properties": {
"osm_id": 2001,
"osm_type": "W",
"name": "Pioneer Park",
"street": "Airport Way",
"city": "Fairbanks",
"state": "Alaska",
"country": "United States"
}
}
]
}

View file

@ -0,0 +1,32 @@
import { defineScenario, tid } from '../support/e2e';
import { alice, maria } from '../support/phones';
export default defineScenario('quotes from San Francisco rate cards and pays drivers their share', () => {
cy.resetDispatch({ rates: 'real' });
cy.openPhones([alice, maria]);
// The fixture trip is 5 km (3.11 mi) and 10 min. UberX in SF:
// $2.50 base + 3.11 mi × $1.55 + 10 min × $0.32 + $3.00 booking fee = $13.52.
cy.phone('alice').find(tid('where-to')).click();
cy.phone('alice').find(tid('destination-input')).type('ferry');
cy.phone('alice').contains('Ferry Building').click();
cy.phone('alice').find(tid('price-x')).should('have.text', '$13.52');
cy.phone('alice').find(tid('price-comfort')).should('have.text', '$15.55');
cy.phone('alice').find(tid('price-xl')).should('have.text', '$16.52');
cy.phone('alice').find(tid('price-black')).should('have.text', '$28.65');
cy.phone('alice').find(tid('surge-notice')).should('not.exist');
cy.phone('alice').find(tid('confirm-ride')).click();
// Round 1 offers 80% of the quote; Maria sees what she'd earn: 75% of what Alice pays.
cy.phone('maria').find(tid('offer-price')).should('have.text', '$10.82');
cy.phone('maria').find(tid('offer-earnings')).should('have.text', '$8.12');
cy.accept('maria');
cy.phone('maria').find(tid('job-earnings')).should('have.text', 'You earn $8.12');
cy.phone('alice').find(tid('ride-fare')).should('have.text', '$10.82');
cy.dispatchState().then(({ rides, pricing }) => {
expect(pricing.driverShare).to.equal(0.75);
expect(rides[0]).to.include({ rideTypeId: 'x', quotedFare: 13.52, surge: 1, fare: 10.82 });
});
});

View file

@ -0,0 +1,34 @@
import { defineScenario, tid } from '../support/e2e';
import { alice, maria } from '../support/phones';
const downtownFairbanks = { latitude: 64.8378, longitude: -147.7164 };
export default defineScenario('prices rides in Fairbanks from Fairbanks rate cards', () => {
cy.intercept('GET', /photon\.komoot\.io\/api/, { fixture: 'photon-pioneer-park.json' });
cy.resetDispatch({ rates: 'real' });
cy.openPhones([
{ ...alice, location: downtownFairbanks },
{ ...maria, location: downtownFairbanks },
]);
// Same 5 km, 10 min fixture trip, Fairbanks UberX: $2.77 + 3.11 mi × $1.86 + 10 min × $0.31
// + $2.55 booking fee = $14.20. Comfort uses Anchorage's rates; no Black in Fairbanks.
cy.phone('alice').find(tid('where-to')).click();
cy.phone('alice').find(tid('destination-input')).type('pioneer');
cy.phone('alice').contains('Pioneer Park').click();
cy.phone('alice').find(tid('price-x')).should('have.text', '$14.20');
cy.phone('alice').find(tid('price-comfort')).should('have.text', '$17.29');
cy.phone('alice').find(tid('price-xl')).should('have.text', '$16.39');
cy.phone('alice').find(tid('price-black')).should('not.exist');
cy.phone('alice').find(tid('confirm-ride')).click();
cy.phone('alice').find(tid('ride-status')).should('have.text', 'Finding your driver');
cy.phone('maria').find(tid('offer-destination')).should('have.text', 'Alice → Pioneer Park');
cy.phone('maria').find(tid('offer-price')).should('have.text', '$11.36');
cy.phone('maria').find(tid('offer-earnings')).should('have.text', '$8.52');
cy.waitForDispatch((s) => s.rides.length === 1, 'ride requested').then(({ rides }) => {
expect(rides[0]).to.include({ rideTypeId: 'x', quotedFare: 14.2 });
expect(rides[0].pickup).to.deep.equal(downtownFairbanks);
});
});

View file

@ -44,7 +44,7 @@ export default defineScenario('gives the ride to the first driver who accepts, a
// The driver finishes the trip; the rider's phone follows.
cy.phone('james').find(tid('complete-trip')).click();
cy.phone('james').find(tid('driver-result')).should('have.text', 'Trip complete · you earned $8.80');
cy.phone('james').find(tid('driver-result')).should('have.text', 'Trip complete · you earned $6.60');
cy.phone('alice').find(tid('ride-status')).should('have.text', 'You’ve arrived');
cy.phone('alice').should('contain.text', 'Thanks for riding with James.');

View file

@ -49,7 +49,7 @@ export default defineScenario('interrupts a waiting driver, and a driver mid-rid
cy.pass('maria');
cy.accept('james');
cy.phone('james').find(tid('offer-sheet')).should('not.exist');
cy.phone('james').find(tid('next-job')).should('have.text', 'Next: Bob → Ferry Building · $8.80');
cy.phone('james').find(tid('next-job')).should('have.text', 'Next: Bob → Ferry Building · you earn $6.60');
cy.phone('james')
.find(tid('driver-navigation'))
.should(([el]) => expect(el, 'navigation was never remounted').to.equal(navigation));

View file

@ -3,7 +3,7 @@ import { scaled } from '../support/pace';
import { alice, james, maria } from '../support/phones';
export default defineScenario('raises the price when a round runs out of time', () => {
cy.resetDispatch({ roundDurationMs: 3000 });
cy.resetDispatch({ policy: { roundDurationMs: 3000 } });
cy.openPhones([alice, maria, james]);
cy.requestRide('alice', 'ferry', 'Ferry Building');

View file

@ -3,7 +3,7 @@ import { alice, maria } from '../support/phones';
export default defineScenario('follows the configured pricing, including above the quote', () => {
// Start at 120% of the quote, add 10% a round, no cap.
cy.resetDispatch({ startMultiplier: 1.2, stepMultiplier: 0.1, maxMultiplier: null });
cy.resetDispatch({ policy: { startMultiplier: 1.2, stepMultiplier: 0.1, maxMultiplier: null } });
cy.openPhones([alice, maria]);
cy.requestRide('alice', 'ferry', 'Ferry Building');

View file

@ -0,0 +1,67 @@
import { defineScenario, tid } from '../support/e2e';
import { alice, bob, carol, james, maria } from '../support/phones';
const oakland = { latitude: 37.8044, longitude: -122.2712 };
const downtown = { latitude: 37.7749, longitude: -122.4194 };
/** Rider searches for the Ferry Building and picks it, which gets them a fresh quote. */
function pickFerryBuilding(riderId: string) {
cy.phone(riderId).find(tid('where-to')).click();
cy.phone(riderId).find(tid('destination-input')).type('ferry');
cy.phone(riderId).contains('Ferry Building').click();
}
export default defineScenario('surges prices in a zone where requests outnumber drivers', () => {
// Surge on and recomputed on every quote, so each step sees the latest demand and supply.
cy.resetDispatch({ pricing: { surge: { enabled: true, intervalMs: 0 } } });
cy.openPhones([
alice,
bob,
carol,
{ role: 'rider', id: 'dave', name: 'Dave', location: oakland },
maria,
{ ...james, driving: false },
]);
cy.waitForDispatch((s) => s.devices.some((d) => d.id === 'maria' && d.position !== null), 'Maria reported her position').then(
({ devices }) => expect(devices.find((d) => d.id === 'maria')?.position).to.deep.equal(downtown),
);
// One driver downtown. Alice and then Bob are each quoted before their own request
// counts, so neither sees more requests than drivers: no surge.
cy.requestRide('alice', 'ferry', 'Ferry Building');
cy.requestRide('bob', 'ferry', 'Ferry Building');
// Now two requests for one driver in the zone: 1 + (2 − 1) × 0.5 = 1.5×.
pickFerryBuilding('carol');
cy.phone('carol').find(tid('surge-notice')).should('have.text', 'Busy area · prices are 1.5× higher right now');
cy.phone('carol').find(tid('price-x')).should('have.text', '$16.50');
// Dave is across the bay, in a zone with no demand: no surge.
pickFerryBuilding('dave');
cy.phone('dave').find(tid('price-x')).should('have.text', '$11.00');
cy.phone('dave').find(tid('surge-notice')).should('not.exist');
// More supply: James starts driving downtown, so two requests for two drivers.
cy.startDriving('james');
cy.phone('carol').find(tid('clear-destination')).click();
pickFerryBuilding('carol');
cy.phone('carol').find(tid('price-x')).should('have.text', '$11.00');
cy.phone('carol').find(tid('surge-notice')).should('not.exist');
// James goes offline again, and the surge comes back.
cy.goOffline('james');
cy.phone('carol').find(tid('clear-destination')).click();
pickFerryBuilding('carol');
cy.phone('carol').find(tid('price-x')).should('have.text', '$16.50');
// Carol books at her surged quote; the auction starts from it (80% of $16.50).
cy.phone('carol').find(tid('confirm-ride')).click();
cy.phone('maria').find(tid('offer')).filter(':contains("Carol →")').find(tid('offer-price')).should('have.text', '$13.20');
cy.waitForDispatch((s) => s.rides.length === 3, 'Carol requested').then(({ rides }) => {
expect(rides.map((r) => [r.riderId, r.surge, r.quotedFare])).to.deep.equal([
['alice', 1, 11],
['bob', 1, 11],
['carol', 1.5, 16.5],
]);
});
});

View file

@ -3,7 +3,7 @@ import { alice, james, maria } from '../support/phones';
export default defineScenario('gives up once nobody takes the capped price', () => {
// 90% of the quote, then 100%, which is the cap: two rounds, $9.90 and $11.00.
cy.resetDispatch({ startMultiplier: 0.9, stepMultiplier: 0.1, maxMultiplier: 1 });
cy.resetDispatch({ policy: { startMultiplier: 0.9, stepMultiplier: 0.1, maxMultiplier: 1 } });
cy.openPhones([alice, maria, james]);
cy.requestRide('alice', 'ferry', 'Ferry Building');

View file

@ -28,13 +28,16 @@ export const defineScenario = (title: string, run: () => void): Scenario => ({ t
export type DispatchState = {
policy: AuctionPolicy;
devices: (PhoneSpec & { online: boolean; driving: boolean; jobs: string[] })[];
pricing: PricingConfig;
devices: (PhoneSpec & { online: boolean; driving: boolean; jobs: string[]; position: { latitude: number; longitude: number } | null })[];
rides: {
id: string;
riderId: string;
status: 'auction' | 'matched' | 'completed' | 'unmatched' | 'cancelled';
destination: string;
rideTypeId: string;
quotedFare: number;
surge: number;
pickup: { latitude: number; longitude: number };
rounds: {
number: number;
@ -56,10 +59,13 @@ declare global {
namespace Cypress {
interface Chainable {
/**
* Clear all rides on the dispatch server and set its auction policy: TEST_POLICY,
* with any fields given here overriding it. roundDurationMs is stretched on slow runs.
* Clear all rides on the dispatch server and configure it for a scenario:
* - policy: auction settings over TEST_POLICY (roundDurationMs is stretched on slow runs)
* - pricing: pricing settings over TEST_PRICING
* - rates: 'test' (default) for TEST_PRICING's simple UberX card, 'real' for the
* server's actual rate cards
*/
resetDispatch(policy?: Partial<AuctionPolicy>): Chainable<DispatchState>;
resetDispatch(options?: ResetOptions): Chainable<DispatchState>;
/** Load the simulator with these phones and wait until each is connected and on screen. */
openPhones(phones: PhoneSpec[]): Chainable<void>;
/** The <body> of one phone's iframe. */
@ -88,7 +94,7 @@ declare global {
}
}
import type { AuctionPolicy } from '../../src/lib/dispatch/protocol';
import type { AuctionPolicy, PricingConfig, PricingOverrides } from '../../src/lib/dispatch/protocol';
import '../../src/lib/dispatch/testHooks';
import { scaled, stepDelayMs, typeDelayMs } from './pace';
@ -108,10 +114,32 @@ export const TEST_POLICY: AuctionPolicy = {
roundDurationMs: 15_000,
};
Cypress.Commands.add('resetDispatch', (overrides = {}) => {
const policy = { ...TEST_POLICY, ...overrides };
/**
* Pricing every scenario starts from. The simple UberX card makes the fixture's 5 km,
* 10 min trip cost exactly $11.00 (2.50 + 10 × 0.85), and surge is off so prices don't
* depend on how many requests a scenario makes. Drivers earn the default 75%.
*/
export const TEST_PRICING: PricingOverrides = {
rateCards: { sf: { x: { baseFare: 2.5, perMile: 0, perMinute: 0.85, bookingFee: 0, minimumFare: 7 } } },
surge: { enabled: false },
};
export type ResetOptions = {
policy?: Partial<AuctionPolicy>;
pricing?: PricingOverrides;
rates?: 'test' | 'real';
};
Cypress.Commands.add('resetDispatch', ({ policy: policyOverrides = {}, pricing: pricingOverrides = {}, rates = 'test' } = {}) => {
const policy = { ...TEST_POLICY, ...policyOverrides };
policy.roundDurationMs = scaled(policy.roundDurationMs);
return cy.request('POST', `${dispatchUrl()}/test/reset`, { policy }).its('body');
const pricing: PricingOverrides = {
rateCards: { ...(rates === 'test' ? TEST_PRICING.rateCards : {}), ...pricingOverrides.rateCards },
surge: { ...TEST_PRICING.surge, ...pricingOverrides.surge },
driverShare: pricingOverrides.driverShare,
quoteTtlMs: pricingOverrides.quoteTtlMs,
};
return cy.request('POST', `${dispatchUrl()}/test/reset`, { policy, pricing }).its('body');
});
Cypress.Commands.add('dispatchState', () => {

View file

@ -61,7 +61,7 @@ Then open <http://localhost:8090/sim.html?phones=rider:alice:Alice,driver:maria:
The dispatch server runs a rising-price auction in rounds:
1. A rider requests a ride. The fare shown on the ride-options sheet becomes the **quote**.
1. A rider picks a destination and dispatch quotes each ride type upfront (rate cards and surge; see [`pricing.md`](pricing.md)). Booking a ride type makes its quoted fare the **quote** the auction works from.
2. Round 1 opens. Every **connected driver in driving mode with room for another ride** is offered the same **suggested fare** and taps **Accept** or **Pass**. A driver who becomes available mid-round (starts driving, reconnects, or finishes a trip) is offered that round too.
3. The **first driver to accept wins** at that round's price, and the auction ends immediately.
4. A round ends with no taker when every driver in it has passed (or has since gone offline or filled up), or when its time runs out.
@ -127,6 +127,7 @@ What each driver sees after a ride is decided:
| Path | What it is |
| -------------------------------------- | --------------------------------------------------------------------------- |
| `server/dispatch.mts` | Dispatch server: WebSocket for phones, HTTP for state and test control. Run directly by Node (type stripping, no build step). |
| `server/pricing.mts` | The pricing model: rate cards, surge by zone, driver share ([`pricing.md`](pricing.md)). |
| `src/lib/dispatch/protocol.ts` | Message and snapshot types shared by the app and the server. |
| `src/lib/dispatch/device.ts` | Which app this is, who this phone is (from the URL in the simulator), and where dispatch lives. |
| `src/lib/dispatch/DispatchContext.tsx` | WebSocket client: connects, says hello, reconnects, exposes the latest snapshot. Also the pause hook (below). |
@ -161,6 +162,9 @@ What each driver sees after a ride is decided:
| `rider-cancels` | Cancelling withdraws the offer from every driver. |
| `offline-drivers-get-no-requests` | Only drivers in driving mode are asked; starting mid-round joins it, going offline mid-round leaves it. |
| `offer-interrupts-driving` | A request interrupts a waiting driver and a driver mid-ride; mid-ride, the directions keep running and accepting queues the ride; completing the trip moves on to it. |
| `base-rates` | Quotes follow San Francisco's rate cards, and the driver sees and earns their 75% share. |
| `fairbanks-rates` | A rider in Fairbanks gets Fairbanks prices (Comfort from Anchorage, no Black). |
| `surge-pricing` | Prices surge where requests outnumber drivers, drop when another driver starts driving there, and don't reach other zones. |
| `ride-queue-limit` | A driver can queue one ride behind the current one; when full they're skipped, and completing a trip lets them back in. |
| `mocked-location` | A phone placed at Union Square uses that position for search, routing and its pickup, with no location prompt. |
| `simultaneous-accepts` | Two drivers accept at once: whoever reached dispatch first wins, even if they tapped second. |
@ -169,8 +173,8 @@ What each driver sees after a ride is decided:
The server is the source of truth. After **every** change, it pushes a full snapshot to every connected device, so clients never have to replay missed events.
- Client → server: `hello`, `requestRide`, `cancelRide`, `ackRide`, `accept`, `decline`, `completeTrip`, `setDriving`
- Server → client: `snapshot` (the rider's current ride; or the driver's driving mode, offers, current and queued job, and last result) and `error` (a rejected action, shown on screen)
- Client → server: `hello`, `requestQuote`, `requestRide`, `cancelRide`, `ackRide`, `accept`, `decline`, `completeTrip`, `setDriving`, `setPosition`
- Server → client: `snapshot` (the rider's latest quote and current ride; or the driver's driving mode, offers, current and queued job, and last result) and `error` (a rejected action, shown on screen)
A rider's ride stays in their snapshot until they acknowledge it (`ackRide`: tapping **Done** on the trip screen or **Back** on "No drivers available").
@ -200,8 +204,8 @@ A phone reads its identity from its URL once, at startup, before the router rewr
| Method and path | Purpose |
| ------------------------------- | ----------------------------------------------------------------------- |
| `GET /state` | The policy, devices (online; drivers also `driving` and their `jobs`), and every ride with its pickup and rounds (price, who was offered it, who passed, who accepted). Used by the simulator panel and by assertions. |
| `POST /test/reset` | Clears all rides and results and sets the policy: body `{ "policy": { "startMultiplier": 1.2, "maxMultiplier": null } }`. Fields left out take the server's defaults. Connected phones stay connected. |
| `GET /state` | The policy and pricing, devices (online; drivers also `driving`, `jobs` and `position`), and every ride with its pickup, ride type, quoted fare, surge and rounds (price, who was offered it, who passed, who accepted). Used by the simulator panel and by assertions. |
| `POST /test/reset` | Clears all rides, quotes and results and configures the server: body `{ "policy": { "startMultiplier": 1.2 }, "pricing": { "surge": { "enabled": false } } }`. Anything left out takes the server's defaults. Connected phones stay connected. |
| `POST /test/rides/:id/end-round` | Ends the current round now, as if its time ran out. |
Environment variables: `PORT` (default 8787), plus the policy variables above.
@ -253,7 +257,7 @@ Each scenario starts with `cy.resetDispatch()` and opens its own phones, so any
| Command | Does |
| ---------------------------------------------------- | --------------------------------------------------------------------- |
| `cy.resetDispatch(policyOverrides?)` | `POST /test/reset` with `TEST_POLICY` plus any overrides. `roundDurationMs` is stretched on slow runs. |
| `cy.resetDispatch({ policy?, pricing?, rates? })` | `POST /test/reset` with `TEST_POLICY` and `TEST_PRICING` plus any overrides; `rates: 'real'` uses the server's actual rate cards instead of the test card. `roundDurationMs` is stretched on slow runs. |
| `cy.openPhones(phones)` | Loads `/sim.html` with those phones and waits until each is on screen, connected, and (drivers) in its starting mode. A phone spec can set `location` and, for drivers, `driving: false`. |
| `cy.phone(id)` | Yields that phone's `<body>`. Chain `.find(...)` / `.contains(...)` from it. |
| `cy.requestRide(riderId, query, placeTitle)` | Searches, picks the place, taps **Choose UberX**, waits for "Finding your driver". |
@ -289,10 +293,10 @@ The hook is `window.__dispatchLink` (`hold()`, `release()`, `held`), set on each
| Screen | IDs |
| ------------------- | --------------------------------------------------------------------------------------- |
| Home / search | `where-to`, `destination-input`, `confirm-ride`, `price-<rideTypeId>`, `dispatch-error` |
| Home / search | `where-to`, `destination-input`, `clear-destination`, `confirm-ride`, `price-<rideTypeId>`, `surge-notice`, `dispatch-error` |
| Rider: finding | `ride-status`, `round-price`, `round-number`, `cancel-request`, `ride-fare`, `fare-vs-quote`, `matched-driver`, `driver-name`, `driver-finishing`, `unmatched-back` |
| Rider: trip done | `ride-status` ("You've arrived"), `trip-done` |
| Driver | `driver-offline`, `start-driving`, `stop-driving`, `driver-waiting`, `driver-navigation`, `nav-heading`, `nav-instruction`, `next-job`, `driver-status` (`Connecting…` / `Offline` / `Driving`), `driver-error`, `driver-result`, `offer-sheet`, `offer`, `offer-round`, `offer-destination`, `offer-price`, `offer-passed`, `accept-offer`, `decline-offer`, `driver-job`, `job-fare`, `complete-trip` |
| Driver | `driver-offline`, `start-driving`, `stop-driving`, `driver-waiting`, `driver-navigation`, `nav-heading`, `nav-instruction`, `next-job`, `driver-status` (`Connecting…` / `Offline` / `Driving`), `driver-error`, `driver-result`, `offer-sheet`, `offer`, `offer-round`, `offer-destination`, `offer-earnings`, `offer-price`, `offer-passed`, `accept-offer`, `decline-offer`, `driver-job`, `job-earnings`, `job-fare`, `complete-trip` |
When you add UI that a scenario needs to touch, give it a `testID` rather than matching on text or styles.
@ -305,7 +309,7 @@ Place search (Photon) and routing (OSRM) are external services, so every test st
So the quoted fares are: UberX **$11.00**, Comfort $14.00, UberXL $17.50, Black $26.00. To test other fares, add a fixture and override the intercept inside the test.
Tests don't rely on the server's default policy. `cy.resetDispatch()` always sends `TEST_POLICY` from `cypress/support/e2e.ts` (the same values as the defaults), so UberX rounds offer **$8.80, $9.35, $9.90, $10.45, $11.00** however the server was started.
The quoted fares come from dispatch, and tests don't rely on its defaults. `cy.resetDispatch()` always sends `TEST_POLICY` and `TEST_PRICING` from `cypress/support/e2e.ts`: a simple UberX card ($2.50 + $0.85/min) that prices the fixture trip at exactly **$11.00**, with surge off. So UberX rounds offer **$8.80, $9.35, $9.90, $10.45, $11.00**, and drivers earn 75% of that (`offer-earnings`), however the server was started. Scenarios about the real rates or surge opt in (`rates: 'real'`, `pricing: { surge: { enabled: true } }`).
### Slow runs
@ -319,7 +323,7 @@ A slow spec plays the same steps for a human to follow:
Tune these in `PACES` in `cypress/support/pace.ts`. If a scenario waits on a round running out, wrap that assertion's timeout in `scaled()` so it still holds when slow:
```ts
cy.resetDispatch({ roundDurationMs: 3000 }); // stretched automatically
cy.resetDispatch({ policy: { roundDurationMs: 3000 } }); // stretched automatically
cy.phone('alice').find(tid('round-number'), { timeout: scaled(8000) }).should('have.text', 'Round 2');
```
@ -328,9 +332,9 @@ Use `typeDelayMs()` for any `.type()` a scenario does directly. The pace is set
### Timing tips
- Prefer having every driver pass to move to the next round; it opens at once, with no waiting.
- To test a round timing out, reset with a short round (`cy.resetDispatch({ roundDurationMs: 3000 })`) and give the waiting assertion a longer `timeout`.
- To test a round timing out, reset with a short round (`cy.resetDispatch({ policy: { roundDurationMs: 3000 } })`) and give the waiting assertion a longer `timeout`.
- `cy.accept` and `cy.pass` don't assert anything after tapping, because the offer card changes or disappears straight away. Assert the outcome on the phones you care about.
- A short policy keeps cap tests quick, e.g. `{ startMultiplier: 0.9, stepMultiplier: 0.1, maxMultiplier: 1 }` gives just two rounds.
- A short policy keeps cap tests quick, e.g. `{ policy: { startMultiplier: 0.9, stepMultiplier: 0.1, maxMultiplier: 1 } }` gives just two rounds.
## Running the app on a device

95
docs/pricing.md Normal file
View file

@ -0,0 +1,95 @@
# Pricing model
RideRN prices rides with a basic model inspired by what's publicly known about Uber and Lyft. Their actual models are proprietary machine-learning systems; this is a transparent approximation to build on, not a copy. The code is `server/pricing.mts`; the dispatch server applies it (`server/dispatch.mts`).
## What's publicly known
- **Base formula.** A base fare, plus a per-minute rate times estimated time, plus a per-mile rate times estimated distance, plus a booking fee, with a minimum fare. Rates differ by city and ride type. ([Ridester](https://www.ridester.com/uber-rates-cost/), [RideGuru](https://ride.guru/lounge/p/uber-pricing-how-does-it-work))
- **Surge / Prime Time.** A multiplier on that base when demand outstrips drivers. Lyft applies Prime Time as a percentage of the subtotal ([TaxiFareFinder](https://www.taxifarefinder.com/newsroom/2016/04/05/lyft-how-to-calculate-your-fare/)). Measuring Uber over four weeks in San Francisco and Manhattan, Northeastern researchers found surge is set per zone (a jigsaw of areas), updated every 5 minutes, usually lasts under 10 minutes, and strongly reduces demand while barely drawing more drivers ([paper](https://mislove.org/publications/Uber-IMC.pdf), [summary](https://news.northeastern.edu/2015/10/29/northeastern-researchers-unlock-details-of-ubers-surge-pricing-and-suggest-ways-to-avoid-it/)).
- **Upfront pricing.** Since 2016 Uber quotes a price before the trip, predicted from route, time of day and demand. In 2017 its "route-based pricing" used machine learning to estimate what groups of riders would pay for a route and time, so the same trip could cost more from a wealthier neighborhood ([Bloomberg](https://www.bloomberg.com/news/articles/2017-05-19/uber-s-future-may-rely-on-predicting-how-much-you-re-willing-to-pay), [Hoodline](https://hoodline.com/2017/05/uber-s-route-based-pricing-charges-riders-based-on-neighborhood/)).
- **Driver pay is separate.** After Uber's 2023 dynamic pay change, a study of 1.5M UK trips found its cut rose from about 25% to 29%, larger on higher fares ([Oxford](https://www.ox.ac.uk/news/2025-06-23-new-oxford-research-reveals-uber-s-algorithmic-pricing-leaves-drivers-and-passengers), [paper](https://arxiv.org/pdf/2506.15278)). Lyft guarantees drivers at least 70% of rider payments each week, and says they averaged about 88% after external fees ([Lyft](https://www.lyft.com/blog/posts/lyft-expands-driver-earnings-commitment-nationwide)).
- **APIs don't help.** Uber's price-estimate endpoint is restricted to approved partners and its terms forbid competitive price comparison ([Uber docs](https://developer.uber.com/docs/riders/references/api/v1.2/estimates-price-get)). Automated quote collection from their apps would breach their terms.
## The model
```
fare = max(minimumFare, (baseFare + perMile × miles + perMinute × minutes) × surge + bookingFee)
earnings = fare × driverShare
```
1. **Rate card** for the pickup's market and the ride type. A ride type with no card in a market isn't offered there.
2. **Surge** for the pickup's zone (below). The booking fee isn't surged.
3. **Minimum fare** applies to the total.
4. The rider gets an **upfront quote** with a price per ride type. Booking uses the quote's price if it hasn't expired (2 minutes); otherwise dispatch sends fresh prices to confirm.
5. The **auction** (see `docs/multi-phone-simulator.md`) starts from the quoted fare: its rounds are multiples of it.
6. The driver sees and earns **`driverShare`** (75%) of what the rider pays; the rest is the platform's cut.
Deliberately left out: pricing by predicted willingness to pay, i.e. by who the rider seems to be or where they're coming from. Surge by zone responds to supply and demand; neighborhood-based pricing is the practice that drew criticism, and a basic model doesn't need it.
### Base rates (San Francisco)
Uber no longer publishes rate cards, so these come from third parties.
| Type | Base | Per mile | Per minute | Booking fee | Minimum | Source |
| ------- | ----- | -------- | ---------- | ----------- | ------- | ------ |
| UberX | $2.50 | $1.55 | $0.32 | $3.00 | $7.00 | [RideWise, March 2026](https://getridewise.com/blog/uber-cost-per-mile) (checked against receipts); minimum from [TaxiFareFinder](https://www.taxifarefinder.com/main.php?city=Uber-X-San-Francisco-CA) |
| Comfort | $3.00 | $1.85 | $0.38 | $3.00 | $9.00 | Estimate: scaled from reported national Comfort vs X averages |
| UberXL | $3.00 | $2.10 | $0.40 | $3.00 | $9.25 | Estimate: [TaxiFareFinder SF](https://www.taxifarefinder.com/main.php?city=Uber-XL-San-Francisco-CA) base and minimum, national per-mile average |
| Black | $8.00 | $3.75 | $0.60 | $3.00 | $15.00 | Estimate: older SF Black rates and national averages |
Sources disagree: TaxiFareFinder lists older UberX rates of $2.00 + $1.21/mi + $0.22/min. Worth checking against a few real receipts before relying on any of them.
For the simulator's 5 km, 10 minute fixture trip these give UberX $13.52, Comfort $15.55, UberXL $16.52, Black $28.65.
### Base rates (Fairbanks, AK)
From [uphail.com's Fairbanks page](https://uphail.com/us/ak/fairbanks/) (September 2026). Fairbanks lists only UberX and UberXL, so Comfort uses [Anchorage's](https://uphail.com/us/ak/anchorage/) rates from the same source. Neither city lists Black, so Black isn't offered in Fairbanks.
| Type | Base | Per mile | Per minute | Booking fee | Minimum | Source |
| ------- | ----- | -------- | ---------- | ----------- | ------- | ------ |
| UberX | $2.77 | $1.86 | $0.31 | $2.55 | $8.43 | Fairbanks |
| Comfort | $3.74 | $1.92 | $0.40 | $3.58 | $11.75 | Anchorage (substitute) |
| UberXL | $3.25 | $2.25 | $0.36 | $2.55 | $8.70 | Fairbanks |
Other estimator sites disagree widely for Fairbanks: [taxis-fare.com](https://taxis-fare.com/uber-fare-city-fairbanks-fairbanks-north-star) lists UberX at $2.55 + $1.75/km + $0.35/min, apparently per-mile figures labeled per km, and [taxi-allo.com](https://www.taxi-allo.com/uber-cost-fairbanks-uber-price-alaska-uber-fare-united-states)'s figures don't fit the usual structure. Uphail's are the most complete and match what other searches return, but they're still third-party estimates: check them against real Fairbanks receipts.
For the 5 km, 10 minute fixture trip: UberX $14.20, Comfort $17.29, UberXL $16.39.
### Markets
A market is a center and a radius; a pickup uses the rate cards of the nearest market it's within (`marketFor` in `server/pricing.mts`), or `defaultMarket` if none.
| Market | Center | Radius |
| --- | --- | --- |
| `sf` San Francisco | 37.7749, −122.4194 | 60 km |
| `fairbanks` Fairbanks | 64.8378, −147.7164 | 60 km (includes North Pole and Fort Wainwright) |
The default market is San Francisco, so pickups anywhere else (Anchorage included) get San Francisco prices; that keeps the app usable in development wherever you are. A launch would rather refuse pickups outside its markets.
### Surge
The city is a grid of zones, `zoneSizeDegrees` (0.02°, about 2 km) across. For the pickup's zone:
- **demand** = ride requests from that zone in the last `demandWindowMs` (10 minutes)
- **supply** = drivers there who are connected, driving, and have room for a ride (drivers report their position)
- **multiplier** = 1 + (demand ÷ max(supply, 1) − `threshold`) × `sensitivity`, rounded down to `step` (0.1), between 1 and `maxMultiplier` (3)
With the defaults (threshold 1, sensitivity 0.5): 2 requests for 1 driver → 1.5×; 3 for 1 → 2×; as many requests as drivers → no surge. A zone's multiplier is recomputed at most every `intervalMs` (60 s); the 2015 study measured Uber at 5 minutes. The rider sees "Busy area · prices are 1.5× higher right now".
## Configuring it
Everything above is a setting, since the right values depend on the market:
| Setting | Default |
| --- | --- |
| `markets`, `defaultMarket` | San Francisco and Fairbanks; default `sf` |
| `rateCards` | `{ sf: { x, comfort, xl, black }, fairbanks: { x, comfort, xl } }` |
| `surge` | `{ enabled: true, zoneSizeDegrees: 0.02, intervalMs: 60000, demandWindowMs: 600000, threshold: 1, sensitivity: 0.5, step: 0.1, maxMultiplier: 3 }` |
| `driverShare` | `0.75` |
| `quoteTtlMs` | `120000` |
- **At startup:** `PRICING_FILE=path/to/pricing.json npm run dispatch`, with any subset of the settings; rate cards merge per market and ride type, so a file can adjust one rate or add a market (with its `markets` entry and `rateCards`).
- **Per test:** `POST /test/reset` with `{ "pricing": { ... } }`, or `cy.resetDispatch({ pricing, rates })`.
- **By market:** add a `markets` entry and its `rateCards`; `marketFor(pickup)` picks it for pickups in range.
Tests default to a simple UberX card (`TEST_PRICING` in `cypress/support/e2e.ts`: $2.50 + $0.85/min, so the fixture trip is exactly $11.00) with surge off, so auction scenarios don't depend on the real rates. The `base-rates`, `fairbanks-rates` and `surge-pricing` scenarios cover the real cards, markets and surge.

View file

@ -7,12 +7,17 @@
// at a higher price, until a driver accepts or the price cap is reached. Prices, step,
// cap and round length come from the auction policy (see AuctionPolicy).
//
// What the rider is quoted comes from the pricing model (pricing.mts): rate cards, surge
// by zone, and the driver's share. Riders ask for a quote, then book against it.
//
// Phones talk to it over a WebSocket; each change pushes a fresh snapshot to every device.
// A small HTTP API (GET /state, POST /test/*) lets test scenarios reset and inspect it.
//
// Run: node server/dispatch.mts
// Env: PORT, AUCTION_START, AUCTION_STEP, AUCTION_MAX ("none" for no cap), AUCTION_ROUND_MS
// Env: PORT, AUCTION_START, AUCTION_STEP, AUCTION_MAX ("none" for no cap), AUCTION_ROUND_MS,
// PRICING_FILE (JSON of pricing overrides, e.g. other cities' rate cards)
import { readFileSync } from 'node:fs';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { WebSocketServer, type WebSocket } from 'ws';
@ -21,11 +26,25 @@ import type {
ClientMessage,
DeviceProfile,
DriverResult,
RideRequest,
PricingConfig,
PricingOverrides,
Quote,
QuoteRequest,
RideStatus,
ServerMessage,
Snapshot,
} from '../src/lib/dispatch/protocol';
import type { Coord, Place } from '../src/lib/types';
import {
DEFAULT_PRICING,
earningsFor,
fareFor,
marketFor,
mergePricing,
surgeMultiplier,
validatePricing,
zoneOf,
} from './pricing.mts';
const PORT = Number(process.env.PORT ?? 8787);
@ -41,6 +60,25 @@ const DEFAULT_POLICY: AuctionPolicy = {
roundDurationMs: Number(process.env.AUCTION_ROUND_MS ?? 15_000),
};
// Pricing as the server starts: the defaults, plus any overrides from PRICING_FILE.
const STARTUP_PRICING = mergePricing(
DEFAULT_PRICING,
process.env.PRICING_FILE ? (JSON.parse(readFileSync(process.env.PRICING_FILE, 'utf8')) as PricingOverrides) : {},
);
type RideRequest = {
pickup: Coord;
destination: Place;
rideTypeId: string;
distanceMeters: number;
durationSeconds: number;
/** What the rider was quoted for this ride type. Each round's price is a multiple of it. */
quotedFare: number;
surge: number;
};
type StoredQuote = Quote & { riderId: string; request: QuoteRequest };
type Round = {
number: number;
price: number;
@ -76,13 +114,21 @@ type Device = {
sockets: Set<WebSocket>;
/** Drivers only: in driving mode, i.e. taking ride requests. */
driving: boolean;
/** Drivers only: last reported position, for counting supply by surge zone. */
position: Coord | null;
};
let policy: AuctionPolicy = { ...DEFAULT_POLICY };
let pricing: PricingConfig = STARTUP_PRICING;
const devices = new Map<string, Device>();
const rides = new Map<string, Ride>();
const lastResults = new Map<string, DriverResult>();
const quotes = new Map<string, StoredQuote>();
/** When and where rides were requested, for surge demand. */
let requestLog: { at: number; zone: string }[] = [];
const surgeCache = new Map<string, { multiplier: number; at: number }>();
let nextRideId = 1;
let nextQuoteId = 1;
// ---------------------------------------------------------------------------
// Pricing
@ -112,6 +158,50 @@ function roundPrice(ride: Ride, number: number): { price: number; final: boolean
return { price: Math.round(ride.request.quotedFare * multiplier * 100) / 100, final };
}
// ---------------------------------------------------------------------------
// Surge and quotes
function surgeIn(zone: string): number {
const now = Date.now();
const cached = surgeCache.get(zone);
if (cached && now - cached.at < pricing.surge.intervalMs) return cached.multiplier;
requestLog = requestLog.filter((r) => now - r.at < pricing.surge.demandWindowMs);
const demand = requestLog.filter((r) => r.zone === zone).length;
const supply = [...devices.values()].filter(
(d) => d.profile.role === 'driver' && d.position && zoneOf(d.position, pricing.surge) === zone && isAvailable(d.profile.id),
).length;
const multiplier = surgeMultiplier(demand, supply, pricing.surge);
surgeCache.set(zone, { multiplier, at: now });
return multiplier;
}
function quote(riderId: string, request: QuoteRequest): StoredQuote {
const surge = surgeIn(zoneOf(request.pickup, pricing.surge));
const cards = pricing.rateCards[marketFor(request.pickup, pricing)];
const fares = Object.fromEntries(
Object.entries(cards).map(([type, card]) => [type, fareFor(card, request.distanceMeters, request.durationSeconds, surge)]),
);
const stored: StoredQuote = {
id: `quote-${nextQuoteId++}`,
destinationId: request.destination.id,
surge,
expiresAt: Date.now() + pricing.quoteTtlMs,
fares,
riderId,
request,
};
// A rider only ever books against their latest quote.
for (const [id, q] of quotes) if (q.riderId === riderId) quotes.delete(id);
quotes.set(stored.id, stored);
return stored;
}
function latestQuote(riderId: string): StoredQuote | undefined {
for (const q of quotes.values()) if (q.riderId === riderId) return q;
return undefined;
}
// ---------------------------------------------------------------------------
// Queries
@ -129,6 +219,8 @@ const isDriving = (driverId: string) => devices.get(driverId)?.driving ?? false;
/** Driving, with room for another ride. Doesn't care whether the phone is connected right now. */
const canTakeRides = (driverId: string) => isDriving(driverId) && jobs(driverId).length < MAX_RIDES_PER_DRIVER;
const isAvailable = (driverId: string) => isOnline(driverId) && canTakeRides(driverId);
const currentRound = (ride: Ride) => ride.rounds[ride.rounds.length - 1];
function riderRide(riderId: string): Ride | undefined {
@ -141,7 +233,7 @@ function riderRide(riderId: string): Ride | undefined {
function availableDrivers(): string[] {
return [...devices.values()]
.filter((d) => d.profile.role === 'driver' && isOnline(d.profile.id) && canTakeRides(d.profile.id))
.filter((d) => d.profile.role === 'driver' && isAvailable(d.profile.id))
.map((d) => d.profile.id);
}
@ -154,7 +246,9 @@ const riderName = (ride: Ride) => devices.get(ride.riderId)?.profile.name ?? 'Ri
function snapshotFor(profile: DeviceProfile): Snapshot {
if (profile.role === 'rider') {
const ride = riderRide(profile.id);
if (!ride) return { role: 'rider', ride: null };
const q = latestQuote(profile.id);
const quoteView = q ? { id: q.id, destinationId: q.destinationId, surge: q.surge, expiresAt: q.expiresAt, fares: q.fares } : null;
if (!ride) return { role: 'rider', ride: null, quote: quoteView };
const round = currentRound(ride);
const driver = ride.winnerId ? driverProfile(ride.winnerId) : null;
return {
@ -180,6 +274,7 @@ function snapshotFor(profile: DeviceProfile): Snapshot {
},
completedAt: ride.completedAt,
},
quote: quoteView,
};
}
@ -199,12 +294,21 @@ function snapshotFor(profile: DeviceProfile): Snapshot {
durationSeconds: r.request.durationSeconds,
round: round.number,
price: round.price,
earnings: earningsFor(round.price, pricing),
closesAt: round.closesAt,
declined: round.declined.has(profile.id),
};
});
const jobView = (ride: Ride | undefined) =>
ride ? { rideId: ride.id, riderName: riderName(ride), destination: ride.request.destination, fare: ride.fare! } : null;
ride
? {
rideId: ride.id,
riderName: riderName(ride),
destination: ride.request.destination,
fare: ride.fare!,
earnings: earningsFor(ride.fare!, pricing),
}
: null;
return {
role: 'driver',
driving: isDriving(profile.id),
@ -234,8 +338,18 @@ class DispatchError extends Error {}
// ---------------------------------------------------------------------------
// Auction
function requestRide(riderId: string, request: RideRequest): Ride {
if (!(request.quotedFare > 0)) throw new DispatchError('Invalid fare');
function requestRide(riderId: string, quoteId: string, rideTypeId: string): Ride {
const q = quotes.get(quoteId);
if (!q || q.riderId !== riderId || q.expiresAt <= Date.now()) {
// Hand the rider fresh prices to confirm, rather than booking at old ones.
const latest = latestQuote(riderId);
if (latest) quote(riderId, latest.request);
throw new DispatchError('Prices have changed. Check the new price and try again.');
}
const quotedFare = q.fares[rideTypeId];
if (!(quotedFare > 0)) throw new DispatchError('That ride type isn’t available here');
const request: RideRequest = { ...q.request, rideTypeId, quotedFare, surge: q.surge };
const existing = riderRide(riderId);
if (existing && (existing.status === 'auction' || existing.status === 'matched')) {
throw new DispatchError('You already have a ride in progress');
@ -258,7 +372,9 @@ function requestRide(riderId: string, request: RideRequest): Ride {
timer: null,
};
rides.set(ride.id, ride);
log(`${ride.id} requested by ${riderId}, quoted $${request.quotedFare.toFixed(2)}`);
quotes.delete(q.id);
requestLog.push({ at: Date.now(), zone: zoneOf(request.pickup, pricing.surge) });
log(`${ride.id} requested by ${riderId}, ${rideTypeId} quoted $${quotedFare.toFixed(2)}${q.surge > 1 ? ` (surge ${q.surge}×)` : ''}`);
startRound(ride);
return ride;
}
@ -358,9 +474,13 @@ function handle(device: Device, message: ClientMessage) {
switch (message.type) {
case 'hello':
return; // handled at connection time
case 'requestQuote':
if (role !== 'rider') throw new DispatchError('Only riders can get quotes');
quote(id, message.request);
return;
case 'requestRide':
if (role !== 'rider') throw new DispatchError('Only riders can request rides');
requestRide(id, message.request);
requestRide(id, message.quoteId, message.rideTypeId);
return;
case 'cancelRide': {
const ride = riderRide(id);
@ -401,7 +521,7 @@ function handle(device: Device, message: ClientMessage) {
if (!current || current.id !== message.rideId) throw new DispatchError('That isn’t your current trip');
current.status = 'completed';
current.completedAt = new Date().toISOString();
lastResults.set(id, { rideId: current.id, outcome: 'completed', fare: current.fare! });
lastResults.set(id, { rideId: current.id, outcome: 'completed', earnings: earningsFor(current.fare!, pricing) });
log(`${current.id} completed by ${id}`);
// Room for another ride again.
inviteToOpenRounds(id);
@ -417,6 +537,11 @@ function handle(device: Device, message: ClientMessage) {
else endOpenRoundsIfEveryonePassed();
return;
}
case 'setPosition': {
if (role !== 'driver') return;
device.position = message.position;
return;
}
}
}
@ -432,12 +557,19 @@ function inviteToOpenRounds(driverId: string) {
// ---------------------------------------------------------------------------
// HTTP: state inspection and test control
function reset(overrides: Partial<AuctionPolicy>) {
function reset(overrides: Partial<AuctionPolicy>, pricingOverrides: PricingOverrides) {
const next = { ...DEFAULT_POLICY, ...overrides };
validatePolicy(next);
const nextPricing = mergePricing(STARTUP_PRICING, pricingOverrides);
const problem = validatePricing(nextPricing);
if (problem) throw new DispatchError(problem);
for (const ride of rides.values()) if (ride.timer) clearTimeout(ride.timer);
rides.clear();
lastResults.clear();
quotes.clear();
requestLog = [];
surgeCache.clear();
pricing = nextPricing;
// Phones say whether they're driving when they connect; start everyone off the road.
for (const device of devices.values()) device.driving = false;
nextRideId = 1;
@ -448,18 +580,22 @@ function reset(overrides: Partial<AuctionPolicy>) {
function publicState() {
return {
policy,
devices: [...devices.values()].map(({ profile }) => ({
pricing,
devices: [...devices.values()].map(({ profile, position }) => ({
...profile,
online: isOnline(profile.id),
driving: profile.role === 'driver' && isDriving(profile.id),
jobs: profile.role === 'driver' ? jobs(profile.id).map((ride) => ride.id) : [],
position,
})),
rides: [...rides.values()].map((r) => ({
id: r.id,
riderId: r.riderId,
status: r.status,
destination: r.request.destination.title,
rideTypeId: r.request.rideTypeId,
quotedFare: r.request.quotedFare,
surge: r.request.surge,
pickup: r.request.pickup,
rounds: r.rounds.map((round) => ({ ...round, declined: [...round.declined] })),
winnerId: r.winnerId,
@ -489,7 +625,7 @@ async function handleHttp(req: IncomingMessage, res: ServerResponse) {
if (req.method === 'POST' && url.pathname === '/test/reset') {
const body = await readJson(req);
reset((body.policy ?? {}) as Partial<AuctionPolicy>);
reset((body.policy ?? {}) as Partial<AuctionPolicy>, (body.pricing ?? {}) as PricingOverrides);
broadcast();
return json(200, publicState());
}
@ -530,7 +666,7 @@ wss.on('connection', (socket) => {
const known = devices.get(message.device.id);
const sockets = known?.sockets ?? new Set<WebSocket>();
sockets.add(socket);
device = { profile: message.device, sockets, driving: known?.driving ?? false };
device = { profile: message.device, sockets, driving: known?.driving ?? false, position: known?.position ?? null };
devices.set(message.device.id, device);
if (message.device.role === 'driver') inviteToOpenRounds(message.device.id);
log(`${message.device.role} ${message.device.id} connected`);
@ -561,4 +697,6 @@ function log(message: string) {
}
validatePolicy(DEFAULT_POLICY);
const startupProblem = validatePricing(STARTUP_PRICING);
if (startupProblem) throw new Error(`Invalid pricing: ${startupProblem}`);
server.listen(PORT, () => log(`listening on :${PORT}, policy ${JSON.stringify(DEFAULT_POLICY)}`));

148
server/pricing.mts Normal file
View file

@ -0,0 +1,148 @@
// Pricing model for the dispatch server: a basic model inspired by how Uber and Lyft are
// publicly known to price rides. Their real models are proprietary; this is not them.
//
// fare = max(minimumFare, (baseFare + perMile × miles + perMinute × minutes) × surge + bookingFee)
//
// - Rate cards are per market (a city and its surroundings) and ride type; docs/pricing.md
// has the sources.
// - Surge is per zone: recent ride requests against available drivers nearby.
// - The rider is quoted upfront; the driver earns a fixed share of what the rider pays.
import type { Coord } from '../src/lib/types';
import type { Market, PricingConfig, PricingOverrides, RateCard, SurgeConfig } from '../src/lib/dispatch/protocol';
const METERS_PER_MILE = 1609.344;
// San Francisco, 2026. UberX is RideWise's March 2026 card (checked against receipts);
// the minimum is TaxiFareFinder's. Uber doesn't publish rate cards any more, and the
// other tiers are estimates scaled from reported national averages and older SF rates.
const SAN_FRANCISCO: Record<string, RateCard> = {
x: { baseFare: 2.5, perMile: 1.55, perMinute: 0.32, bookingFee: 3, minimumFare: 7 },
comfort: { baseFare: 3, perMile: 1.85, perMinute: 0.38, bookingFee: 3, minimumFare: 9 },
xl: { baseFare: 3, perMile: 2.1, perMinute: 0.4, bookingFee: 3, minimumFare: 9.25 },
black: { baseFare: 8, perMile: 3.75, perMinute: 0.6, bookingFee: 3, minimumFare: 15 },
};
// Fairbanks, September 2026, from uphail.com. UberX and UberXL are Fairbanks's own rates;
// Fairbanks has no Comfort rates listed, so Comfort is Anchorage's from the same source.
// Neither city lists Black, so it isn't offered there.
const FAIRBANKS: Record<string, RateCard> = {
x: { baseFare: 2.77, perMile: 1.86, perMinute: 0.31, bookingFee: 2.55, minimumFare: 8.43 },
comfort: { baseFare: 3.74, perMile: 1.92, perMinute: 0.4, bookingFee: 3.58, minimumFare: 11.75 },
xl: { baseFare: 3.25, perMile: 2.25, perMinute: 0.36, bookingFee: 2.55, minimumFare: 8.7 },
};
const MARKETS: Record<string, Market> = {
sf: { name: 'San Francisco', center: { latitude: 37.7749, longitude: -122.4194 }, radiusKm: 60 },
// Includes North Pole and Fort Wainwright.
fairbanks: { name: 'Fairbanks', center: { latitude: 64.8378, longitude: -147.7164 }, radiusKm: 60 },
};
export const DEFAULT_PRICING: PricingConfig = {
markets: MARKETS,
defaultMarket: 'sf',
rateCards: { sf: SAN_FRANCISCO, fairbanks: FAIRBANKS },
surge: {
enabled: true,
// About 2 km across in San Francisco.
zoneSizeDegrees: 0.02,
// Uber's surge was measured updating every 5 minutes in 2015; a minute feels closer to today.
intervalMs: 60_000,
demandWindowMs: 10 * 60_000,
// Surge starts once requests outnumber drivers, adding 0.5× per extra request per driver.
threshold: 1,
sensitivity: 0.5,
step: 0.1,
maxMultiplier: 3,
},
// Reported take rates run about 25–29% (Uber); Lyft guarantees drivers at least 70%.
driverShare: 0.75,
quoteTtlMs: 2 * 60_000,
};
export const round2 = (n: number) => Math.round(n * 100) / 100;
export function mergePricing(base: PricingConfig, overrides: PricingOverrides = {}): PricingConfig {
const rateCards: PricingConfig['rateCards'] = {};
for (const market of new Set([...Object.keys(base.rateCards), ...Object.keys(overrides.rateCards ?? {})])) {
rateCards[market] = { ...base.rateCards[market] };
for (const [type, card] of Object.entries(overrides.rateCards?.[market] ?? {})) {
rateCards[market][type] = { ...rateCards[market][type], ...card } as RateCard;
}
}
return {
markets: { ...base.markets, ...overrides.markets },
defaultMarket: overrides.defaultMarket ?? base.defaultMarket,
rateCards,
surge: { ...base.surge, ...overrides.surge },
driverShare: overrides.driverShare ?? base.driverShare,
quoteTtlMs: overrides.quoteTtlMs ?? base.quoteTtlMs,
};
}
export function validatePricing(p: PricingConfig): string | null {
for (const [market, cards] of Object.entries(p.rateCards)) {
for (const [type, card] of Object.entries(cards)) {
for (const [field, value] of Object.entries(card)) {
if (!(typeof value === 'number' && value >= 0)) return `rateCards.${market}.${type}.${field} must be a number ≥ 0`;
}
}
}
for (const [id, market] of Object.entries(p.markets)) {
if (!p.rateCards[id]) return `markets.${id} has no rate cards`;
if (!(market.radiusKm > 0)) return `markets.${id}.radiusKm must be more than 0`;
}
if (!p.markets[p.defaultMarket]) return `defaultMarket "${p.defaultMarket}" isn't one of the markets`;
if (!(p.driverShare > 0 && p.driverShare <= 1)) return 'driverShare must be more than 0 and at most 1';
if (!(p.quoteTtlMs > 0)) return 'quoteTtlMs must be more than 0';
const s = p.surge;
if (!(s.zoneSizeDegrees > 0)) return 'surge.zoneSizeDegrees must be more than 0';
if (!(s.step > 0)) return 'surge.step must be more than 0';
if (!(s.maxMultiplier >= 1)) return 'surge.maxMultiplier must be at least 1';
return null;
}
/** The market whose rate cards apply to a pickup: the nearest one in range, else the default. */
export function marketFor(pickup: Coord, pricing: PricingConfig): string {
let best: { id: string; km: number } | null = null;
for (const [id, market] of Object.entries(pricing.markets)) {
const km = distanceKm(pickup, market.center);
if (km <= market.radiusKm && (!best || km < best.km)) best = { id, km };
}
return best?.id ?? pricing.defaultMarket;
}
function distanceKm(a: Coord, b: Coord): number {
const toRad = (deg: number) => (deg * Math.PI) / 180;
const dLat = toRad(b.latitude - a.latitude);
const dLon = toRad(b.longitude - a.longitude);
const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a.latitude)) * Math.cos(toRad(b.latitude)) * Math.sin(dLon / 2) ** 2;
return 2 * 6371 * Math.asin(Math.sqrt(h));
}
/** The upfront fare for one ride type. */
export function fareFor(card: RateCard, distanceMeters: number, durationSeconds: number, surge: number): number {
const miles = distanceMeters / METERS_PER_MILE;
const minutes = durationSeconds / 60;
const ride = card.baseFare + card.perMile * miles + card.perMinute * minutes;
return round2(Math.max(card.minimumFare, ride * surge + card.bookingFee));
}
export const earningsFor = (fare: number, pricing: PricingConfig) => round2(fare * pricing.driverShare);
/** The surge zone a point falls in: a square of the grid, as "row:column". */
export function zoneOf(point: Coord, surge: SurgeConfig): string {
const size = surge.zoneSizeDegrees;
return `${Math.floor(point.latitude / size)}:${Math.floor(point.longitude / size)}`;
}
/** The surge multiplier for a zone with this many recent requests and available drivers. */
export function surgeMultiplier(demand: number, supply: number, surge: SurgeConfig): number {
if (!surge.enabled) return 1;
// With no drivers at all, count as if there were one: every request is unmet demand.
const ratio = demand / Math.max(supply, 1);
const raw = 1 + (ratio - surge.threshold) * surge.sensitivity;
// Round down to the step, with a little slack so 1.5 doesn't become 1.4 from float error.
const stepped = Math.floor((raw + 1e-9) / surge.step) * surge.step;
return round2(Math.min(surge.maxMultiplier, Math.max(1, stepped)));
}

View file

@ -24,6 +24,11 @@ export default function DriverScreen() {
if (connected) send({ type: 'setDriving', driving });
}, [connected, driving, send]);
// Where the driver is counts toward supply in that surge zone.
useEffect(() => {
if (connected) send({ type: 'setPosition', position });
}, [connected, position, send]);
const driver = snapshot?.role === 'driver' ? snapshot : null;
const job = driver?.job ?? null;
const nextJob = driver?.nextJob ?? null;
@ -46,7 +51,7 @@ export default function DriverScreen() {
{job && <Job job={job} onComplete={() => send({ type: 'completeTrip', rideId: job.rideId })} />}
{nextJob && (
<Text style={styles.next} testID="next-job">
Next: {nextJob.riderName} → {nextJob.destination.title} · {formatPrice(nextJob.fare)}
Next: {nextJob.riderName} → {nextJob.destination.title} · you earn {formatPrice(nextJob.earnings)}
</Text>
)}
{!job &&
@ -156,16 +161,21 @@ function Offer({ offer, onAccept, onPass }: { offer: DriverOfferView; onAccept:
<Text style={styles.muted}>
{getRideType(offer.rideTypeId).name} · {formatDistance(offer.distanceMeters)} · {formatDuration(offer.durationSeconds)}
</Text>
<Text style={styles.price} testID="offer-price">
{formatPrice(offer.price)}
<View>
<Text style={styles.price} testID="offer-earnings">
{formatPrice(offer.earnings)}
</Text>
<Text style={styles.muted}>
You earn · rider pays <Text testID="offer-price">{formatPrice(offer.price)}</Text>
</Text>
</View>
<View style={styles.buttons}>
<Pressable style={[styles.button, styles.secondary]} onPress={onPass} accessibilityRole="button" testID="decline-offer">
<Text style={styles.secondaryText}>Pass</Text>
</Pressable>
<Pressable style={[styles.button, styles.primary]} onPress={onAccept} accessibilityRole="button" testID="accept-offer">
<Text style={styles.primaryText}>Accept {formatPrice(offer.price)}</Text>
<Text style={styles.primaryText}>Accept</Text>
</Pressable>
</View>
</View>
@ -179,7 +189,10 @@ function Job({ job, onComplete }: { job: DriverJobView; onComplete: () => void }
<Text style={styles.destination}>
{job.riderName} → {job.destination.title}
</Text>
<Text style={styles.fare} testID="job-fare">
<Text style={styles.fare} testID="job-earnings">
You earn {formatPrice(job.earnings)}
</Text>
<Text style={styles.muted} testID="job-fare">
Fare {formatPrice(job.fare)}
</Text>
<Pressable style={[styles.button, styles.primary]} onPress={onComplete} accessibilityRole="button" testID="complete-trip">
@ -200,7 +213,7 @@ function ResultBanner({ result }: { result: DriverResult }) {
return (
<Text style={styles.banner} testID="driver-result">
{RESULT_TEXT[result.outcome]}
{result.outcome === 'completed' ? ` · you earned ${formatPrice(result.fare)}` : ''}
{result.outcome === 'completed' ? ` · you earned ${formatPrice(result.earnings)}` : ''}
</Text>
);
}

View file

@ -16,6 +16,7 @@ export default function HomeScreen() {
route,
routeLoading,
rideTypeId,
quote,
dispatchError,
setDestination,
setRideTypeId,
@ -42,7 +43,7 @@ export default function HomeScreen() {
<View style={[styles.top, { paddingTop: insets.top + 8 }]}>
{destination ? (
<View style={styles.row}>
<Pressable style={styles.circleButton} onPress={clearDestination} accessibilityLabel="Clear destination">
<Pressable style={styles.circleButton} onPress={clearDestination} accessibilityLabel="Clear destination" testID="clear-destination">
<Text style={styles.circleButtonText}>←</Text>
</Pressable>
<View style={[styles.searchBar, styles.flex]}>
@ -77,7 +78,7 @@ export default function HomeScreen() {
{destination && (
<View style={styles.bottom}>
<RideOptions route={route} loading={routeLoading} selectedId={rideTypeId} onSelect={setRideTypeId} onConfirm={confirmRide} />
<RideOptions route={route} quote={quote} loading={routeLoading} selectedId={rideTypeId} onSelect={setRideTypeId} onConfirm={confirmRide} />
</View>
)}
</View>

View file

@ -1,27 +1,31 @@
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native';
import { formatDistance, formatDuration, formatPrice, priceFor, RIDE_TYPES } from '@/lib/rideTypes';
import type { Quote } from '@/lib/dispatch/protocol';
import { formatDistance, formatDuration, formatPrice, RIDE_TYPES } from '@/lib/rideTypes';
import type { Route } from '@/lib/types';
type Props = {
route: Route | null;
quote: Quote | null;
loading: boolean;
selectedId: string;
onSelect: (id: string) => void;
onConfirm: () => void;
};
export function RideOptions({ route, loading, selectedId, onSelect, onConfirm }: Props) {
if (loading || !route) {
export function RideOptions({ route, quote, loading, selectedId, onSelect, onConfirm }: Props) {
if (loading || !route || !quote) {
return (
<View style={[styles.sheet, styles.loading]}>
<ActivityIndicator color="#111" />
<Text style={styles.muted}>Finding the best route…</Text>
<Text style={styles.muted}>{loading || !route ? 'Finding the best route…' : 'Getting prices…'}</Text>
</View>
);
}
const selected = RIDE_TYPES.find((r) => r.id === selectedId) ?? RIDE_TYPES[0];
// Only ride types priced in this city.
const available = RIDE_TYPES.filter((ride) => quote.fares[ride.id] !== undefined);
const selected = available.find((r) => r.id === selectedId) ?? available[0];
return (
<View style={styles.sheet}>
@ -29,7 +33,12 @@ export function RideOptions({ route, loading, selectedId, onSelect, onConfirm }:
{formatDistance(route.distanceMeters)} · {formatDuration(route.durationSeconds)}
{route.approximate ? ' · estimate' : ''}
</Text>
{RIDE_TYPES.map((ride) => {
{quote.surge > 1 && (
<Text style={styles.surge} testID="surge-notice">
Busy area · prices are {quote.surge}× higher right now
</Text>
)}
{available.map((ride) => {
const isSelected = ride.id === selected.id;
return (
<Pressable
@ -46,7 +55,9 @@ export function RideOptions({ route, loading, selectedId, onSelect, onConfirm }:
</Text>
<Text style={styles.muted}>{ride.description}</Text>
</View>
<Text style={styles.price} testID={`price-${ride.id}`}>{formatPrice(priceFor(ride, route))}</Text>
<Text style={styles.price} testID={`price-${ride.id}`}>
{formatPrice(quote.fares[ride.id])}
</Text>
</Pressable>
);
})}
@ -71,6 +82,7 @@ const styles = StyleSheet.create({
elevation: 12,
},
loading: { alignItems: 'center', gap: 8, paddingVertical: 32 },
surge: { backgroundColor: '#eaf1ff', color: '#1d4ed8', padding: 8, borderRadius: 8, overflow: 'hidden', textAlign: 'center', marginBottom: 6, fontWeight: '500' },
tripMeta: { textAlign: 'center', color: '#555', marginBottom: 8, fontWeight: '500' },
option: {
flexDirection: 'row',

View file

@ -3,9 +3,9 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
import { simulatedLocation } from './dispatch/device';
import { useDispatch } from './dispatch/DispatchContext';
import type { RiderRideView } from './dispatch/protocol';
import type { Quote, RiderRideView } from './dispatch/protocol';
import { DEFAULT_LOCATION } from './location';
import { getRideType, priceFor, RIDE_TYPES } from './rideTypes';
import { RIDE_TYPES } from './rideTypes';
import { fetchRoute } from './route';
import { saveTrip } from './storage';
import type { Coord, Place, Route, Trip } from './types';
@ -20,6 +20,8 @@ type RideState = {
route: Route | null;
routeLoading: boolean;
rideTypeId: string;
/** Upfront prices from dispatch for the chosen destination, once it's routed. */
quote: Quote | null;
/** The rider's current ride, as dispatch sees it. */
ride: RiderRideView | null;
/** A ride request was sent and dispatch hasn't answered yet. */
@ -48,9 +50,12 @@ export function RideProvider({ children }: { children: ReactNode }) {
const [rideTypeId, setRideTypeId] = useState(RIDE_TYPES[0].id);
const [requestPending, setRequestPending] = useState(false);
const { snapshot, error: dispatchError, send } = useDispatch();
const { connected, snapshot, error: dispatchError, send } = useDispatch();
const ride = snapshot?.role === 'rider' ? snapshot.ride : null;
const requesting = requestPending && !ride && !dispatchError;
const latestQuote = snapshot?.role === 'rider' ? snapshot.quote : null;
// Only show prices for the destination on screen.
const quote = latestQuote && latestQuote.destinationId === destination?.id ? latestQuote : null;
const pickup = userLocation ?? DEFAULT_LOCATION;
const routeRequest = useRef<AbortController | null>(null);
@ -109,22 +114,36 @@ export function RideProvider({ children }: { children: ReactNode }) {
[pickup],
);
const requestRide = useCallback(() => {
if (!destination || !route) return false;
const sent = send({
type: 'requestRide',
request: {
pickup,
destination,
rideTypeId,
distanceMeters: route.distanceMeters,
durationSeconds: route.durationSeconds,
quotedFare: priceFor(getRideType(rideTypeId), route),
},
// Ask dispatch to price each new route (picking the same place again is a new route, so
// prices are always current), and again whenever the quote expires. Not while a ride is
// underway: its price is already settled.
const needsQuote = connected && destination !== null && route !== null && !ride && !requestPending;
const quotedRoute = useRef<Route | null>(null);
const quoteExpiresAt = quote?.expiresAt;
useEffect(() => {
if (!needsQuote || !destination || !route) return;
const requestQuote = () =>
send({
type: 'requestQuote',
request: { pickup, destination, distanceMeters: route.distanceMeters, durationSeconds: route.durationSeconds },
});
if (quotedRoute.current !== route || quoteExpiresAt === undefined) {
quotedRoute.current = route;
requestQuote();
return;
}
const timer = setTimeout(requestQuote, Math.max(0, quoteExpiresAt - Date.now()));
return () => clearTimeout(timer);
// The pickup drifting doesn't re-price; a new destination or route does.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [needsQuote, destination, route, quoteExpiresAt, send]);
const requestRide = useCallback(() => {
if (!quote) return false;
const sent = send({ type: 'requestRide', quoteId: quote.id, rideTypeId });
setRequestPending(sent);
return sent;
}, [destination, route, rideTypeId, pickup, send]);
}, [quote, rideTypeId, send]);
const cancelRide = useCallback(() => {
send({ type: 'cancelRide' });
@ -152,6 +171,7 @@ export function RideProvider({ children }: { children: ReactNode }) {
route,
routeLoading,
rideTypeId,
quote,
ride,
requesting,
dispatchError,
@ -170,6 +190,7 @@ export function RideProvider({ children }: { children: ReactNode }) {
route,
routeLoading,
rideTypeId,
quote,
ride,
requesting,
dispatchError,

View file

@ -15,14 +15,80 @@ export type DeviceProfile = {
rating?: number;
};
export type RideRequest = {
/** What a rider sends to be quoted: the trip, as their app routed it. */
export type QuoteRequest = {
pickup: Coord;
destination: Place;
rideTypeId: string;
distanceMeters: number;
durationSeconds: number;
/** The fare quoted to the rider. Each round's suggested price is a multiple of it. */
quotedFare: number;
};
/**
* Upfront prices for one trip, one per ride type, set by dispatch. Booking a ride uses
* the quote's price for that type, as long as the quote hasn't expired.
*/
export type Quote = {
id: string;
/** The destination this quote is for, so a stale quote is never shown for a new one. */
destinationId: string;
/** The surge multiplier in the pickup's zone when quoted; 1 means none. */
surge: number;
expiresAt: number;
fares: Record<string, number>;
};
/** A city's prices for one ride type. Distance is in miles, as US rate cards quote it. */
export type RateCard = {
baseFare: number;
perMile: number;
perMinute: number;
/** Flat fee per trip; not multiplied by surge. */
bookingFee: number;
minimumFare: number;
};
/**
* Surge: each zone's price multiplier, from recent ride requests (demand) against the
* available drivers there (supply). Recomputed at most every `intervalMs` per zone.
* multiplier = 1 + (demand / supply − threshold) × sensitivity, rounded down to `step`,
* between 1 and `maxMultiplier`.
*/
export type SurgeConfig = {
enabled: boolean;
/** Zones are a grid of squares this many degrees of latitude/longitude across. */
zoneSizeDegrees: number;
intervalMs: number;
/** How far back ride requests count toward demand. */
demandWindowMs: number;
threshold: number;
sensitivity: number;
step: number;
maxMultiplier: number;
};
/** A place with its own prices: pickups within `radiusKm` of `center` use its rate cards. */
export type Market = { name: string; center: Coord; radiusKm: number };
export type PricingConfig = {
markets: Record<string, Market>;
/** For pickups outside every market, so the app still prices rides anywhere in development. */
defaultMarket: string;
/** Rate cards by market id, then ride type id. A ride type without a card isn't offered there. */
rateCards: Record<string, Record<string, RateCard>>;
surge: SurgeConfig;
/** The driver's share of what the rider pays; the rest is the platform's cut. */
driverShare: number;
quoteTtlMs: number;
};
/** Partial pricing settings, merged over the defaults (per city and ride type for rates). */
export type PricingOverrides = {
markets?: Record<string, Market>;
defaultMarket?: string;
rateCards?: Record<string, Record<string, Partial<RateCard>>>;
surge?: Partial<SurgeConfig>;
driverShare?: number;
quoteTtlMs?: number;
};
/**
@ -76,8 +142,10 @@ export type DriverOfferView = {
distanceMeters: number;
durationSeconds: number;
round: number;
/** The suggested fare; the driver accepts it or passes. */
/** The suggested fare the rider would pay; the driver accepts it or passes. */
price: number;
/** What the driver would earn at that price. */
earnings: number;
closesAt: number;
/** The driver passed on this round; a later round may offer more. */
declined: boolean;
@ -88,14 +156,15 @@ export type DriverJobView = {
riderName: string;
destination: Place;
fare: number;
earnings: number;
};
export type DriverResult =
| { rideId: string; outcome: 'lost' | 'cancelled' | 'expired' }
| { rideId: string; outcome: 'completed'; fare: number };
| { rideId: string; outcome: 'completed'; earnings: number };
export type Snapshot =
| { role: 'rider'; ride: RiderRideView | null }
| { role: 'rider'; ride: RiderRideView | null; quote: Quote | null }
| {
role: 'driver';
/** In driving mode, i.e. taking ride requests. */
@ -110,7 +179,9 @@ export type Snapshot =
export type ClientMessage =
| { type: 'hello'; device: DeviceProfile }
| { type: 'requestRide'; request: RideRequest }
| { type: 'requestQuote'; request: QuoteRequest }
/** Book a ride type at the price in one of this rider's unexpired quotes. */
| { type: 'requestRide'; quoteId: string; rideTypeId: string }
| { type: 'cancelRide' }
/** Rider has seen the final state of their ride (trip done, or no match) and wants it cleared. */
| { type: 'ackRide' }
@ -118,6 +189,8 @@ export type ClientMessage =
| { type: 'decline'; rideId: string; round: number }
| { type: 'completeTrip'; rideId: string }
/** Driver starts or stops taking ride requests. Sent on every (re)connect, too. */
| { type: 'setDriving'; driving: boolean };
| { type: 'setDriving'; driving: boolean }
/** Where a driver is, for counting supply in surge zones. */
| { type: 'setPosition'; position: Coord };
export type ServerMessage = { type: 'snapshot'; snapshot: Snapshot } | { type: 'error'; message: string };

View file

@ -1,35 +1,23 @@
import type { Route } from './types';
// How each ride type is shown. Prices come from dispatch (server/pricing.mts), per city.
export type RideType = {
id: string;
name: string;
description: string;
seats: number;
icon: string;
baseFare: number;
perKm: number;
perMinute: number;
minimumFare: number;
};
export const RIDE_TYPES: RideType[] = [
{ id: 'x', name: 'UberX', description: 'Affordable rides', seats: 4, icon: '🚗', baseFare: 2.5, perKm: 1.1, perMinute: 0.3, minimumFare: 7 },
{ id: 'comfort', name: 'Comfort', description: 'Newer cars, extra legroom', seats: 4, icon: '🚙', baseFare: 3.5, perKm: 1.4, perMinute: 0.35, minimumFare: 9 },
{ id: 'xl', name: 'UberXL', description: 'Rides for groups', seats: 6, icon: '🚐', baseFare: 4, perKm: 1.8, perMinute: 0.45, minimumFare: 11 },
{ id: 'black', name: 'Black', description: 'Premium rides', seats: 4, icon: '🚘', baseFare: 7, perKm: 2.6, perMinute: 0.6, minimumFare: 18 },
{ id: 'x', name: 'UberX', description: 'Affordable rides', seats: 4, icon: '🚗' },
{ id: 'comfort', name: 'Comfort', description: 'Newer cars, extra legroom', seats: 4, icon: '🚙' },
{ id: 'xl', name: 'UberXL', description: 'Rides for groups', seats: 6, icon: '🚐' },
{ id: 'black', name: 'Black', description: 'Premium rides', seats: 4, icon: '🚘' },
];
export function getRideType(id: string): RideType {
return RIDE_TYPES.find((r) => r.id === id) ?? RIDE_TYPES[0];
}
export function priceFor(ride: RideType, route: Route): number {
const km = route.distanceMeters / 1000;
const minutes = route.durationSeconds / 60;
const fare = ride.baseFare + km * ride.perKm + minutes * ride.perMinute;
return Math.max(ride.minimumFare, Math.round(fare * 100) / 100);
}
export function formatPrice(amount: number): string {
return `$${amount.toFixed(2)}`;
}

View file

@ -6,7 +6,8 @@
"@/*": [
"./src/*"
]
}
},
"allowImportingTsExtensions": true
},
"include": [
"**/*.ts",