quine-RideRN/public/sim.html
Philip Peterson da9172e594 Add driver app with driving mode, and mock location in the simulator
Build a separate driver app from the same codebase: APP_VARIANT=driver
(app.config.ts) sets its name, bundle ID and scheme, eas.json gets
driver build profiles, and Stack.Protected keeps each app to its own
screens. metro.config.js keys Metro's cache by variant so switching
apps never serves the other's baked-in config.

Drivers get ride requests only in driving mode. While driving they see
a waiting animation or, on a ride, turn-by-turn directions; requests
slide up over either without disturbing it. Accepting mid-ride queues
the ride (one in progress plus one queued). Going offline or online
mid-round leaves or joins it.

Simulated phones no longer touch the location API: each sits at a
mocked position from its URL (default downtown San Francisco). Phone
specs in sim.html move to named key=value options.

Scenarios cover driving mode, interruptions, the ride queue and
mocked location, each with a slow version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 08:14:23 -08:00

189 lines
8.3 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Phone Simulator</title>
<!--
Multi-phone simulator. Each phone is the web build of the app in its own iframe,
configured by URL. Served from the Expo dev server so the iframes are same-origin
(Cypress can only reach into same-origin iframes).
/sim.html?phones=rider:alice:Alice,driver:maria:Maria,driver:james:James
Phone spec: role:id:name[:key=value...] (each part may be URL-encoded)
car, plate, rating how the driver appears to riders
lat, lng the phone's mocked location (phones never use real GPS;
default downtown San Francisco)
driving=0 start a driver offline (drivers start in driving mode)
e.g. driver:maria:Maria:plate=7KDL294:driving=0
Optional: &dispatch=http://host:8787 (defaults to this host, port 8787)
-->
<style>
:root {
--bg: #f3f4f6;
--panel: #ffffff;
--text: #111827;
--muted: #6b7280;
--border: #e5e7eb;
--accent: #111827;
--win: #15803d;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 14px/1.4 -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
header {
display: flex;
align-items: baseline;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
background: var(--panel);
}
header h1 { margin: 0; font-size: 16px; }
header span { color: var(--muted); }
main { display: flex; gap: 16px; padding: 16px; align-items: flex-start; overflow-x: auto; }
.phone { flex: none; display: flex; flex-direction: column; gap: 6px; }
.phone-label { display: flex; justify-content: space-between; font-weight: 600; padding: 0 4px; }
.phone-label .role { color: var(--muted); font-weight: 400; text-transform: uppercase; font-size: 12px; }
.phone iframe {
width: 360px;
height: 740px;
border: 10px solid #111;
border-radius: 36px;
background: #fff;
transition: border-color 0.2s;
}
/* Slow test runs mark the phone that's about to act. */
.phone iframe.acting { border-color: #2563eb; }
/* A phone whose link to dispatch is held (cy.pausePhone). */
.phone iframe.paused { outline: 4px dashed #d97706; outline-offset: 3px; }
.phone:has(iframe.paused) .role::before { content: 'paused · '; color: #d97706; font-weight: 600; }
aside {
flex: none;
width: 320px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px;
max-height: 780px;
overflow: auto;
}
aside h2 { margin: 0 0 8px; font-size: 13px; text-transform: uppercase; color: var(--muted); }
.ride { border-top: 1px solid var(--border); padding: 8px 0; }
.ride-head { display: flex; justify-content: space-between; font-weight: 600; }
.status { font-size: 12px; padding: 1px 6px; border-radius: 4px; background: var(--bg); }
.bid { display: flex; justify-content: space-between; gap: 8px; color: var(--muted); }
.bid.winner { color: var(--win); font-weight: 600; }
.device { display: flex; justify-content: space-between; color: var(--muted); }
.empty { color: var(--muted); }
</style>
</head>
<body>
<header>
<h1>RideRN phone simulator</h1>
<span id="summary"></span>
</header>
<main id="phones">
<aside id="dispatch" data-testid="dispatch-panel">
<h2>Dispatch</h2>
<div id="dispatch-body" class="empty">Connecting…</div>
</aside>
</main>
<script>
const params = new URLSearchParams(location.search);
const dispatchUrl = params.get('dispatch') ?? `${location.protocol}//${location.hostname}:8787`;
const specs = (params.get('phones') ?? 'rider:rider-1:Rider,driver:driver-1:Driver')
.split(',')
.filter(Boolean)
.map((spec) => {
const [role, id, name, ...extras] = spec.split(':').map(decodeURIComponent);
const options = Object.fromEntries(
extras.filter(Boolean).map((extra) => {
const at = extra.indexOf('=');
return at < 0 ? [extra, '1'] : [extra.slice(0, at), extra.slice(at + 1)];
}),
);
return { role: role === 'driver' ? 'driver' : 'rider', id, name: name || id, options };
});
const main = document.getElementById('phones');
for (const spec of specs) {
const query = new URLSearchParams({ device: spec.id, role: spec.role, name: spec.name });
for (const key of ['car', 'plate', 'rating', 'lat', 'lng']) if (spec.options[key]) query.set(key, spec.options[key]);
if (spec.role === 'driver') query.set('driving', spec.options.driving === '0' ? '0' : '1');
const path = spec.role === 'driver' ? '/driver' : '/';
const wrapper = document.createElement('div');
wrapper.className = 'phone';
const label = document.createElement('div');
label.className = 'phone-label';
label.innerHTML = `<span></span><span class="role"></span>`;
label.children[0].textContent = spec.name;
label.children[1].textContent = spec.role;
const frame = document.createElement('iframe');
frame.src = `${path}?${query}`;
frame.title = `${spec.name} (${spec.role})`;
frame.dataset.phone = spec.id;
wrapper.append(label, frame);
main.insertBefore(wrapper, document.getElementById('dispatch'));
}
document.getElementById('summary').textContent = `${specs.length} phones · dispatch ${dispatchUrl}`;
// Live view of the dispatch server, for humans watching a scenario run.
const body = document.getElementById('dispatch-body');
const money = (n) => `$${Number(n).toFixed(2)}`;
const el = (tag, cls, text) => {
const node = document.createElement(tag);
if (cls) node.className = cls;
if (text !== undefined) node.textContent = text;
return node;
};
async function poll() {
try {
const state = await (await fetch(`${dispatchUrl}/state`)).json();
body.className = '';
body.replaceChildren();
for (const d of state.devices) {
const row = el('div', 'device');
const status = !d.online
? 'disconnected'
: d.role !== 'driver'
? 'online'
: [d.driving ? 'driving' : 'offline', d.jobs.length ? `${d.jobs.length} ride${d.jobs.length > 1 ? 's' : ''}` : '']
.filter(Boolean)
.join(' · ');
row.append(el('span', '', `${d.name} (${d.role})`), el('span', '', status));
body.append(row);
}
if (state.rides.length === 0) body.append(el('p', 'empty', 'No ride requests yet.'));
for (const ride of [...state.rides].reverse()) {
const box = el('div', 'ride');
const head = el('div', 'ride-head');
head.append(el('span', '', `${ride.id} → ${ride.destination}`), el('span', 'status', ride.status));
box.append(head, el('div', 'empty', `rider ${ride.riderId} · quoted ${money(ride.quotedFare)}`));
for (const round of ride.rounds) {
const row = el('div', round.acceptedBy ? 'bid winner' : 'bid');
const outcome = round.acceptedBy
? `accepted by ${round.acceptedBy}${round.lateAccepts.length ? ` · too late: ${round.lateAccepts.join(', ')}` : ''}`
: `offered to ${round.invited.join(', ') || 'nobody'}${round.declined.length ? ` · passed: ${round.declined.join(', ')}` : ''}`;
row.append(el('span', '', `Round ${round.number} · ${money(round.price)}${round.final ? ' (final)' : ''}`), el('span', '', outcome));
box.append(row);
}
body.append(box);
}
} catch {
body.className = 'empty';
body.textContent = `Can't reach dispatch at ${dispatchUrl}`;
}
setTimeout(poll, 500);
}
poll();
</script>
</body>
</html>