diff --git a/cypress/e2e/base-rates.cy.ts b/cypress/e2e/base-rates.cy.ts new file mode 100644 index 0000000..34b3f15 --- /dev/null +++ b/cypress/e2e/base-rates.cy.ts @@ -0,0 +1,4 @@ +import scenario from '../scenarios/base-rates'; +import { runScenario } from '../support/pace'; + +runScenario(scenario); diff --git a/cypress/e2e/fairbanks-rates.cy.ts b/cypress/e2e/fairbanks-rates.cy.ts new file mode 100644 index 0000000..b45573c --- /dev/null +++ b/cypress/e2e/fairbanks-rates.cy.ts @@ -0,0 +1,4 @@ +import scenario from '../scenarios/fairbanks-rates'; +import { runScenario } from '../support/pace'; + +runScenario(scenario); diff --git a/cypress/e2e/slow/base-rates.cy.ts b/cypress/e2e/slow/base-rates.cy.ts new file mode 100644 index 0000000..e3e99aa --- /dev/null +++ b/cypress/e2e/slow/base-rates.cy.ts @@ -0,0 +1,4 @@ +import scenario from '../../scenarios/base-rates'; +import { runScenario } from '../../support/pace'; + +runScenario(scenario, 'slow'); diff --git a/cypress/e2e/slow/fairbanks-rates.cy.ts b/cypress/e2e/slow/fairbanks-rates.cy.ts new file mode 100644 index 0000000..4f58864 --- /dev/null +++ b/cypress/e2e/slow/fairbanks-rates.cy.ts @@ -0,0 +1,4 @@ +import scenario from '../../scenarios/fairbanks-rates'; +import { runScenario } from '../../support/pace'; + +runScenario(scenario, 'slow'); diff --git a/cypress/e2e/slow/surge-pricing.cy.ts b/cypress/e2e/slow/surge-pricing.cy.ts new file mode 100644 index 0000000..8f13c65 --- /dev/null +++ b/cypress/e2e/slow/surge-pricing.cy.ts @@ -0,0 +1,4 @@ +import scenario from '../../scenarios/surge-pricing'; +import { runScenario } from '../../support/pace'; + +runScenario(scenario, 'slow'); diff --git a/cypress/e2e/surge-pricing.cy.ts b/cypress/e2e/surge-pricing.cy.ts new file mode 100644 index 0000000..2ac6222 --- /dev/null +++ b/cypress/e2e/surge-pricing.cy.ts @@ -0,0 +1,4 @@ +import scenario from '../scenarios/surge-pricing'; +import { runScenario } from '../support/pace'; + +runScenario(scenario); diff --git a/cypress/fixtures/photon-pioneer-park.json b/cypress/fixtures/photon-pioneer-park.json new file mode 100644 index 0000000..c97354a --- /dev/null +++ b/cypress/fixtures/photon-pioneer-park.json @@ -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" + } + } + ] +} diff --git a/cypress/scenarios/base-rates.ts b/cypress/scenarios/base-rates.ts new file mode 100644 index 0000000..d86a55b --- /dev/null +++ b/cypress/scenarios/base-rates.ts @@ -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 }); + }); +}); diff --git a/cypress/scenarios/fairbanks-rates.ts b/cypress/scenarios/fairbanks-rates.ts new file mode 100644 index 0000000..815df31 --- /dev/null +++ b/cypress/scenarios/fairbanks-rates.ts @@ -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); + }); +}); diff --git a/cypress/scenarios/first-accept-wins.ts b/cypress/scenarios/first-accept-wins.ts index 4a77149..308984a 100644 --- a/cypress/scenarios/first-accept-wins.ts +++ b/cypress/scenarios/first-accept-wins.ts @@ -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.'); diff --git a/cypress/scenarios/offer-interrupts-driving.ts b/cypress/scenarios/offer-interrupts-driving.ts index e864a02..27235d3 100644 --- a/cypress/scenarios/offer-interrupts-driving.ts +++ b/cypress/scenarios/offer-interrupts-driving.ts @@ -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)); diff --git a/cypress/scenarios/price-rises-when-round-times-out.ts b/cypress/scenarios/price-rises-when-round-times-out.ts index 8169e99..ab94cfa 100644 --- a/cypress/scenarios/price-rises-when-round-times-out.ts +++ b/cypress/scenarios/price-rises-when-round-times-out.ts @@ -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'); diff --git a/cypress/scenarios/pricing-is-configurable.ts b/cypress/scenarios/pricing-is-configurable.ts index ccfe5b5..a4422ca 100644 --- a/cypress/scenarios/pricing-is-configurable.ts +++ b/cypress/scenarios/pricing-is-configurable.ts @@ -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'); diff --git a/cypress/scenarios/surge-pricing.ts b/cypress/scenarios/surge-pricing.ts new file mode 100644 index 0000000..9c50661 --- /dev/null +++ b/cypress/scenarios/surge-pricing.ts @@ -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], + ]); + }); +}); diff --git a/cypress/scenarios/unmatched-at-price-cap.ts b/cypress/scenarios/unmatched-at-price-cap.ts index 9a60583..5e94a66 100644 --- a/cypress/scenarios/unmatched-at-price-cap.ts +++ b/cypress/scenarios/unmatched-at-price-cap.ts @@ -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'); diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index fe3822c..8846ce2 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -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): Chainable; + resetDispatch(options?: ResetOptions): Chainable; /** Load the simulator with these phones and wait until each is connected and on screen. */ openPhones(phones: PhoneSpec[]): Chainable; /** The 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; + 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', () => { diff --git a/docs/multi-phone-simulator.md b/docs/multi-phone-simulator.md index b4576d2..59a9589 100644 --- a/docs/multi-phone-simulator.md +++ b/docs/multi-phone-simulator.md @@ -61,7 +61,7 @@ Then open `. 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-`, `dispatch-error` | +| Home / search | `where-to`, `destination-input`, `clear-destination`, `confirm-ride`, `price-`, `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 diff --git a/docs/pricing.md b/docs/pricing.md new file mode 100644 index 0000000..7b61dd6 --- /dev/null +++ b/docs/pricing.md @@ -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. diff --git a/server/dispatch.mts b/server/dispatch.mts index 70cf1f8..cfb97c5 100644 --- a/server/dispatch.mts +++ b/server/dispatch.mts @@ -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; /** 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(); const rides = new Map(); const lastResults = new Map(); +const quotes = new Map(); +/** When and where rides were requested, for surge demand. */ +let requestLog: { at: number; zone: string }[] = []; +const surgeCache = new Map(); 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) { +function reset(overrides: Partial, 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) { 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); + reset((body.policy ?? {}) as Partial, (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(); 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)}`)); diff --git a/server/pricing.mts b/server/pricing.mts new file mode 100644 index 0000000..dd73f0d --- /dev/null +++ b/server/pricing.mts @@ -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 = { + 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 = { + 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 = { + 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))); +} diff --git a/src/app/driver.tsx b/src/app/driver.tsx index f6f2d1a..e796576 100644 --- a/src/app/driver.tsx +++ b/src/app/driver.tsx @@ -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 && send({ type: 'completeTrip', rideId: job.rideId })} />} {nextJob && ( - Next: {nextJob.riderName} → {nextJob.destination.title} · {formatPrice(nextJob.fare)} + Next: {nextJob.riderName} → {nextJob.destination.title} · you earn {formatPrice(nextJob.earnings)} )} {!job && @@ -156,16 +161,21 @@ function Offer({ offer, onAccept, onPass }: { offer: DriverOfferView; onAccept: {getRideType(offer.rideTypeId).name} · {formatDistance(offer.distanceMeters)} · {formatDuration(offer.durationSeconds)} - - {formatPrice(offer.price)} - + + + {formatPrice(offer.earnings)} + + + You earn · rider pays {formatPrice(offer.price)} + + Pass - Accept {formatPrice(offer.price)} + Accept @@ -179,7 +189,10 @@ function Job({ job, onComplete }: { job: DriverJobView; onComplete: () => void } {job.riderName} → {job.destination.title} - + + You earn {formatPrice(job.earnings)} + + Fare {formatPrice(job.fare)} @@ -200,7 +213,7 @@ function ResultBanner({ result }: { result: DriverResult }) { return ( {RESULT_TEXT[result.outcome]} - {result.outcome === 'completed' ? ` · you earned ${formatPrice(result.fare)}` : ''} + {result.outcome === 'completed' ? ` · you earned ${formatPrice(result.earnings)}` : ''} ); } diff --git a/src/app/index.tsx b/src/app/index.tsx index 584ebb7..38b8dd1 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -16,6 +16,7 @@ export default function HomeScreen() { route, routeLoading, rideTypeId, + quote, dispatchError, setDestination, setRideTypeId, @@ -42,7 +43,7 @@ export default function HomeScreen() { {destination ? ( - + ← @@ -77,7 +78,7 @@ export default function HomeScreen() { {destination && ( - + )} diff --git a/src/components/RideOptions.tsx b/src/components/RideOptions.tsx index b945e21..b1c706d 100644 --- a/src/components/RideOptions.tsx +++ b/src/components/RideOptions.tsx @@ -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 ( - Finding the best route… + {loading || !route ? 'Finding the best route…' : 'Getting prices…'} ); } - 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 ( @@ -29,7 +33,12 @@ export function RideOptions({ route, loading, selectedId, onSelect, onConfirm }: {formatDistance(route.distanceMeters)} · {formatDuration(route.durationSeconds)} {route.approximate ? ' · estimate' : ''} - {RIDE_TYPES.map((ride) => { + {quote.surge > 1 && ( + + Busy area · prices are {quote.surge}× higher right now + + )} + {available.map((ride) => { const isSelected = ride.id === selected.id; return ( {ride.description} - {formatPrice(priceFor(ride, route))} + + {formatPrice(quote.fares[ride.id])} + ); })} @@ -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', diff --git a/src/lib/RideContext.tsx b/src/lib/RideContext.tsx index 3df30ed..af4fe45 100644 --- a/src/lib/RideContext.tsx +++ b/src/lib/RideContext.tsx @@ -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(null); @@ -109,22 +114,36 @@ export function RideProvider({ children }: { children: ReactNode }) { [pickup], ); + // 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(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 (!destination || !route) return false; - const sent = send({ - type: 'requestRide', - request: { - pickup, - destination, - rideTypeId, - distanceMeters: route.distanceMeters, - durationSeconds: route.durationSeconds, - quotedFare: priceFor(getRideType(rideTypeId), route), - }, - }); + 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, diff --git a/src/lib/dispatch/protocol.ts b/src/lib/dispatch/protocol.ts index b8cfadd..6b077af 100644 --- a/src/lib/dispatch/protocol.ts +++ b/src/lib/dispatch/protocol.ts @@ -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; +}; + +/** 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; + /** 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>; + 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; + defaultMarket?: string; + rateCards?: Record>>; + surge?: Partial; + 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 }; diff --git a/src/lib/rideTypes.ts b/src/lib/rideTypes.ts index 713103b..bbe1644 100644 --- a/src/lib/rideTypes.ts +++ b/src/lib/rideTypes.ts @@ -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)}`; } diff --git a/tsconfig.json b/tsconfig.json index 122828d..90196e3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,8 @@ "@/*": [ "./src/*" ] - } + }, + "allowImportingTsExtensions": true }, "include": [ "**/*.ts",