🏑 index : quine-global-labs/quine-ridern.git

<!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>