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:
parent
da9172e594
commit
d0fb299f13
27 changed files with 793 additions and 98 deletions
4
cypress/e2e/base-rates.cy.ts
Normal file
4
cypress/e2e/base-rates.cy.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import scenario from '../scenarios/base-rates';
|
||||
import { runScenario } from '../support/pace';
|
||||
|
||||
runScenario(scenario);
|
||||
4
cypress/e2e/fairbanks-rates.cy.ts
Normal file
4
cypress/e2e/fairbanks-rates.cy.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import scenario from '../scenarios/fairbanks-rates';
|
||||
import { runScenario } from '../support/pace';
|
||||
|
||||
runScenario(scenario);
|
||||
4
cypress/e2e/slow/base-rates.cy.ts
Normal file
4
cypress/e2e/slow/base-rates.cy.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import scenario from '../../scenarios/base-rates';
|
||||
import { runScenario } from '../../support/pace';
|
||||
|
||||
runScenario(scenario, 'slow');
|
||||
4
cypress/e2e/slow/fairbanks-rates.cy.ts
Normal file
4
cypress/e2e/slow/fairbanks-rates.cy.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import scenario from '../../scenarios/fairbanks-rates';
|
||||
import { runScenario } from '../../support/pace';
|
||||
|
||||
runScenario(scenario, 'slow');
|
||||
4
cypress/e2e/slow/surge-pricing.cy.ts
Normal file
4
cypress/e2e/slow/surge-pricing.cy.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import scenario from '../../scenarios/surge-pricing';
|
||||
import { runScenario } from '../../support/pace';
|
||||
|
||||
runScenario(scenario, 'slow');
|
||||
4
cypress/e2e/surge-pricing.cy.ts
Normal file
4
cypress/e2e/surge-pricing.cy.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import scenario from '../scenarios/surge-pricing';
|
||||
import { runScenario } from '../support/pace';
|
||||
|
||||
runScenario(scenario);
|
||||
16
cypress/fixtures/photon-pioneer-park.json
Normal file
16
cypress/fixtures/photon-pioneer-park.json
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
32
cypress/scenarios/base-rates.ts
Normal file
32
cypress/scenarios/base-rates.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
34
cypress/scenarios/fairbanks-rates.ts
Normal file
34
cypress/scenarios/fairbanks-rates.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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.');
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
67
cypress/scenarios/surge-pricing.ts
Normal file
67
cypress/scenarios/surge-pricing.ts
Normal 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],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
95
docs/pricing.md
Normal 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.
|
||||
|
|
@ -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
148
server/pricing.mts
Normal 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)));
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
|
|
|
|||
Loading…
Reference in a new issue