{
  "config": {
    "configFile": "/var/www/360lm/playwright.config.js",
    "rootDir": "/var/www/360lm/tests",
    "forbidOnly": false,
    "fullyParallel": false,
    "globalSetup": null,
    "globalTeardown": null,
    "globalTimeout": 0,
    "grep": {},
    "grepInvert": null,
    "maxFailures": 0,
    "metadata": {
      "actualWorkers": 1
    },
    "preserveOutput": "always",
    "projects": [
      {
        "outputDir": "/var/www/360lm/test-results",
        "repeatEach": 1,
        "retries": 1,
        "metadata": {
          "actualWorkers": 1
        },
        "id": "android-chrome",
        "name": "android-chrome",
        "testDir": "/var/www/360lm/tests",
        "testIgnore": [],
        "testMatch": [
          "**/*.@(spec|test).?(c|m)[jt]s?(x)"
        ],
        "timeout": 30000
      },
      {
        "outputDir": "/var/www/360lm/test-results",
        "repeatEach": 1,
        "retries": 1,
        "metadata": {
          "actualWorkers": 1
        },
        "id": "desktop-chrome",
        "name": "desktop-chrome",
        "testDir": "/var/www/360lm/tests",
        "testIgnore": [],
        "testMatch": [
          "**/*.@(spec|test).?(c|m)[jt]s?(x)"
        ],
        "timeout": 30000
      }
    ],
    "quiet": false,
    "reporter": [
      [
        "json"
      ]
    ],
    "reportSlowTests": {
      "max": 5,
      "threshold": 300000
    },
    "shard": null,
    "tags": [],
    "updateSnapshots": "missing",
    "updateSourceMethod": "patch",
    "version": "1.60.0",
    "workers": 1,
    "webServer": null
  },
  "suites": [
    {
      "title": "tour_pg.spec.js",
      "file": "tour_pg.spec.js",
      "column": 0,
      "line": 0,
      "specs": [],
      "suites": [
        {
          "title": "Tour Playground — Smoke",
          "file": "tour_pg.spec.js",
          "line": 31,
          "column": 6,
          "specs": [
            {
              "title": "page returns 200",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "android-chrome",
                  "projectName": "android-chrome",
                  "results": [
                    {
                      "workerIndex": 0,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 5531,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:14:59.068Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-07a7be6c69471b097587",
              "file": "tour_pg.spec.js",
              "line": 32,
              "column": 3
            },
            {
              "title": "manifest.json returns 200",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "android-chrome",
                  "projectName": "android-chrome",
                  "results": [
                    {
                      "workerIndex": 0,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 486,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:06.672Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-3663c6956c03edf85c87",
              "file": "tour_pg.spec.js",
              "line": 37,
              "column": 3
            },
            {
              "title": "sample.xlsx is downloadable",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "android-chrome",
                  "projectName": "android-chrome",
                  "results": [
                    {
                      "workerIndex": 0,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 148,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:07.223Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-a984cd7f10c08f210a94",
              "file": "tour_pg.spec.js",
              "line": 42,
              "column": 3
            },
            {
              "title": "HTML carries tour-pg-v10 SW version",
              "ok": false,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "android-chrome",
                  "projectName": "android-chrome",
                  "results": [
                    {
                      "workerIndex": 0,
                      "parallelIndex": 0,
                      "status": "failed",
                      "duration": 99,
                      "error": {
                        "message": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoContain\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // indexOf\u001b[22m\n\nExpected substring: \u001b[32m\"tour-pg-v10\"\u001b[39m\nReceived string:    \u001b[31m\"<!DOCTYPE html>\u001b[39m\n\u001b[31m<html lang=\\\"en\\\">\u001b[39m\n\u001b[31m<head>\u001b[39m\n\u001b[31m<meta charset=\\\"UTF-8\\\">\u001b[39m\n\u001b[31m<meta name=\\\"viewport\\\" content=\\\"width=device-width,initial-scale=1,viewport-fit=cover\\\">\u001b[39m\n\u001b[31m<title>Tour Planner Playground</title>\u001b[39m\n\u001b[31m<link rel=\\\"manifest\\\" href=\\\"manifest.json\\\">\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"/shared/safe-bottom.css\\\">\u001b[39m\n\u001b[31m<meta name=\\\"theme-color\\\" content=\\\"#0a0a0a\\\">\u001b[39m\n\u001b[31m<link rel=\\\"preconnect\\\" href=\\\"https://fonts.googleapis.com\\\">\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&display=swap\\\">\u001b[39m\n\u001b[31m<script src=\\\"https://cdn.sheetjs.com/xlsx-0.20.2/package/dist/xlsx.full.min.js\\\"></script>\u001b[39m\n\u001b[31m<script src=\\\"https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js\\\"></script>\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.css\\\" crossorigin=\\\"\\\">\u001b[39m\n\u001b[31m<script src=\\\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.js\\\" crossorigin=\\\"\\\" defer></script>\u001b[39m\n\u001b[31m<style>\u001b[39m\n\u001b[31m:root{\u001b[39m\n\u001b[31m  --bg:#0a0a0a;--card:#141414;--card2:#1a1a1a;--border:#262626;--text:#f5f5f5;--muted:#a3a3a3;\u001b[39m\n\u001b[31m  --accent:#0ea5e9;--accent2:#0284c7;--g:#22c55e;--r:#ef4444;--y:#eab308;--p:#a855f7;\u001b[39m\n\u001b[31m  --rad:12px;--rads:8px;--bnav-h:64px;\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31m*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}\u001b[39m\n\u001b[31mbody{background:var(--bg);color:var(--text);font-family:'DM Sans',sans-serif;min-height:100vh;overscroll-behavior-y:none}\u001b[39m\n\u001b[31mbutton{font-family:inherit;cursor:pointer;border:none;background:none;color:inherit}\u001b[39m\n\u001b[31ma{color:inherit;text-decoration:none}·\u001b[39m\n\u001b[31m.scr{display:none;flex-direction:column;min-height:100vh;padding-bottom:calc(var(--bnav-h) + env(safe-area-inset-bottom))}\u001b[39m\n\u001b[31m.scr.active{display:flex}·\u001b[39m\n\u001b[31m.hdr{background:var(--card);border-bottom:1px solid var(--border);padding:12px 14px;display:flex;align-items:center;gap:8px;position:sticky;top:0;z-index:10}\u001b[39m\n\u001b[31m.hdr h1{font-size:15px;font-weight:800;flex:1;min-width:0}\u001b[39m\n\u001b[31m.hdr-sub{font-size:11px;color:var(--muted)}\u001b[39m\n\u001b[31m.hdr-btn{background:rgba(255,255,255,.08);border:1px solid var(--border);font-size:12px;font-weight:700;padding:5px 10px;border-radius:14px;cursor:pointer;white-space:nowrap}\u001b[39m\n\u001b[31m.hdr-btn:active{background:rgba(255,255,255,.16)}\u001b[39m\n\u001b[31m.user-pill{background:rgba(255,255,255,.1);border:1px solid var(--border);color:var(--muted);font-size:10px;font-weight:600;padding:3px 8px;border-radius:11px}\u001b[39m\n\u001b[31m.user-pill b{color:var(--text)}·\u001b[39m\n\u001b[31m.body{flex:1;padding:14px;display:flex;flex-direction:column;gap:12px}·\u001b[39m\n\u001b[31m/* ── editor: header + controls + route preview stay put; only the stops list scrolls ── */\u001b[39m\n\u001b[31m#s-editor.scr{height:100vh;height:100dvh;min-height:0;padding-bottom:0;overflow:hidden}\u001b[39m\n\u001b[31m.editor-body{flex:1;min-height:0;display:flex;flex-direction:column;padding:14px 14px 0}\u001b[39m\n\u001b[31m.editor-fixed{display:flex;flex-direction:column;gap:12px;flex-shrink:0}\u001b[39m\n\u001b[31m.editor-scroll{flex:1;min-height:0;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:2px 0 calc(32px + env(safe-area-inset-bottom));margin-top:6px;scrollbar-gutter:stable}\u001b[39m\n\u001b[31m/* visible scrollbar so it's obvious the stops list is independently scrollable */\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar{width:8px}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-thumb{background:#3a3a3a;border-radius:4px}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-thumb:active{background:#525252}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-track{background:transparent}·\u001b[39m\n\u001b[31m/* ── column-mapping table (parsed preview): scroll horizontally if it doesn't fit ── */\u001b[39m\n\u001b[31m.map-table-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--border);border-radius:6px}\u001b[39m\n\u001b[31m.map-table{border-collapse:collapse;table-layout:fixed;width:100%;min-width:440px;font-size:11px}\u001b[39m\n\u001b[31m.map-table th{text-align:left;padding:6px 8px;color:var(--muted);font-weight:800;font-size:9.5px;text-transform:uppercase;letter-spacing:.3px;background:var(--card2);position:sticky;top:0}\u001b[39m\n\u001b[31m.map-table td{padding:6px 8px;vertical-align:middle}\u001b[39m\n\u001b[31m.map-table tr+tr td,.map-table tbody tr td{border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.map-table col.c-src{width:40%}.map-table col.c-field{width:28%}.map-table col.c-sample{width:32%}\u001b[39m\n\u001b[31m.map-table select{background:#0b1220;border:1px solid var(--border);color:var(--text);padding:6px 7px;border-radius:4px;font-size:11px;width:100%}\u001b[39m\n\u001b[31m.map-field{font-weight:800;color:var(--text);white-space:nowrap}\u001b[39m\n\u001b[31m.map-sample{color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}·\u001b[39m\n\u001b[31m/* ── upload ── */\u001b[39m\n\u001b[31m.dropzone{background:var(--card);border:2px dashed var(--border);border-radius:var(--rad);padding:28px 16px;text-align:center;cursor:pointer;transition:.15s}\u001b[39m\n\u001b[31m.dropzone:hover,.dropzone.drag{border-color:var(--accent);background:#0e1726}\u001b[39m\n\u001b[31m.dropzone .ico{font-size:42px;margin-bottom:8px}\u001b[39m\n\u001b[31m.dropzone .ttl{font-weight:800;font-size:15px;margin-bottom:4px}\u001b[39m\n\u001b[31m.dropzone .sub{font-size:11.5px;color:var(--muted);line-height:1.4}\u001b[39m\n\u001b[31m.dropzone input[type=file]{display:none}·\u001b[39m\n\u001b[31m.section-title{font-size:11px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.4px}·\u001b[39m\n\u001b[31m.form-card{background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:12px;display:flex;flex-direction:column;gap:10px}\u001b[39m\n\u001b[31m.field{display:flex;flex-direction:column;gap:3px}\u001b[39m\n\u001b[31m.field label{font-size:11px;font-weight:600;color:var(--muted)}\u001b[39m\n\u001b[31m.field input,.field select{background:#0b1220;border:1px solid var(--border);border-radius:6px;color:var(--text);padding:9px 11px;font-size:13.5px;font-family:inherit;outline:none}\u001b[39m\n\u001b[31m.field input:focus,.field select:focus{border-color:var(--accent)}\u001b[39m\n\u001b[31m.toggle{display:flex;align-items:center;gap:8px;font-size:13px}\u001b[39m\n\u001b[31m.toggle input{accent-color:var(--accent);width:18px;height:18px}·\u001b[39m\n\u001b[31m.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;border-radius:var(--rads);padding:12px 14px;font-size:14px;font-weight:800;cursor:pointer;width:100%;transition:.12s}\u001b[39m\n\u001b[31m.btn-p{background:var(--accent);color:#fff}\u001b[39m\n\u001b[31m.btn-p:active{background:var(--accent2)}\u001b[39m\n\u001b[31m.btn-p:disabled{opacity:.4;cursor:default}\u001b[39m\n\u001b[31m.btn-s{background:var(--card);color:var(--text);border:1px solid var(--border)}\u001b[39m\n\u001b[31m.btn-r{background:rgba(239,68,68,.15);color:#fca5a5;border:1px solid rgba(239,68,68,.3)}·\u001b[39m\n\u001b[31m/* ── progress ── */\u001b[39m\n\u001b[31m.progress{background:#0b1220;border-radius:8px;height:10px;overflow:hidden;border:1px solid var(--border)}\u001b[39m\n\u001b[31m.progress-bar{height:100%;background:linear-gradient(90deg,var(--accent),var(--g));transition:.2s}·\u001b[39m\n\u001b[31m/* ── tiles ── */\u001b[39m\n\u001b[31m.origin-pill{background:linear-gradient(135deg,#0c2e3a,#062a3a);border:1px solid var(--accent);border-radius:var(--rad);padding:11px 13px;display:flex;align-items:center;gap:10px}\u001b[39m\n\u001b[31m.origin-pill .ico{font-size:18px}\u001b[39m\n\u001b[31m.origin-pill .meta{flex:1;min-width:0}\u001b[39m\n\u001b[31m.origin-pill .lbl{font-size:10.5px;color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.4px}\u001b[39m\n\u001b[31m.origin-pill .val{font-size:13.5px;font-weight:700;margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.origin-pill button{background:rgba(255,255,255,.08);border-radius:14px;padding:5px 10px;font-size:11px;font-weight:700;color:var(--accent)}·\u001b[39m\n\u001b[31m.tiles{display:flex;flex-direction:column;gap:6px}\u001b[39m\n\u001b[31m.tile{background:var(--card);border:1px solid var(--border);border-radius:var(--rads);padding:9px 10px;display:flex;align-items:center;gap:10px;transition:.12s;touch-action:none}\u001b[39m\n\u001b[31m.tile.sortable-ghost{opacity:.35;background:var(--card2)}\u001b[39m\n\u001b[31m.tile.sortable-chosen{transform:scale(.98);box-shadow:0 4px 20px rgba(14,165,233,.25)}\u001b[39m\n\u001b[31m.tile.excluded{opacity:.4;background:#0c0c0c}\u001b[39m\n\u001b[31m.tile.excluded .name,.tile.excluded .city{text-decoration:line-through}\u001b[39m\n\u001b[31m.tile.err{border-color:var(--r);background:#1f0a0a}\u001b[39m\n\u001b[31m.tile .grip{font-size:18px;color:var(--muted);cursor:grab;padding:4px;line-height:1}\u001b[39m\n\u001b[31m.tile .grip:active{cursor:grabbing}\u001b[39m\n\u001b[31m.tile .ord{background:var(--accent);color:#fff;width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11.5px;font-weight:800;flex-shrink:0}\u001b[39m\n\u001b[31m.tile.excluded .ord{background:#444}\u001b[39m\n\u001b[31m.tile .info{flex:1;min-width:0}\u001b[39m\n\u001b[31m.tile .name{font-size:13.5px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .city{font-size:11.5px;color:#cbd5e1;font-weight:600;margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .addr{font-size:9.5px;color:var(--muted);margin-top:1px;line-height:1.3;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-style:italic;opacity:.75}\u001b[39m\n\u001b[31m.tile .leg{font-size:10.5px;color:var(--accent2);font-weight:600;margin-top:2px}\u001b[39m\n\u001b[31m.tile.excluded .leg{display:none}\u001b[39m\n\u001b[31m.tile .badge-provider{display:inline-block;font-size:8.5px;padding:1px 5px;border-radius:8px;background:rgba(255,255,255,.08);color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.3px;margin-left:4px;vertical-align:middle}\u001b[39m\n\u001b[31m.tile .incl{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:50%;border:2px solid var(--border);font-size:16px;font-weight:800}\u001b[39m\n\u001b[31m.tile .incl.on{background:var(--g);border-color:var(--g);color:#fff}\u001b[39m\n\u001b[31m.tile .incl.off{background:#0c0c0c;color:var(--r);border-color:var(--r)}·\u001b[39m\n\u001b[31m/* ── prominent top action bar (KM, Share, Re-optimise) ── */\u001b[39m\n\u001b[31m.top-actions{background:linear-gradient(135deg,#0c2e3a,#08111c);border:1px solid var(--accent);border-radius:var(--rad);padding:10px;display:grid;grid-template-columns:1fr auto auto;gap:8px;align-items:center;margin-bottom:6px}\u001b[39m\n\u001b[31m.top-totals{min-width:0}\u001b[39m\n\u001b[31m.top-totals-big{font-size:18px;font-weight:900;line-height:1.05;letter-spacing:.3px}\u001b[39m\n\u001b[31m.top-totals-delta{font-size:11px;color:var(--muted);margin-top:2px}\u001b[39m\n\u001b[31m.top-totals-delta.better{color:var(--g)}\u001b[39m\n\u001b[31m.top-totals-delta.worse{color:var(--y)}\u001b[39m\n\u001b[31m.top-actions button{padding:10px 12px;font-size:12px;font-weight:800;border-radius:8px;border:none;cursor:pointer;display:flex;align-items:center;gap:5px;white-space:nowrap}\u001b[39m\n\u001b[31m.top-share{background:#f59e0b;color:#000}\u001b[39m\n\u001b[31m.top-share:active{background:#d97706}\u001b[39m\n\u001b[31m.top-reopt{background:var(--accent);color:#fff}\u001b[39m\n\u001b[31m.top-reopt:active{background:var(--accent2)}\u001b[39m\n\u001b[31m/* ── Re-optimisation needed (after include/exclude toggle) ── */\u001b[39m\n\u001b[31m.reopt-needed{font-size:11px;font-weight:700;color:#fbbf24;background:rgba(234,179,8,.12);border-left:3px solid #fbbf24;padding:5px 8px;border-radius:4px;margin-top:5px;display:none;line-height:1.3}\u001b[39m\n\u001b[31m.reopt-needed.show{display:block}·\u001b[39m\n\u001b[31m/* ── inline route preview (Leaflet + OSM) ── */\u001b[39m\n\u001b[31m.map-wrap{margin:8px 0 0;background:var(--card);border:1px solid var(--border);border-radius:var(--rads);overflow:hidden}\u001b[39m\n\u001b[31m.map-hdr{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;font-size:12px;color:var(--muted);background:var(--card2);border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.map-hdr .ttl{display:flex;align-items:center;gap:6px;font-weight:600}\u001b[39m\n\u001b[31m.map-hdr .toggle{font-size:14px;color:var(--text);background:none;border:none;cursor:pointer;padding:0 4px}\u001b[39m\n\u001b[31m#tour-map{height:260px;width:100%;background:#1a1a1a;transition:height .2s ease}\u001b[39m\n\u001b[31m.map-wrap.collapsed #tour-map{height:0}\u001b[39m\n\u001b[31m.map-wrap.collapsed .map-hdr{border-bottom:none}\u001b[39m\n\u001b[31m.tour-pin{background:transparent;border:none}\u001b[39m\n\u001b[31m.tour-pin > div{background:#0ea5e9;color:#fff;border-radius:50%;width:26px;height:26px;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:12px;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,.45);font-family:'DM Sans',sans-serif}\u001b[39m\n\u001b[31m.tour-pin.origin > div{background:#22c55e;font-size:14px}\u001b[39m\n\u001b[31m.tour-pin.out > div{background:#6b7280;opacity:.55}\u001b[39m\n\u001b[31m.leaflet-tooltip{background:#141414;color:#f5f5f5;border:1px solid #262626;font-size:11px;padding:3px 6px;font-family:'DM Sans',sans-serif;box-shadow:0 1px 6px rgba(0,0,0,.4)}\u001b[39m\n\u001b[31m.leaflet-tooltip-top:before,.leaflet-tooltip-bottom:before,.leaflet-tooltip-left:before,.leaflet-tooltip-right:before{border-top-color:#262626;border-bottom-color:#262626;border-left-color:#262626;border-right-color:#262626}\u001b[39m\n\u001b[31m.leaflet-control-attribution{background:rgba(20,20,20,.85)!important;color:#a3a3a3!important;font-size:9px!important}\u001b[39m\n\u001b[31m.leaflet-control-attribution a{color:#0ea5e9!important}\u001b[39m\n\u001b[31m.top-reopt.pulse{animation:pulseReopt 1.4s ease-in-out infinite;box-shadow:0 0 0 0 rgba(14,165,233,.6)}\u001b[39m\n\u001b[31m@keyframes pulseReopt{0%{box-shadow:0 0 0 0 rgba(14,165,233,.7)}50%{box-shadow:0 0 0 8px rgba(14,165,233,0)}100%{box-shadow:0 0 0 0 rgba(14,165,233,0)}}\u001b[39m\n\u001b[31m/* Totals dim when stale to make it visually obvious */\u001b[39m\n\u001b[31m.top-totals-big.stale, .top-totals-delta.stale{opacity:.5;text-decoration:line-through;text-decoration-color:rgba(251,191,36,.6)}·\u001b[39m\n\u001b[31m/* recalc spinner that appears inline next to the top totals during recompute */\u001b[39m\n\u001b[31m.recalc{display:none;font-size:11px;color:var(--accent);margin-left:6px;vertical-align:middle}\u001b[39m\n\u001b[31m.recalc.show{display:inline-block;animation:spin 1s linear infinite}\u001b[39m\n\u001b[31m@keyframes spin{to{transform:rotate(360deg)}}·\u001b[39m\n\u001b[31m/* ── share modal ── */\u001b[39m\n\u001b[31m.modal{position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:200;display:none;align-items:flex-end;justify-content:center}\u001b[39m\n\u001b[31m.modal.show{display:flex}\u001b[39m\n\u001b[31m.modal-box{background:var(--card);border-radius:18px 18px 0 0;width:100%;max-height:85vh;overflow-y:auto;padding:16px 14px calc(16px + env(safe-area-inset-bottom));border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.modal-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\u001b[39m\n\u001b[31m.modal-ttl{font-size:15px;font-weight:800}\u001b[39m\n\u001b[31m.modal-close{font-size:24px;color:var(--muted);background:none;border:none;cursor:pointer}·\u001b[39m\n\u001b[31m/* ── toast ── */\u001b[39m\n\u001b[31m#toast{position:fixed;bottom:calc(var(--bnav-h)+16px);left:12px;right:12px;z-index:1000;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:11px 14px;font-size:13px;font-weight:600;text-align:center;transform:translateY(150px);opacity:0;transition:.25s;pointer-events:none}\u001b[39m\n\u001b[31m#toast.show{transform:translateY(0);opacity:1}\u001b[39m\n\u001b[31m#toast.ok{background:rgba(34,197,94,.18);border-color:var(--g);color:#86efac}\u001b[39m\n\u001b[31m#toast.err{background:rgba(239,68,68,.18);border-color:var(--r);color:#fca5a5}·\u001b[39m\n\u001b[31m/* ── helpers ── */\u001b[39m\n\u001b[31m.spinner{display:inline-block;width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;vertical-align:middle}·\u001b[39m\n\u001b[31m.empty{text-align:center;padding:30px 16px;color:var(--muted);font-size:12.5px}·\u001b[39m\n\u001b[31mtextarea{background:#0b1220;border:1px solid var(--border);color:var(--text);padding:10px;font-family:inherit;font-size:12px;border-radius:6px;resize:vertical;outline:none}·\u001b[39m\n\u001b[31m.kbd{background:rgba(255,255,255,.06);padding:1px 6px;border-radius:4px;font-family:monospace;font-size:11px;color:var(--muted)}\u001b[39m\n\u001b[31m</style>\u001b[39m\n\u001b[31m</head>\u001b[39m\n\u001b[31m<body>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: UPLOAD ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-upload\\\" class=\\\"scr active\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗺 Tour Playground</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Excel → optimised route → tweak</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"loadSaved()\\\" id=\\\"btn-load\\\" style=\\\"display:none\\\">↻ Load</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"location.href='/hub/'\\\">🏡 Hub</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <label class=\\\"dropzone\\\" id=\\\"dropzone\\\" for=\\\"excel-file\\\">\u001b[39m\n\u001b[31m      <input type=\\\"file\\\" id=\\\"excel-file\\\" accept=\\\".xlsx,.xls,.csv\\\">\u001b[39m\n\u001b[31m      <div class=\\\"ico\\\">📊</div>\u001b[39m\n\u001b[31m      <div class=\\\"ttl\\\">Drop Excel here, or tap to choose</div>\u001b[39m\n\u001b[31m      <div class=\\\"sub\\\">Required: <b>store name</b> + <b>address</b><br>Optional: <b>city</b>, <b>lat</b>, <b>lng</b>, <b>phone</b>, anything else.</div>\u001b[39m\n\u001b[31m    </label>·\u001b[39m\n\u001b[31m    <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\">Origin</div>\u001b[39m\n\u001b[31m      <div class=\\\"field\\\">\u001b[39m\n\u001b[31m        <label>Start point</label>\u001b[39m\n\u001b[31m        <input type=\\\"text\\\" id=\\\"inp-origin\\\" value=\\\"Mohali office Punjab\\\" placeholder=\\\"Address or place name\\\">\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div class=\\\"toggle\\\">\u001b[39m\n\u001b[31m        <input type=\\\"checkbox\\\" id=\\\"inp-roundtrip\\\" checked>\u001b[39m\n\u001b[31m        <label for=\\\"inp-roundtrip\\\" style=\\\"cursor:pointer;font-size:13px;font-weight:600\\\">Round-trip (return to start)</label>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"dest-wrap\\\" class=\\\"field\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m        <label>Final destination</label>\u001b[39m\n\u001b[31m        <input type=\\\"text\\\" id=\\\"inp-destination\\\" placeholder=\\\"Where to end if not returning to origin\\\">\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" id=\\\"btn-go\\\" onclick=\\\"parseAndOptimise()\\\" disabled>\u001b[39m\n\u001b[31m      📐 Parse + optimise route\u001b[39m\n\u001b[31m    </button>·\u001b[39m\n\u001b[31m    <div id=\\\"upload-progress\\\" style=\\\"display:none;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;font-size:12px\\\">\u001b[39m\n\u001b[31m        <span id=\\\"prog-label\\\">Geocoding…</span>\u001b[39m\n\u001b[31m        <span id=\\\"prog-count\\\" style=\\\"color:var(--muted)\\\">0 / 0</span>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div class=\\\"progress\\\"><div class=\\\"progress-bar\\\" id=\\\"prog-bar\\\" style=\\\"width:0%\\\"></div></div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div id=\\\"parsed-preview\\\" style=\\\"display:none;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:11px;font-size:12px\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\" style=\\\"margin-bottom:6px\\\">Parsed</div>\u001b[39m\n\u001b[31m      <div id=\\\"parsed-summary\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: EDITOR ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-editor\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"confirmBack()\\\">‹ New</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>Tour Editor</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"editor-sub\\\">— stops</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-2\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"openShare()\\\">📤</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"editor-body\\\">\u001b[39m\n\u001b[31m   <div class=\\\"editor-fixed\\\">·\u001b[39m\n\u001b[31m    <div class=\\\"origin-pill\\\" id=\\\"origin-pill\\\">\u001b[39m\n\u001b[31m      <span class=\\\"ico\\\">📍</span>\u001b[39m\n\u001b[31m      <div class=\\\"meta\\\">\u001b[39m\n\u001b[31m        <div class=\\\"lbl\\\">Start from</div>\u001b[39m\n\u001b[31m        <div class=\\\"val\\\" id=\\\"origin-val\\\">—</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button onclick=\\\"openOriginEdit()\\\">✏️ Change</button>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div id=\\\"origin-edit\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m        <div class=\\\"field\\\">\u001b[39m\n\u001b[31m          <label>New origin (for mid-tour replan)</label>\u001b[39m\n\u001b[31m          <input type=\\\"text\\\" id=\\\"inp-new-origin\\\" placeholder=\\\"Hotel B Mohali, or address…\\\">\u001b[39m\n\u001b[31m          <div style=\\\"font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4\\\">\u001b[39m\n\u001b[31m            Tip: toggle ✗ on completed stops, change origin to where you are now, hit Replan.\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div style=\\\"display:flex;gap:8px\\\">\u001b[39m\n\u001b[31m          <button class=\\\"btn btn-s\\\" onclick=\\\"closeOriginEdit()\\\" style=\\\"flex:1\\\">Cancel</button>\u001b[39m\n\u001b[31m          <button class=\\\"btn btn-p\\\" onclick=\\\"applyOriginAndReplan()\\\" style=\\\"flex:2\\\">🔄 Replan from here</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"top-actions\\\" id=\\\"top-actions\\\">\u001b[39m\n\u001b[31m      <div class=\\\"top-totals\\\">\u001b[39m\n\u001b[31m        <div class=\\\"top-totals-big\\\" id=\\\"top-totals-big\\\">— km · — min<span class=\\\"recalc\\\" id=\\\"recalc\\\">↻</span></div>\u001b[39m\n\u001b[31m        <div class=\\\"top-totals-delta\\\" id=\\\"top-totals-delta\\\">vs proposed: —</div>\u001b[39m\n\u001b[31m        <div class=\\\"reopt-needed\\\" id=\\\"reopt-needed\\\">⚠ Stops changed — tap 🔁 Re-optim to refresh the route plan</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"top-share\\\" onclick=\\\"openShare()\\\">📤 Share</button>\u001b[39m\n\u001b[31m      <button class=\\\"top-reopt\\\" onclick=\\\"replanAll()\\\">🔁 Re-optim</button>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"map-wrap\\\" id=\\\"map-wrap\\\">\u001b[39m\n\u001b[31m      <div class=\\\"map-hdr\\\">\u001b[39m\n\u001b[31m        <div class=\\\"ttl\\\">🗺️ Route preview <span style=\\\"opacity:.6;font-weight:400\\\">(straight-line)</span></div>\u001b[39m\n\u001b[31m        <button class=\\\"toggle\\\" id=\\\"map-toggle\\\" onclick=\\\"toggleMap()\\\" aria-label=\\\"Toggle map\\\">▾</button>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"tour-map\\\"></div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"section-title\\\" style=\\\"margin-top:2px\\\">Stops (drag ≡ to reorder · scroll ↕ to see all)</div>\u001b[39m\n\u001b[31m   </div><!-- /editor-fixed -->·\u001b[39m\n\u001b[31m   <div class=\\\"editor-scroll\\\" id=\\\"editor-scroll\\\">\u001b[39m\n\u001b[31m    <div id=\\\"tiles\\\" class=\\\"tiles\\\"></div>\u001b[39m\n\u001b[31m   </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── MODAL: SHARE ── -->\u001b[39m\n\u001b[31m<div class=\\\"modal\\\" id=\\\"modal-share\\\">\u001b[39m\n\u001b[31m  <div class=\\\"modal-box\\\">\u001b[39m\n\u001b[31m    <div class=\\\"modal-hdr\\\">\u001b[39m\n\u001b[31m      <div class=\\\"modal-ttl\\\">📤 Share / Export</div>\u001b[39m\n\u001b[31m      <button class=\\\"modal-close\\\" onclick=\\\"closeShare()\\\">×</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"share-summary\\\" style=\\\"background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:10px;font-size:12px;line-height:1.6;white-space:pre-wrap;font-family:monospace\\\"></div>\u001b[39m\n\u001b[31m    <div style=\\\"margin-top:12px;display:grid;gap:8px\\\">\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"openInGoogleMaps()\\\">🗺️ Open route in Google Maps</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"shareWhatsApp()\\\">💬 WhatsApp</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"copyText()\\\">📋 Copy text</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"downloadCsv()\\\">📥 Download CSV</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"saveLocal()\\\">💾 Save to this device</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<div id=\\\"toast\\\"></div>·\u001b[39m\n\u001b[31m<script>\u001b[39m\n\u001b[31m'use strict';·\u001b[39m\n\u001b[31mconst CACHE_VER = 'tour-pg-v11';\u001b[39m\n\u001b[31mconst PROXY     = '/tour-pg-proxy';·\u001b[39m\n\u001b[31m// ── State ────────────────────────────────────────────\u001b[39m\n\u001b[31mlet session   = null;\u001b[39m\n\u001b[31mlet myId      = '';\u001b[39m\n\u001b[31mlet myName    = '';\u001b[39m\n\u001b[31mlet state     = newState();\u001b[39m\n\u001b[31mlet _recalcTimer = null;\u001b[39m\n\u001b[31mlet _sortable    = null;·\u001b[39m\n\u001b[31mfunction newState() {\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    origin:     { text: 'Mohali office Punjab', location: null, provider: null },\u001b[39m\n\u001b[31m    destination:null,\u001b[39m\n\u001b[31m    roundTrip:  true,\u001b[39m\n\u001b[31m    stops:      [],          // { id, name, city, address, phone, location, included, legDistance_m, legDuration_s, provider, raw, geocodeErr }\u001b[39m\n\u001b[31m    proposedOrder: null,     // array of stop IDs in optimise-time order\u001b[39m\n\u001b[31m    baseTotal: { distance_m: 0, duration_s: 0 },\u001b[39m\n\u001b[31m    currentTotal: { distance_m: 0, duration_s: 0 },\u001b[39m\n\u001b[31m    lastOptimisation: null,  // 'tomtom-computeBestOrder' | 'matrix-nn-2opt'\u001b[39m\n\u001b[31m    needsReoptimize: false,  // true after include/exclude toggle → totals may be stale; prompt user to tap Re-optim\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction uid(prefix='st') { return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,6)}`; }·\u001b[39m\n\u001b[31m// ── Auth ─────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction init() {\u001b[39m\n\u001b[31m  registerSW();\u001b[39m\n\u001b[31m  const raw = localStorage.getItem('lm360-session');\u001b[39m\n\u001b[31m  // Bounce to Hub with ?next= so user lands back here after login (Hub honours\u001b[39m\n\u001b[31m  // same-origin relative paths). Pattern documented in\u001b[39m\n\u001b[31m  // feedback_hub_next_redirect.md memory file — all gated PWAs should follow.\u001b[39m\n\u001b[31m  const nextParam = '?next=' + encodeURIComponent(location.pathname + location.search);\u001b[39m\n\u001b[31m  if (!raw) { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  try { session = JSON.parse(raw); } catch { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  if (Date.now() - (session.loginAt ? new Date(session.loginAt).getTime() : 0) > 12*3600*1000) { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  myId   = session.empId || '';\u001b[39m\n\u001b[31m  myName = session.name  || myId;\u001b[39m\n\u001b[31m  const userText = `👤 <b>${escHtml(myName)}</b>`;\u001b[39m\n\u001b[31m  document.getElementById('user-pill').innerHTML   = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill-2').innerHTML = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill').style.display   = '';\u001b[39m\n\u001b[31m  document.getElementById('user-pill-2').style.display = '';·\u001b[39m\n\u001b[31m  // Load-saved chip if we have a saved tour\u001b[39m\n\u001b[31m  if (localStorage.getItem('tour-pg-saved')) document.getElementById('btn-load').style.display = '';·\u001b[39m\n\u001b[31m  // Wire UI\u001b[39m\n\u001b[31m  document.getElementById('inp-roundtrip').addEventListener('change', e => {\u001b[39m\n\u001b[31m    document.getElementById('dest-wrap').style.display = e.target.checked ? 'none' : '';\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('dragover', e => { e.preventDefault(); e.currentTarget.classList.add('drag'); });\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('dragleave', e => e.currentTarget.classList.remove('drag'));\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('drop', e => {\u001b[39m\n\u001b[31m    e.preventDefault();\u001b[39m\n\u001b[31m    e.currentTarget.classList.remove('drag');\u001b[39m\n\u001b[31m    const f = e.dataTransfer.files[0]; if (f) handleFile(f);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  document.getElementById('excel-file').addEventListener('change', e => {\u001b[39m\n\u001b[31m    const f = e.target.files[0]; if (f) handleFile(f);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction hubAuth() {\u001b[39m\n\u001b[31m  return 'Hub ' + btoa(JSON.stringify(session));\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function api(path, body) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, {\u001b[39m\n\u001b[31m    method: 'POST',\u001b[39m\n\u001b[31m    headers: { 'Authorization': hubAuth(), 'Content-Type': 'application/json' },\u001b[39m\n\u001b[31m    body: JSON.stringify(body),\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (r.status === 401) {\u001b[39m\n\u001b[31m    toast('Session expired — refreshing to re-login', 'err');\u001b[39m\n\u001b[31m    const nextParam = '?next=' + encodeURIComponent(location.pathname + location.search);\u001b[39m\n\u001b[31m    setTimeout(() => location.href = '/hub/' + nextParam, 1500);\u001b[39m\n\u001b[31m    throw new Error('unauthorized');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!r.ok) {\u001b[39m\n\u001b[31m    const txt = await r.text().catch(() => '');\u001b[39m\n\u001b[31m    throw new Error(`${path}: ${r.status} ${txt.slice(0, 100)}`);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return r.json();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Excel parsing + column detection ────────────────\u001b[39m\n\u001b[31mlet _parsedCols = null;   // exposed for the column-remap UI\u001b[39m\n\u001b[31mlet _parsedRows = null;\u001b[39m\n\u001b[31mfunction handleFile(file) {\u001b[39m\n\u001b[31m  const reader = new FileReader();\u001b[39m\n\u001b[31m  reader.onload = (e) => {\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      const wb = XLSX.read(e.target.result, { type: 'array' });\u001b[39m\n\u001b[31m      const ws = wb.Sheets[wb.SheetNames[0]];\u001b[39m\n\u001b[31m      const rows = XLSX.utils.sheet_to_json(ws, { defval: '' });\u001b[39m\n\u001b[31m      if (!rows.length) throw new Error('Empty sheet');\u001b[39m\n\u001b[31m      _parsedRows = rows;\u001b[39m\n\u001b[31m      _parsedCols = detectColumns(rows[0]);\u001b[39m\n\u001b[31m      if (!_parsedCols.name && !_parsedCols.address && !_parsedCols.city) throw new Error('Missing required columns (need at least one of: name, address, city)');\u001b[39m\n\u001b[31m      rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m      renderParsedPreview();\u001b[39m\n\u001b[31m      document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m      toast('Excel parsed ✓', 'ok');\u001b[39m\n\u001b[31m    } catch (err) {\u001b[39m\n\u001b[31m      toast('Excel error: ' + err.message, 'err');\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  reader.readAsArrayBuffer(file);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction rebuildStopsFromParsedRows() {\u001b[39m\n\u001b[31m  state.stops = _parsedRows.map((row, idx) => buildStop(row, _parsedCols, idx)).filter(s => s);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderParsedPreview() {\u001b[39m\n\u001b[31m  const skip = _parsedRows.length - state.stops.length;\u001b[39m\n\u001b[31m  const allCols = Object.keys(_parsedRows[0]);\u001b[39m\n\u001b[31m  // For each role, show:  <role>: <selected col name>  with a tiny dropdown to override\u001b[39m\n\u001b[31m  const ROLES = ['name','city','address','lat','lng','phone','notes'];\u001b[39m\n\u001b[31m  const sample = _parsedRows[0];\u001b[39m\n\u001b[31m  const previewVal = (key) => {\u001b[39m\n\u001b[31m    const v = sample[key]; if (v == null || v === '') return '';\u001b[39m\n\u001b[31m    return String(v).slice(0, 40);\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  // Column order (per user request): YOUR Excel column first, then the PWA field\u001b[39m\n\u001b[31m  // it maps to, then a sample value. Equal-ish widths via <colgroup>; the wrapper\u001b[39m\n\u001b[31m  // scrolls horizontally so long header names are never clipped on narrow phones.\u001b[39m\n\u001b[31m  let rows = '<div class=\\\"map-table-wrap\\\"><table class=\\\"map-table\\\">' +\u001b[39m\n\u001b[31m    '<colgroup><col class=\\\"c-src\\\"><col class=\\\"c-field\\\"><col class=\\\"c-sample\\\"></colgroup>' +\u001b[39m\n\u001b[31m    '<thead><tr><th>Your file column</th><th>→ PWA field</th><th>Sample value</th></tr></thead><tbody>';\u001b[39m\n\u001b[31m  for (const role of ROLES) {\u001b[39m\n\u001b[31m    const sel  = _parsedCols[role];\u001b[39m\n\u001b[31m    const need = role === 'name';\u001b[39m\n\u001b[31m    const opts = `<option value=\\\"\\\">— ignore —</option>` +\u001b[39m\n\u001b[31m      allCols.map(c => `<option value=\\\"${escHtml(c)}\\\" ${sel === c ? 'selected' : ''}>${escHtml(c)}</option>`).join('');\u001b[39m\n\u001b[31m    const previewText = sel ? escHtml(previewVal(sel)) : '';\u001b[39m\n\u001b[31m    const tip = !sel && need ? `<span style=\\\"color:var(--y);font-weight:700\\\">⚠ no match — uses city or \\\"Stop #N\\\"</span>` : '';\u001b[39m\n\u001b[31m    rows += `<tr>\u001b[39m\n\u001b[31m      <td><select onchange=\\\"remapColumn('${role}', this.value)\\\">${opts}</select></td>\u001b[39m\n\u001b[31m      <td class=\\\"map-field\\\">${role}${need?' <span style=\\\"color:var(--y)\\\">*</span>':''}</td>\u001b[39m\n\u001b[31m      <td class=\\\"map-sample\\\">${previewText}${tip}</td>\u001b[39m\n\u001b[31m    </tr>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  rows += '</tbody></table></div>';\u001b[39m\n\u001b[31m  document.getElementById('parsed-preview').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('parsed-summary').innerHTML =\u001b[39m\n\u001b[31m    `<div style=\\\"margin-bottom:6px\\\"><b>${state.stops.length}</b> stops detected` +\u001b[39m\n\u001b[31m    `${skip > 0 ? ` · ${skip} skipped (no name/city/address)` : ''}</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:10.5px;color:var(--muted);margin-bottom:6px\\\">If detection is wrong, pick the correct column from the dropdowns:</div>` +\u001b[39m\n\u001b[31m    rows;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction remapColumn(role, newCol) {\u001b[39m\n\u001b[31m  if (!_parsedCols) return;\u001b[39m\n\u001b[31m  _parsedCols[role] = newCol || null;\u001b[39m\n\u001b[31m  rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction normaliseColName(s) {\u001b[39m\n\u001b[31m  return String(s).toLowerCase().replace(/[^a-z]/g, '');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction detectColumns(sampleRow) {\u001b[39m\n\u001b[31m  const map = { name:null, city:null, address:null, lat:null, lng:null, phone:null, notes:null, others:[] };\u001b[39m\n\u001b[31m  const keys = Object.keys(sampleRow);\u001b[39m\n\u001b[31m  // Broadened aliases so most user-provided Excel files map sensibly without a UI:\u001b[39m\n\u001b[31m  //   name  ← store / shop / outlet / party / dealer / firm / customer / business /\u001b[39m\n\u001b[31m  //           company / vendor / agency (and \\\"<thing> name\\\")\u001b[39m\n\u001b[31m  //   addr  ← address / fulladdress / location / loc / addr\u001b[39m\n\u001b[31m  //   city  ← city / town / district / location (only if no address column took it)\u001b[39m\n\u001b[31m  for (const k of keys) {\u001b[39m\n\u001b[31m    const n = normaliseColName(k);\u001b[39m\n\u001b[31m    if (!map.name && /^(storename|name|store|client|outlet|party(name)?|dealer(name)?|firm(name)?|customer(name)?|shop(name)?|business(name)?|companyname|company|vendor(name)?|agency(name)?|merchant|chain|sitename|site)$/.test(n)) { map.name = k; continue; }\u001b[39m\n\u001b[31m    if (!map.address && /^(address|fulladdress|streetaddress|addr|location|loc|street)$/.test(n)) { map.address = k; continue; }\u001b[39m\n\u001b[31m    if (!map.city && /^(city|town|district|cityname|locality|area|region)$/.test(n)) { map.city = k; continue; }\u001b[39m\n\u001b[31m    if (!map.lat && /^(lat|latitude|y)$/.test(n)) { map.lat = k; continue; }\u001b[39m\n\u001b[31m    if (!map.lng && /^(lng|lon|long|longitude|x)$/.test(n)) { map.lng = k; continue; }\u001b[39m\n\u001b[31m    if (!map.phone && /^(phone(no)?|mobile(no)?|contact(no)?|tel(no)?|whatsapp|wa|cell)$/.test(n)) { map.phone = k; continue; }\u001b[39m\n\u001b[31m    if (!map.notes && /^(notes?|remarks?|comments?|description|desc|memo)$/.test(n)) { map.notes = k; continue; }\u001b[39m\n\u001b[31m    map.others.push(k);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return map;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction buildStop(row, cols, idx) {\u001b[39m\n\u001b[31m  const name    = cols.name    ? String(row[cols.name]   || '').trim() : '';\u001b[39m\n\u001b[31m  const address = cols.address ? String(row[cols.address]|| '').trim() : '';\u001b[39m\n\u001b[31m  const city    = cols.city    ? String(row[cols.city]   || '').trim() : '';\u001b[39m\n\u001b[31m  const phone   = cols.phone   ? String(row[cols.phone]  || '').trim() : '';\u001b[39m\n\u001b[31m  const notes   = cols.notes   ? String(row[cols.notes]  || '').trim() : '';\u001b[39m\n\u001b[31m  if (!name && !address && !city) return null;\u001b[39m\n\u001b[31m  // pre-resolved coords?\u001b[39m\n\u001b[31m  let location = null;\u001b[39m\n\u001b[31m  if (cols.lat && cols.lng) {\u001b[39m\n\u001b[31m    const lat = parseFloat(row[cols.lat]);\u001b[39m\n\u001b[31m    const lng = parseFloat(row[cols.lng]);\u001b[39m\n\u001b[31m    if (!isNaN(lat) && !isNaN(lng) && lat !== 0 && lng !== 0) location = { lat, lng };\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const fullAddr = [address, city].filter(Boolean).join(', ');\u001b[39m\n\u001b[31m  // Display name fallback hierarchy (NEVER truncated address — that confused users):\u001b[39m\n\u001b[31m  //   1. Explicit name column value\u001b[39m\n\u001b[31m  //   2. City + index (so excluded/duplicate cities stay distinguishable)\u001b[39m\n\u001b[31m  //   3. \\\"Stop #N\\\"\u001b[39m\n\u001b[31m  let displayName;\u001b[39m\n\u001b[31m  if (name) displayName = name;\u001b[39m\n\u001b[31m  else if (city) displayName = `${city} (stop ${idx + 1})`;\u001b[39m\n\u001b[31m  else displayName = `Stop ${idx + 1}`;\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    id:           uid('st'),\u001b[39m\n\u001b[31m    name:         displayName,\u001b[39m\n\u001b[31m    hasExplicitName: !!name,         // for UI to flag \\\"name auto-derived\\\"\u001b[39m\n\u001b[31m    city, phone, notes,\u001b[39m\n\u001b[31m    address:      fullAddr || name || '',\u001b[39m\n\u001b[31m    location,\u001b[39m\n\u001b[31m    provider:     location ? 'excel' : null,\u001b[39m\n\u001b[31m    included:     true,\u001b[39m\n\u001b[31m    legDistance_m: 0,\u001b[39m\n\u001b[31m    legDuration_s: 0,\u001b[39m\n\u001b[31m    geocodeErr:   null,\u001b[39m\n\u001b[31m    raw:          { ...row },\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Geocode + optimise ──────────────────────────────\u001b[39m\n\u001b[31masync function parseAndOptimise() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('Parse an Excel first', 'err');\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = true;\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = '';\u001b[39m\n\u001b[31m  state.origin.text  = document.getElementById('inp-origin').value.trim() || 'Mohali office Punjab';\u001b[39m\n\u001b[31m  state.roundTrip    = document.getElementById('inp-roundtrip').checked;\u001b[39m\n\u001b[31m  state.destination  = state.roundTrip ? null : (document.getElementById('inp-destination').value.trim() || null);\u001b[39m\n\u001b[31m  // Geocode origin first\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: state.origin.text });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin.location = og.location;\u001b[39m\n\u001b[31m    state.origin.provider = og.provider;\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    setProgress(0, 0, '');\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('Origin geocode failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Geocode each stop (skip if pre-resolved). Sequential to avoid rate limits.\u001b[39m\n\u001b[31m  const total = state.stops.length;\u001b[39m\n\u001b[31m  let done = 0;\u001b[39m\n\u001b[31m  for (const s of state.stops) {\u001b[39m\n\u001b[31m    if (s.location) { done++; setProgress(done, total, 'Pre-geocoded'); continue; }\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      const r = await api('/geocode', { address: s.address });\u001b[39m\n\u001b[31m      if (r.ok && r.location) {\u001b[39m\n\u001b[31m        s.location = r.location;\u001b[39m\n\u001b[31m        s.provider = r.provider;\u001b[39m\n\u001b[31m      } else {\u001b[39m\n\u001b[31m        s.geocodeErr = r.error || 'No match';\u001b[39m\n\u001b[31m      }\u001b[39m\n\u001b[31m    } catch (e) {\u001b[39m\n\u001b[31m      s.geocodeErr = e.message;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    done++;\u001b[39m\n\u001b[31m    setProgress(done, total, `Geocoding ${s.name.slice(0, 30)}…`);\u001b[39m\n\u001b[31m    // Tiny delay to be polite to the provider\u001b[39m\n\u001b[31m    await sleep(80);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Filter out failed-geocode stops\u001b[39m\n\u001b[31m  const good = state.stops.filter(s => s.location && !s.geocodeErr);\u001b[39m\n\u001b[31m  const bad  = state.stops.filter(s => !s.location || s.geocodeErr);\u001b[39m\n\u001b[31m  if (!good.length) {\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('No stops could be geocoded', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (bad.length) toast(`${bad.length} stop(s) failed geocode (excluded)`, 'err');\u001b[39m\n\u001b[31m  state.stops = good;·\u001b[39m\n\u001b[31m  // Optimise\u001b[39m\n\u001b[31m  setProgress(total, total, 'Optimising route…');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const opt = await api('/optimize', {\u001b[39m\n\u001b[31m      origin:    locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:     state.stops.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:  state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!opt.ok) throw new Error(opt.error || 'optimise failed');\u001b[39m\n\u001b[31m    // Apply order\u001b[39m\n\u001b[31m    const order = opt.waypoint_order || state.stops.map((_, i) => i);\u001b[39m\n\u001b[31m    state.stops = order.map(i => state.stops[i]);\u001b[39m\n\u001b[31m    state.proposedOrder = state.stops.map(s => s.id);\u001b[39m\n\u001b[31m    state.baseTotal     = { distance_m: opt.total_distance_m, duration_s: opt.total_duration_s };\u001b[39m\n\u001b[31m    state.currentTotal  = { ...state.baseTotal };\u001b[39m\n\u001b[31m    state.lastOptimisation = opt.optimisation;\u001b[39m\n\u001b[31m    state.needsReoptimize  = false;   // fresh optimise — flag cleared\u001b[39m\n\u001b[31m    // Map legs to stops (route returned len = stops + 1 (round-trip) or stops + 1 (one-way))\u001b[39m\n\u001b[31m    if (Array.isArray(opt.legs) && opt.legs.length >= state.stops.length) {\u001b[39m\n\u001b[31m      state.stops.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = opt.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('Optimise failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m  showScreen('s-editor');\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction setProgress(done, total, label) {\u001b[39m\n\u001b[31m  const pct = total ? Math.round(done/total*100) : 0;\u001b[39m\n\u001b[31m  document.getElementById('prog-bar').style.width   = pct + '%';\u001b[39m\n\u001b[31m  document.getElementById('prog-count').textContent = `${done} / ${total}`;\u001b[39m\n\u001b[31m  document.getElementById('prog-label').textContent = label;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction locStr(loc) { return loc ? `${loc.lat},${loc.lng}` : null; }\u001b[39m\n\u001b[31mconst sleep = ms => new Promise(r => setTimeout(r, ms));·\u001b[39m\n\u001b[31m// ── Editor screen ───────────────────────────────────\u001b[39m\n\u001b[31mfunction renderEditor() {\u001b[39m\n\u001b[31m  restoreMapPref();\u001b[39m\n\u001b[31m  document.getElementById('origin-val').textContent = state.origin.text;\u001b[39m\n\u001b[31m  document.getElementById('editor-sub').textContent =\u001b[39m\n\u001b[31m    `${state.stops.filter(s => s.included).length} of ${state.stops.length} included` +\u001b[39m\n\u001b[31m    (state.lastOptimisation ? ` · ${state.lastOptimisation}` : '');\u001b[39m\n\u001b[31m  const cont = document.getElementById('tiles');\u001b[39m\n\u001b[31m  cont.innerHTML = '';\u001b[39m\n\u001b[31m  state.stops.forEach((s, idx) => cont.appendChild(renderTile(s, idx)));\u001b[39m\n\u001b[31m  if (_sortable) _sortable.destroy();\u001b[39m\n\u001b[31m  _sortable = Sortable.create(cont, {\u001b[39m\n\u001b[31m    handle: '.grip',\u001b[39m\n\u001b[31m    animation: 150,\u001b[39m\n\u001b[31m    onEnd: (evt) => {\u001b[39m\n\u001b[31m      const moved = state.stops.splice(evt.oldIndex, 1)[0];\u001b[39m\n\u001b[31m      state.stops.splice(evt.newIndex, 0, moved);\u001b[39m\n\u001b[31m      renderEditor();\u001b[39m\n\u001b[31m      scheduleRecalc();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  updateTotalsBar();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderTile(s, idx) {\u001b[39m\n\u001b[31m  const el = document.createElement('div');\u001b[39m\n\u001b[31m  el.className = 'tile' + (s.included ? '' : ' excluded') + (s.geocodeErr ? ' err' : '');\u001b[39m\n\u001b[31m  el.dataset.id = s.id;\u001b[39m\n\u001b[31m  const providerBadge = s.provider ? `<span class=\\\"badge-provider\\\">${escHtml(s.provider)}</span>` : '';\u001b[39m\n\u001b[31m  const leg = s.legDistance_m > 0\u001b[39m\n\u001b[31m    ? `${(s.legDistance_m/1000).toFixed(1)} km · ${Math.round(s.legDuration_s/60)} min from prev`\u001b[39m\n\u001b[31m    : '';\u001b[39m\n\u001b[31m  // Per UX rule: Name + City are the primary display. Address is shown\u001b[39m\n\u001b[31m  // smaller below as supporting context. Falls back gracefully when city\u001b[39m\n\u001b[31m  // is missing.\u001b[39m\n\u001b[31m  const cityLine = s.city || '—';\u001b[39m\n\u001b[31m  const addrLine = s.address && s.address !== s.city ? s.address : '';\u001b[39m\n\u001b[31m  el.innerHTML = `\u001b[39m\n\u001b[31m    <span class=\\\"grip\\\">≡</span>\u001b[39m\n\u001b[31m    <span class=\\\"ord\\\">${idx + 1}</span>\u001b[39m\n\u001b[31m    <div class=\\\"info\\\">\u001b[39m\n\u001b[31m      <div class=\\\"name\\\">${escHtml(s.name)} ${providerBadge}</div>\u001b[39m\n\u001b[31m      <div class=\\\"city\\\">${escHtml(cityLine)}</div>\u001b[39m\n\u001b[31m      ${addrLine ? `<div class=\\\"addr\\\">${escHtml(addrLine)}</div>` : ''}\u001b[39m\n\u001b[31m      ${leg ? `<div class=\\\"leg\\\">${escHtml(leg)}</div>` : ''}\u001b[39m\n\u001b[31m      ${s.geocodeErr ? `<div class=\\\"leg\\\" style=\\\"color:var(--r)\\\">⚠ ${escHtml(s.geocodeErr)}</div>` : ''}\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"incl ${s.included ? 'on' : 'off'}\\\" onclick=\\\"toggleInclude('${s.id}')\\\" title=\\\"Include / exclude\\\">\u001b[39m\n\u001b[31m      ${s.included ? '✓' : '✗'}\u001b[39m\n\u001b[31m    </button>`;\u001b[39m\n\u001b[31m  return el;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleInclude(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id); if (!s) return;\u001b[39m\n\u001b[31m  s.included = !s.included;\u001b[39m\n\u001b[31m  // The SET of stops just changed — the existing route may no longer be optimal.\u001b[39m\n\u001b[31m  // Surface a banner + pulse the Re-optim button so the user knows to refresh.\u001b[39m\n\u001b[31m  // recalcRoute still fires (debounced) to update the cost of the CURRENT order,\u001b[39m\n\u001b[31m  // but that number is just informational until the user re-optimises.\u001b[39m\n\u001b[31m  state.needsReoptimize = true;\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  scheduleRecalc();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction scheduleRecalc() {\u001b[39m\n\u001b[31m  clearTimeout(_recalcTimer);\u001b[39m\n\u001b[31m  document.getElementById('recalc').classList.add('show');\u001b[39m\n\u001b[31m  _recalcTimer = setTimeout(recalcRoute, 400);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function recalcRoute() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  if (!included.length) {\u001b[39m\n\u001b[31m    state.currentTotal = { distance_m: 0, duration_s: 0 };\u001b[39m\n\u001b[31m    document.getElementById('recalc').classList.remove('show');\u001b[39m\n\u001b[31m    updateTotalsBar();\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/route', {\u001b[39m\n\u001b[31m      origin:      locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:       included.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:   state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!r.ok) throw new Error(r.error || 'route failed');\u001b[39m\n\u001b[31m    state.currentTotal = { distance_m: r.total_distance_m, duration_s: r.total_duration_s };\u001b[39m\n\u001b[31m    // Map legs back to tiles (legs index 0 = origin→stop0, n = lastStop→dest)\u001b[39m\n\u001b[31m    if (Array.isArray(r.legs)) {\u001b[39m\n\u001b[31m      included.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = r.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    // Clear leg info on excluded stops\u001b[39m\n\u001b[31m    state.stops.filter(s => !s.included).forEach(s => { s.legDistance_m = 0; s.legDuration_s = 0; });\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Recalc failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  } finally {\u001b[39m\n\u001b[31m    document.getElementById('recalc').classList.remove('show');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateTotalsBar() {\u001b[39m\n\u001b[31m  // Totals now live ONLY in the top-actions card (the sticky bottom bar was\u001b[39m\n\u001b[31m  // removed after user feedback that it duplicated the top info 2026-06-04).\u001b[39m\n\u001b[31m  // The big-text element holds the inline recalc spinner as a child <span>.\u001b[39m\n\u001b[31m  const km  = (state.currentTotal.distance_m / 1000).toFixed(1);\u001b[39m\n\u001b[31m  const min = Math.round(state.currentTotal.duration_s / 60);\u001b[39m\n\u001b[31m  const hrs = Math.floor(min / 60), mm = min % 60;\u001b[39m\n\u001b[31m  const timeStr = hrs > 0 ? `${hrs}h ${mm}min` : `${mm} min`;\u001b[39m\n\u001b[31m  const totalsText = `${km} km · ${timeStr}`;\u001b[39m\n\u001b[31m  const bigEl  = document.getElementById('top-totals-big');\u001b[39m\n\u001b[31m  const recalc = document.getElementById('recalc');\u001b[39m\n\u001b[31m  bigEl.textContent = totalsText;\u001b[39m\n\u001b[31m  if (recalc) bigEl.appendChild(document.createTextNode(' '));\u001b[39m\n\u001b[31m  if (recalc) bigEl.appendChild(recalc);\u001b[39m\n\u001b[31m  // Delta vs base\u001b[39m\n\u001b[31m  const baseKm   = state.baseTotal.distance_m / 1000;\u001b[39m\n\u001b[31m  const curKm    = state.currentTotal.distance_m / 1000;\u001b[39m\n\u001b[31m  const deltaKm  = curKm - baseKm;\u001b[39m\n\u001b[31m  const deltaMin = Math.round((state.currentTotal.duration_s - state.baseTotal.duration_s) / 60);\u001b[39m\n\u001b[31m  const dT = document.getElementById('top-totals-delta');\u001b[39m\n\u001b[31m  let deltaText, deltaClass;\u001b[39m\n\u001b[31m  if (Math.abs(deltaKm) < 0.05) {\u001b[39m\n\u001b[31m    deltaText  = 'matches proposed route';\u001b[39m\n\u001b[31m    deltaClass = '';\u001b[39m\n\u001b[31m  } else {\u001b[39m\n\u001b[31m    const sign = deltaKm > 0 ? '+' : '';\u001b[39m\n\u001b[31m    deltaText  = `vs proposed: ${sign}${deltaKm.toFixed(1)} km · ${sign}${deltaMin} min`;\u001b[39m\n\u001b[31m    deltaClass = deltaKm < 0 ? 'better' : 'worse';\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  dT.textContent = deltaText;\u001b[39m\n\u001b[31m  dT.className   = 'top-totals-delta ' + deltaClass;·\u001b[39m\n\u001b[31m  // ── Re-optim-needed banner + pulse + stale styling ──\u001b[39m\n\u001b[31m  // Surfaces a clear message whenever the user has added/removed stops since\u001b[39m\n\u001b[31m  // the last /optimize call. The current-order cost is still shown (so the\u001b[39m\n\u001b[31m  // user sees \\\"what this order costs\\\"), but it's struck-through and the\u001b[39m\n\u001b[31m  // Re-optim button pulses to draw attention.\u001b[39m\n\u001b[31m  const needsReopt = !!state.needsReoptimize;\u001b[39m\n\u001b[31m  document.getElementById('reopt-needed').classList.toggle('show', needsReopt);\u001b[39m\n\u001b[31m  bigEl.classList.toggle('stale', needsReopt);\u001b[39m\n\u001b[31m  dT.classList.toggle('stale', needsReopt);\u001b[39m\n\u001b[31m  document.querySelector('.top-reopt')?.classList.toggle('pulse', needsReopt);·\u001b[39m\n\u001b[31m  // Re-render inline map preview to reflect current order/include flags.\u001b[39m\n\u001b[31m  // Debounced via setTimeout 0 so multiple updates in the same tick coalesce.\u001b[39m\n\u001b[31m  scheduleMapRender();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Inline route preview (Leaflet + free OSM tiles) ──────────────\u001b[39m\n\u001b[31m// Renders origin + stops + straight-line polyline inside the editor screen.\u001b[39m\n\u001b[31m// \\\"Straight-line\\\" because the proxy only returns leg distances/durations,\u001b[39m\n\u001b[31m// not the per-leg point arrays from TomTom — this preview is enough for\u001b[39m\n\u001b[31m// orientation; \\\"Open in Google Maps\\\" gives the user the real road geometry.\u001b[39m\n\u001b[31mlet _leafletMap   = null;\u001b[39m\n\u001b[31mlet _leafletLayer = null;\u001b[39m\n\u001b[31mlet _mapRenderTimer = null;·\u001b[39m\n\u001b[31mfunction ensureMap() {\u001b[39m\n\u001b[31m  if (_leafletMap) return _leafletMap;\u001b[39m\n\u001b[31m  if (typeof L === 'undefined') return null;   // CDN not ready yet\u001b[39m\n\u001b[31m  const el = document.getElementById('tour-map');\u001b[39m\n\u001b[31m  if (!el || !el.offsetParent) return null;    // container not visible yet\u001b[39m\n\u001b[31m  _leafletMap = L.map(el, { attributionControl: false, zoomControl: true, preferCanvas: true });\u001b[39m\n\u001b[31m  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18 })\u001b[39m\n\u001b[31m    .addTo(_leafletMap);\u001b[39m\n\u001b[31m  L.control.attribution({ position: 'bottomright', prefix: false })\u001b[39m\n\u001b[31m    .addAttribution('© OpenStreetMap contributors')\u001b[39m\n\u001b[31m    .addTo(_leafletMap);\u001b[39m\n\u001b[31m  return _leafletMap;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderMap() {\u001b[39m\n\u001b[31m  const map = ensureMap();\u001b[39m\n\u001b[31m  if (!map) return;\u001b[39m\n\u001b[31m  if (_leafletLayer) { map.removeLayer(_leafletLayer); _leafletLayer = null; }\u001b[39m\n\u001b[31m  const layers = [];\u001b[39m\n\u001b[31m  const linePts = [];\u001b[39m\n\u001b[31m  // Origin marker (always first in line)\u001b[39m\n\u001b[31m  if (state.origin?.location) {\u001b[39m\n\u001b[31m    const ll = [state.origin.location.lat, state.origin.location.lng];\u001b[39m\n\u001b[31m    layers.push(L.marker(ll, { icon: L.divIcon({\u001b[39m\n\u001b[31m      className: 'tour-pin origin', html: '<div>🏁</div>',\u001b[39m\n\u001b[31m      iconSize: [28, 28], iconAnchor: [14, 14],\u001b[39m\n\u001b[31m    })}).bindTooltip('Start: ' + (state.origin.text || 'origin'), { direction: 'top' }));\u001b[39m\n\u001b[31m    linePts.push(ll);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Stops in current visit order\u001b[39m\n\u001b[31m  let visitNum = 0;\u001b[39m\n\u001b[31m  state.stops.forEach(s => {\u001b[39m\n\u001b[31m    if (!s.location) return;\u001b[39m\n\u001b[31m    const ll = [s.location.lat, s.location.lng];\u001b[39m\n\u001b[31m    const included = s.included;\u001b[39m\n\u001b[31m    if (included) visitNum++;\u001b[39m\n\u001b[31m    layers.push(L.marker(ll, { icon: L.divIcon({\u001b[39m\n\u001b[31m      className: 'tour-pin' + (included ? '' : ' out'),\u001b[39m\n\u001b[31m      html: `<div>${included ? visitNum : '·'}</div>`,\u001b[39m\n\u001b[31m      iconSize: [26, 26], iconAnchor: [13, 13],\u001b[39m\n\u001b[31m    })}).bindTooltip(\u001b[39m\n\u001b[31m      (included ? `${visitNum}. ` : '✗ ') + s.name + (s.city ? ` · ${s.city}` : ''),\u001b[39m\n\u001b[31m      { direction: 'top' }\u001b[39m\n\u001b[31m    ));\u001b[39m\n\u001b[31m    if (included) linePts.push(ll);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  // Close the loop on round-trip\u001b[39m\n\u001b[31m  if (state.roundTrip && state.origin?.location) {\u001b[39m\n\u001b[31m    linePts.push([state.origin.location.lat, state.origin.location.lng]);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (linePts.length >= 2) {\u001b[39m\n\u001b[31m    layers.push(L.polyline(linePts, {\u001b[39m\n\u001b[31m      color: '#0ea5e9', weight: 3, opacity: 0.85, dashArray: '6 4',\u001b[39m\n\u001b[31m    }));\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  _leafletLayer = L.layerGroup(layers).addTo(map);\u001b[39m\n\u001b[31m  if (linePts.length) {\u001b[39m\n\u001b[31m    try { map.fitBounds(L.latLngBounds(linePts), { padding: [30, 30], maxZoom: 13 }); }\u001b[39m\n\u001b[31m    catch (_) {}\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction scheduleMapRender() {\u001b[39m\n\u001b[31m  if (_mapRenderTimer) return;\u001b[39m\n\u001b[31m  _mapRenderTimer = setTimeout(() => { _mapRenderTimer = null; renderMap(); }, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleMap() {\u001b[39m\n\u001b[31m  const wrap = document.getElementById('map-wrap');\u001b[39m\n\u001b[31m  const tog  = document.getElementById('map-toggle');\u001b[39m\n\u001b[31m  const open = !wrap.classList.toggle('collapsed');\u001b[39m\n\u001b[31m  tog.textContent = open ? '▾' : '▴';\u001b[39m\n\u001b[31m  try { localStorage.setItem('tour-pg-map-open', open ? '1' : '0'); } catch (_) {}\u001b[39m\n\u001b[31m  if (open && _leafletMap) {\u001b[39m\n\u001b[31m    // After expansion the container size changed — Leaflet must recompute\u001b[39m\n\u001b[31m    setTimeout(() => { _leafletMap.invalidateSize(); renderMap(); }, 220);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction restoreMapPref() {\u001b[39m\n\u001b[31m  // Default collapsed — user must explicitly expand once for it to stick open.\u001b[39m\n\u001b[31m  let open = false;\u001b[39m\n\u001b[31m  try { open = localStorage.getItem('tour-pg-map-open') === '1'; } catch (_) {}\u001b[39m\n\u001b[31m  const wrap = document.getElementById('map-wrap');\u001b[39m\n\u001b[31m  const tog  = document.getElementById('map-toggle');\u001b[39m\n\u001b[31m  wrap.classList.toggle('collapsed', !open);\u001b[39m\n\u001b[31m  tog.textContent = open ? '▾' : '▴';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Origin edit / mid-tour replan ────────────────────\u001b[39m\n\u001b[31mfunction openOriginEdit() {\u001b[39m\n\u001b[31m  document.getElementById('origin-edit').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('inp-new-origin').value = state.origin.text;\u001b[39m\n\u001b[31m  document.getElementById('inp-new-origin').focus();\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction closeOriginEdit() {\u001b[39m\n\u001b[31m  document.getElementById('origin-edit').style.display = 'none';\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31masync function applyOriginAndReplan() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('inp-new-origin').value.trim();\u001b[39m\n\u001b[31m  if (!txt) return toast('Enter an origin', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: txt });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin = { text: txt, location: og.location, provider: og.provider };\u001b[39m\n\u001b[31m    closeOriginEdit();\u001b[39m\n\u001b[31m    await replanAll();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Origin failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function replanAll() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  if (!included.length) return toast('No stops included', 'err');\u001b[39m\n\u001b[31m  toast('Optimising…', 'ok');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const opt = await api('/optimize', {\u001b[39m\n\u001b[31m      origin:      locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:       included.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:   state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!opt.ok) throw new Error(opt.error || 'optimise failed');\u001b[39m\n\u001b[31m    const order = opt.waypoint_order || included.map((_, i) => i);\u001b[39m\n\u001b[31m    const reordered = order.map(i => included[i]);\u001b[39m\n\u001b[31m    // KEEP EXCLUDED STOPS IN THEIR EXISTING POSITIONS (per user feedback\u001b[39m\n\u001b[31m    // 2026-06-04). Excluded slots stay put; reordered-included stops fill\u001b[39m\n\u001b[31m    // the gaps. Previously appended at end (confusing) → then dropped\u001b[39m\n\u001b[31m    // (lossy) → final: stay where they are. User can re-include to put\u001b[39m\n\u001b[31m    // them back into the optimised route.\u001b[39m\n\u001b[31m    const newStops = [];\u001b[39m\n\u001b[31m    let r = 0;\u001b[39m\n\u001b[31m    for (const s of state.stops) {\u001b[39m\n\u001b[31m      if (s.included) newStops.push(reordered[r++]);\u001b[39m\n\u001b[31m      else            newStops.push(s);\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    state.stops = newStops;\u001b[39m\n\u001b[31m    state.proposedOrder = state.stops.map(s => s.id);\u001b[39m\n\u001b[31m    state.baseTotal     = { distance_m: opt.total_distance_m, duration_s: opt.total_duration_s };\u001b[39m\n\u001b[31m    state.currentTotal  = { ...state.baseTotal };\u001b[39m\n\u001b[31m    state.lastOptimisation = opt.optimisation;\u001b[39m\n\u001b[31m    state.needsReoptimize  = false;   // re-optim done — banner cleared\u001b[39m\n\u001b[31m    if (Array.isArray(opt.legs)) {\u001b[39m\n\u001b[31m      reordered.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = opt.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Re-optimised ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Replan failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Share / export ──────────────────────────────────\u001b[39m\n\u001b[31mfunction openShare() {\u001b[39m\n\u001b[31m  const km   = (state.currentTotal.distance_m / 1000).toFixed(1);\u001b[39m\n\u001b[31m  const min  = Math.round(state.currentTotal.duration_s / 60);\u001b[39m\n\u001b[31m  const hrs  = Math.floor(min / 60), mm = min % 60;\u001b[39m\n\u001b[31m  const timeStr = hrs > 0 ? `${hrs}h ${mm}min` : `${mm} min`;\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  let txt = `🚗 Tour plan (${new Date().toLocaleDateString('en-IN')})\\\\n`;\u001b[39m\n\u001b[31m  txt += `From: ${state.origin.text}\\\\n`;\u001b[39m\n\u001b[31m  txt += `${included.length} stops · ${km} km · ${timeStr}\\\\n`;\u001b[39m\n\u001b[31m  if (state.roundTrip) txt += `Round-trip\\\\n\\\\n`;\u001b[39m\n\u001b[31m  else                  txt += `One-way → ${state.destination || included[included.length-1]?.address || ''}\\\\n\\\\n`;\u001b[39m\n\u001b[31m  included.forEach((s, i) => {\u001b[39m\n\u001b[31m    const legKm  = (s.legDistance_m/1000).toFixed(1);\u001b[39m\n\u001b[31m    const legMin = Math.round(s.legDuration_s/60);\u001b[39m\n\u001b[31m    txt += `${i+1}. ${s.name}`;\u001b[39m\n\u001b[31m    if (s.city) txt += ` · ${s.city}`;\u001b[39m\n\u001b[31m    if (s.legDistance_m > 0) txt += `  (+${legKm}km · ${legMin}min)`;\u001b[39m\n\u001b[31m    if (s.phone) txt += `  ${s.phone}`;\u001b[39m\n\u001b[31m    txt += `\\\\n`;\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (state.roundTrip) txt += `↩ Return to ${state.origin.text}`;\u001b[39m\n\u001b[31m  document.getElementById('share-summary').textContent = txt;\u001b[39m\n\u001b[31m  document.getElementById('modal-share').classList.add('show');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction closeShare() {\u001b[39m\n\u001b[31m  document.getElementById('modal-share').classList.remove('show');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction shareWhatsApp() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('share-summary').textContent;\u001b[39m\n\u001b[31m  const url = `https://wa.me/?text=${encodeURIComponent(txt)}`;\u001b[39m\n\u001b[31m  window.open(url, '_blank');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Hands off the optimised tour to Google Maps Directions (free, zero infra).\u001b[39m\n\u001b[31m// Uses path-style URL `dir/A/B/C/...` because it accepts many more stops than\u001b[39m\n\u001b[31m// the `api=1` Directions URL (which Google caps at 9 waypoints). Each segment\u001b[39m\n\u001b[31m// is \\\"lat,lng\\\" for geocoded points (most reliable). If the user picked a\u001b[39m\n\u001b[31m// text destination on a one-way tour we URL-encode it as the last segment.\u001b[39m\n\u001b[31mfunction openInGoogleMaps() {\u001b[39m\n\u001b[31m  if (!state.origin?.location) { toast('Origin not yet geocoded', 'err'); return; }\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included && s.location);\u001b[39m\n\u001b[31m  if (included.length === 0)   { toast('No included stops to view', 'err'); return; }\u001b[39m\n\u001b[31m  const fmt = (loc) => `${loc.lat.toFixed(6)},${loc.lng.toFixed(6)}`;\u001b[39m\n\u001b[31m  const segs = [ fmt(state.origin.location) ];\u001b[39m\n\u001b[31m  included.forEach(s => segs.push(fmt(s.location)));\u001b[39m\n\u001b[31m  if (state.roundTrip) {\u001b[39m\n\u001b[31m    segs.push(fmt(state.origin.location));\u001b[39m\n\u001b[31m  } else if (state.destination) {\u001b[39m\n\u001b[31m    segs.push(encodeURIComponent(state.destination));\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // travelmode and dir_action=navigate auto-open in driving directions\u001b[39m\n\u001b[31m  const url = `https://www.google.com/maps/dir/${segs.join('/')}/?travelmode=driving`;\u001b[39m\n\u001b[31m  window.open(url, '_blank', 'noopener');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function copyText() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('share-summary').textContent;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await navigator.clipboard.writeText(txt);\u001b[39m\n\u001b[31m    toast('Copied ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Copy failed', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction downloadCsv() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  const rows = [['#','Name','City','Address','Phone','Lat','Lng','Leg km','Leg min','Provider']];\u001b[39m\n\u001b[31m  included.forEach((s, i) => {\u001b[39m\n\u001b[31m    rows.push([\u001b[39m\n\u001b[31m      i + 1,\u001b[39m\n\u001b[31m      s.name, s.city, s.address, s.phone,\u001b[39m\n\u001b[31m      s.location?.lat ?? '', s.location?.lng ?? '',\u001b[39m\n\u001b[31m      (s.legDistance_m/1000).toFixed(2),\u001b[39m\n\u001b[31m      Math.round(s.legDuration_s/60),\u001b[39m\n\u001b[31m      s.provider || '',\u001b[39m\n\u001b[31m    ]);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  const csv = rows.map(r => r.map(c => `\\\"${String(c).replace(/\\\"/g, '\\\"\\\"')}\\\"`).join(',')).join('\\\\n');\u001b[39m\n\u001b[31m  const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });\u001b[39m\n\u001b[31m  const url = URL.createObjectURL(blob);\u001b[39m\n\u001b[31m  const a = document.createElement('a');\u001b[39m\n\u001b[31m  a.href = url; a.download = `tour-${new Date().toISOString().slice(0,10)}.csv`;\u001b[39m\n\u001b[31m  document.body.appendChild(a); a.click(); a.remove();\u001b[39m\n\u001b[31m  URL.revokeObjectURL(url);\u001b[39m\n\u001b[31m  toast('CSV downloaded ✓', 'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction saveLocal() {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    localStorage.setItem('tour-pg-saved', JSON.stringify({\u001b[39m\n\u001b[31m      saved_at: new Date().toISOString(),\u001b[39m\n\u001b[31m      state,\u001b[39m\n\u001b[31m    }));\u001b[39m\n\u001b[31m    toast('Saved ✓', 'ok');\u001b[39m\n\u001b[31m    document.getElementById('btn-load').style.display = '';\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed (storage full?)', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction loadSaved() {\u001b[39m\n\u001b[31m  const raw = localStorage.getItem('tour-pg-saved');\u001b[39m\n\u001b[31m  if (!raw) return toast('No saved tour', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const { state: s } = JSON.parse(raw);\u001b[39m\n\u001b[31m    state = s;\u001b[39m\n\u001b[31m    showScreen('s-editor');\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Loaded ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Load failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Navigation ──────────────────────────────────────\u001b[39m\n\u001b[31mfunction confirmBack() {\u001b[39m\n\u001b[31m  if (!confirm('Start a new tour? Current state will be cleared (use Save first if you want to keep it).')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m  document.getElementById('parsed-preview').style.display = 'none';\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = true;\u001b[39m\n\u001b[31m  document.getElementById('excel-file').value = '';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showScreen(id) {\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById(id).classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Utils ───────────────────────────────────────────\u001b[39m\n\u001b[31mfunction escHtml(s) { if (s == null) return ''; return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\\\"/g,'&quot;'); }\u001b[39m\n\u001b[31mlet _toastTimer;\u001b[39m\n\u001b[31mfunction toast(msg, type='') {\u001b[39m\n\u001b[31m  const el = document.getElementById('toast');\u001b[39m\n\u001b[31m  el.textContent = msg; el.className = 'show ' + type;\u001b[39m\n\u001b[31m  clearTimeout(_toastTimer);\u001b[39m\n\u001b[31m  _toastTimer = setTimeout(() => el.className = '', 3200);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction registerSW() {\u001b[39m\n\u001b[31m  if (!('serviceWorker' in navigator)) return;\u001b[39m\n\u001b[31m  const sw = `\u001b[39m\n\u001b[31mconst C='${CACHE_VER}';\u001b[39m\n\u001b[31mself.addEventListener('install', e => self.skipWaiting());\u001b[39m\n\u001b[31mself.addEventListener('activate', e => {\u001b[39m\n\u001b[31m  e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== C).map(k => caches.delete(k)))));\u001b[39m\n\u001b[31m  self.clients.claim();\u001b[39m\n\u001b[31m});\u001b[39m\n\u001b[31mself.addEventListener('fetch', e => {\u001b[39m\n\u001b[31m  if (e.request.method !== 'GET') return;\u001b[39m\n\u001b[31m  const url = new URL(e.request.url);\u001b[39m\n\u001b[31m  if (url.pathname.startsWith('/tour-pg-proxy')) return;\u001b[39m\n\u001b[31m  if (url.pathname.startsWith('/tour-pg/')) {\u001b[39m\n\u001b[31m    e.respondWith(\u001b[39m\n\u001b[31m      caches.open(C).then(c =>\u001b[39m\n\u001b[31m        c.match(e.request).then(r => r || fetch(e.request).then(f => { c.put(e.request, f.clone()); return f; }))\u001b[39m\n\u001b[31m      )\u001b[39m\n\u001b[31m    );\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m});`;\u001b[39m\n\u001b[31m  const blob = new Blob([sw], { type: 'application/javascript' });\u001b[39m\n\u001b[31m  navigator.serviceWorker.register(URL.createObjectURL(blob), { scope: '/tour-pg/' }).catch(() => {});\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31minit();\u001b[39m\n\u001b[31m</script>·\u001b[39m\n\u001b[31m</body>\u001b[39m\n\u001b[31m</html>\u001b[39m\n\u001b[31m\"\u001b[39m",
                        "stack": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoContain\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // indexOf\u001b[22m\n\nExpected substring: \u001b[32m\"tour-pg-v10\"\u001b[39m\nReceived string:    \u001b[31m\"<!DOCTYPE html>\u001b[39m\n\u001b[31m<html lang=\\\"en\\\">\u001b[39m\n\u001b[31m<head>\u001b[39m\n\u001b[31m<meta charset=\\\"UTF-8\\\">\u001b[39m\n\u001b[31m<meta name=\\\"viewport\\\" content=\\\"width=device-width,initial-scale=1,viewport-fit=cover\\\">\u001b[39m\n\u001b[31m<title>Tour Planner Playground</title>\u001b[39m\n\u001b[31m<link rel=\\\"manifest\\\" href=\\\"manifest.json\\\">\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"/shared/safe-bottom.css\\\">\u001b[39m\n\u001b[31m<meta name=\\\"theme-color\\\" content=\\\"#0a0a0a\\\">\u001b[39m\n\u001b[31m<link rel=\\\"preconnect\\\" href=\\\"https://fonts.googleapis.com\\\">\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&display=swap\\\">\u001b[39m\n\u001b[31m<script src=\\\"https://cdn.sheetjs.com/xlsx-0.20.2/package/dist/xlsx.full.min.js\\\"></script>\u001b[39m\n\u001b[31m<script src=\\\"https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js\\\"></script>\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.css\\\" crossorigin=\\\"\\\">\u001b[39m\n\u001b[31m<script src=\\\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.js\\\" crossorigin=\\\"\\\" defer></script>\u001b[39m\n\u001b[31m<style>\u001b[39m\n\u001b[31m:root{\u001b[39m\n\u001b[31m  --bg:#0a0a0a;--card:#141414;--card2:#1a1a1a;--border:#262626;--text:#f5f5f5;--muted:#a3a3a3;\u001b[39m\n\u001b[31m  --accent:#0ea5e9;--accent2:#0284c7;--g:#22c55e;--r:#ef4444;--y:#eab308;--p:#a855f7;\u001b[39m\n\u001b[31m  --rad:12px;--rads:8px;--bnav-h:64px;\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31m*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}\u001b[39m\n\u001b[31mbody{background:var(--bg);color:var(--text);font-family:'DM Sans',sans-serif;min-height:100vh;overscroll-behavior-y:none}\u001b[39m\n\u001b[31mbutton{font-family:inherit;cursor:pointer;border:none;background:none;color:inherit}\u001b[39m\n\u001b[31ma{color:inherit;text-decoration:none}·\u001b[39m\n\u001b[31m.scr{display:none;flex-direction:column;min-height:100vh;padding-bottom:calc(var(--bnav-h) + env(safe-area-inset-bottom))}\u001b[39m\n\u001b[31m.scr.active{display:flex}·\u001b[39m\n\u001b[31m.hdr{background:var(--card);border-bottom:1px solid var(--border);padding:12px 14px;display:flex;align-items:center;gap:8px;position:sticky;top:0;z-index:10}\u001b[39m\n\u001b[31m.hdr h1{font-size:15px;font-weight:800;flex:1;min-width:0}\u001b[39m\n\u001b[31m.hdr-sub{font-size:11px;color:var(--muted)}\u001b[39m\n\u001b[31m.hdr-btn{background:rgba(255,255,255,.08);border:1px solid var(--border);font-size:12px;font-weight:700;padding:5px 10px;border-radius:14px;cursor:pointer;white-space:nowrap}\u001b[39m\n\u001b[31m.hdr-btn:active{background:rgba(255,255,255,.16)}\u001b[39m\n\u001b[31m.user-pill{background:rgba(255,255,255,.1);border:1px solid var(--border);color:var(--muted);font-size:10px;font-weight:600;padding:3px 8px;border-radius:11px}\u001b[39m\n\u001b[31m.user-pill b{color:var(--text)}·\u001b[39m\n\u001b[31m.body{flex:1;padding:14px;display:flex;flex-direction:column;gap:12px}·\u001b[39m\n\u001b[31m/* ── editor: header + controls + route preview stay put; only the stops list scrolls ── */\u001b[39m\n\u001b[31m#s-editor.scr{height:100vh;height:100dvh;min-height:0;padding-bottom:0;overflow:hidden}\u001b[39m\n\u001b[31m.editor-body{flex:1;min-height:0;display:flex;flex-direction:column;padding:14px 14px 0}\u001b[39m\n\u001b[31m.editor-fixed{display:flex;flex-direction:column;gap:12px;flex-shrink:0}\u001b[39m\n\u001b[31m.editor-scroll{flex:1;min-height:0;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:2px 0 calc(32px + env(safe-area-inset-bottom));margin-top:6px;scrollbar-gutter:stable}\u001b[39m\n\u001b[31m/* visible scrollbar so it's obvious the stops list is independently scrollable */\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar{width:8px}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-thumb{background:#3a3a3a;border-radius:4px}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-thumb:active{background:#525252}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-track{background:transparent}·\u001b[39m\n\u001b[31m/* ── column-mapping table (parsed preview): scroll horizontally if it doesn't fit ── */\u001b[39m\n\u001b[31m.map-table-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--border);border-radius:6px}\u001b[39m\n\u001b[31m.map-table{border-collapse:collapse;table-layout:fixed;width:100%;min-width:440px;font-size:11px}\u001b[39m\n\u001b[31m.map-table th{text-align:left;padding:6px 8px;color:var(--muted);font-weight:800;font-size:9.5px;text-transform:uppercase;letter-spacing:.3px;background:var(--card2);position:sticky;top:0}\u001b[39m\n\u001b[31m.map-table td{padding:6px 8px;vertical-align:middle}\u001b[39m\n\u001b[31m.map-table tr+tr td,.map-table tbody tr td{border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.map-table col.c-src{width:40%}.map-table col.c-field{width:28%}.map-table col.c-sample{width:32%}\u001b[39m\n\u001b[31m.map-table select{background:#0b1220;border:1px solid var(--border);color:var(--text);padding:6px 7px;border-radius:4px;font-size:11px;width:100%}\u001b[39m\n\u001b[31m.map-field{font-weight:800;color:var(--text);white-space:nowrap}\u001b[39m\n\u001b[31m.map-sample{color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}·\u001b[39m\n\u001b[31m/* ── upload ── */\u001b[39m\n\u001b[31m.dropzone{background:var(--card);border:2px dashed var(--border);border-radius:var(--rad);padding:28px 16px;text-align:center;cursor:pointer;transition:.15s}\u001b[39m\n\u001b[31m.dropzone:hover,.dropzone.drag{border-color:var(--accent);background:#0e1726}\u001b[39m\n\u001b[31m.dropzone .ico{font-size:42px;margin-bottom:8px}\u001b[39m\n\u001b[31m.dropzone .ttl{font-weight:800;font-size:15px;margin-bottom:4px}\u001b[39m\n\u001b[31m.dropzone .sub{font-size:11.5px;color:var(--muted);line-height:1.4}\u001b[39m\n\u001b[31m.dropzone input[type=file]{display:none}·\u001b[39m\n\u001b[31m.section-title{font-size:11px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.4px}·\u001b[39m\n\u001b[31m.form-card{background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:12px;display:flex;flex-direction:column;gap:10px}\u001b[39m\n\u001b[31m.field{display:flex;flex-direction:column;gap:3px}\u001b[39m\n\u001b[31m.field label{font-size:11px;font-weight:600;color:var(--muted)}\u001b[39m\n\u001b[31m.field input,.field select{background:#0b1220;border:1px solid var(--border);border-radius:6px;color:var(--text);padding:9px 11px;font-size:13.5px;font-family:inherit;outline:none}\u001b[39m\n\u001b[31m.field input:focus,.field select:focus{border-color:var(--accent)}\u001b[39m\n\u001b[31m.toggle{display:flex;align-items:center;gap:8px;font-size:13px}\u001b[39m\n\u001b[31m.toggle input{accent-color:var(--accent);width:18px;height:18px}·\u001b[39m\n\u001b[31m.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;border-radius:var(--rads);padding:12px 14px;font-size:14px;font-weight:800;cursor:pointer;width:100%;transition:.12s}\u001b[39m\n\u001b[31m.btn-p{background:var(--accent);color:#fff}\u001b[39m\n\u001b[31m.btn-p:active{background:var(--accent2)}\u001b[39m\n\u001b[31m.btn-p:disabled{opacity:.4;cursor:default}\u001b[39m\n\u001b[31m.btn-s{background:var(--card);color:var(--text);border:1px solid var(--border)}\u001b[39m\n\u001b[31m.btn-r{background:rgba(239,68,68,.15);color:#fca5a5;border:1px solid rgba(239,68,68,.3)}·\u001b[39m\n\u001b[31m/* ── progress ── */\u001b[39m\n\u001b[31m.progress{background:#0b1220;border-radius:8px;height:10px;overflow:hidden;border:1px solid var(--border)}\u001b[39m\n\u001b[31m.progress-bar{height:100%;background:linear-gradient(90deg,var(--accent),var(--g));transition:.2s}·\u001b[39m\n\u001b[31m/* ── tiles ── */\u001b[39m\n\u001b[31m.origin-pill{background:linear-gradient(135deg,#0c2e3a,#062a3a);border:1px solid var(--accent);border-radius:var(--rad);padding:11px 13px;display:flex;align-items:center;gap:10px}\u001b[39m\n\u001b[31m.origin-pill .ico{font-size:18px}\u001b[39m\n\u001b[31m.origin-pill .meta{flex:1;min-width:0}\u001b[39m\n\u001b[31m.origin-pill .lbl{font-size:10.5px;color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.4px}\u001b[39m\n\u001b[31m.origin-pill .val{font-size:13.5px;font-weight:700;margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.origin-pill button{background:rgba(255,255,255,.08);border-radius:14px;padding:5px 10px;font-size:11px;font-weight:700;color:var(--accent)}·\u001b[39m\n\u001b[31m.tiles{display:flex;flex-direction:column;gap:6px}\u001b[39m\n\u001b[31m.tile{background:var(--card);border:1px solid var(--border);border-radius:var(--rads);padding:9px 10px;display:flex;align-items:center;gap:10px;transition:.12s;touch-action:none}\u001b[39m\n\u001b[31m.tile.sortable-ghost{opacity:.35;background:var(--card2)}\u001b[39m\n\u001b[31m.tile.sortable-chosen{transform:scale(.98);box-shadow:0 4px 20px rgba(14,165,233,.25)}\u001b[39m\n\u001b[31m.tile.excluded{opacity:.4;background:#0c0c0c}\u001b[39m\n\u001b[31m.tile.excluded .name,.tile.excluded .city{text-decoration:line-through}\u001b[39m\n\u001b[31m.tile.err{border-color:var(--r);background:#1f0a0a}\u001b[39m\n\u001b[31m.tile .grip{font-size:18px;color:var(--muted);cursor:grab;padding:4px;line-height:1}\u001b[39m\n\u001b[31m.tile .grip:active{cursor:grabbing}\u001b[39m\n\u001b[31m.tile .ord{background:var(--accent);color:#fff;width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11.5px;font-weight:800;flex-shrink:0}\u001b[39m\n\u001b[31m.tile.excluded .ord{background:#444}\u001b[39m\n\u001b[31m.tile .info{flex:1;min-width:0}\u001b[39m\n\u001b[31m.tile .name{font-size:13.5px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .city{font-size:11.5px;color:#cbd5e1;font-weight:600;margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .addr{font-size:9.5px;color:var(--muted);margin-top:1px;line-height:1.3;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-style:italic;opacity:.75}\u001b[39m\n\u001b[31m.tile .leg{font-size:10.5px;color:var(--accent2);font-weight:600;margin-top:2px}\u001b[39m\n\u001b[31m.tile.excluded .leg{display:none}\u001b[39m\n\u001b[31m.tile .badge-provider{display:inline-block;font-size:8.5px;padding:1px 5px;border-radius:8px;background:rgba(255,255,255,.08);color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.3px;margin-left:4px;vertical-align:middle}\u001b[39m\n\u001b[31m.tile .incl{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:50%;border:2px solid var(--border);font-size:16px;font-weight:800}\u001b[39m\n\u001b[31m.tile .incl.on{background:var(--g);border-color:var(--g);color:#fff}\u001b[39m\n\u001b[31m.tile .incl.off{background:#0c0c0c;color:var(--r);border-color:var(--r)}·\u001b[39m\n\u001b[31m/* ── prominent top action bar (KM, Share, Re-optimise) ── */\u001b[39m\n\u001b[31m.top-actions{background:linear-gradient(135deg,#0c2e3a,#08111c);border:1px solid var(--accent);border-radius:var(--rad);padding:10px;display:grid;grid-template-columns:1fr auto auto;gap:8px;align-items:center;margin-bottom:6px}\u001b[39m\n\u001b[31m.top-totals{min-width:0}\u001b[39m\n\u001b[31m.top-totals-big{font-size:18px;font-weight:900;line-height:1.05;letter-spacing:.3px}\u001b[39m\n\u001b[31m.top-totals-delta{font-size:11px;color:var(--muted);margin-top:2px}\u001b[39m\n\u001b[31m.top-totals-delta.better{color:var(--g)}\u001b[39m\n\u001b[31m.top-totals-delta.worse{color:var(--y)}\u001b[39m\n\u001b[31m.top-actions button{padding:10px 12px;font-size:12px;font-weight:800;border-radius:8px;border:none;cursor:pointer;display:flex;align-items:center;gap:5px;white-space:nowrap}\u001b[39m\n\u001b[31m.top-share{background:#f59e0b;color:#000}\u001b[39m\n\u001b[31m.top-share:active{background:#d97706}\u001b[39m\n\u001b[31m.top-reopt{background:var(--accent);color:#fff}\u001b[39m\n\u001b[31m.top-reopt:active{background:var(--accent2)}\u001b[39m\n\u001b[31m/* ── Re-optimisation needed (after include/exclude toggle) ── */\u001b[39m\n\u001b[31m.reopt-needed{font-size:11px;font-weight:700;color:#fbbf24;background:rgba(234,179,8,.12);border-left:3px solid #fbbf24;padding:5px 8px;border-radius:4px;margin-top:5px;display:none;line-height:1.3}\u001b[39m\n\u001b[31m.reopt-needed.show{display:block}·\u001b[39m\n\u001b[31m/* ── inline route preview (Leaflet + OSM) ── */\u001b[39m\n\u001b[31m.map-wrap{margin:8px 0 0;background:var(--card);border:1px solid var(--border);border-radius:var(--rads);overflow:hidden}\u001b[39m\n\u001b[31m.map-hdr{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;font-size:12px;color:var(--muted);background:var(--card2);border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.map-hdr .ttl{display:flex;align-items:center;gap:6px;font-weight:600}\u001b[39m\n\u001b[31m.map-hdr .toggle{font-size:14px;color:var(--text);background:none;border:none;cursor:pointer;padding:0 4px}\u001b[39m\n\u001b[31m#tour-map{height:260px;width:100%;background:#1a1a1a;transition:height .2s ease}\u001b[39m\n\u001b[31m.map-wrap.collapsed #tour-map{height:0}\u001b[39m\n\u001b[31m.map-wrap.collapsed .map-hdr{border-bottom:none}\u001b[39m\n\u001b[31m.tour-pin{background:transparent;border:none}\u001b[39m\n\u001b[31m.tour-pin > div{background:#0ea5e9;color:#fff;border-radius:50%;width:26px;height:26px;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:12px;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,.45);font-family:'DM Sans',sans-serif}\u001b[39m\n\u001b[31m.tour-pin.origin > div{background:#22c55e;font-size:14px}\u001b[39m\n\u001b[31m.tour-pin.out > div{background:#6b7280;opacity:.55}\u001b[39m\n\u001b[31m.leaflet-tooltip{background:#141414;color:#f5f5f5;border:1px solid #262626;font-size:11px;padding:3px 6px;font-family:'DM Sans',sans-serif;box-shadow:0 1px 6px rgba(0,0,0,.4)}\u001b[39m\n\u001b[31m.leaflet-tooltip-top:before,.leaflet-tooltip-bottom:before,.leaflet-tooltip-left:before,.leaflet-tooltip-right:before{border-top-color:#262626;border-bottom-color:#262626;border-left-color:#262626;border-right-color:#262626}\u001b[39m\n\u001b[31m.leaflet-control-attribution{background:rgba(20,20,20,.85)!important;color:#a3a3a3!important;font-size:9px!important}\u001b[39m\n\u001b[31m.leaflet-control-attribution a{color:#0ea5e9!important}\u001b[39m\n\u001b[31m.top-reopt.pulse{animation:pulseReopt 1.4s ease-in-out infinite;box-shadow:0 0 0 0 rgba(14,165,233,.6)}\u001b[39m\n\u001b[31m@keyframes pulseReopt{0%{box-shadow:0 0 0 0 rgba(14,165,233,.7)}50%{box-shadow:0 0 0 8px rgba(14,165,233,0)}100%{box-shadow:0 0 0 0 rgba(14,165,233,0)}}\u001b[39m\n\u001b[31m/* Totals dim when stale to make it visually obvious */\u001b[39m\n\u001b[31m.top-totals-big.stale, .top-totals-delta.stale{opacity:.5;text-decoration:line-through;text-decoration-color:rgba(251,191,36,.6)}·\u001b[39m\n\u001b[31m/* recalc spinner that appears inline next to the top totals during recompute */\u001b[39m\n\u001b[31m.recalc{display:none;font-size:11px;color:var(--accent);margin-left:6px;vertical-align:middle}\u001b[39m\n\u001b[31m.recalc.show{display:inline-block;animation:spin 1s linear infinite}\u001b[39m\n\u001b[31m@keyframes spin{to{transform:rotate(360deg)}}·\u001b[39m\n\u001b[31m/* ── share modal ── */\u001b[39m\n\u001b[31m.modal{position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:200;display:none;align-items:flex-end;justify-content:center}\u001b[39m\n\u001b[31m.modal.show{display:flex}\u001b[39m\n\u001b[31m.modal-box{background:var(--card);border-radius:18px 18px 0 0;width:100%;max-height:85vh;overflow-y:auto;padding:16px 14px calc(16px + env(safe-area-inset-bottom));border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.modal-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\u001b[39m\n\u001b[31m.modal-ttl{font-size:15px;font-weight:800}\u001b[39m\n\u001b[31m.modal-close{font-size:24px;color:var(--muted);background:none;border:none;cursor:pointer}·\u001b[39m\n\u001b[31m/* ── toast ── */\u001b[39m\n\u001b[31m#toast{position:fixed;bottom:calc(var(--bnav-h)+16px);left:12px;right:12px;z-index:1000;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:11px 14px;font-size:13px;font-weight:600;text-align:center;transform:translateY(150px);opacity:0;transition:.25s;pointer-events:none}\u001b[39m\n\u001b[31m#toast.show{transform:translateY(0);opacity:1}\u001b[39m\n\u001b[31m#toast.ok{background:rgba(34,197,94,.18);border-color:var(--g);color:#86efac}\u001b[39m\n\u001b[31m#toast.err{background:rgba(239,68,68,.18);border-color:var(--r);color:#fca5a5}·\u001b[39m\n\u001b[31m/* ── helpers ── */\u001b[39m\n\u001b[31m.spinner{display:inline-block;width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;vertical-align:middle}·\u001b[39m\n\u001b[31m.empty{text-align:center;padding:30px 16px;color:var(--muted);font-size:12.5px}·\u001b[39m\n\u001b[31mtextarea{background:#0b1220;border:1px solid var(--border);color:var(--text);padding:10px;font-family:inherit;font-size:12px;border-radius:6px;resize:vertical;outline:none}·\u001b[39m\n\u001b[31m.kbd{background:rgba(255,255,255,.06);padding:1px 6px;border-radius:4px;font-family:monospace;font-size:11px;color:var(--muted)}\u001b[39m\n\u001b[31m</style>\u001b[39m\n\u001b[31m</head>\u001b[39m\n\u001b[31m<body>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: UPLOAD ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-upload\\\" class=\\\"scr active\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗺 Tour Playground</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Excel → optimised route → tweak</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"loadSaved()\\\" id=\\\"btn-load\\\" style=\\\"display:none\\\">↻ Load</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"location.href='/hub/'\\\">🏡 Hub</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <label class=\\\"dropzone\\\" id=\\\"dropzone\\\" for=\\\"excel-file\\\">\u001b[39m\n\u001b[31m      <input type=\\\"file\\\" id=\\\"excel-file\\\" accept=\\\".xlsx,.xls,.csv\\\">\u001b[39m\n\u001b[31m      <div class=\\\"ico\\\">📊</div>\u001b[39m\n\u001b[31m      <div class=\\\"ttl\\\">Drop Excel here, or tap to choose</div>\u001b[39m\n\u001b[31m      <div class=\\\"sub\\\">Required: <b>store name</b> + <b>address</b><br>Optional: <b>city</b>, <b>lat</b>, <b>lng</b>, <b>phone</b>, anything else.</div>\u001b[39m\n\u001b[31m    </label>·\u001b[39m\n\u001b[31m    <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\">Origin</div>\u001b[39m\n\u001b[31m      <div class=\\\"field\\\">\u001b[39m\n\u001b[31m        <label>Start point</label>\u001b[39m\n\u001b[31m        <input type=\\\"text\\\" id=\\\"inp-origin\\\" value=\\\"Mohali office Punjab\\\" placeholder=\\\"Address or place name\\\">\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div class=\\\"toggle\\\">\u001b[39m\n\u001b[31m        <input type=\\\"checkbox\\\" id=\\\"inp-roundtrip\\\" checked>\u001b[39m\n\u001b[31m        <label for=\\\"inp-roundtrip\\\" style=\\\"cursor:pointer;font-size:13px;font-weight:600\\\">Round-trip (return to start)</label>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"dest-wrap\\\" class=\\\"field\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m        <label>Final destination</label>\u001b[39m\n\u001b[31m        <input type=\\\"text\\\" id=\\\"inp-destination\\\" placeholder=\\\"Where to end if not returning to origin\\\">\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" id=\\\"btn-go\\\" onclick=\\\"parseAndOptimise()\\\" disabled>\u001b[39m\n\u001b[31m      📐 Parse + optimise route\u001b[39m\n\u001b[31m    </button>·\u001b[39m\n\u001b[31m    <div id=\\\"upload-progress\\\" style=\\\"display:none;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;font-size:12px\\\">\u001b[39m\n\u001b[31m        <span id=\\\"prog-label\\\">Geocoding…</span>\u001b[39m\n\u001b[31m        <span id=\\\"prog-count\\\" style=\\\"color:var(--muted)\\\">0 / 0</span>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div class=\\\"progress\\\"><div class=\\\"progress-bar\\\" id=\\\"prog-bar\\\" style=\\\"width:0%\\\"></div></div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div id=\\\"parsed-preview\\\" style=\\\"display:none;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:11px;font-size:12px\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\" style=\\\"margin-bottom:6px\\\">Parsed</div>\u001b[39m\n\u001b[31m      <div id=\\\"parsed-summary\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: EDITOR ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-editor\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"confirmBack()\\\">‹ New</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>Tour Editor</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"editor-sub\\\">— stops</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-2\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"openShare()\\\">📤</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"editor-body\\\">\u001b[39m\n\u001b[31m   <div class=\\\"editor-fixed\\\">·\u001b[39m\n\u001b[31m    <div class=\\\"origin-pill\\\" id=\\\"origin-pill\\\">\u001b[39m\n\u001b[31m      <span class=\\\"ico\\\">📍</span>\u001b[39m\n\u001b[31m      <div class=\\\"meta\\\">\u001b[39m\n\u001b[31m        <div class=\\\"lbl\\\">Start from</div>\u001b[39m\n\u001b[31m        <div class=\\\"val\\\" id=\\\"origin-val\\\">—</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button onclick=\\\"openOriginEdit()\\\">✏️ Change</button>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div id=\\\"origin-edit\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m        <div class=\\\"field\\\">\u001b[39m\n\u001b[31m          <label>New origin (for mid-tour replan)</label>\u001b[39m\n\u001b[31m          <input type=\\\"text\\\" id=\\\"inp-new-origin\\\" placeholder=\\\"Hotel B Mohali, or address…\\\">\u001b[39m\n\u001b[31m          <div style=\\\"font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4\\\">\u001b[39m\n\u001b[31m            Tip: toggle ✗ on completed stops, change origin to where you are now, hit Replan.\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div style=\\\"display:flex;gap:8px\\\">\u001b[39m\n\u001b[31m          <button class=\\\"btn btn-s\\\" onclick=\\\"closeOriginEdit()\\\" style=\\\"flex:1\\\">Cancel</button>\u001b[39m\n\u001b[31m          <button class=\\\"btn btn-p\\\" onclick=\\\"applyOriginAndReplan()\\\" style=\\\"flex:2\\\">🔄 Replan from here</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"top-actions\\\" id=\\\"top-actions\\\">\u001b[39m\n\u001b[31m      <div class=\\\"top-totals\\\">\u001b[39m\n\u001b[31m        <div class=\\\"top-totals-big\\\" id=\\\"top-totals-big\\\">— km · — min<span class=\\\"recalc\\\" id=\\\"recalc\\\">↻</span></div>\u001b[39m\n\u001b[31m        <div class=\\\"top-totals-delta\\\" id=\\\"top-totals-delta\\\">vs proposed: —</div>\u001b[39m\n\u001b[31m        <div class=\\\"reopt-needed\\\" id=\\\"reopt-needed\\\">⚠ Stops changed — tap 🔁 Re-optim to refresh the route plan</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"top-share\\\" onclick=\\\"openShare()\\\">📤 Share</button>\u001b[39m\n\u001b[31m      <button class=\\\"top-reopt\\\" onclick=\\\"replanAll()\\\">🔁 Re-optim</button>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"map-wrap\\\" id=\\\"map-wrap\\\">\u001b[39m\n\u001b[31m      <div class=\\\"map-hdr\\\">\u001b[39m\n\u001b[31m        <div class=\\\"ttl\\\">🗺️ Route preview <span style=\\\"opacity:.6;font-weight:400\\\">(straight-line)</span></div>\u001b[39m\n\u001b[31m        <button class=\\\"toggle\\\" id=\\\"map-toggle\\\" onclick=\\\"toggleMap()\\\" aria-label=\\\"Toggle map\\\">▾</button>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"tour-map\\\"></div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"section-title\\\" style=\\\"margin-top:2px\\\">Stops (drag ≡ to reorder · scroll ↕ to see all)</div>\u001b[39m\n\u001b[31m   </div><!-- /editor-fixed -->·\u001b[39m\n\u001b[31m   <div class=\\\"editor-scroll\\\" id=\\\"editor-scroll\\\">\u001b[39m\n\u001b[31m    <div id=\\\"tiles\\\" class=\\\"tiles\\\"></div>\u001b[39m\n\u001b[31m   </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── MODAL: SHARE ── -->\u001b[39m\n\u001b[31m<div class=\\\"modal\\\" id=\\\"modal-share\\\">\u001b[39m\n\u001b[31m  <div class=\\\"modal-box\\\">\u001b[39m\n\u001b[31m    <div class=\\\"modal-hdr\\\">\u001b[39m\n\u001b[31m      <div class=\\\"modal-ttl\\\">📤 Share / Export</div>\u001b[39m\n\u001b[31m      <button class=\\\"modal-close\\\" onclick=\\\"closeShare()\\\">×</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"share-summary\\\" style=\\\"background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:10px;font-size:12px;line-height:1.6;white-space:pre-wrap;font-family:monospace\\\"></div>\u001b[39m\n\u001b[31m    <div style=\\\"margin-top:12px;display:grid;gap:8px\\\">\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"openInGoogleMaps()\\\">🗺️ Open route in Google Maps</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"shareWhatsApp()\\\">💬 WhatsApp</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"copyText()\\\">📋 Copy text</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"downloadCsv()\\\">📥 Download CSV</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"saveLocal()\\\">💾 Save to this device</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<div id=\\\"toast\\\"></div>·\u001b[39m\n\u001b[31m<script>\u001b[39m\n\u001b[31m'use strict';·\u001b[39m\n\u001b[31mconst CACHE_VER = 'tour-pg-v11';\u001b[39m\n\u001b[31mconst PROXY     = '/tour-pg-proxy';·\u001b[39m\n\u001b[31m// ── State ────────────────────────────────────────────\u001b[39m\n\u001b[31mlet session   = null;\u001b[39m\n\u001b[31mlet myId      = '';\u001b[39m\n\u001b[31mlet myName    = '';\u001b[39m\n\u001b[31mlet state     = newState();\u001b[39m\n\u001b[31mlet _recalcTimer = null;\u001b[39m\n\u001b[31mlet _sortable    = null;·\u001b[39m\n\u001b[31mfunction newState() {\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    origin:     { text: 'Mohali office Punjab', location: null, provider: null },\u001b[39m\n\u001b[31m    destination:null,\u001b[39m\n\u001b[31m    roundTrip:  true,\u001b[39m\n\u001b[31m    stops:      [],          // { id, name, city, address, phone, location, included, legDistance_m, legDuration_s, provider, raw, geocodeErr }\u001b[39m\n\u001b[31m    proposedOrder: null,     // array of stop IDs in optimise-time order\u001b[39m\n\u001b[31m    baseTotal: { distance_m: 0, duration_s: 0 },\u001b[39m\n\u001b[31m    currentTotal: { distance_m: 0, duration_s: 0 },\u001b[39m\n\u001b[31m    lastOptimisation: null,  // 'tomtom-computeBestOrder' | 'matrix-nn-2opt'\u001b[39m\n\u001b[31m    needsReoptimize: false,  // true after include/exclude toggle → totals may be stale; prompt user to tap Re-optim\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction uid(prefix='st') { return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,6)}`; }·\u001b[39m\n\u001b[31m// ── Auth ─────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction init() {\u001b[39m\n\u001b[31m  registerSW();\u001b[39m\n\u001b[31m  const raw = localStorage.getItem('lm360-session');\u001b[39m\n\u001b[31m  // Bounce to Hub with ?next= so user lands back here after login (Hub honours\u001b[39m\n\u001b[31m  // same-origin relative paths). Pattern documented in\u001b[39m\n\u001b[31m  // feedback_hub_next_redirect.md memory file — all gated PWAs should follow.\u001b[39m\n\u001b[31m  const nextParam = '?next=' + encodeURIComponent(location.pathname + location.search);\u001b[39m\n\u001b[31m  if (!raw) { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  try { session = JSON.parse(raw); } catch { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  if (Date.now() - (session.loginAt ? new Date(session.loginAt).getTime() : 0) > 12*3600*1000) { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  myId   = session.empId || '';\u001b[39m\n\u001b[31m  myName = session.name  || myId;\u001b[39m\n\u001b[31m  const userText = `👤 <b>${escHtml(myName)}</b>`;\u001b[39m\n\u001b[31m  document.getElementById('user-pill').innerHTML   = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill-2').innerHTML = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill').style.display   = '';\u001b[39m\n\u001b[31m  document.getElementById('user-pill-2').style.display = '';·\u001b[39m\n\u001b[31m  // Load-saved chip if we have a saved tour\u001b[39m\n\u001b[31m  if (localStorage.getItem('tour-pg-saved')) document.getElementById('btn-load').style.display = '';·\u001b[39m\n\u001b[31m  // Wire UI\u001b[39m\n\u001b[31m  document.getElementById('inp-roundtrip').addEventListener('change', e => {\u001b[39m\n\u001b[31m    document.getElementById('dest-wrap').style.display = e.target.checked ? 'none' : '';\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('dragover', e => { e.preventDefault(); e.currentTarget.classList.add('drag'); });\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('dragleave', e => e.currentTarget.classList.remove('drag'));\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('drop', e => {\u001b[39m\n\u001b[31m    e.preventDefault();\u001b[39m\n\u001b[31m    e.currentTarget.classList.remove('drag');\u001b[39m\n\u001b[31m    const f = e.dataTransfer.files[0]; if (f) handleFile(f);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  document.getElementById('excel-file').addEventListener('change', e => {\u001b[39m\n\u001b[31m    const f = e.target.files[0]; if (f) handleFile(f);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction hubAuth() {\u001b[39m\n\u001b[31m  return 'Hub ' + btoa(JSON.stringify(session));\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function api(path, body) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, {\u001b[39m\n\u001b[31m    method: 'POST',\u001b[39m\n\u001b[31m    headers: { 'Authorization': hubAuth(), 'Content-Type': 'application/json' },\u001b[39m\n\u001b[31m    body: JSON.stringify(body),\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (r.status === 401) {\u001b[39m\n\u001b[31m    toast('Session expired — refreshing to re-login', 'err');\u001b[39m\n\u001b[31m    const nextParam = '?next=' + encodeURIComponent(location.pathname + location.search);\u001b[39m\n\u001b[31m    setTimeout(() => location.href = '/hub/' + nextParam, 1500);\u001b[39m\n\u001b[31m    throw new Error('unauthorized');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!r.ok) {\u001b[39m\n\u001b[31m    const txt = await r.text().catch(() => '');\u001b[39m\n\u001b[31m    throw new Error(`${path}: ${r.status} ${txt.slice(0, 100)}`);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return r.json();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Excel parsing + column detection ────────────────\u001b[39m\n\u001b[31mlet _parsedCols = null;   // exposed for the column-remap UI\u001b[39m\n\u001b[31mlet _parsedRows = null;\u001b[39m\n\u001b[31mfunction handleFile(file) {\u001b[39m\n\u001b[31m  const reader = new FileReader();\u001b[39m\n\u001b[31m  reader.onload = (e) => {\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      const wb = XLSX.read(e.target.result, { type: 'array' });\u001b[39m\n\u001b[31m      const ws = wb.Sheets[wb.SheetNames[0]];\u001b[39m\n\u001b[31m      const rows = XLSX.utils.sheet_to_json(ws, { defval: '' });\u001b[39m\n\u001b[31m      if (!rows.length) throw new Error('Empty sheet');\u001b[39m\n\u001b[31m      _parsedRows = rows;\u001b[39m\n\u001b[31m      _parsedCols = detectColumns(rows[0]);\u001b[39m\n\u001b[31m      if (!_parsedCols.name && !_parsedCols.address && !_parsedCols.city) throw new Error('Missing required columns (need at least one of: name, address, city)');\u001b[39m\n\u001b[31m      rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m      renderParsedPreview();\u001b[39m\n\u001b[31m      document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m      toast('Excel parsed ✓', 'ok');\u001b[39m\n\u001b[31m    } catch (err) {\u001b[39m\n\u001b[31m      toast('Excel error: ' + err.message, 'err');\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  reader.readAsArrayBuffer(file);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction rebuildStopsFromParsedRows() {\u001b[39m\n\u001b[31m  state.stops = _parsedRows.map((row, idx) => buildStop(row, _parsedCols, idx)).filter(s => s);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderParsedPreview() {\u001b[39m\n\u001b[31m  const skip = _parsedRows.length - state.stops.length;\u001b[39m\n\u001b[31m  const allCols = Object.keys(_parsedRows[0]);\u001b[39m\n\u001b[31m  // For each role, show:  <role>: <selected col name>  with a tiny dropdown to override\u001b[39m\n\u001b[31m  const ROLES = ['name','city','address','lat','lng','phone','notes'];\u001b[39m\n\u001b[31m  const sample = _parsedRows[0];\u001b[39m\n\u001b[31m  const previewVal = (key) => {\u001b[39m\n\u001b[31m    const v = sample[key]; if (v == null || v === '') return '';\u001b[39m\n\u001b[31m    return String(v).slice(0, 40);\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  // Column order (per user request): YOUR Excel column first, then the PWA field\u001b[39m\n\u001b[31m  // it maps to, then a sample value. Equal-ish widths via <colgroup>; the wrapper\u001b[39m\n\u001b[31m  // scrolls horizontally so long header names are never clipped on narrow phones.\u001b[39m\n\u001b[31m  let rows = '<div class=\\\"map-table-wrap\\\"><table class=\\\"map-table\\\">' +\u001b[39m\n\u001b[31m    '<colgroup><col class=\\\"c-src\\\"><col class=\\\"c-field\\\"><col class=\\\"c-sample\\\"></colgroup>' +\u001b[39m\n\u001b[31m    '<thead><tr><th>Your file column</th><th>→ PWA field</th><th>Sample value</th></tr></thead><tbody>';\u001b[39m\n\u001b[31m  for (const role of ROLES) {\u001b[39m\n\u001b[31m    const sel  = _parsedCols[role];\u001b[39m\n\u001b[31m    const need = role === 'name';\u001b[39m\n\u001b[31m    const opts = `<option value=\\\"\\\">— ignore —</option>` +\u001b[39m\n\u001b[31m      allCols.map(c => `<option value=\\\"${escHtml(c)}\\\" ${sel === c ? 'selected' : ''}>${escHtml(c)}</option>`).join('');\u001b[39m\n\u001b[31m    const previewText = sel ? escHtml(previewVal(sel)) : '';\u001b[39m\n\u001b[31m    const tip = !sel && need ? `<span style=\\\"color:var(--y);font-weight:700\\\">⚠ no match — uses city or \\\"Stop #N\\\"</span>` : '';\u001b[39m\n\u001b[31m    rows += `<tr>\u001b[39m\n\u001b[31m      <td><select onchange=\\\"remapColumn('${role}', this.value)\\\">${opts}</select></td>\u001b[39m\n\u001b[31m      <td class=\\\"map-field\\\">${role}${need?' <span style=\\\"color:var(--y)\\\">*</span>':''}</td>\u001b[39m\n\u001b[31m      <td class=\\\"map-sample\\\">${previewText}${tip}</td>\u001b[39m\n\u001b[31m    </tr>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  rows += '</tbody></table></div>';\u001b[39m\n\u001b[31m  document.getElementById('parsed-preview').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('parsed-summary').innerHTML =\u001b[39m\n\u001b[31m    `<div style=\\\"margin-bottom:6px\\\"><b>${state.stops.length}</b> stops detected` +\u001b[39m\n\u001b[31m    `${skip > 0 ? ` · ${skip} skipped (no name/city/address)` : ''}</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:10.5px;color:var(--muted);margin-bottom:6px\\\">If detection is wrong, pick the correct column from the dropdowns:</div>` +\u001b[39m\n\u001b[31m    rows;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction remapColumn(role, newCol) {\u001b[39m\n\u001b[31m  if (!_parsedCols) return;\u001b[39m\n\u001b[31m  _parsedCols[role] = newCol || null;\u001b[39m\n\u001b[31m  rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction normaliseColName(s) {\u001b[39m\n\u001b[31m  return String(s).toLowerCase().replace(/[^a-z]/g, '');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction detectColumns(sampleRow) {\u001b[39m\n\u001b[31m  const map = { name:null, city:null, address:null, lat:null, lng:null, phone:null, notes:null, others:[] };\u001b[39m\n\u001b[31m  const keys = Object.keys(sampleRow);\u001b[39m\n\u001b[31m  // Broadened aliases so most user-provided Excel files map sensibly without a UI:\u001b[39m\n\u001b[31m  //   name  ← store / shop / outlet / party / dealer / firm / customer / business /\u001b[39m\n\u001b[31m  //           company / vendor / agency (and \\\"<thing> name\\\")\u001b[39m\n\u001b[31m  //   addr  ← address / fulladdress / location / loc / addr\u001b[39m\n\u001b[31m  //   city  ← city / town / district / location (only if no address column took it)\u001b[39m\n\u001b[31m  for (const k of keys) {\u001b[39m\n\u001b[31m    const n = normaliseColName(k);\u001b[39m\n\u001b[31m    if (!map.name && /^(storename|name|store|client|outlet|party(name)?|dealer(name)?|firm(name)?|customer(name)?|shop(name)?|business(name)?|companyname|company|vendor(name)?|agency(name)?|merchant|chain|sitename|site)$/.test(n)) { map.name = k; continue; }\u001b[39m\n\u001b[31m    if (!map.address && /^(address|fulladdress|streetaddress|addr|location|loc|street)$/.test(n)) { map.address = k; continue; }\u001b[39m\n\u001b[31m    if (!map.city && /^(city|town|district|cityname|locality|area|region)$/.test(n)) { map.city = k; continue; }\u001b[39m\n\u001b[31m    if (!map.lat && /^(lat|latitude|y)$/.test(n)) { map.lat = k; continue; }\u001b[39m\n\u001b[31m    if (!map.lng && /^(lng|lon|long|longitude|x)$/.test(n)) { map.lng = k; continue; }\u001b[39m\n\u001b[31m    if (!map.phone && /^(phone(no)?|mobile(no)?|contact(no)?|tel(no)?|whatsapp|wa|cell)$/.test(n)) { map.phone = k; continue; }\u001b[39m\n\u001b[31m    if (!map.notes && /^(notes?|remarks?|comments?|description|desc|memo)$/.test(n)) { map.notes = k; continue; }\u001b[39m\n\u001b[31m    map.others.push(k);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return map;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction buildStop(row, cols, idx) {\u001b[39m\n\u001b[31m  const name    = cols.name    ? String(row[cols.name]   || '').trim() : '';\u001b[39m\n\u001b[31m  const address = cols.address ? String(row[cols.address]|| '').trim() : '';\u001b[39m\n\u001b[31m  const city    = cols.city    ? String(row[cols.city]   || '').trim() : '';\u001b[39m\n\u001b[31m  const phone   = cols.phone   ? String(row[cols.phone]  || '').trim() : '';\u001b[39m\n\u001b[31m  const notes   = cols.notes   ? String(row[cols.notes]  || '').trim() : '';\u001b[39m\n\u001b[31m  if (!name && !address && !city) return null;\u001b[39m\n\u001b[31m  // pre-resolved coords?\u001b[39m\n\u001b[31m  let location = null;\u001b[39m\n\u001b[31m  if (cols.lat && cols.lng) {\u001b[39m\n\u001b[31m    const lat = parseFloat(row[cols.lat]);\u001b[39m\n\u001b[31m    const lng = parseFloat(row[cols.lng]);\u001b[39m\n\u001b[31m    if (!isNaN(lat) && !isNaN(lng) && lat !== 0 && lng !== 0) location = { lat, lng };\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const fullAddr = [address, city].filter(Boolean).join(', ');\u001b[39m\n\u001b[31m  // Display name fallback hierarchy (NEVER truncated address — that confused users):\u001b[39m\n\u001b[31m  //   1. Explicit name column value\u001b[39m\n\u001b[31m  //   2. City + index (so excluded/duplicate cities stay distinguishable)\u001b[39m\n\u001b[31m  //   3. \\\"Stop #N\\\"\u001b[39m\n\u001b[31m  let displayName;\u001b[39m\n\u001b[31m  if (name) displayName = name;\u001b[39m\n\u001b[31m  else if (city) displayName = `${city} (stop ${idx + 1})`;\u001b[39m\n\u001b[31m  else displayName = `Stop ${idx + 1}`;\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    id:           uid('st'),\u001b[39m\n\u001b[31m    name:         displayName,\u001b[39m\n\u001b[31m    hasExplicitName: !!name,         // for UI to flag \\\"name auto-derived\\\"\u001b[39m\n\u001b[31m    city, phone, notes,\u001b[39m\n\u001b[31m    address:      fullAddr || name || '',\u001b[39m\n\u001b[31m    location,\u001b[39m\n\u001b[31m    provider:     location ? 'excel' : null,\u001b[39m\n\u001b[31m    included:     true,\u001b[39m\n\u001b[31m    legDistance_m: 0,\u001b[39m\n\u001b[31m    legDuration_s: 0,\u001b[39m\n\u001b[31m    geocodeErr:   null,\u001b[39m\n\u001b[31m    raw:          { ...row },\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Geocode + optimise ──────────────────────────────\u001b[39m\n\u001b[31masync function parseAndOptimise() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('Parse an Excel first', 'err');\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = true;\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = '';\u001b[39m\n\u001b[31m  state.origin.text  = document.getElementById('inp-origin').value.trim() || 'Mohali office Punjab';\u001b[39m\n\u001b[31m  state.roundTrip    = document.getElementById('inp-roundtrip').checked;\u001b[39m\n\u001b[31m  state.destination  = state.roundTrip ? null : (document.getElementById('inp-destination').value.trim() || null);\u001b[39m\n\u001b[31m  // Geocode origin first\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: state.origin.text });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin.location = og.location;\u001b[39m\n\u001b[31m    state.origin.provider = og.provider;\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    setProgress(0, 0, '');\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('Origin geocode failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Geocode each stop (skip if pre-resolved). Sequential to avoid rate limits.\u001b[39m\n\u001b[31m  const total = state.stops.length;\u001b[39m\n\u001b[31m  let done = 0;\u001b[39m\n\u001b[31m  for (const s of state.stops) {\u001b[39m\n\u001b[31m    if (s.location) { done++; setProgress(done, total, 'Pre-geocoded'); continue; }\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      const r = await api('/geocode', { address: s.address });\u001b[39m\n\u001b[31m      if (r.ok && r.location) {\u001b[39m\n\u001b[31m        s.location = r.location;\u001b[39m\n\u001b[31m        s.provider = r.provider;\u001b[39m\n\u001b[31m      } else {\u001b[39m\n\u001b[31m        s.geocodeErr = r.error || 'No match';\u001b[39m\n\u001b[31m      }\u001b[39m\n\u001b[31m    } catch (e) {\u001b[39m\n\u001b[31m      s.geocodeErr = e.message;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    done++;\u001b[39m\n\u001b[31m    setProgress(done, total, `Geocoding ${s.name.slice(0, 30)}…`);\u001b[39m\n\u001b[31m    // Tiny delay to be polite to the provider\u001b[39m\n\u001b[31m    await sleep(80);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Filter out failed-geocode stops\u001b[39m\n\u001b[31m  const good = state.stops.filter(s => s.location && !s.geocodeErr);\u001b[39m\n\u001b[31m  const bad  = state.stops.filter(s => !s.location || s.geocodeErr);\u001b[39m\n\u001b[31m  if (!good.length) {\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('No stops could be geocoded', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (bad.length) toast(`${bad.length} stop(s) failed geocode (excluded)`, 'err');\u001b[39m\n\u001b[31m  state.stops = good;·\u001b[39m\n\u001b[31m  // Optimise\u001b[39m\n\u001b[31m  setProgress(total, total, 'Optimising route…');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const opt = await api('/optimize', {\u001b[39m\n\u001b[31m      origin:    locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:     state.stops.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:  state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!opt.ok) throw new Error(opt.error || 'optimise failed');\u001b[39m\n\u001b[31m    // Apply order\u001b[39m\n\u001b[31m    const order = opt.waypoint_order || state.stops.map((_, i) => i);\u001b[39m\n\u001b[31m    state.stops = order.map(i => state.stops[i]);\u001b[39m\n\u001b[31m    state.proposedOrder = state.stops.map(s => s.id);\u001b[39m\n\u001b[31m    state.baseTotal     = { distance_m: opt.total_distance_m, duration_s: opt.total_duration_s };\u001b[39m\n\u001b[31m    state.currentTotal  = { ...state.baseTotal };\u001b[39m\n\u001b[31m    state.lastOptimisation = opt.optimisation;\u001b[39m\n\u001b[31m    state.needsReoptimize  = false;   // fresh optimise — flag cleared\u001b[39m\n\u001b[31m    // Map legs to stops (route returned len = stops + 1 (round-trip) or stops + 1 (one-way))\u001b[39m\n\u001b[31m    if (Array.isArray(opt.legs) && opt.legs.length >= state.stops.length) {\u001b[39m\n\u001b[31m      state.stops.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = opt.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('Optimise failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m  showScreen('s-editor');\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction setProgress(done, total, label) {\u001b[39m\n\u001b[31m  const pct = total ? Math.round(done/total*100) : 0;\u001b[39m\n\u001b[31m  document.getElementById('prog-bar').style.width   = pct + '%';\u001b[39m\n\u001b[31m  document.getElementById('prog-count').textContent = `${done} / ${total}`;\u001b[39m\n\u001b[31m  document.getElementById('prog-label').textContent = label;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction locStr(loc) { return loc ? `${loc.lat},${loc.lng}` : null; }\u001b[39m\n\u001b[31mconst sleep = ms => new Promise(r => setTimeout(r, ms));·\u001b[39m\n\u001b[31m// ── Editor screen ───────────────────────────────────\u001b[39m\n\u001b[31mfunction renderEditor() {\u001b[39m\n\u001b[31m  restoreMapPref();\u001b[39m\n\u001b[31m  document.getElementById('origin-val').textContent = state.origin.text;\u001b[39m\n\u001b[31m  document.getElementById('editor-sub').textContent =\u001b[39m\n\u001b[31m    `${state.stops.filter(s => s.included).length} of ${state.stops.length} included` +\u001b[39m\n\u001b[31m    (state.lastOptimisation ? ` · ${state.lastOptimisation}` : '');\u001b[39m\n\u001b[31m  const cont = document.getElementById('tiles');\u001b[39m\n\u001b[31m  cont.innerHTML = '';\u001b[39m\n\u001b[31m  state.stops.forEach((s, idx) => cont.appendChild(renderTile(s, idx)));\u001b[39m\n\u001b[31m  if (_sortable) _sortable.destroy();\u001b[39m\n\u001b[31m  _sortable = Sortable.create(cont, {\u001b[39m\n\u001b[31m    handle: '.grip',\u001b[39m\n\u001b[31m    animation: 150,\u001b[39m\n\u001b[31m    onEnd: (evt) => {\u001b[39m\n\u001b[31m      const moved = state.stops.splice(evt.oldIndex, 1)[0];\u001b[39m\n\u001b[31m      state.stops.splice(evt.newIndex, 0, moved);\u001b[39m\n\u001b[31m      renderEditor();\u001b[39m\n\u001b[31m      scheduleRecalc();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  updateTotalsBar();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderTile(s, idx) {\u001b[39m\n\u001b[31m  const el = document.createElement('div');\u001b[39m\n\u001b[31m  el.className = 'tile' + (s.included ? '' : ' excluded') + (s.geocodeErr ? ' err' : '');\u001b[39m\n\u001b[31m  el.dataset.id = s.id;\u001b[39m\n\u001b[31m  const providerBadge = s.provider ? `<span class=\\\"badge-provider\\\">${escHtml(s.provider)}</span>` : '';\u001b[39m\n\u001b[31m  const leg = s.legDistance_m > 0\u001b[39m\n\u001b[31m    ? `${(s.legDistance_m/1000).toFixed(1)} km · ${Math.round(s.legDuration_s/60)} min from prev`\u001b[39m\n\u001b[31m    : '';\u001b[39m\n\u001b[31m  // Per UX rule: Name + City are the primary display. Address is shown\u001b[39m\n\u001b[31m  // smaller below as supporting context. Falls back gracefully when city\u001b[39m\n\u001b[31m  // is missing.\u001b[39m\n\u001b[31m  const cityLine = s.city || '—';\u001b[39m\n\u001b[31m  const addrLine = s.address && s.address !== s.city ? s.address : '';\u001b[39m\n\u001b[31m  el.innerHTML = `\u001b[39m\n\u001b[31m    <span class=\\\"grip\\\">≡</span>\u001b[39m\n\u001b[31m    <span class=\\\"ord\\\">${idx + 1}</span>\u001b[39m\n\u001b[31m    <div class=\\\"info\\\">\u001b[39m\n\u001b[31m      <div class=\\\"name\\\">${escHtml(s.name)} ${providerBadge}</div>\u001b[39m\n\u001b[31m      <div class=\\\"city\\\">${escHtml(cityLine)}</div>\u001b[39m\n\u001b[31m      ${addrLine ? `<div class=\\\"addr\\\">${escHtml(addrLine)}</div>` : ''}\u001b[39m\n\u001b[31m      ${leg ? `<div class=\\\"leg\\\">${escHtml(leg)}</div>` : ''}\u001b[39m\n\u001b[31m      ${s.geocodeErr ? `<div class=\\\"leg\\\" style=\\\"color:var(--r)\\\">⚠ ${escHtml(s.geocodeErr)}</div>` : ''}\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"incl ${s.included ? 'on' : 'off'}\\\" onclick=\\\"toggleInclude('${s.id}')\\\" title=\\\"Include / exclude\\\">\u001b[39m\n\u001b[31m      ${s.included ? '✓' : '✗'}\u001b[39m\n\u001b[31m    </button>`;\u001b[39m\n\u001b[31m  return el;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleInclude(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id); if (!s) return;\u001b[39m\n\u001b[31m  s.included = !s.included;\u001b[39m\n\u001b[31m  // The SET of stops just changed — the existing route may no longer be optimal.\u001b[39m\n\u001b[31m  // Surface a banner + pulse the Re-optim button so the user knows to refresh.\u001b[39m\n\u001b[31m  // recalcRoute still fires (debounced) to update the cost of the CURRENT order,\u001b[39m\n\u001b[31m  // but that number is just informational until the user re-optimises.\u001b[39m\n\u001b[31m  state.needsReoptimize = true;\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  scheduleRecalc();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction scheduleRecalc() {\u001b[39m\n\u001b[31m  clearTimeout(_recalcTimer);\u001b[39m\n\u001b[31m  document.getElementById('recalc').classList.add('show');\u001b[39m\n\u001b[31m  _recalcTimer = setTimeout(recalcRoute, 400);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function recalcRoute() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  if (!included.length) {\u001b[39m\n\u001b[31m    state.currentTotal = { distance_m: 0, duration_s: 0 };\u001b[39m\n\u001b[31m    document.getElementById('recalc').classList.remove('show');\u001b[39m\n\u001b[31m    updateTotalsBar();\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/route', {\u001b[39m\n\u001b[31m      origin:      locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:       included.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:   state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!r.ok) throw new Error(r.error || 'route failed');\u001b[39m\n\u001b[31m    state.currentTotal = { distance_m: r.total_distance_m, duration_s: r.total_duration_s };\u001b[39m\n\u001b[31m    // Map legs back to tiles (legs index 0 = origin→stop0, n = lastStop→dest)\u001b[39m\n\u001b[31m    if (Array.isArray(r.legs)) {\u001b[39m\n\u001b[31m      included.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = r.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    // Clear leg info on excluded stops\u001b[39m\n\u001b[31m    state.stops.filter(s => !s.included).forEach(s => { s.legDistance_m = 0; s.legDuration_s = 0; });\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Recalc failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  } finally {\u001b[39m\n\u001b[31m    document.getElementById('recalc').classList.remove('show');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateTotalsBar() {\u001b[39m\n\u001b[31m  // Totals now live ONLY in the top-actions card (the sticky bottom bar was\u001b[39m\n\u001b[31m  // removed after user feedback that it duplicated the top info 2026-06-04).\u001b[39m\n\u001b[31m  // The big-text element holds the inline recalc spinner as a child <span>.\u001b[39m\n\u001b[31m  const km  = (state.currentTotal.distance_m / 1000).toFixed(1);\u001b[39m\n\u001b[31m  const min = Math.round(state.currentTotal.duration_s / 60);\u001b[39m\n\u001b[31m  const hrs = Math.floor(min / 60), mm = min % 60;\u001b[39m\n\u001b[31m  const timeStr = hrs > 0 ? `${hrs}h ${mm}min` : `${mm} min`;\u001b[39m\n\u001b[31m  const totalsText = `${km} km · ${timeStr}`;\u001b[39m\n\u001b[31m  const bigEl  = document.getElementById('top-totals-big');\u001b[39m\n\u001b[31m  const recalc = document.getElementById('recalc');\u001b[39m\n\u001b[31m  bigEl.textContent = totalsText;\u001b[39m\n\u001b[31m  if (recalc) bigEl.appendChild(document.createTextNode(' '));\u001b[39m\n\u001b[31m  if (recalc) bigEl.appendChild(recalc);\u001b[39m\n\u001b[31m  // Delta vs base\u001b[39m\n\u001b[31m  const baseKm   = state.baseTotal.distance_m / 1000;\u001b[39m\n\u001b[31m  const curKm    = state.currentTotal.distance_m / 1000;\u001b[39m\n\u001b[31m  const deltaKm  = curKm - baseKm;\u001b[39m\n\u001b[31m  const deltaMin = Math.round((state.currentTotal.duration_s - state.baseTotal.duration_s) / 60);\u001b[39m\n\u001b[31m  const dT = document.getElementById('top-totals-delta');\u001b[39m\n\u001b[31m  let deltaText, deltaClass;\u001b[39m\n\u001b[31m  if (Math.abs(deltaKm) < 0.05) {\u001b[39m\n\u001b[31m    deltaText  = 'matches proposed route';\u001b[39m\n\u001b[31m    deltaClass = '';\u001b[39m\n\u001b[31m  } else {\u001b[39m\n\u001b[31m    const sign = deltaKm > 0 ? '+' : '';\u001b[39m\n\u001b[31m    deltaText  = `vs proposed: ${sign}${deltaKm.toFixed(1)} km · ${sign}${deltaMin} min`;\u001b[39m\n\u001b[31m    deltaClass = deltaKm < 0 ? 'better' : 'worse';\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  dT.textContent = deltaText;\u001b[39m\n\u001b[31m  dT.className   = 'top-totals-delta ' + deltaClass;·\u001b[39m\n\u001b[31m  // ── Re-optim-needed banner + pulse + stale styling ──\u001b[39m\n\u001b[31m  // Surfaces a clear message whenever the user has added/removed stops since\u001b[39m\n\u001b[31m  // the last /optimize call. The current-order cost is still shown (so the\u001b[39m\n\u001b[31m  // user sees \\\"what this order costs\\\"), but it's struck-through and the\u001b[39m\n\u001b[31m  // Re-optim button pulses to draw attention.\u001b[39m\n\u001b[31m  const needsReopt = !!state.needsReoptimize;\u001b[39m\n\u001b[31m  document.getElementById('reopt-needed').classList.toggle('show', needsReopt);\u001b[39m\n\u001b[31m  bigEl.classList.toggle('stale', needsReopt);\u001b[39m\n\u001b[31m  dT.classList.toggle('stale', needsReopt);\u001b[39m\n\u001b[31m  document.querySelector('.top-reopt')?.classList.toggle('pulse', needsReopt);·\u001b[39m\n\u001b[31m  // Re-render inline map preview to reflect current order/include flags.\u001b[39m\n\u001b[31m  // Debounced via setTimeout 0 so multiple updates in the same tick coalesce.\u001b[39m\n\u001b[31m  scheduleMapRender();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Inline route preview (Leaflet + free OSM tiles) ──────────────\u001b[39m\n\u001b[31m// Renders origin + stops + straight-line polyline inside the editor screen.\u001b[39m\n\u001b[31m// \\\"Straight-line\\\" because the proxy only returns leg distances/durations,\u001b[39m\n\u001b[31m// not the per-leg point arrays from TomTom — this preview is enough for\u001b[39m\n\u001b[31m// orientation; \\\"Open in Google Maps\\\" gives the user the real road geometry.\u001b[39m\n\u001b[31mlet _leafletMap   = null;\u001b[39m\n\u001b[31mlet _leafletLayer = null;\u001b[39m\n\u001b[31mlet _mapRenderTimer = null;·\u001b[39m\n\u001b[31mfunction ensureMap() {\u001b[39m\n\u001b[31m  if (_leafletMap) return _leafletMap;\u001b[39m\n\u001b[31m  if (typeof L === 'undefined') return null;   // CDN not ready yet\u001b[39m\n\u001b[31m  const el = document.getElementById('tour-map');\u001b[39m\n\u001b[31m  if (!el || !el.offsetParent) return null;    // container not visible yet\u001b[39m\n\u001b[31m  _leafletMap = L.map(el, { attributionControl: false, zoomControl: true, preferCanvas: true });\u001b[39m\n\u001b[31m  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18 })\u001b[39m\n\u001b[31m    .addTo(_leafletMap);\u001b[39m\n\u001b[31m  L.control.attribution({ position: 'bottomright', prefix: false })\u001b[39m\n\u001b[31m    .addAttribution('© OpenStreetMap contributors')\u001b[39m\n\u001b[31m    .addTo(_leafletMap);\u001b[39m\n\u001b[31m  return _leafletMap;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderMap() {\u001b[39m\n\u001b[31m  const map = ensureMap();\u001b[39m\n\u001b[31m  if (!map) return;\u001b[39m\n\u001b[31m  if (_leafletLayer) { map.removeLayer(_leafletLayer); _leafletLayer = null; }\u001b[39m\n\u001b[31m  const layers = [];\u001b[39m\n\u001b[31m  const linePts = [];\u001b[39m\n\u001b[31m  // Origin marker (always first in line)\u001b[39m\n\u001b[31m  if (state.origin?.location) {\u001b[39m\n\u001b[31m    const ll = [state.origin.location.lat, state.origin.location.lng];\u001b[39m\n\u001b[31m    layers.push(L.marker(ll, { icon: L.divIcon({\u001b[39m\n\u001b[31m      className: 'tour-pin origin', html: '<div>🏁</div>',\u001b[39m\n\u001b[31m      iconSize: [28, 28], iconAnchor: [14, 14],\u001b[39m\n\u001b[31m    })}).bindTooltip('Start: ' + (state.origin.text || 'origin'), { direction: 'top' }));\u001b[39m\n\u001b[31m    linePts.push(ll);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Stops in current visit order\u001b[39m\n\u001b[31m  let visitNum = 0;\u001b[39m\n\u001b[31m  state.stops.forEach(s => {\u001b[39m\n\u001b[31m    if (!s.location) return;\u001b[39m\n\u001b[31m    const ll = [s.location.lat, s.location.lng];\u001b[39m\n\u001b[31m    const included = s.included;\u001b[39m\n\u001b[31m    if (included) visitNum++;\u001b[39m\n\u001b[31m    layers.push(L.marker(ll, { icon: L.divIcon({\u001b[39m\n\u001b[31m      className: 'tour-pin' + (included ? '' : ' out'),\u001b[39m\n\u001b[31m      html: `<div>${included ? visitNum : '·'}</div>`,\u001b[39m\n\u001b[31m      iconSize: [26, 26], iconAnchor: [13, 13],\u001b[39m\n\u001b[31m    })}).bindTooltip(\u001b[39m\n\u001b[31m      (included ? `${visitNum}. ` : '✗ ') + s.name + (s.city ? ` · ${s.city}` : ''),\u001b[39m\n\u001b[31m      { direction: 'top' }\u001b[39m\n\u001b[31m    ));\u001b[39m\n\u001b[31m    if (included) linePts.push(ll);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  // Close the loop on round-trip\u001b[39m\n\u001b[31m  if (state.roundTrip && state.origin?.location) {\u001b[39m\n\u001b[31m    linePts.push([state.origin.location.lat, state.origin.location.lng]);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (linePts.length >= 2) {\u001b[39m\n\u001b[31m    layers.push(L.polyline(linePts, {\u001b[39m\n\u001b[31m      color: '#0ea5e9', weight: 3, opacity: 0.85, dashArray: '6 4',\u001b[39m\n\u001b[31m    }));\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  _leafletLayer = L.layerGroup(layers).addTo(map);\u001b[39m\n\u001b[31m  if (linePts.length) {\u001b[39m\n\u001b[31m    try { map.fitBounds(L.latLngBounds(linePts), { padding: [30, 30], maxZoom: 13 }); }\u001b[39m\n\u001b[31m    catch (_) {}\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction scheduleMapRender() {\u001b[39m\n\u001b[31m  if (_mapRenderTimer) return;\u001b[39m\n\u001b[31m  _mapRenderTimer = setTimeout(() => { _mapRenderTimer = null; renderMap(); }, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleMap() {\u001b[39m\n\u001b[31m  const wrap = document.getElementById('map-wrap');\u001b[39m\n\u001b[31m  const tog  = document.getElementById('map-toggle');\u001b[39m\n\u001b[31m  const open = !wrap.classList.toggle('collapsed');\u001b[39m\n\u001b[31m  tog.textContent = open ? '▾' : '▴';\u001b[39m\n\u001b[31m  try { localStorage.setItem('tour-pg-map-open', open ? '1' : '0'); } catch (_) {}\u001b[39m\n\u001b[31m  if (open && _leafletMap) {\u001b[39m\n\u001b[31m    // After expansion the container size changed — Leaflet must recompute\u001b[39m\n\u001b[31m    setTimeout(() => { _leafletMap.invalidateSize(); renderMap(); }, 220);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction restoreMapPref() {\u001b[39m\n\u001b[31m  // Default collapsed — user must explicitly expand once for it to stick open.\u001b[39m\n\u001b[31m  let open = false;\u001b[39m\n\u001b[31m  try { open = localStorage.getItem('tour-pg-map-open') === '1'; } catch (_) {}\u001b[39m\n\u001b[31m  const wrap = document.getElementById('map-wrap');\u001b[39m\n\u001b[31m  const tog  = document.getElementById('map-toggle');\u001b[39m\n\u001b[31m  wrap.classList.toggle('collapsed', !open);\u001b[39m\n\u001b[31m  tog.textContent = open ? '▾' : '▴';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Origin edit / mid-tour replan ────────────────────\u001b[39m\n\u001b[31mfunction openOriginEdit() {\u001b[39m\n\u001b[31m  document.getElementById('origin-edit').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('inp-new-origin').value = state.origin.text;\u001b[39m\n\u001b[31m  document.getElementById('inp-new-origin').focus();\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction closeOriginEdit() {\u001b[39m\n\u001b[31m  document.getElementById('origin-edit').style.display = 'none';\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31masync function applyOriginAndReplan() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('inp-new-origin').value.trim();\u001b[39m\n\u001b[31m  if (!txt) return toast('Enter an origin', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: txt });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin = { text: txt, location: og.location, provider: og.provider };\u001b[39m\n\u001b[31m    closeOriginEdit();\u001b[39m\n\u001b[31m    await replanAll();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Origin failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function replanAll() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  if (!included.length) return toast('No stops included', 'err');\u001b[39m\n\u001b[31m  toast('Optimising…', 'ok');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const opt = await api('/optimize', {\u001b[39m\n\u001b[31m      origin:      locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:       included.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:   state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!opt.ok) throw new Error(opt.error || 'optimise failed');\u001b[39m\n\u001b[31m    const order = opt.waypoint_order || included.map((_, i) => i);\u001b[39m\n\u001b[31m    const reordered = order.map(i => included[i]);\u001b[39m\n\u001b[31m    // KEEP EXCLUDED STOPS IN THEIR EXISTING POSITIONS (per user feedback\u001b[39m\n\u001b[31m    // 2026-06-04). Excluded slots stay put; reordered-included stops fill\u001b[39m\n\u001b[31m    // the gaps. Previously appended at end (confusing) → then dropped\u001b[39m\n\u001b[31m    // (lossy) → final: stay where they are. User can re-include to put\u001b[39m\n\u001b[31m    // them back into the optimised route.\u001b[39m\n\u001b[31m    const newStops = [];\u001b[39m\n\u001b[31m    let r = 0;\u001b[39m\n\u001b[31m    for (const s of state.stops) {\u001b[39m\n\u001b[31m      if (s.included) newStops.push(reordered[r++]);\u001b[39m\n\u001b[31m      else            newStops.push(s);\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    state.stops = newStops;\u001b[39m\n\u001b[31m    state.proposedOrder = state.stops.map(s => s.id);\u001b[39m\n\u001b[31m    state.baseTotal     = { distance_m: opt.total_distance_m, duration_s: opt.total_duration_s };\u001b[39m\n\u001b[31m    state.currentTotal  = { ...state.baseTotal };\u001b[39m\n\u001b[31m    state.lastOptimisation = opt.optimisation;\u001b[39m\n\u001b[31m    state.needsReoptimize  = false;   // re-optim done — banner cleared\u001b[39m\n\u001b[31m    if (Array.isArray(opt.legs)) {\u001b[39m\n\u001b[31m      reordered.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = opt.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Re-optimised ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Replan failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Share / export ──────────────────────────────────\u001b[39m\n\u001b[31mfunction openShare() {\u001b[39m\n\u001b[31m  const km   = (state.currentTotal.distance_m / 1000).toFixed(1);\u001b[39m\n\u001b[31m  const min  = Math.round(state.currentTotal.duration_s / 60);\u001b[39m\n\u001b[31m  const hrs  = Math.floor(min / 60), mm = min % 60;\u001b[39m\n\u001b[31m  const timeStr = hrs > 0 ? `${hrs}h ${mm}min` : `${mm} min`;\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  let txt = `🚗 Tour plan (${new Date().toLocaleDateString('en-IN')})\\\\n`;\u001b[39m\n\u001b[31m  txt += `From: ${state.origin.text}\\\\n`;\u001b[39m\n\u001b[31m  txt += `${included.length} stops · ${km} km · ${timeStr}\\\\n`;\u001b[39m\n\u001b[31m  if (state.roundTrip) txt += `Round-trip\\\\n\\\\n`;\u001b[39m\n\u001b[31m  else                  txt += `One-way → ${state.destination || included[included.length-1]?.address || ''}\\\\n\\\\n`;\u001b[39m\n\u001b[31m  included.forEach((s, i) => {\u001b[39m\n\u001b[31m    const legKm  = (s.legDistance_m/1000).toFixed(1);\u001b[39m\n\u001b[31m    const legMin = Math.round(s.legDuration_s/60);\u001b[39m\n\u001b[31m    txt += `${i+1}. ${s.name}`;\u001b[39m\n\u001b[31m    if (s.city) txt += ` · ${s.city}`;\u001b[39m\n\u001b[31m    if (s.legDistance_m > 0) txt += `  (+${legKm}km · ${legMin}min)`;\u001b[39m\n\u001b[31m    if (s.phone) txt += `  ${s.phone}`;\u001b[39m\n\u001b[31m    txt += `\\\\n`;\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (state.roundTrip) txt += `↩ Return to ${state.origin.text}`;\u001b[39m\n\u001b[31m  document.getElementById('share-summary').textContent = txt;\u001b[39m\n\u001b[31m  document.getElementById('modal-share').classList.add('show');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction closeShare() {\u001b[39m\n\u001b[31m  document.getElementById('modal-share').classList.remove('show');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction shareWhatsApp() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('share-summary').textContent;\u001b[39m\n\u001b[31m  const url = `https://wa.me/?text=${encodeURIComponent(txt)}`;\u001b[39m\n\u001b[31m  window.open(url, '_blank');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Hands off the optimised tour to Google Maps Directions (free, zero infra).\u001b[39m\n\u001b[31m// Uses path-style URL `dir/A/B/C/...` because it accepts many more stops than\u001b[39m\n\u001b[31m// the `api=1` Directions URL (which Google caps at 9 waypoints). Each segment\u001b[39m\n\u001b[31m// is \\\"lat,lng\\\" for geocoded points (most reliable). If the user picked a\u001b[39m\n\u001b[31m// text destination on a one-way tour we URL-encode it as the last segment.\u001b[39m\n\u001b[31mfunction openInGoogleMaps() {\u001b[39m\n\u001b[31m  if (!state.origin?.location) { toast('Origin not yet geocoded', 'err'); return; }\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included && s.location);\u001b[39m\n\u001b[31m  if (included.length === 0)   { toast('No included stops to view', 'err'); return; }\u001b[39m\n\u001b[31m  const fmt = (loc) => `${loc.lat.toFixed(6)},${loc.lng.toFixed(6)}`;\u001b[39m\n\u001b[31m  const segs = [ fmt(state.origin.location) ];\u001b[39m\n\u001b[31m  included.forEach(s => segs.push(fmt(s.location)));\u001b[39m\n\u001b[31m  if (state.roundTrip) {\u001b[39m\n\u001b[31m    segs.push(fmt(state.origin.location));\u001b[39m\n\u001b[31m  } else if (state.destination) {\u001b[39m\n\u001b[31m    segs.push(encodeURIComponent(state.destination));\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // travelmode and dir_action=navigate auto-open in driving directions\u001b[39m\n\u001b[31m  const url = `https://www.google.com/maps/dir/${segs.join('/')}/?travelmode=driving`;\u001b[39m\n\u001b[31m  window.open(url, '_blank', 'noopener');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function copyText() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('share-summary').textContent;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await navigator.clipboard.writeText(txt);\u001b[39m\n\u001b[31m    toast('Copied ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Copy failed', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction downloadCsv() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  const rows = [['#','Name','City','Address','Phone','Lat','Lng','Leg km','Leg min','Provider']];\u001b[39m\n\u001b[31m  included.forEach((s, i) => {\u001b[39m\n\u001b[31m    rows.push([\u001b[39m\n\u001b[31m      i + 1,\u001b[39m\n\u001b[31m      s.name, s.city, s.address, s.phone,\u001b[39m\n\u001b[31m      s.location?.lat ?? '', s.location?.lng ?? '',\u001b[39m\n\u001b[31m      (s.legDistance_m/1000).toFixed(2),\u001b[39m\n\u001b[31m      Math.round(s.legDuration_s/60),\u001b[39m\n\u001b[31m      s.provider || '',\u001b[39m\n\u001b[31m    ]);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  const csv = rows.map(r => r.map(c => `\\\"${String(c).replace(/\\\"/g, '\\\"\\\"')}\\\"`).join(',')).join('\\\\n');\u001b[39m\n\u001b[31m  const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });\u001b[39m\n\u001b[31m  const url = URL.createObjectURL(blob);\u001b[39m\n\u001b[31m  const a = document.createElement('a');\u001b[39m\n\u001b[31m  a.href = url; a.download = `tour-${new Date().toISOString().slice(0,10)}.csv`;\u001b[39m\n\u001b[31m  document.body.appendChild(a); a.click(); a.remove();\u001b[39m\n\u001b[31m  URL.revokeObjectURL(url);\u001b[39m\n\u001b[31m  toast('CSV downloaded ✓', 'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction saveLocal() {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    localStorage.setItem('tour-pg-saved', JSON.stringify({\u001b[39m\n\u001b[31m      saved_at: new Date().toISOString(),\u001b[39m\n\u001b[31m      state,\u001b[39m\n\u001b[31m    }));\u001b[39m\n\u001b[31m    toast('Saved ✓', 'ok');\u001b[39m\n\u001b[31m    document.getElementById('btn-load').style.display = '';\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed (storage full?)', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction loadSaved() {\u001b[39m\n\u001b[31m  const raw = localStorage.getItem('tour-pg-saved');\u001b[39m\n\u001b[31m  if (!raw) return toast('No saved tour', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const { state: s } = JSON.parse(raw);\u001b[39m\n\u001b[31m    state = s;\u001b[39m\n\u001b[31m    showScreen('s-editor');\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Loaded ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Load failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Navigation ──────────────────────────────────────\u001b[39m\n\u001b[31mfunction confirmBack() {\u001b[39m\n\u001b[31m  if (!confirm('Start a new tour? Current state will be cleared (use Save first if you want to keep it).')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m  document.getElementById('parsed-preview').style.display = 'none';\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = true;\u001b[39m\n\u001b[31m  document.getElementById('excel-file').value = '';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showScreen(id) {\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById(id).classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Utils ───────────────────────────────────────────\u001b[39m\n\u001b[31mfunction escHtml(s) { if (s == null) return ''; return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\\\"/g,'&quot;'); }\u001b[39m\n\u001b[31mlet _toastTimer;\u001b[39m\n\u001b[31mfunction toast(msg, type='') {\u001b[39m\n\u001b[31m  const el = document.getElementById('toast');\u001b[39m\n\u001b[31m  el.textContent = msg; el.className = 'show ' + type;\u001b[39m\n\u001b[31m  clearTimeout(_toastTimer);\u001b[39m\n\u001b[31m  _toastTimer = setTimeout(() => el.className = '', 3200);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction registerSW() {\u001b[39m\n\u001b[31m  if (!('serviceWorker' in navigator)) return;\u001b[39m\n\u001b[31m  const sw = `\u001b[39m\n\u001b[31mconst C='${CACHE_VER}';\u001b[39m\n\u001b[31mself.addEventListener('install', e => self.skipWaiting());\u001b[39m\n\u001b[31mself.addEventListener('activate', e => {\u001b[39m\n\u001b[31m  e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== C).map(k => caches.delete(k)))));\u001b[39m\n\u001b[31m  self.clients.claim();\u001b[39m\n\u001b[31m});\u001b[39m\n\u001b[31mself.addEventListener('fetch', e => {\u001b[39m\n\u001b[31m  if (e.request.method !== 'GET') return;\u001b[39m\n\u001b[31m  const url = new URL(e.request.url);\u001b[39m\n\u001b[31m  if (url.pathname.startsWith('/tour-pg-proxy')) return;\u001b[39m\n\u001b[31m  if (url.pathname.startsWith('/tour-pg/')) {\u001b[39m\n\u001b[31m    e.respondWith(\u001b[39m\n\u001b[31m      caches.open(C).then(c =>\u001b[39m\n\u001b[31m        c.match(e.request).then(r => r || fetch(e.request).then(f => { c.put(e.request, f.clone()); return f; }))\u001b[39m\n\u001b[31m      )\u001b[39m\n\u001b[31m    );\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m});`;\u001b[39m\n\u001b[31m  const blob = new Blob([sw], { type: 'application/javascript' });\u001b[39m\n\u001b[31m  navigator.serviceWorker.register(URL.createObjectURL(blob), { scope: '/tour-pg/' }).catch(() => {});\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31minit();\u001b[39m\n\u001b[31m</script>·\u001b[39m\n\u001b[31m</body>\u001b[39m\n\u001b[31m</html>\u001b[39m\n\u001b[31m\"\u001b[39m\n    at /var/www/360lm/tests/tour_pg.spec.js:54:18",
                        "location": {
                          "file": "/var/www/360lm/tests/tour_pg.spec.js",
                          "column": 18,
                          "line": 54
                        },
                        "snippet": "  52 |     const ctx = await request.newContext();\n  53 |     const html = await (await ctx.get(BASE_URL)).text();\n> 54 |     expect(html).toContain('tour-pg-v10');\n     |                  ^\n  55 |     expect(html).toContain('Tour Playground');\n  56 |     await ctx.dispose();\n  57 |   });"
                      },
                      "errors": [
                        {
                          "location": {
                            "file": "/var/www/360lm/tests/tour_pg.spec.js",
                            "column": 18,
                            "line": 54
                          },
                          "message": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoContain\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // indexOf\u001b[22m\n\nExpected substring: \u001b[32m\"tour-pg-v10\"\u001b[39m\nReceived string:    \u001b[31m\"<!DOCTYPE html>\u001b[39m\n\u001b[31m<html lang=\\\"en\\\">\u001b[39m\n\u001b[31m<head>\u001b[39m\n\u001b[31m<meta charset=\\\"UTF-8\\\">\u001b[39m\n\u001b[31m<meta name=\\\"viewport\\\" content=\\\"width=device-width,initial-scale=1,viewport-fit=cover\\\">\u001b[39m\n\u001b[31m<title>Tour Planner Playground</title>\u001b[39m\n\u001b[31m<link rel=\\\"manifest\\\" href=\\\"manifest.json\\\">\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"/shared/safe-bottom.css\\\">\u001b[39m\n\u001b[31m<meta name=\\\"theme-color\\\" content=\\\"#0a0a0a\\\">\u001b[39m\n\u001b[31m<link rel=\\\"preconnect\\\" href=\\\"https://fonts.googleapis.com\\\">\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&display=swap\\\">\u001b[39m\n\u001b[31m<script src=\\\"https://cdn.sheetjs.com/xlsx-0.20.2/package/dist/xlsx.full.min.js\\\"></script>\u001b[39m\n\u001b[31m<script src=\\\"https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js\\\"></script>\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.css\\\" crossorigin=\\\"\\\">\u001b[39m\n\u001b[31m<script src=\\\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.js\\\" crossorigin=\\\"\\\" defer></script>\u001b[39m\n\u001b[31m<style>\u001b[39m\n\u001b[31m:root{\u001b[39m\n\u001b[31m  --bg:#0a0a0a;--card:#141414;--card2:#1a1a1a;--border:#262626;--text:#f5f5f5;--muted:#a3a3a3;\u001b[39m\n\u001b[31m  --accent:#0ea5e9;--accent2:#0284c7;--g:#22c55e;--r:#ef4444;--y:#eab308;--p:#a855f7;\u001b[39m\n\u001b[31m  --rad:12px;--rads:8px;--bnav-h:64px;\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31m*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}\u001b[39m\n\u001b[31mbody{background:var(--bg);color:var(--text);font-family:'DM Sans',sans-serif;min-height:100vh;overscroll-behavior-y:none}\u001b[39m\n\u001b[31mbutton{font-family:inherit;cursor:pointer;border:none;background:none;color:inherit}\u001b[39m\n\u001b[31ma{color:inherit;text-decoration:none}·\u001b[39m\n\u001b[31m.scr{display:none;flex-direction:column;min-height:100vh;padding-bottom:calc(var(--bnav-h) + env(safe-area-inset-bottom))}\u001b[39m\n\u001b[31m.scr.active{display:flex}·\u001b[39m\n\u001b[31m.hdr{background:var(--card);border-bottom:1px solid var(--border);padding:12px 14px;display:flex;align-items:center;gap:8px;position:sticky;top:0;z-index:10}\u001b[39m\n\u001b[31m.hdr h1{font-size:15px;font-weight:800;flex:1;min-width:0}\u001b[39m\n\u001b[31m.hdr-sub{font-size:11px;color:var(--muted)}\u001b[39m\n\u001b[31m.hdr-btn{background:rgba(255,255,255,.08);border:1px solid var(--border);font-size:12px;font-weight:700;padding:5px 10px;border-radius:14px;cursor:pointer;white-space:nowrap}\u001b[39m\n\u001b[31m.hdr-btn:active{background:rgba(255,255,255,.16)}\u001b[39m\n\u001b[31m.user-pill{background:rgba(255,255,255,.1);border:1px solid var(--border);color:var(--muted);font-size:10px;font-weight:600;padding:3px 8px;border-radius:11px}\u001b[39m\n\u001b[31m.user-pill b{color:var(--text)}·\u001b[39m\n\u001b[31m.body{flex:1;padding:14px;display:flex;flex-direction:column;gap:12px}·\u001b[39m\n\u001b[31m/* ── editor: header + controls + route preview stay put; only the stops list scrolls ── */\u001b[39m\n\u001b[31m#s-editor.scr{height:100vh;height:100dvh;min-height:0;padding-bottom:0;overflow:hidden}\u001b[39m\n\u001b[31m.editor-body{flex:1;min-height:0;display:flex;flex-direction:column;padding:14px 14px 0}\u001b[39m\n\u001b[31m.editor-fixed{display:flex;flex-direction:column;gap:12px;flex-shrink:0}\u001b[39m\n\u001b[31m.editor-scroll{flex:1;min-height:0;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:2px 0 calc(32px + env(safe-area-inset-bottom));margin-top:6px;scrollbar-gutter:stable}\u001b[39m\n\u001b[31m/* visible scrollbar so it's obvious the stops list is independently scrollable */\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar{width:8px}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-thumb{background:#3a3a3a;border-radius:4px}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-thumb:active{background:#525252}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-track{background:transparent}·\u001b[39m\n\u001b[31m/* ── column-mapping table (parsed preview): scroll horizontally if it doesn't fit ── */\u001b[39m\n\u001b[31m.map-table-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--border);border-radius:6px}\u001b[39m\n\u001b[31m.map-table{border-collapse:collapse;table-layout:fixed;width:100%;min-width:440px;font-size:11px}\u001b[39m\n\u001b[31m.map-table th{text-align:left;padding:6px 8px;color:var(--muted);font-weight:800;font-size:9.5px;text-transform:uppercase;letter-spacing:.3px;background:var(--card2);position:sticky;top:0}\u001b[39m\n\u001b[31m.map-table td{padding:6px 8px;vertical-align:middle}\u001b[39m\n\u001b[31m.map-table tr+tr td,.map-table tbody tr td{border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.map-table col.c-src{width:40%}.map-table col.c-field{width:28%}.map-table col.c-sample{width:32%}\u001b[39m\n\u001b[31m.map-table select{background:#0b1220;border:1px solid var(--border);color:var(--text);padding:6px 7px;border-radius:4px;font-size:11px;width:100%}\u001b[39m\n\u001b[31m.map-field{font-weight:800;color:var(--text);white-space:nowrap}\u001b[39m\n\u001b[31m.map-sample{color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}·\u001b[39m\n\u001b[31m/* ── upload ── */\u001b[39m\n\u001b[31m.dropzone{background:var(--card);border:2px dashed var(--border);border-radius:var(--rad);padding:28px 16px;text-align:center;cursor:pointer;transition:.15s}\u001b[39m\n\u001b[31m.dropzone:hover,.dropzone.drag{border-color:var(--accent);background:#0e1726}\u001b[39m\n\u001b[31m.dropzone .ico{font-size:42px;margin-bottom:8px}\u001b[39m\n\u001b[31m.dropzone .ttl{font-weight:800;font-size:15px;margin-bottom:4px}\u001b[39m\n\u001b[31m.dropzone .sub{font-size:11.5px;color:var(--muted);line-height:1.4}\u001b[39m\n\u001b[31m.dropzone input[type=file]{display:none}·\u001b[39m\n\u001b[31m.section-title{font-size:11px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.4px}·\u001b[39m\n\u001b[31m.form-card{background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:12px;display:flex;flex-direction:column;gap:10px}\u001b[39m\n\u001b[31m.field{display:flex;flex-direction:column;gap:3px}\u001b[39m\n\u001b[31m.field label{font-size:11px;font-weight:600;color:var(--muted)}\u001b[39m\n\u001b[31m.field input,.field select{background:#0b1220;border:1px solid var(--border);border-radius:6px;color:var(--text);padding:9px 11px;font-size:13.5px;font-family:inherit;outline:none}\u001b[39m\n\u001b[31m.field input:focus,.field select:focus{border-color:var(--accent)}\u001b[39m\n\u001b[31m.toggle{display:flex;align-items:center;gap:8px;font-size:13px}\u001b[39m\n\u001b[31m.toggle input{accent-color:var(--accent);width:18px;height:18px}·\u001b[39m\n\u001b[31m.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;border-radius:var(--rads);padding:12px 14px;font-size:14px;font-weight:800;cursor:pointer;width:100%;transition:.12s}\u001b[39m\n\u001b[31m.btn-p{background:var(--accent);color:#fff}\u001b[39m\n\u001b[31m.btn-p:active{background:var(--accent2)}\u001b[39m\n\u001b[31m.btn-p:disabled{opacity:.4;cursor:default}\u001b[39m\n\u001b[31m.btn-s{background:var(--card);color:var(--text);border:1px solid var(--border)}\u001b[39m\n\u001b[31m.btn-r{background:rgba(239,68,68,.15);color:#fca5a5;border:1px solid rgba(239,68,68,.3)}·\u001b[39m\n\u001b[31m/* ── progress ── */\u001b[39m\n\u001b[31m.progress{background:#0b1220;border-radius:8px;height:10px;overflow:hidden;border:1px solid var(--border)}\u001b[39m\n\u001b[31m.progress-bar{height:100%;background:linear-gradient(90deg,var(--accent),var(--g));transition:.2s}·\u001b[39m\n\u001b[31m/* ── tiles ── */\u001b[39m\n\u001b[31m.origin-pill{background:linear-gradient(135deg,#0c2e3a,#062a3a);border:1px solid var(--accent);border-radius:var(--rad);padding:11px 13px;display:flex;align-items:center;gap:10px}\u001b[39m\n\u001b[31m.origin-pill .ico{font-size:18px}\u001b[39m\n\u001b[31m.origin-pill .meta{flex:1;min-width:0}\u001b[39m\n\u001b[31m.origin-pill .lbl{font-size:10.5px;color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.4px}\u001b[39m\n\u001b[31m.origin-pill .val{font-size:13.5px;font-weight:700;margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.origin-pill button{background:rgba(255,255,255,.08);border-radius:14px;padding:5px 10px;font-size:11px;font-weight:700;color:var(--accent)}·\u001b[39m\n\u001b[31m.tiles{display:flex;flex-direction:column;gap:6px}\u001b[39m\n\u001b[31m.tile{background:var(--card);border:1px solid var(--border);border-radius:var(--rads);padding:9px 10px;display:flex;align-items:center;gap:10px;transition:.12s;touch-action:none}\u001b[39m\n\u001b[31m.tile.sortable-ghost{opacity:.35;background:var(--card2)}\u001b[39m\n\u001b[31m.tile.sortable-chosen{transform:scale(.98);box-shadow:0 4px 20px rgba(14,165,233,.25)}\u001b[39m\n\u001b[31m.tile.excluded{opacity:.4;background:#0c0c0c}\u001b[39m\n\u001b[31m.tile.excluded .name,.tile.excluded .city{text-decoration:line-through}\u001b[39m\n\u001b[31m.tile.err{border-color:var(--r);background:#1f0a0a}\u001b[39m\n\u001b[31m.tile .grip{font-size:18px;color:var(--muted);cursor:grab;padding:4px;line-height:1}\u001b[39m\n\u001b[31m.tile .grip:active{cursor:grabbing}\u001b[39m\n\u001b[31m.tile .ord{background:var(--accent);color:#fff;width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11.5px;font-weight:800;flex-shrink:0}\u001b[39m\n\u001b[31m.tile.excluded .ord{background:#444}\u001b[39m\n\u001b[31m.tile .info{flex:1;min-width:0}\u001b[39m\n\u001b[31m.tile .name{font-size:13.5px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .city{font-size:11.5px;color:#cbd5e1;font-weight:600;margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .addr{font-size:9.5px;color:var(--muted);margin-top:1px;line-height:1.3;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-style:italic;opacity:.75}\u001b[39m\n\u001b[31m.tile .leg{font-size:10.5px;color:var(--accent2);font-weight:600;margin-top:2px}\u001b[39m\n\u001b[31m.tile.excluded .leg{display:none}\u001b[39m\n\u001b[31m.tile .badge-provider{display:inline-block;font-size:8.5px;padding:1px 5px;border-radius:8px;background:rgba(255,255,255,.08);color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.3px;margin-left:4px;vertical-align:middle}\u001b[39m\n\u001b[31m.tile .incl{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:50%;border:2px solid var(--border);font-size:16px;font-weight:800}\u001b[39m\n\u001b[31m.tile .incl.on{background:var(--g);border-color:var(--g);color:#fff}\u001b[39m\n\u001b[31m.tile .incl.off{background:#0c0c0c;color:var(--r);border-color:var(--r)}·\u001b[39m\n\u001b[31m/* ── prominent top action bar (KM, Share, Re-optimise) ── */\u001b[39m\n\u001b[31m.top-actions{background:linear-gradient(135deg,#0c2e3a,#08111c);border:1px solid var(--accent);border-radius:var(--rad);padding:10px;display:grid;grid-template-columns:1fr auto auto;gap:8px;align-items:center;margin-bottom:6px}\u001b[39m\n\u001b[31m.top-totals{min-width:0}\u001b[39m\n\u001b[31m.top-totals-big{font-size:18px;font-weight:900;line-height:1.05;letter-spacing:.3px}\u001b[39m\n\u001b[31m.top-totals-delta{font-size:11px;color:var(--muted);margin-top:2px}\u001b[39m\n\u001b[31m.top-totals-delta.better{color:var(--g)}\u001b[39m\n\u001b[31m.top-totals-delta.worse{color:var(--y)}\u001b[39m\n\u001b[31m.top-actions button{padding:10px 12px;font-size:12px;font-weight:800;border-radius:8px;border:none;cursor:pointer;display:flex;align-items:center;gap:5px;white-space:nowrap}\u001b[39m\n\u001b[31m.top-share{background:#f59e0b;color:#000}\u001b[39m\n\u001b[31m.top-share:active{background:#d97706}\u001b[39m\n\u001b[31m.top-reopt{background:var(--accent);color:#fff}\u001b[39m\n\u001b[31m.top-reopt:active{background:var(--accent2)}\u001b[39m\n\u001b[31m/* ── Re-optimisation needed (after include/exclude toggle) ── */\u001b[39m\n\u001b[31m.reopt-needed{font-size:11px;font-weight:700;color:#fbbf24;background:rgba(234,179,8,.12);border-left:3px solid #fbbf24;padding:5px 8px;border-radius:4px;margin-top:5px;display:none;line-height:1.3}\u001b[39m\n\u001b[31m.reopt-needed.show{display:block}·\u001b[39m\n\u001b[31m/* ── inline route preview (Leaflet + OSM) ── */\u001b[39m\n\u001b[31m.map-wrap{margin:8px 0 0;background:var(--card);border:1px solid var(--border);border-radius:var(--rads);overflow:hidden}\u001b[39m\n\u001b[31m.map-hdr{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;font-size:12px;color:var(--muted);background:var(--card2);border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.map-hdr .ttl{display:flex;align-items:center;gap:6px;font-weight:600}\u001b[39m\n\u001b[31m.map-hdr .toggle{font-size:14px;color:var(--text);background:none;border:none;cursor:pointer;padding:0 4px}\u001b[39m\n\u001b[31m#tour-map{height:260px;width:100%;background:#1a1a1a;transition:height .2s ease}\u001b[39m\n\u001b[31m.map-wrap.collapsed #tour-map{height:0}\u001b[39m\n\u001b[31m.map-wrap.collapsed .map-hdr{border-bottom:none}\u001b[39m\n\u001b[31m.tour-pin{background:transparent;border:none}\u001b[39m\n\u001b[31m.tour-pin > div{background:#0ea5e9;color:#fff;border-radius:50%;width:26px;height:26px;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:12px;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,.45);font-family:'DM Sans',sans-serif}\u001b[39m\n\u001b[31m.tour-pin.origin > div{background:#22c55e;font-size:14px}\u001b[39m\n\u001b[31m.tour-pin.out > div{background:#6b7280;opacity:.55}\u001b[39m\n\u001b[31m.leaflet-tooltip{background:#141414;color:#f5f5f5;border:1px solid #262626;font-size:11px;padding:3px 6px;font-family:'DM Sans',sans-serif;box-shadow:0 1px 6px rgba(0,0,0,.4)}\u001b[39m\n\u001b[31m.leaflet-tooltip-top:before,.leaflet-tooltip-bottom:before,.leaflet-tooltip-left:before,.leaflet-tooltip-right:before{border-top-color:#262626;border-bottom-color:#262626;border-left-color:#262626;border-right-color:#262626}\u001b[39m\n\u001b[31m.leaflet-control-attribution{background:rgba(20,20,20,.85)!important;color:#a3a3a3!important;font-size:9px!important}\u001b[39m\n\u001b[31m.leaflet-control-attribution a{color:#0ea5e9!important}\u001b[39m\n\u001b[31m.top-reopt.pulse{animation:pulseReopt 1.4s ease-in-out infinite;box-shadow:0 0 0 0 rgba(14,165,233,.6)}\u001b[39m\n\u001b[31m@keyframes pulseReopt{0%{box-shadow:0 0 0 0 rgba(14,165,233,.7)}50%{box-shadow:0 0 0 8px rgba(14,165,233,0)}100%{box-shadow:0 0 0 0 rgba(14,165,233,0)}}\u001b[39m\n\u001b[31m/* Totals dim when stale to make it visually obvious */\u001b[39m\n\u001b[31m.top-totals-big.stale, .top-totals-delta.stale{opacity:.5;text-decoration:line-through;text-decoration-color:rgba(251,191,36,.6)}·\u001b[39m\n\u001b[31m/* recalc spinner that appears inline next to the top totals during recompute */\u001b[39m\n\u001b[31m.recalc{display:none;font-size:11px;color:var(--accent);margin-left:6px;vertical-align:middle}\u001b[39m\n\u001b[31m.recalc.show{display:inline-block;animation:spin 1s linear infinite}\u001b[39m\n\u001b[31m@keyframes spin{to{transform:rotate(360deg)}}·\u001b[39m\n\u001b[31m/* ── share modal ── */\u001b[39m\n\u001b[31m.modal{position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:200;display:none;align-items:flex-end;justify-content:center}\u001b[39m\n\u001b[31m.modal.show{display:flex}\u001b[39m\n\u001b[31m.modal-box{background:var(--card);border-radius:18px 18px 0 0;width:100%;max-height:85vh;overflow-y:auto;padding:16px 14px calc(16px + env(safe-area-inset-bottom));border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.modal-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\u001b[39m\n\u001b[31m.modal-ttl{font-size:15px;font-weight:800}\u001b[39m\n\u001b[31m.modal-close{font-size:24px;color:var(--muted);background:none;border:none;cursor:pointer}·\u001b[39m\n\u001b[31m/* ── toast ── */\u001b[39m\n\u001b[31m#toast{position:fixed;bottom:calc(var(--bnav-h)+16px);left:12px;right:12px;z-index:1000;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:11px 14px;font-size:13px;font-weight:600;text-align:center;transform:translateY(150px);opacity:0;transition:.25s;pointer-events:none}\u001b[39m\n\u001b[31m#toast.show{transform:translateY(0);opacity:1}\u001b[39m\n\u001b[31m#toast.ok{background:rgba(34,197,94,.18);border-color:var(--g);color:#86efac}\u001b[39m\n\u001b[31m#toast.err{background:rgba(239,68,68,.18);border-color:var(--r);color:#fca5a5}·\u001b[39m\n\u001b[31m/* ── helpers ── */\u001b[39m\n\u001b[31m.spinner{display:inline-block;width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;vertical-align:middle}·\u001b[39m\n\u001b[31m.empty{text-align:center;padding:30px 16px;color:var(--muted);font-size:12.5px}·\u001b[39m\n\u001b[31mtextarea{background:#0b1220;border:1px solid var(--border);color:var(--text);padding:10px;font-family:inherit;font-size:12px;border-radius:6px;resize:vertical;outline:none}·\u001b[39m\n\u001b[31m.kbd{background:rgba(255,255,255,.06);padding:1px 6px;border-radius:4px;font-family:monospace;font-size:11px;color:var(--muted)}\u001b[39m\n\u001b[31m</style>\u001b[39m\n\u001b[31m</head>\u001b[39m\n\u001b[31m<body>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: UPLOAD ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-upload\\\" class=\\\"scr active\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗺 Tour Playground</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Excel → optimised route → tweak</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"loadSaved()\\\" id=\\\"btn-load\\\" style=\\\"display:none\\\">↻ Load</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"location.href='/hub/'\\\">🏡 Hub</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <label class=\\\"dropzone\\\" id=\\\"dropzone\\\" for=\\\"excel-file\\\">\u001b[39m\n\u001b[31m      <input type=\\\"file\\\" id=\\\"excel-file\\\" accept=\\\".xlsx,.xls,.csv\\\">\u001b[39m\n\u001b[31m      <div class=\\\"ico\\\">📊</div>\u001b[39m\n\u001b[31m      <div class=\\\"ttl\\\">Drop Excel here, or tap to choose</div>\u001b[39m\n\u001b[31m      <div class=\\\"sub\\\">Required: <b>store name</b> + <b>address</b><br>Optional: <b>city</b>, <b>lat</b>, <b>lng</b>, <b>phone</b>, anything else.</div>\u001b[39m\n\u001b[31m    </label>·\u001b[39m\n\u001b[31m    <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\">Origin</div>\u001b[39m\n\u001b[31m      <div class=\\\"field\\\">\u001b[39m\n\u001b[31m        <label>Start point</label>\u001b[39m\n\u001b[31m        <input type=\\\"text\\\" id=\\\"inp-origin\\\" value=\\\"Mohali office Punjab\\\" placeholder=\\\"Address or place name\\\">\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div class=\\\"toggle\\\">\u001b[39m\n\u001b[31m        <input type=\\\"checkbox\\\" id=\\\"inp-roundtrip\\\" checked>\u001b[39m\n\u001b[31m        <label for=\\\"inp-roundtrip\\\" style=\\\"cursor:pointer;font-size:13px;font-weight:600\\\">Round-trip (return to start)</label>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"dest-wrap\\\" class=\\\"field\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m        <label>Final destination</label>\u001b[39m\n\u001b[31m        <input type=\\\"text\\\" id=\\\"inp-destination\\\" placeholder=\\\"Where to end if not returning to origin\\\">\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" id=\\\"btn-go\\\" onclick=\\\"parseAndOptimise()\\\" disabled>\u001b[39m\n\u001b[31m      📐 Parse + optimise route\u001b[39m\n\u001b[31m    </button>·\u001b[39m\n\u001b[31m    <div id=\\\"upload-progress\\\" style=\\\"display:none;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;font-size:12px\\\">\u001b[39m\n\u001b[31m        <span id=\\\"prog-label\\\">Geocoding…</span>\u001b[39m\n\u001b[31m        <span id=\\\"prog-count\\\" style=\\\"color:var(--muted)\\\">0 / 0</span>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div class=\\\"progress\\\"><div class=\\\"progress-bar\\\" id=\\\"prog-bar\\\" style=\\\"width:0%\\\"></div></div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div id=\\\"parsed-preview\\\" style=\\\"display:none;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:11px;font-size:12px\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\" style=\\\"margin-bottom:6px\\\">Parsed</div>\u001b[39m\n\u001b[31m      <div id=\\\"parsed-summary\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: EDITOR ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-editor\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"confirmBack()\\\">‹ New</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>Tour Editor</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"editor-sub\\\">— stops</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-2\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"openShare()\\\">📤</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"editor-body\\\">\u001b[39m\n\u001b[31m   <div class=\\\"editor-fixed\\\">·\u001b[39m\n\u001b[31m    <div class=\\\"origin-pill\\\" id=\\\"origin-pill\\\">\u001b[39m\n\u001b[31m      <span class=\\\"ico\\\">📍</span>\u001b[39m\n\u001b[31m      <div class=\\\"meta\\\">\u001b[39m\n\u001b[31m        <div class=\\\"lbl\\\">Start from</div>\u001b[39m\n\u001b[31m        <div class=\\\"val\\\" id=\\\"origin-val\\\">—</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button onclick=\\\"openOriginEdit()\\\">✏️ Change</button>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div id=\\\"origin-edit\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m        <div class=\\\"field\\\">\u001b[39m\n\u001b[31m          <label>New origin (for mid-tour replan)</label>\u001b[39m\n\u001b[31m          <input type=\\\"text\\\" id=\\\"inp-new-origin\\\" placeholder=\\\"Hotel B Mohali, or address…\\\">\u001b[39m\n\u001b[31m          <div style=\\\"font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4\\\">\u001b[39m\n\u001b[31m            Tip: toggle ✗ on completed stops, change origin to where you are now, hit Replan.\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div style=\\\"display:flex;gap:8px\\\">\u001b[39m\n\u001b[31m          <button class=\\\"btn btn-s\\\" onclick=\\\"closeOriginEdit()\\\" style=\\\"flex:1\\\">Cancel</button>\u001b[39m\n\u001b[31m          <button class=\\\"btn btn-p\\\" onclick=\\\"applyOriginAndReplan()\\\" style=\\\"flex:2\\\">🔄 Replan from here</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"top-actions\\\" id=\\\"top-actions\\\">\u001b[39m\n\u001b[31m      <div class=\\\"top-totals\\\">\u001b[39m\n\u001b[31m        <div class=\\\"top-totals-big\\\" id=\\\"top-totals-big\\\">— km · — min<span class=\\\"recalc\\\" id=\\\"recalc\\\">↻</span></div>\u001b[39m\n\u001b[31m        <div class=\\\"top-totals-delta\\\" id=\\\"top-totals-delta\\\">vs proposed: —</div>\u001b[39m\n\u001b[31m        <div class=\\\"reopt-needed\\\" id=\\\"reopt-needed\\\">⚠ Stops changed — tap 🔁 Re-optim to refresh the route plan</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"top-share\\\" onclick=\\\"openShare()\\\">📤 Share</button>\u001b[39m\n\u001b[31m      <button class=\\\"top-reopt\\\" onclick=\\\"replanAll()\\\">🔁 Re-optim</button>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"map-wrap\\\" id=\\\"map-wrap\\\">\u001b[39m\n\u001b[31m      <div class=\\\"map-hdr\\\">\u001b[39m\n\u001b[31m        <div class=\\\"ttl\\\">🗺️ Route preview <span style=\\\"opacity:.6;font-weight:400\\\">(straight-line)</span></div>\u001b[39m\n\u001b[31m        <button class=\\\"toggle\\\" id=\\\"map-toggle\\\" onclick=\\\"toggleMap()\\\" aria-label=\\\"Toggle map\\\">▾</button>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"tour-map\\\"></div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"section-title\\\" style=\\\"margin-top:2px\\\">Stops (drag ≡ to reorder · scroll ↕ to see all)</div>\u001b[39m\n\u001b[31m   </div><!-- /editor-fixed -->·\u001b[39m\n\u001b[31m   <div class=\\\"editor-scroll\\\" id=\\\"editor-scroll\\\">\u001b[39m\n\u001b[31m    <div id=\\\"tiles\\\" class=\\\"tiles\\\"></div>\u001b[39m\n\u001b[31m   </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── MODAL: SHARE ── -->\u001b[39m\n\u001b[31m<div class=\\\"modal\\\" id=\\\"modal-share\\\">\u001b[39m\n\u001b[31m  <div class=\\\"modal-box\\\">\u001b[39m\n\u001b[31m    <div class=\\\"modal-hdr\\\">\u001b[39m\n\u001b[31m      <div class=\\\"modal-ttl\\\">📤 Share / Export</div>\u001b[39m\n\u001b[31m      <button class=\\\"modal-close\\\" onclick=\\\"closeShare()\\\">×</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"share-summary\\\" style=\\\"background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:10px;font-size:12px;line-height:1.6;white-space:pre-wrap;font-family:monospace\\\"></div>\u001b[39m\n\u001b[31m    <div style=\\\"margin-top:12px;display:grid;gap:8px\\\">\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"openInGoogleMaps()\\\">🗺️ Open route in Google Maps</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"shareWhatsApp()\\\">💬 WhatsApp</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"copyText()\\\">📋 Copy text</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"downloadCsv()\\\">📥 Download CSV</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"saveLocal()\\\">💾 Save to this device</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<div id=\\\"toast\\\"></div>·\u001b[39m\n\u001b[31m<script>\u001b[39m\n\u001b[31m'use strict';·\u001b[39m\n\u001b[31mconst CACHE_VER = 'tour-pg-v11';\u001b[39m\n\u001b[31mconst PROXY     = '/tour-pg-proxy';·\u001b[39m\n\u001b[31m// ── State ────────────────────────────────────────────\u001b[39m\n\u001b[31mlet session   = null;\u001b[39m\n\u001b[31mlet myId      = '';\u001b[39m\n\u001b[31mlet myName    = '';\u001b[39m\n\u001b[31mlet state     = newState();\u001b[39m\n\u001b[31mlet _recalcTimer = null;\u001b[39m\n\u001b[31mlet _sortable    = null;·\u001b[39m\n\u001b[31mfunction newState() {\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    origin:     { text: 'Mohali office Punjab', location: null, provider: null },\u001b[39m\n\u001b[31m    destination:null,\u001b[39m\n\u001b[31m    roundTrip:  true,\u001b[39m\n\u001b[31m    stops:      [],          // { id, name, city, address, phone, location, included, legDistance_m, legDuration_s, provider, raw, geocodeErr }\u001b[39m\n\u001b[31m    proposedOrder: null,     // array of stop IDs in optimise-time order\u001b[39m\n\u001b[31m    baseTotal: { distance_m: 0, duration_s: 0 },\u001b[39m\n\u001b[31m    currentTotal: { distance_m: 0, duration_s: 0 },\u001b[39m\n\u001b[31m    lastOptimisation: null,  // 'tomtom-computeBestOrder' | 'matrix-nn-2opt'\u001b[39m\n\u001b[31m    needsReoptimize: false,  // true after include/exclude toggle → totals may be stale; prompt user to tap Re-optim\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction uid(prefix='st') { return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,6)}`; }·\u001b[39m\n\u001b[31m// ── Auth ─────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction init() {\u001b[39m\n\u001b[31m  registerSW();\u001b[39m\n\u001b[31m  const raw = localStorage.getItem('lm360-session');\u001b[39m\n\u001b[31m  // Bounce to Hub with ?next= so user lands back here after login (Hub honours\u001b[39m\n\u001b[31m  // same-origin relative paths). Pattern documented in\u001b[39m\n\u001b[31m  // feedback_hub_next_redirect.md memory file — all gated PWAs should follow.\u001b[39m\n\u001b[31m  const nextParam = '?next=' + encodeURIComponent(location.pathname + location.search);\u001b[39m\n\u001b[31m  if (!raw) { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  try { session = JSON.parse(raw); } catch { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  if (Date.now() - (session.loginAt ? new Date(session.loginAt).getTime() : 0) > 12*3600*1000) { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  myId   = session.empId || '';\u001b[39m\n\u001b[31m  myName = session.name  || myId;\u001b[39m\n\u001b[31m  const userText = `👤 <b>${escHtml(myName)}</b>`;\u001b[39m\n\u001b[31m  document.getElementById('user-pill').innerHTML   = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill-2').innerHTML = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill').style.display   = '';\u001b[39m\n\u001b[31m  document.getElementById('user-pill-2').style.display = '';·\u001b[39m\n\u001b[31m  // Load-saved chip if we have a saved tour\u001b[39m\n\u001b[31m  if (localStorage.getItem('tour-pg-saved')) document.getElementById('btn-load').style.display = '';·\u001b[39m\n\u001b[31m  // Wire UI\u001b[39m\n\u001b[31m  document.getElementById('inp-roundtrip').addEventListener('change', e => {\u001b[39m\n\u001b[31m    document.getElementById('dest-wrap').style.display = e.target.checked ? 'none' : '';\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('dragover', e => { e.preventDefault(); e.currentTarget.classList.add('drag'); });\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('dragleave', e => e.currentTarget.classList.remove('drag'));\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('drop', e => {\u001b[39m\n\u001b[31m    e.preventDefault();\u001b[39m\n\u001b[31m    e.currentTarget.classList.remove('drag');\u001b[39m\n\u001b[31m    const f = e.dataTransfer.files[0]; if (f) handleFile(f);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  document.getElementById('excel-file').addEventListener('change', e => {\u001b[39m\n\u001b[31m    const f = e.target.files[0]; if (f) handleFile(f);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction hubAuth() {\u001b[39m\n\u001b[31m  return 'Hub ' + btoa(JSON.stringify(session));\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function api(path, body) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, {\u001b[39m\n\u001b[31m    method: 'POST',\u001b[39m\n\u001b[31m    headers: { 'Authorization': hubAuth(), 'Content-Type': 'application/json' },\u001b[39m\n\u001b[31m    body: JSON.stringify(body),\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (r.status === 401) {\u001b[39m\n\u001b[31m    toast('Session expired — refreshing to re-login', 'err');\u001b[39m\n\u001b[31m    const nextParam = '?next=' + encodeURIComponent(location.pathname + location.search);\u001b[39m\n\u001b[31m    setTimeout(() => location.href = '/hub/' + nextParam, 1500);\u001b[39m\n\u001b[31m    throw new Error('unauthorized');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!r.ok) {\u001b[39m\n\u001b[31m    const txt = await r.text().catch(() => '');\u001b[39m\n\u001b[31m    throw new Error(`${path}: ${r.status} ${txt.slice(0, 100)}`);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return r.json();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Excel parsing + column detection ────────────────\u001b[39m\n\u001b[31mlet _parsedCols = null;   // exposed for the column-remap UI\u001b[39m\n\u001b[31mlet _parsedRows = null;\u001b[39m\n\u001b[31mfunction handleFile(file) {\u001b[39m\n\u001b[31m  const reader = new FileReader();\u001b[39m\n\u001b[31m  reader.onload = (e) => {\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      const wb = XLSX.read(e.target.result, { type: 'array' });\u001b[39m\n\u001b[31m      const ws = wb.Sheets[wb.SheetNames[0]];\u001b[39m\n\u001b[31m      const rows = XLSX.utils.sheet_to_json(ws, { defval: '' });\u001b[39m\n\u001b[31m      if (!rows.length) throw new Error('Empty sheet');\u001b[39m\n\u001b[31m      _parsedRows = rows;\u001b[39m\n\u001b[31m      _parsedCols = detectColumns(rows[0]);\u001b[39m\n\u001b[31m      if (!_parsedCols.name && !_parsedCols.address && !_parsedCols.city) throw new Error('Missing required columns (need at least one of: name, address, city)');\u001b[39m\n\u001b[31m      rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m      renderParsedPreview();\u001b[39m\n\u001b[31m      document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m      toast('Excel parsed ✓', 'ok');\u001b[39m\n\u001b[31m    } catch (err) {\u001b[39m\n\u001b[31m      toast('Excel error: ' + err.message, 'err');\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  reader.readAsArrayBuffer(file);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction rebuildStopsFromParsedRows() {\u001b[39m\n\u001b[31m  state.stops = _parsedRows.map((row, idx) => buildStop(row, _parsedCols, idx)).filter(s => s);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderParsedPreview() {\u001b[39m\n\u001b[31m  const skip = _parsedRows.length - state.stops.length;\u001b[39m\n\u001b[31m  const allCols = Object.keys(_parsedRows[0]);\u001b[39m\n\u001b[31m  // For each role, show:  <role>: <selected col name>  with a tiny dropdown to override\u001b[39m\n\u001b[31m  const ROLES = ['name','city','address','lat','lng','phone','notes'];\u001b[39m\n\u001b[31m  const sample = _parsedRows[0];\u001b[39m\n\u001b[31m  const previewVal = (key) => {\u001b[39m\n\u001b[31m    const v = sample[key]; if (v == null || v === '') return '';\u001b[39m\n\u001b[31m    return String(v).slice(0, 40);\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  // Column order (per user request): YOUR Excel column first, then the PWA field\u001b[39m\n\u001b[31m  // it maps to, then a sample value. Equal-ish widths via <colgroup>; the wrapper\u001b[39m\n\u001b[31m  // scrolls horizontally so long header names are never clipped on narrow phones.\u001b[39m\n\u001b[31m  let rows = '<div class=\\\"map-table-wrap\\\"><table class=\\\"map-table\\\">' +\u001b[39m\n\u001b[31m    '<colgroup><col class=\\\"c-src\\\"><col class=\\\"c-field\\\"><col class=\\\"c-sample\\\"></colgroup>' +\u001b[39m\n\u001b[31m    '<thead><tr><th>Your file column</th><th>→ PWA field</th><th>Sample value</th></tr></thead><tbody>';\u001b[39m\n\u001b[31m  for (const role of ROLES) {\u001b[39m\n\u001b[31m    const sel  = _parsedCols[role];\u001b[39m\n\u001b[31m    const need = role === 'name';\u001b[39m\n\u001b[31m    const opts = `<option value=\\\"\\\">— ignore —</option>` +\u001b[39m\n\u001b[31m      allCols.map(c => `<option value=\\\"${escHtml(c)}\\\" ${sel === c ? 'selected' : ''}>${escHtml(c)}</option>`).join('');\u001b[39m\n\u001b[31m    const previewText = sel ? escHtml(previewVal(sel)) : '';\u001b[39m\n\u001b[31m    const tip = !sel && need ? `<span style=\\\"color:var(--y);font-weight:700\\\">⚠ no match — uses city or \\\"Stop #N\\\"</span>` : '';\u001b[39m\n\u001b[31m    rows += `<tr>\u001b[39m\n\u001b[31m      <td><select onchange=\\\"remapColumn('${role}', this.value)\\\">${opts}</select></td>\u001b[39m\n\u001b[31m      <td class=\\\"map-field\\\">${role}${need?' <span style=\\\"color:var(--y)\\\">*</span>':''}</td>\u001b[39m\n\u001b[31m      <td class=\\\"map-sample\\\">${previewText}${tip}</td>\u001b[39m\n\u001b[31m    </tr>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  rows += '</tbody></table></div>';\u001b[39m\n\u001b[31m  document.getElementById('parsed-preview').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('parsed-summary').innerHTML =\u001b[39m\n\u001b[31m    `<div style=\\\"margin-bottom:6px\\\"><b>${state.stops.length}</b> stops detected` +\u001b[39m\n\u001b[31m    `${skip > 0 ? ` · ${skip} skipped (no name/city/address)` : ''}</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:10.5px;color:var(--muted);margin-bottom:6px\\\">If detection is wrong, pick the correct column from the dropdowns:</div>` +\u001b[39m\n\u001b[31m    rows;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction remapColumn(role, newCol) {\u001b[39m\n\u001b[31m  if (!_parsedCols) return;\u001b[39m\n\u001b[31m  _parsedCols[role] = newCol || null;\u001b[39m\n\u001b[31m  rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction normaliseColName(s) {\u001b[39m\n\u001b[31m  return String(s).toLowerCase().replace(/[^a-z]/g, '');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction detectColumns(sampleRow) {\u001b[39m\n\u001b[31m  const map = { name:null, city:null, address:null, lat:null, lng:null, phone:null, notes:null, others:[] };\u001b[39m\n\u001b[31m  const keys = Object.keys(sampleRow);\u001b[39m\n\u001b[31m  // Broadened aliases so most user-provided Excel files map sensibly without a UI:\u001b[39m\n\u001b[31m  //   name  ← store / shop / outlet / party / dealer / firm / customer / business /\u001b[39m\n\u001b[31m  //           company / vendor / agency (and \\\"<thing> name\\\")\u001b[39m\n\u001b[31m  //   addr  ← address / fulladdress / location / loc / addr\u001b[39m\n\u001b[31m  //   city  ← city / town / district / location (only if no address column took it)\u001b[39m\n\u001b[31m  for (const k of keys) {\u001b[39m\n\u001b[31m    const n = normaliseColName(k);\u001b[39m\n\u001b[31m    if (!map.name && /^(storename|name|store|client|outlet|party(name)?|dealer(name)?|firm(name)?|customer(name)?|shop(name)?|business(name)?|companyname|company|vendor(name)?|agency(name)?|merchant|chain|sitename|site)$/.test(n)) { map.name = k; continue; }\u001b[39m\n\u001b[31m    if (!map.address && /^(address|fulladdress|streetaddress|addr|location|loc|street)$/.test(n)) { map.address = k; continue; }\u001b[39m\n\u001b[31m    if (!map.city && /^(city|town|district|cityname|locality|area|region)$/.test(n)) { map.city = k; continue; }\u001b[39m\n\u001b[31m    if (!map.lat && /^(lat|latitude|y)$/.test(n)) { map.lat = k; continue; }\u001b[39m\n\u001b[31m    if (!map.lng && /^(lng|lon|long|longitude|x)$/.test(n)) { map.lng = k; continue; }\u001b[39m\n\u001b[31m    if (!map.phone && /^(phone(no)?|mobile(no)?|contact(no)?|tel(no)?|whatsapp|wa|cell)$/.test(n)) { map.phone = k; continue; }\u001b[39m\n\u001b[31m    if (!map.notes && /^(notes?|remarks?|comments?|description|desc|memo)$/.test(n)) { map.notes = k; continue; }\u001b[39m\n\u001b[31m    map.others.push(k);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return map;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction buildStop(row, cols, idx) {\u001b[39m\n\u001b[31m  const name    = cols.name    ? String(row[cols.name]   || '').trim() : '';\u001b[39m\n\u001b[31m  const address = cols.address ? String(row[cols.address]|| '').trim() : '';\u001b[39m\n\u001b[31m  const city    = cols.city    ? String(row[cols.city]   || '').trim() : '';\u001b[39m\n\u001b[31m  const phone   = cols.phone   ? String(row[cols.phone]  || '').trim() : '';\u001b[39m\n\u001b[31m  const notes   = cols.notes   ? String(row[cols.notes]  || '').trim() : '';\u001b[39m\n\u001b[31m  if (!name && !address && !city) return null;\u001b[39m\n\u001b[31m  // pre-resolved coords?\u001b[39m\n\u001b[31m  let location = null;\u001b[39m\n\u001b[31m  if (cols.lat && cols.lng) {\u001b[39m\n\u001b[31m    const lat = parseFloat(row[cols.lat]);\u001b[39m\n\u001b[31m    const lng = parseFloat(row[cols.lng]);\u001b[39m\n\u001b[31m    if (!isNaN(lat) && !isNaN(lng) && lat !== 0 && lng !== 0) location = { lat, lng };\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const fullAddr = [address, city].filter(Boolean).join(', ');\u001b[39m\n\u001b[31m  // Display name fallback hierarchy (NEVER truncated address — that confused users):\u001b[39m\n\u001b[31m  //   1. Explicit name column value\u001b[39m\n\u001b[31m  //   2. City + index (so excluded/duplicate cities stay distinguishable)\u001b[39m\n\u001b[31m  //   3. \\\"Stop #N\\\"\u001b[39m\n\u001b[31m  let displayName;\u001b[39m\n\u001b[31m  if (name) displayName = name;\u001b[39m\n\u001b[31m  else if (city) displayName = `${city} (stop ${idx + 1})`;\u001b[39m\n\u001b[31m  else displayName = `Stop ${idx + 1}`;\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    id:           uid('st'),\u001b[39m\n\u001b[31m    name:         displayName,\u001b[39m\n\u001b[31m    hasExplicitName: !!name,         // for UI to flag \\\"name auto-derived\\\"\u001b[39m\n\u001b[31m    city, phone, notes,\u001b[39m\n\u001b[31m    address:      fullAddr || name || '',\u001b[39m\n\u001b[31m    location,\u001b[39m\n\u001b[31m    provider:     location ? 'excel' : null,\u001b[39m\n\u001b[31m    included:     true,\u001b[39m\n\u001b[31m    legDistance_m: 0,\u001b[39m\n\u001b[31m    legDuration_s: 0,\u001b[39m\n\u001b[31m    geocodeErr:   null,\u001b[39m\n\u001b[31m    raw:          { ...row },\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Geocode + optimise ──────────────────────────────\u001b[39m\n\u001b[31masync function parseAndOptimise() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('Parse an Excel first', 'err');\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = true;\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = '';\u001b[39m\n\u001b[31m  state.origin.text  = document.getElementById('inp-origin').value.trim() || 'Mohali office Punjab';\u001b[39m\n\u001b[31m  state.roundTrip    = document.getElementById('inp-roundtrip').checked;\u001b[39m\n\u001b[31m  state.destination  = state.roundTrip ? null : (document.getElementById('inp-destination').value.trim() || null);\u001b[39m\n\u001b[31m  // Geocode origin first\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: state.origin.text });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin.location = og.location;\u001b[39m\n\u001b[31m    state.origin.provider = og.provider;\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    setProgress(0, 0, '');\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('Origin geocode failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Geocode each stop (skip if pre-resolved). Sequential to avoid rate limits.\u001b[39m\n\u001b[31m  const total = state.stops.length;\u001b[39m\n\u001b[31m  let done = 0;\u001b[39m\n\u001b[31m  for (const s of state.stops) {\u001b[39m\n\u001b[31m    if (s.location) { done++; setProgress(done, total, 'Pre-geocoded'); continue; }\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      const r = await api('/geocode', { address: s.address });\u001b[39m\n\u001b[31m      if (r.ok && r.location) {\u001b[39m\n\u001b[31m        s.location = r.location;\u001b[39m\n\u001b[31m        s.provider = r.provider;\u001b[39m\n\u001b[31m      } else {\u001b[39m\n\u001b[31m        s.geocodeErr = r.error || 'No match';\u001b[39m\n\u001b[31m      }\u001b[39m\n\u001b[31m    } catch (e) {\u001b[39m\n\u001b[31m      s.geocodeErr = e.message;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    done++;\u001b[39m\n\u001b[31m    setProgress(done, total, `Geocoding ${s.name.slice(0, 30)}…`);\u001b[39m\n\u001b[31m    // Tiny delay to be polite to the provider\u001b[39m\n\u001b[31m    await sleep(80);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Filter out failed-geocode stops\u001b[39m\n\u001b[31m  const good = state.stops.filter(s => s.location && !s.geocodeErr);\u001b[39m\n\u001b[31m  const bad  = state.stops.filter(s => !s.location || s.geocodeErr);\u001b[39m\n\u001b[31m  if (!good.length) {\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('No stops could be geocoded', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (bad.length) toast(`${bad.length} stop(s) failed geocode (excluded)`, 'err');\u001b[39m\n\u001b[31m  state.stops = good;·\u001b[39m\n\u001b[31m  // Optimise\u001b[39m\n\u001b[31m  setProgress(total, total, 'Optimising route…');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const opt = await api('/optimize', {\u001b[39m\n\u001b[31m      origin:    locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:     state.stops.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:  state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!opt.ok) throw new Error(opt.error || 'optimise failed');\u001b[39m\n\u001b[31m    // Apply order\u001b[39m\n\u001b[31m    const order = opt.waypoint_order || state.stops.map((_, i) => i);\u001b[39m\n\u001b[31m    state.stops = order.map(i => state.stops[i]);\u001b[39m\n\u001b[31m    state.proposedOrder = state.stops.map(s => s.id);\u001b[39m\n\u001b[31m    state.baseTotal     = { distance_m: opt.total_distance_m, duration_s: opt.total_duration_s };\u001b[39m\n\u001b[31m    state.currentTotal  = { ...state.baseTotal };\u001b[39m\n\u001b[31m    state.lastOptimisation = opt.optimisation;\u001b[39m\n\u001b[31m    state.needsReoptimize  = false;   // fresh optimise — flag cleared\u001b[39m\n\u001b[31m    // Map legs to stops (route returned len = stops + 1 (round-trip) or stops + 1 (one-way))\u001b[39m\n\u001b[31m    if (Array.isArray(opt.legs) && opt.legs.length >= state.stops.length) {\u001b[39m\n\u001b[31m      state.stops.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = opt.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('Optimise failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m  showScreen('s-editor');\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction setProgress(done, total, label) {\u001b[39m\n\u001b[31m  const pct = total ? Math.round(done/total*100) : 0;\u001b[39m\n\u001b[31m  document.getElementById('prog-bar').style.width   = pct + '%';\u001b[39m\n\u001b[31m  document.getElementById('prog-count').textContent = `${done} / ${total}`;\u001b[39m\n\u001b[31m  document.getElementById('prog-label').textContent = label;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction locStr(loc) { return loc ? `${loc.lat},${loc.lng}` : null; }\u001b[39m\n\u001b[31mconst sleep = ms => new Promise(r => setTimeout(r, ms));·\u001b[39m\n\u001b[31m// ── Editor screen ───────────────────────────────────\u001b[39m\n\u001b[31mfunction renderEditor() {\u001b[39m\n\u001b[31m  restoreMapPref();\u001b[39m\n\u001b[31m  document.getElementById('origin-val').textContent = state.origin.text;\u001b[39m\n\u001b[31m  document.getElementById('editor-sub').textContent =\u001b[39m\n\u001b[31m    `${state.stops.filter(s => s.included).length} of ${state.stops.length} included` +\u001b[39m\n\u001b[31m    (state.lastOptimisation ? ` · ${state.lastOptimisation}` : '');\u001b[39m\n\u001b[31m  const cont = document.getElementById('tiles');\u001b[39m\n\u001b[31m  cont.innerHTML = '';\u001b[39m\n\u001b[31m  state.stops.forEach((s, idx) => cont.appendChild(renderTile(s, idx)));\u001b[39m\n\u001b[31m  if (_sortable) _sortable.destroy();\u001b[39m\n\u001b[31m  _sortable = Sortable.create(cont, {\u001b[39m\n\u001b[31m    handle: '.grip',\u001b[39m\n\u001b[31m    animation: 150,\u001b[39m\n\u001b[31m    onEnd: (evt) => {\u001b[39m\n\u001b[31m      const moved = state.stops.splice(evt.oldIndex, 1)[0];\u001b[39m\n\u001b[31m      state.stops.splice(evt.newIndex, 0, moved);\u001b[39m\n\u001b[31m      renderEditor();\u001b[39m\n\u001b[31m      scheduleRecalc();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  updateTotalsBar();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderTile(s, idx) {\u001b[39m\n\u001b[31m  const el = document.createElement('div');\u001b[39m\n\u001b[31m  el.className = 'tile' + (s.included ? '' : ' excluded') + (s.geocodeErr ? ' err' : '');\u001b[39m\n\u001b[31m  el.dataset.id = s.id;\u001b[39m\n\u001b[31m  const providerBadge = s.provider ? `<span class=\\\"badge-provider\\\">${escHtml(s.provider)}</span>` : '';\u001b[39m\n\u001b[31m  const leg = s.legDistance_m > 0\u001b[39m\n\u001b[31m    ? `${(s.legDistance_m/1000).toFixed(1)} km · ${Math.round(s.legDuration_s/60)} min from prev`\u001b[39m\n\u001b[31m    : '';\u001b[39m\n\u001b[31m  // Per UX rule: Name + City are the primary display. Address is shown\u001b[39m\n\u001b[31m  // smaller below as supporting context. Falls back gracefully when city\u001b[39m\n\u001b[31m  // is missing.\u001b[39m\n\u001b[31m  const cityLine = s.city || '—';\u001b[39m\n\u001b[31m  const addrLine = s.address && s.address !== s.city ? s.address : '';\u001b[39m\n\u001b[31m  el.innerHTML = `\u001b[39m\n\u001b[31m    <span class=\\\"grip\\\">≡</span>\u001b[39m\n\u001b[31m    <span class=\\\"ord\\\">${idx + 1}</span>\u001b[39m\n\u001b[31m    <div class=\\\"info\\\">\u001b[39m\n\u001b[31m      <div class=\\\"name\\\">${escHtml(s.name)} ${providerBadge}</div>\u001b[39m\n\u001b[31m      <div class=\\\"city\\\">${escHtml(cityLine)}</div>\u001b[39m\n\u001b[31m      ${addrLine ? `<div class=\\\"addr\\\">${escHtml(addrLine)}</div>` : ''}\u001b[39m\n\u001b[31m      ${leg ? `<div class=\\\"leg\\\">${escHtml(leg)}</div>` : ''}\u001b[39m\n\u001b[31m      ${s.geocodeErr ? `<div class=\\\"leg\\\" style=\\\"color:var(--r)\\\">⚠ ${escHtml(s.geocodeErr)}</div>` : ''}\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"incl ${s.included ? 'on' : 'off'}\\\" onclick=\\\"toggleInclude('${s.id}')\\\" title=\\\"Include / exclude\\\">\u001b[39m\n\u001b[31m      ${s.included ? '✓' : '✗'}\u001b[39m\n\u001b[31m    </button>`;\u001b[39m\n\u001b[31m  return el;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleInclude(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id); if (!s) return;\u001b[39m\n\u001b[31m  s.included = !s.included;\u001b[39m\n\u001b[31m  // The SET of stops just changed — the existing route may no longer be optimal.\u001b[39m\n\u001b[31m  // Surface a banner + pulse the Re-optim button so the user knows to refresh.\u001b[39m\n\u001b[31m  // recalcRoute still fires (debounced) to update the cost of the CURRENT order,\u001b[39m\n\u001b[31m  // but that number is just informational until the user re-optimises.\u001b[39m\n\u001b[31m  state.needsReoptimize = true;\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  scheduleRecalc();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction scheduleRecalc() {\u001b[39m\n\u001b[31m  clearTimeout(_recalcTimer);\u001b[39m\n\u001b[31m  document.getElementById('recalc').classList.add('show');\u001b[39m\n\u001b[31m  _recalcTimer = setTimeout(recalcRoute, 400);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function recalcRoute() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  if (!included.length) {\u001b[39m\n\u001b[31m    state.currentTotal = { distance_m: 0, duration_s: 0 };\u001b[39m\n\u001b[31m    document.getElementById('recalc').classList.remove('show');\u001b[39m\n\u001b[31m    updateTotalsBar();\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/route', {\u001b[39m\n\u001b[31m      origin:      locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:       included.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:   state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!r.ok) throw new Error(r.error || 'route failed');\u001b[39m\n\u001b[31m    state.currentTotal = { distance_m: r.total_distance_m, duration_s: r.total_duration_s };\u001b[39m\n\u001b[31m    // Map legs back to tiles (legs index 0 = origin→stop0, n = lastStop→dest)\u001b[39m\n\u001b[31m    if (Array.isArray(r.legs)) {\u001b[39m\n\u001b[31m      included.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = r.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    // Clear leg info on excluded stops\u001b[39m\n\u001b[31m    state.stops.filter(s => !s.included).forEach(s => { s.legDistance_m = 0; s.legDuration_s = 0; });\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Recalc failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  } finally {\u001b[39m\n\u001b[31m    document.getElementById('recalc').classList.remove('show');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateTotalsBar() {\u001b[39m\n\u001b[31m  // Totals now live ONLY in the top-actions card (the sticky bottom bar was\u001b[39m\n\u001b[31m  // removed after user feedback that it duplicated the top info 2026-06-04).\u001b[39m\n\u001b[31m  // The big-text element holds the inline recalc spinner as a child <span>.\u001b[39m\n\u001b[31m  const km  = (state.currentTotal.distance_m / 1000).toFixed(1);\u001b[39m\n\u001b[31m  const min = Math.round(state.currentTotal.duration_s / 60);\u001b[39m\n\u001b[31m  const hrs = Math.floor(min / 60), mm = min % 60;\u001b[39m\n\u001b[31m  const timeStr = hrs > 0 ? `${hrs}h ${mm}min` : `${mm} min`;\u001b[39m\n\u001b[31m  const totalsText = `${km} km · ${timeStr}`;\u001b[39m\n\u001b[31m  const bigEl  = document.getElementById('top-totals-big');\u001b[39m\n\u001b[31m  const recalc = document.getElementById('recalc');\u001b[39m\n\u001b[31m  bigEl.textContent = totalsText;\u001b[39m\n\u001b[31m  if (recalc) bigEl.appendChild(document.createTextNode(' '));\u001b[39m\n\u001b[31m  if (recalc) bigEl.appendChild(recalc);\u001b[39m\n\u001b[31m  // Delta vs base\u001b[39m\n\u001b[31m  const baseKm   = state.baseTotal.distance_m / 1000;\u001b[39m\n\u001b[31m  const curKm    = state.currentTotal.distance_m / 1000;\u001b[39m\n\u001b[31m  const deltaKm  = curKm - baseKm;\u001b[39m\n\u001b[31m  const deltaMin = Math.round((state.currentTotal.duration_s - state.baseTotal.duration_s) / 60);\u001b[39m\n\u001b[31m  const dT = document.getElementById('top-totals-delta');\u001b[39m\n\u001b[31m  let deltaText, deltaClass;\u001b[39m\n\u001b[31m  if (Math.abs(deltaKm) < 0.05) {\u001b[39m\n\u001b[31m    deltaText  = 'matches proposed route';\u001b[39m\n\u001b[31m    deltaClass = '';\u001b[39m\n\u001b[31m  } else {\u001b[39m\n\u001b[31m    const sign = deltaKm > 0 ? '+' : '';\u001b[39m\n\u001b[31m    deltaText  = `vs proposed: ${sign}${deltaKm.toFixed(1)} km · ${sign}${deltaMin} min`;\u001b[39m\n\u001b[31m    deltaClass = deltaKm < 0 ? 'better' : 'worse';\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  dT.textContent = deltaText;\u001b[39m\n\u001b[31m  dT.className   = 'top-totals-delta ' + deltaClass;·\u001b[39m\n\u001b[31m  // ── Re-optim-needed banner + pulse + stale styling ──\u001b[39m\n\u001b[31m  // Surfaces a clear message whenever the user has added/removed stops since\u001b[39m\n\u001b[31m  // the last /optimize call. The current-order cost is still shown (so the\u001b[39m\n\u001b[31m  // user sees \\\"what this order costs\\\"), but it's struck-through and the\u001b[39m\n\u001b[31m  // Re-optim button pulses to draw attention.\u001b[39m\n\u001b[31m  const needsReopt = !!state.needsReoptimize;\u001b[39m\n\u001b[31m  document.getElementById('reopt-needed').classList.toggle('show', needsReopt);\u001b[39m\n\u001b[31m  bigEl.classList.toggle('stale', needsReopt);\u001b[39m\n\u001b[31m  dT.classList.toggle('stale', needsReopt);\u001b[39m\n\u001b[31m  document.querySelector('.top-reopt')?.classList.toggle('pulse', needsReopt);·\u001b[39m\n\u001b[31m  // Re-render inline map preview to reflect current order/include flags.\u001b[39m\n\u001b[31m  // Debounced via setTimeout 0 so multiple updates in the same tick coalesce.\u001b[39m\n\u001b[31m  scheduleMapRender();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Inline route preview (Leaflet + free OSM tiles) ──────────────\u001b[39m\n\u001b[31m// Renders origin + stops + straight-line polyline inside the editor screen.\u001b[39m\n\u001b[31m// \\\"Straight-line\\\" because the proxy only returns leg distances/durations,\u001b[39m\n\u001b[31m// not the per-leg point arrays from TomTom — this preview is enough for\u001b[39m\n\u001b[31m// orientation; \\\"Open in Google Maps\\\" gives the user the real road geometry.\u001b[39m\n\u001b[31mlet _leafletMap   = null;\u001b[39m\n\u001b[31mlet _leafletLayer = null;\u001b[39m\n\u001b[31mlet _mapRenderTimer = null;·\u001b[39m\n\u001b[31mfunction ensureMap() {\u001b[39m\n\u001b[31m  if (_leafletMap) return _leafletMap;\u001b[39m\n\u001b[31m  if (typeof L === 'undefined') return null;   // CDN not ready yet\u001b[39m\n\u001b[31m  const el = document.getElementById('tour-map');\u001b[39m\n\u001b[31m  if (!el || !el.offsetParent) return null;    // container not visible yet\u001b[39m\n\u001b[31m  _leafletMap = L.map(el, { attributionControl: false, zoomControl: true, preferCanvas: true });\u001b[39m\n\u001b[31m  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18 })\u001b[39m\n\u001b[31m    .addTo(_leafletMap);\u001b[39m\n\u001b[31m  L.control.attribution({ position: 'bottomright', prefix: false })\u001b[39m\n\u001b[31m    .addAttribution('© OpenStreetMap contributors')\u001b[39m\n\u001b[31m    .addTo(_leafletMap);\u001b[39m\n\u001b[31m  return _leafletMap;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderMap() {\u001b[39m\n\u001b[31m  const map = ensureMap();\u001b[39m\n\u001b[31m  if (!map) return;\u001b[39m\n\u001b[31m  if (_leafletLayer) { map.removeLayer(_leafletLayer); _leafletLayer = null; }\u001b[39m\n\u001b[31m  const layers = [];\u001b[39m\n\u001b[31m  const linePts = [];\u001b[39m\n\u001b[31m  // Origin marker (always first in line)\u001b[39m\n\u001b[31m  if (state.origin?.location) {\u001b[39m\n\u001b[31m    const ll = [state.origin.location.lat, state.origin.location.lng];\u001b[39m\n\u001b[31m    layers.push(L.marker(ll, { icon: L.divIcon({\u001b[39m\n\u001b[31m      className: 'tour-pin origin', html: '<div>🏁</div>',\u001b[39m\n\u001b[31m      iconSize: [28, 28], iconAnchor: [14, 14],\u001b[39m\n\u001b[31m    })}).bindTooltip('Start: ' + (state.origin.text || 'origin'), { direction: 'top' }));\u001b[39m\n\u001b[31m    linePts.push(ll);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Stops in current visit order\u001b[39m\n\u001b[31m  let visitNum = 0;\u001b[39m\n\u001b[31m  state.stops.forEach(s => {\u001b[39m\n\u001b[31m    if (!s.location) return;\u001b[39m\n\u001b[31m    const ll = [s.location.lat, s.location.lng];\u001b[39m\n\u001b[31m    const included = s.included;\u001b[39m\n\u001b[31m    if (included) visitNum++;\u001b[39m\n\u001b[31m    layers.push(L.marker(ll, { icon: L.divIcon({\u001b[39m\n\u001b[31m      className: 'tour-pin' + (included ? '' : ' out'),\u001b[39m\n\u001b[31m      html: `<div>${included ? visitNum : '·'}</div>`,\u001b[39m\n\u001b[31m      iconSize: [26, 26], iconAnchor: [13, 13],\u001b[39m\n\u001b[31m    })}).bindTooltip(\u001b[39m\n\u001b[31m      (included ? `${visitNum}. ` : '✗ ') + s.name + (s.city ? ` · ${s.city}` : ''),\u001b[39m\n\u001b[31m      { direction: 'top' }\u001b[39m\n\u001b[31m    ));\u001b[39m\n\u001b[31m    if (included) linePts.push(ll);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  // Close the loop on round-trip\u001b[39m\n\u001b[31m  if (state.roundTrip && state.origin?.location) {\u001b[39m\n\u001b[31m    linePts.push([state.origin.location.lat, state.origin.location.lng]);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (linePts.length >= 2) {\u001b[39m\n\u001b[31m    layers.push(L.polyline(linePts, {\u001b[39m\n\u001b[31m      color: '#0ea5e9', weight: 3, opacity: 0.85, dashArray: '6 4',\u001b[39m\n\u001b[31m    }));\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  _leafletLayer = L.layerGroup(layers).addTo(map);\u001b[39m\n\u001b[31m  if (linePts.length) {\u001b[39m\n\u001b[31m    try { map.fitBounds(L.latLngBounds(linePts), { padding: [30, 30], maxZoom: 13 }); }\u001b[39m\n\u001b[31m    catch (_) {}\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction scheduleMapRender() {\u001b[39m\n\u001b[31m  if (_mapRenderTimer) return;\u001b[39m\n\u001b[31m  _mapRenderTimer = setTimeout(() => { _mapRenderTimer = null; renderMap(); }, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleMap() {\u001b[39m\n\u001b[31m  const wrap = document.getElementById('map-wrap');\u001b[39m\n\u001b[31m  const tog  = document.getElementById('map-toggle');\u001b[39m\n\u001b[31m  const open = !wrap.classList.toggle('collapsed');\u001b[39m\n\u001b[31m  tog.textContent = open ? '▾' : '▴';\u001b[39m\n\u001b[31m  try { localStorage.setItem('tour-pg-map-open', open ? '1' : '0'); } catch (_) {}\u001b[39m\n\u001b[31m  if (open && _leafletMap) {\u001b[39m\n\u001b[31m    // After expansion the container size changed — Leaflet must recompute\u001b[39m\n\u001b[31m    setTimeout(() => { _leafletMap.invalidateSize(); renderMap(); }, 220);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction restoreMapPref() {\u001b[39m\n\u001b[31m  // Default collapsed — user must explicitly expand once for it to stick open.\u001b[39m\n\u001b[31m  let open = false;\u001b[39m\n\u001b[31m  try { open = localStorage.getItem('tour-pg-map-open') === '1'; } catch (_) {}\u001b[39m\n\u001b[31m  const wrap = document.getElementById('map-wrap');\u001b[39m\n\u001b[31m  const tog  = document.getElementById('map-toggle');\u001b[39m\n\u001b[31m  wrap.classList.toggle('collapsed', !open);\u001b[39m\n\u001b[31m  tog.textContent = open ? '▾' : '▴';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Origin edit / mid-tour replan ────────────────────\u001b[39m\n\u001b[31mfunction openOriginEdit() {\u001b[39m\n\u001b[31m  document.getElementById('origin-edit').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('inp-new-origin').value = state.origin.text;\u001b[39m\n\u001b[31m  document.getElementById('inp-new-origin').focus();\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction closeOriginEdit() {\u001b[39m\n\u001b[31m  document.getElementById('origin-edit').style.display = 'none';\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31masync function applyOriginAndReplan() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('inp-new-origin').value.trim();\u001b[39m\n\u001b[31m  if (!txt) return toast('Enter an origin', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: txt });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin = { text: txt, location: og.location, provider: og.provider };\u001b[39m\n\u001b[31m    closeOriginEdit();\u001b[39m\n\u001b[31m    await replanAll();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Origin failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function replanAll() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  if (!included.length) return toast('No stops included', 'err');\u001b[39m\n\u001b[31m  toast('Optimising…', 'ok');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const opt = await api('/optimize', {\u001b[39m\n\u001b[31m      origin:      locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:       included.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:   state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!opt.ok) throw new Error(opt.error || 'optimise failed');\u001b[39m\n\u001b[31m    const order = opt.waypoint_order || included.map((_, i) => i);\u001b[39m\n\u001b[31m    const reordered = order.map(i => included[i]);\u001b[39m\n\u001b[31m    // KEEP EXCLUDED STOPS IN THEIR EXISTING POSITIONS (per user feedback\u001b[39m\n\u001b[31m    // 2026-06-04). Excluded slots stay put; reordered-included stops fill\u001b[39m\n\u001b[31m    // the gaps. Previously appended at end (confusing) → then dropped\u001b[39m\n\u001b[31m    // (lossy) → final: stay where they are. User can re-include to put\u001b[39m\n\u001b[31m    // them back into the optimised route.\u001b[39m\n\u001b[31m    const newStops = [];\u001b[39m\n\u001b[31m    let r = 0;\u001b[39m\n\u001b[31m    for (const s of state.stops) {\u001b[39m\n\u001b[31m      if (s.included) newStops.push(reordered[r++]);\u001b[39m\n\u001b[31m      else            newStops.push(s);\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    state.stops = newStops;\u001b[39m\n\u001b[31m    state.proposedOrder = state.stops.map(s => s.id);\u001b[39m\n\u001b[31m    state.baseTotal     = { distance_m: opt.total_distance_m, duration_s: opt.total_duration_s };\u001b[39m\n\u001b[31m    state.currentTotal  = { ...state.baseTotal };\u001b[39m\n\u001b[31m    state.lastOptimisation = opt.optimisation;\u001b[39m\n\u001b[31m    state.needsReoptimize  = false;   // re-optim done — banner cleared\u001b[39m\n\u001b[31m    if (Array.isArray(opt.legs)) {\u001b[39m\n\u001b[31m      reordered.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = opt.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Re-optimised ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Replan failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Share / export ──────────────────────────────────\u001b[39m\n\u001b[31mfunction openShare() {\u001b[39m\n\u001b[31m  const km   = (state.currentTotal.distance_m / 1000).toFixed(1);\u001b[39m\n\u001b[31m  const min  = Math.round(state.currentTotal.duration_s / 60);\u001b[39m\n\u001b[31m  const hrs  = Math.floor(min / 60), mm = min % 60;\u001b[39m\n\u001b[31m  const timeStr = hrs > 0 ? `${hrs}h ${mm}min` : `${mm} min`;\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  let txt = `🚗 Tour plan (${new Date().toLocaleDateString('en-IN')})\\\\n`;\u001b[39m\n\u001b[31m  txt += `From: ${state.origin.text}\\\\n`;\u001b[39m\n\u001b[31m  txt += `${included.length} stops · ${km} km · ${timeStr}\\\\n`;\u001b[39m\n\u001b[31m  if (state.roundTrip) txt += `Round-trip\\\\n\\\\n`;\u001b[39m\n\u001b[31m  else                  txt += `One-way → ${state.destination || included[included.length-1]?.address || ''}\\\\n\\\\n`;\u001b[39m\n\u001b[31m  included.forEach((s, i) => {\u001b[39m\n\u001b[31m    const legKm  = (s.legDistance_m/1000).toFixed(1);\u001b[39m\n\u001b[31m    const legMin = Math.round(s.legDuration_s/60);\u001b[39m\n\u001b[31m    txt += `${i+1}. ${s.name}`;\u001b[39m\n\u001b[31m    if (s.city) txt += ` · ${s.city}`;\u001b[39m\n\u001b[31m    if (s.legDistance_m > 0) txt += `  (+${legKm}km · ${legMin}min)`;\u001b[39m\n\u001b[31m    if (s.phone) txt += `  ${s.phone}`;\u001b[39m\n\u001b[31m    txt += `\\\\n`;\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (state.roundTrip) txt += `↩ Return to ${state.origin.text}`;\u001b[39m\n\u001b[31m  document.getElementById('share-summary').textContent = txt;\u001b[39m\n\u001b[31m  document.getElementById('modal-share').classList.add('show');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction closeShare() {\u001b[39m\n\u001b[31m  document.getElementById('modal-share').classList.remove('show');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction shareWhatsApp() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('share-summary').textContent;\u001b[39m\n\u001b[31m  const url = `https://wa.me/?text=${encodeURIComponent(txt)}`;\u001b[39m\n\u001b[31m  window.open(url, '_blank');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Hands off the optimised tour to Google Maps Directions (free, zero infra).\u001b[39m\n\u001b[31m// Uses path-style URL `dir/A/B/C/...` because it accepts many more stops than\u001b[39m\n\u001b[31m// the `api=1` Directions URL (which Google caps at 9 waypoints). Each segment\u001b[39m\n\u001b[31m// is \\\"lat,lng\\\" for geocoded points (most reliable). If the user picked a\u001b[39m\n\u001b[31m// text destination on a one-way tour we URL-encode it as the last segment.\u001b[39m\n\u001b[31mfunction openInGoogleMaps() {\u001b[39m\n\u001b[31m  if (!state.origin?.location) { toast('Origin not yet geocoded', 'err'); return; }\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included && s.location);\u001b[39m\n\u001b[31m  if (included.length === 0)   { toast('No included stops to view', 'err'); return; }\u001b[39m\n\u001b[31m  const fmt = (loc) => `${loc.lat.toFixed(6)},${loc.lng.toFixed(6)}`;\u001b[39m\n\u001b[31m  const segs = [ fmt(state.origin.location) ];\u001b[39m\n\u001b[31m  included.forEach(s => segs.push(fmt(s.location)));\u001b[39m\n\u001b[31m  if (state.roundTrip) {\u001b[39m\n\u001b[31m    segs.push(fmt(state.origin.location));\u001b[39m\n\u001b[31m  } else if (state.destination) {\u001b[39m\n\u001b[31m    segs.push(encodeURIComponent(state.destination));\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // travelmode and dir_action=navigate auto-open in driving directions\u001b[39m\n\u001b[31m  const url = `https://www.google.com/maps/dir/${segs.join('/')}/?travelmode=driving`;\u001b[39m\n\u001b[31m  window.open(url, '_blank', 'noopener');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function copyText() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('share-summary').textContent;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await navigator.clipboard.writeText(txt);\u001b[39m\n\u001b[31m    toast('Copied ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Copy failed', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction downloadCsv() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  const rows = [['#','Name','City','Address','Phone','Lat','Lng','Leg km','Leg min','Provider']];\u001b[39m\n\u001b[31m  included.forEach((s, i) => {\u001b[39m\n\u001b[31m    rows.push([\u001b[39m\n\u001b[31m      i + 1,\u001b[39m\n\u001b[31m      s.name, s.city, s.address, s.phone,\u001b[39m\n\u001b[31m      s.location?.lat ?? '', s.location?.lng ?? '',\u001b[39m\n\u001b[31m      (s.legDistance_m/1000).toFixed(2),\u001b[39m\n\u001b[31m      Math.round(s.legDuration_s/60),\u001b[39m\n\u001b[31m      s.provider || '',\u001b[39m\n\u001b[31m    ]);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  const csv = rows.map(r => r.map(c => `\\\"${String(c).replace(/\\\"/g, '\\\"\\\"')}\\\"`).join(',')).join('\\\\n');\u001b[39m\n\u001b[31m  const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });\u001b[39m\n\u001b[31m  const url = URL.createObjectURL(blob);\u001b[39m\n\u001b[31m  const a = document.createElement('a');\u001b[39m\n\u001b[31m  a.href = url; a.download = `tour-${new Date().toISOString().slice(0,10)}.csv`;\u001b[39m\n\u001b[31m  document.body.appendChild(a); a.click(); a.remove();\u001b[39m\n\u001b[31m  URL.revokeObjectURL(url);\u001b[39m\n\u001b[31m  toast('CSV downloaded ✓', 'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction saveLocal() {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    localStorage.setItem('tour-pg-saved', JSON.stringify({\u001b[39m\n\u001b[31m      saved_at: new Date().toISOString(),\u001b[39m\n\u001b[31m      state,\u001b[39m\n\u001b[31m    }));\u001b[39m\n\u001b[31m    toast('Saved ✓', 'ok');\u001b[39m\n\u001b[31m    document.getElementById('btn-load').style.display = '';\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed (storage full?)', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction loadSaved() {\u001b[39m\n\u001b[31m  const raw = localStorage.getItem('tour-pg-saved');\u001b[39m\n\u001b[31m  if (!raw) return toast('No saved tour', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const { state: s } = JSON.parse(raw);\u001b[39m\n\u001b[31m    state = s;\u001b[39m\n\u001b[31m    showScreen('s-editor');\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Loaded ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Load failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Navigation ──────────────────────────────────────\u001b[39m\n\u001b[31mfunction confirmBack() {\u001b[39m\n\u001b[31m  if (!confirm('Start a new tour? Current state will be cleared (use Save first if you want to keep it).')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m  document.getElementById('parsed-preview').style.display = 'none';\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = true;\u001b[39m\n\u001b[31m  document.getElementById('excel-file').value = '';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showScreen(id) {\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById(id).classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Utils ───────────────────────────────────────────\u001b[39m\n\u001b[31mfunction escHtml(s) { if (s == null) return ''; return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\\\"/g,'&quot;'); }\u001b[39m\n\u001b[31mlet _toastTimer;\u001b[39m\n\u001b[31mfunction toast(msg, type='') {\u001b[39m\n\u001b[31m  const el = document.getElementById('toast');\u001b[39m\n\u001b[31m  el.textContent = msg; el.className = 'show ' + type;\u001b[39m\n\u001b[31m  clearTimeout(_toastTimer);\u001b[39m\n\u001b[31m  _toastTimer = setTimeout(() => el.className = '', 3200);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction registerSW() {\u001b[39m\n\u001b[31m  if (!('serviceWorker' in navigator)) return;\u001b[39m\n\u001b[31m  const sw = `\u001b[39m\n\u001b[31mconst C='${CACHE_VER}';\u001b[39m\n\u001b[31mself.addEventListener('install', e => self.skipWaiting());\u001b[39m\n\u001b[31mself.addEventListener('activate', e => {\u001b[39m\n\u001b[31m  e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== C).map(k => caches.delete(k)))));\u001b[39m\n\u001b[31m  self.clients.claim();\u001b[39m\n\u001b[31m});\u001b[39m\n\u001b[31mself.addEventListener('fetch', e => {\u001b[39m\n\u001b[31m  if (e.request.method !== 'GET') return;\u001b[39m\n\u001b[31m  const url = new URL(e.request.url);\u001b[39m\n\u001b[31m  if (url.pathname.startsWith('/tour-pg-proxy')) return;\u001b[39m\n\u001b[31m  if (url.pathname.startsWith('/tour-pg/')) {\u001b[39m\n\u001b[31m    e.respondWith(\u001b[39m\n\u001b[31m      caches.open(C).then(c =>\u001b[39m\n\u001b[31m        c.match(e.request).then(r => r || fetch(e.request).then(f => { c.put(e.request, f.clone()); return f; }))\u001b[39m\n\u001b[31m      )\u001b[39m\n\u001b[31m    );\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m});`;\u001b[39m\n\u001b[31m  const blob = new Blob([sw], { type: 'application/javascript' });\u001b[39m\n\u001b[31m  navigator.serviceWorker.register(URL.createObjectURL(blob), { scope: '/tour-pg/' }).catch(() => {});\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31minit();\u001b[39m\n\u001b[31m</script>·\u001b[39m\n\u001b[31m</body>\u001b[39m\n\u001b[31m</html>\u001b[39m\n\u001b[31m\"\u001b[39m\n\n  52 |     const ctx = await request.newContext();\n  53 |     const html = await (await ctx.get(BASE_URL)).text();\n> 54 |     expect(html).toContain('tour-pg-v10');\n     |                  ^\n  55 |     expect(html).toContain('Tour Playground');\n  56 |     await ctx.dispose();\n  57 |   });\n    at /var/www/360lm/tests/tour_pg.spec.js:54:18"
                        }
                      ],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:07.445Z",
                      "annotations": [],
                      "attachments": [
                        {
                          "name": "error-context",
                          "contentType": "text/markdown",
                          "path": "/var/www/360lm/test-results/tour_pg-Tour-Playground-—--22e4b-ries-tour-pg-v10-SW-version-android-chrome/error-context.md"
                        }
                      ],
                      "errorLocation": {
                        "file": "/var/www/360lm/tests/tour_pg.spec.js",
                        "column": 18,
                        "line": 54
                      }
                    },
                    {
                      "workerIndex": 1,
                      "parallelIndex": 0,
                      "status": "failed",
                      "duration": 132,
                      "error": {
                        "message": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoContain\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // indexOf\u001b[22m\n\nExpected substring: \u001b[32m\"tour-pg-v10\"\u001b[39m\nReceived string:    \u001b[31m\"<!DOCTYPE html>\u001b[39m\n\u001b[31m<html lang=\\\"en\\\">\u001b[39m\n\u001b[31m<head>\u001b[39m\n\u001b[31m<meta charset=\\\"UTF-8\\\">\u001b[39m\n\u001b[31m<meta name=\\\"viewport\\\" content=\\\"width=device-width,initial-scale=1,viewport-fit=cover\\\">\u001b[39m\n\u001b[31m<title>Tour Planner Playground</title>\u001b[39m\n\u001b[31m<link rel=\\\"manifest\\\" href=\\\"manifest.json\\\">\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"/shared/safe-bottom.css\\\">\u001b[39m\n\u001b[31m<meta name=\\\"theme-color\\\" content=\\\"#0a0a0a\\\">\u001b[39m\n\u001b[31m<link rel=\\\"preconnect\\\" href=\\\"https://fonts.googleapis.com\\\">\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&display=swap\\\">\u001b[39m\n\u001b[31m<script src=\\\"https://cdn.sheetjs.com/xlsx-0.20.2/package/dist/xlsx.full.min.js\\\"></script>\u001b[39m\n\u001b[31m<script src=\\\"https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js\\\"></script>\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.css\\\" crossorigin=\\\"\\\">\u001b[39m\n\u001b[31m<script src=\\\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.js\\\" crossorigin=\\\"\\\" defer></script>\u001b[39m\n\u001b[31m<style>\u001b[39m\n\u001b[31m:root{\u001b[39m\n\u001b[31m  --bg:#0a0a0a;--card:#141414;--card2:#1a1a1a;--border:#262626;--text:#f5f5f5;--muted:#a3a3a3;\u001b[39m\n\u001b[31m  --accent:#0ea5e9;--accent2:#0284c7;--g:#22c55e;--r:#ef4444;--y:#eab308;--p:#a855f7;\u001b[39m\n\u001b[31m  --rad:12px;--rads:8px;--bnav-h:64px;\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31m*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}\u001b[39m\n\u001b[31mbody{background:var(--bg);color:var(--text);font-family:'DM Sans',sans-serif;min-height:100vh;overscroll-behavior-y:none}\u001b[39m\n\u001b[31mbutton{font-family:inherit;cursor:pointer;border:none;background:none;color:inherit}\u001b[39m\n\u001b[31ma{color:inherit;text-decoration:none}·\u001b[39m\n\u001b[31m.scr{display:none;flex-direction:column;min-height:100vh;padding-bottom:calc(var(--bnav-h) + env(safe-area-inset-bottom))}\u001b[39m\n\u001b[31m.scr.active{display:flex}·\u001b[39m\n\u001b[31m.hdr{background:var(--card);border-bottom:1px solid var(--border);padding:12px 14px;display:flex;align-items:center;gap:8px;position:sticky;top:0;z-index:10}\u001b[39m\n\u001b[31m.hdr h1{font-size:15px;font-weight:800;flex:1;min-width:0}\u001b[39m\n\u001b[31m.hdr-sub{font-size:11px;color:var(--muted)}\u001b[39m\n\u001b[31m.hdr-btn{background:rgba(255,255,255,.08);border:1px solid var(--border);font-size:12px;font-weight:700;padding:5px 10px;border-radius:14px;cursor:pointer;white-space:nowrap}\u001b[39m\n\u001b[31m.hdr-btn:active{background:rgba(255,255,255,.16)}\u001b[39m\n\u001b[31m.user-pill{background:rgba(255,255,255,.1);border:1px solid var(--border);color:var(--muted);font-size:10px;font-weight:600;padding:3px 8px;border-radius:11px}\u001b[39m\n\u001b[31m.user-pill b{color:var(--text)}·\u001b[39m\n\u001b[31m.body{flex:1;padding:14px;display:flex;flex-direction:column;gap:12px}·\u001b[39m\n\u001b[31m/* ── editor: header + controls + route preview stay put; only the stops list scrolls ── */\u001b[39m\n\u001b[31m#s-editor.scr{height:100vh;height:100dvh;min-height:0;padding-bottom:0;overflow:hidden}\u001b[39m\n\u001b[31m.editor-body{flex:1;min-height:0;display:flex;flex-direction:column;padding:14px 14px 0}\u001b[39m\n\u001b[31m.editor-fixed{display:flex;flex-direction:column;gap:12px;flex-shrink:0}\u001b[39m\n\u001b[31m.editor-scroll{flex:1;min-height:0;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:2px 0 calc(32px + env(safe-area-inset-bottom));margin-top:6px;scrollbar-gutter:stable}\u001b[39m\n\u001b[31m/* visible scrollbar so it's obvious the stops list is independently scrollable */\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar{width:8px}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-thumb{background:#3a3a3a;border-radius:4px}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-thumb:active{background:#525252}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-track{background:transparent}·\u001b[39m\n\u001b[31m/* ── column-mapping table (parsed preview): scroll horizontally if it doesn't fit ── */\u001b[39m\n\u001b[31m.map-table-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--border);border-radius:6px}\u001b[39m\n\u001b[31m.map-table{border-collapse:collapse;table-layout:fixed;width:100%;min-width:440px;font-size:11px}\u001b[39m\n\u001b[31m.map-table th{text-align:left;padding:6px 8px;color:var(--muted);font-weight:800;font-size:9.5px;text-transform:uppercase;letter-spacing:.3px;background:var(--card2);position:sticky;top:0}\u001b[39m\n\u001b[31m.map-table td{padding:6px 8px;vertical-align:middle}\u001b[39m\n\u001b[31m.map-table tr+tr td,.map-table tbody tr td{border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.map-table col.c-src{width:40%}.map-table col.c-field{width:28%}.map-table col.c-sample{width:32%}\u001b[39m\n\u001b[31m.map-table select{background:#0b1220;border:1px solid var(--border);color:var(--text);padding:6px 7px;border-radius:4px;font-size:11px;width:100%}\u001b[39m\n\u001b[31m.map-field{font-weight:800;color:var(--text);white-space:nowrap}\u001b[39m\n\u001b[31m.map-sample{color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}·\u001b[39m\n\u001b[31m/* ── upload ── */\u001b[39m\n\u001b[31m.dropzone{background:var(--card);border:2px dashed var(--border);border-radius:var(--rad);padding:28px 16px;text-align:center;cursor:pointer;transition:.15s}\u001b[39m\n\u001b[31m.dropzone:hover,.dropzone.drag{border-color:var(--accent);background:#0e1726}\u001b[39m\n\u001b[31m.dropzone .ico{font-size:42px;margin-bottom:8px}\u001b[39m\n\u001b[31m.dropzone .ttl{font-weight:800;font-size:15px;margin-bottom:4px}\u001b[39m\n\u001b[31m.dropzone .sub{font-size:11.5px;color:var(--muted);line-height:1.4}\u001b[39m\n\u001b[31m.dropzone input[type=file]{display:none}·\u001b[39m\n\u001b[31m.section-title{font-size:11px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.4px}·\u001b[39m\n\u001b[31m.form-card{background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:12px;display:flex;flex-direction:column;gap:10px}\u001b[39m\n\u001b[31m.field{display:flex;flex-direction:column;gap:3px}\u001b[39m\n\u001b[31m.field label{font-size:11px;font-weight:600;color:var(--muted)}\u001b[39m\n\u001b[31m.field input,.field select{background:#0b1220;border:1px solid var(--border);border-radius:6px;color:var(--text);padding:9px 11px;font-size:13.5px;font-family:inherit;outline:none}\u001b[39m\n\u001b[31m.field input:focus,.field select:focus{border-color:var(--accent)}\u001b[39m\n\u001b[31m.toggle{display:flex;align-items:center;gap:8px;font-size:13px}\u001b[39m\n\u001b[31m.toggle input{accent-color:var(--accent);width:18px;height:18px}·\u001b[39m\n\u001b[31m.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;border-radius:var(--rads);padding:12px 14px;font-size:14px;font-weight:800;cursor:pointer;width:100%;transition:.12s}\u001b[39m\n\u001b[31m.btn-p{background:var(--accent);color:#fff}\u001b[39m\n\u001b[31m.btn-p:active{background:var(--accent2)}\u001b[39m\n\u001b[31m.btn-p:disabled{opacity:.4;cursor:default}\u001b[39m\n\u001b[31m.btn-s{background:var(--card);color:var(--text);border:1px solid var(--border)}\u001b[39m\n\u001b[31m.btn-r{background:rgba(239,68,68,.15);color:#fca5a5;border:1px solid rgba(239,68,68,.3)}·\u001b[39m\n\u001b[31m/* ── progress ── */\u001b[39m\n\u001b[31m.progress{background:#0b1220;border-radius:8px;height:10px;overflow:hidden;border:1px solid var(--border)}\u001b[39m\n\u001b[31m.progress-bar{height:100%;background:linear-gradient(90deg,var(--accent),var(--g));transition:.2s}·\u001b[39m\n\u001b[31m/* ── tiles ── */\u001b[39m\n\u001b[31m.origin-pill{background:linear-gradient(135deg,#0c2e3a,#062a3a);border:1px solid var(--accent);border-radius:var(--rad);padding:11px 13px;display:flex;align-items:center;gap:10px}\u001b[39m\n\u001b[31m.origin-pill .ico{font-size:18px}\u001b[39m\n\u001b[31m.origin-pill .meta{flex:1;min-width:0}\u001b[39m\n\u001b[31m.origin-pill .lbl{font-size:10.5px;color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.4px}\u001b[39m\n\u001b[31m.origin-pill .val{font-size:13.5px;font-weight:700;margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.origin-pill button{background:rgba(255,255,255,.08);border-radius:14px;padding:5px 10px;font-size:11px;font-weight:700;color:var(--accent)}·\u001b[39m\n\u001b[31m.tiles{display:flex;flex-direction:column;gap:6px}\u001b[39m\n\u001b[31m.tile{background:var(--card);border:1px solid var(--border);border-radius:var(--rads);padding:9px 10px;display:flex;align-items:center;gap:10px;transition:.12s;touch-action:none}\u001b[39m\n\u001b[31m.tile.sortable-ghost{opacity:.35;background:var(--card2)}\u001b[39m\n\u001b[31m.tile.sortable-chosen{transform:scale(.98);box-shadow:0 4px 20px rgba(14,165,233,.25)}\u001b[39m\n\u001b[31m.tile.excluded{opacity:.4;background:#0c0c0c}\u001b[39m\n\u001b[31m.tile.excluded .name,.tile.excluded .city{text-decoration:line-through}\u001b[39m\n\u001b[31m.tile.err{border-color:var(--r);background:#1f0a0a}\u001b[39m\n\u001b[31m.tile .grip{font-size:18px;color:var(--muted);cursor:grab;padding:4px;line-height:1}\u001b[39m\n\u001b[31m.tile .grip:active{cursor:grabbing}\u001b[39m\n\u001b[31m.tile .ord{background:var(--accent);color:#fff;width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11.5px;font-weight:800;flex-shrink:0}\u001b[39m\n\u001b[31m.tile.excluded .ord{background:#444}\u001b[39m\n\u001b[31m.tile .info{flex:1;min-width:0}\u001b[39m\n\u001b[31m.tile .name{font-size:13.5px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .city{font-size:11.5px;color:#cbd5e1;font-weight:600;margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .addr{font-size:9.5px;color:var(--muted);margin-top:1px;line-height:1.3;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-style:italic;opacity:.75}\u001b[39m\n\u001b[31m.tile .leg{font-size:10.5px;color:var(--accent2);font-weight:600;margin-top:2px}\u001b[39m\n\u001b[31m.tile.excluded .leg{display:none}\u001b[39m\n\u001b[31m.tile .badge-provider{display:inline-block;font-size:8.5px;padding:1px 5px;border-radius:8px;background:rgba(255,255,255,.08);color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.3px;margin-left:4px;vertical-align:middle}\u001b[39m\n\u001b[31m.tile .incl{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:50%;border:2px solid var(--border);font-size:16px;font-weight:800}\u001b[39m\n\u001b[31m.tile .incl.on{background:var(--g);border-color:var(--g);color:#fff}\u001b[39m\n\u001b[31m.tile .incl.off{background:#0c0c0c;color:var(--r);border-color:var(--r)}·\u001b[39m\n\u001b[31m/* ── prominent top action bar (KM, Share, Re-optimise) ── */\u001b[39m\n\u001b[31m.top-actions{background:linear-gradient(135deg,#0c2e3a,#08111c);border:1px solid var(--accent);border-radius:var(--rad);padding:10px;display:grid;grid-template-columns:1fr auto auto;gap:8px;align-items:center;margin-bottom:6px}\u001b[39m\n\u001b[31m.top-totals{min-width:0}\u001b[39m\n\u001b[31m.top-totals-big{font-size:18px;font-weight:900;line-height:1.05;letter-spacing:.3px}\u001b[39m\n\u001b[31m.top-totals-delta{font-size:11px;color:var(--muted);margin-top:2px}\u001b[39m\n\u001b[31m.top-totals-delta.better{color:var(--g)}\u001b[39m\n\u001b[31m.top-totals-delta.worse{color:var(--y)}\u001b[39m\n\u001b[31m.top-actions button{padding:10px 12px;font-size:12px;font-weight:800;border-radius:8px;border:none;cursor:pointer;display:flex;align-items:center;gap:5px;white-space:nowrap}\u001b[39m\n\u001b[31m.top-share{background:#f59e0b;color:#000}\u001b[39m\n\u001b[31m.top-share:active{background:#d97706}\u001b[39m\n\u001b[31m.top-reopt{background:var(--accent);color:#fff}\u001b[39m\n\u001b[31m.top-reopt:active{background:var(--accent2)}\u001b[39m\n\u001b[31m/* ── Re-optimisation needed (after include/exclude toggle) ── */\u001b[39m\n\u001b[31m.reopt-needed{font-size:11px;font-weight:700;color:#fbbf24;background:rgba(234,179,8,.12);border-left:3px solid #fbbf24;padding:5px 8px;border-radius:4px;margin-top:5px;display:none;line-height:1.3}\u001b[39m\n\u001b[31m.reopt-needed.show{display:block}·\u001b[39m\n\u001b[31m/* ── inline route preview (Leaflet + OSM) ── */\u001b[39m\n\u001b[31m.map-wrap{margin:8px 0 0;background:var(--card);border:1px solid var(--border);border-radius:var(--rads);overflow:hidden}\u001b[39m\n\u001b[31m.map-hdr{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;font-size:12px;color:var(--muted);background:var(--card2);border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.map-hdr .ttl{display:flex;align-items:center;gap:6px;font-weight:600}\u001b[39m\n\u001b[31m.map-hdr .toggle{font-size:14px;color:var(--text);background:none;border:none;cursor:pointer;padding:0 4px}\u001b[39m\n\u001b[31m#tour-map{height:260px;width:100%;background:#1a1a1a;transition:height .2s ease}\u001b[39m\n\u001b[31m.map-wrap.collapsed #tour-map{height:0}\u001b[39m\n\u001b[31m.map-wrap.collapsed .map-hdr{border-bottom:none}\u001b[39m\n\u001b[31m.tour-pin{background:transparent;border:none}\u001b[39m\n\u001b[31m.tour-pin > div{background:#0ea5e9;color:#fff;border-radius:50%;width:26px;height:26px;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:12px;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,.45);font-family:'DM Sans',sans-serif}\u001b[39m\n\u001b[31m.tour-pin.origin > div{background:#22c55e;font-size:14px}\u001b[39m\n\u001b[31m.tour-pin.out > div{background:#6b7280;opacity:.55}\u001b[39m\n\u001b[31m.leaflet-tooltip{background:#141414;color:#f5f5f5;border:1px solid #262626;font-size:11px;padding:3px 6px;font-family:'DM Sans',sans-serif;box-shadow:0 1px 6px rgba(0,0,0,.4)}\u001b[39m\n\u001b[31m.leaflet-tooltip-top:before,.leaflet-tooltip-bottom:before,.leaflet-tooltip-left:before,.leaflet-tooltip-right:before{border-top-color:#262626;border-bottom-color:#262626;border-left-color:#262626;border-right-color:#262626}\u001b[39m\n\u001b[31m.leaflet-control-attribution{background:rgba(20,20,20,.85)!important;color:#a3a3a3!important;font-size:9px!important}\u001b[39m\n\u001b[31m.leaflet-control-attribution a{color:#0ea5e9!important}\u001b[39m\n\u001b[31m.top-reopt.pulse{animation:pulseReopt 1.4s ease-in-out infinite;box-shadow:0 0 0 0 rgba(14,165,233,.6)}\u001b[39m\n\u001b[31m@keyframes pulseReopt{0%{box-shadow:0 0 0 0 rgba(14,165,233,.7)}50%{box-shadow:0 0 0 8px rgba(14,165,233,0)}100%{box-shadow:0 0 0 0 rgba(14,165,233,0)}}\u001b[39m\n\u001b[31m/* Totals dim when stale to make it visually obvious */\u001b[39m\n\u001b[31m.top-totals-big.stale, .top-totals-delta.stale{opacity:.5;text-decoration:line-through;text-decoration-color:rgba(251,191,36,.6)}·\u001b[39m\n\u001b[31m/* recalc spinner that appears inline next to the top totals during recompute */\u001b[39m\n\u001b[31m.recalc{display:none;font-size:11px;color:var(--accent);margin-left:6px;vertical-align:middle}\u001b[39m\n\u001b[31m.recalc.show{display:inline-block;animation:spin 1s linear infinite}\u001b[39m\n\u001b[31m@keyframes spin{to{transform:rotate(360deg)}}·\u001b[39m\n\u001b[31m/* ── share modal ── */\u001b[39m\n\u001b[31m.modal{position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:200;display:none;align-items:flex-end;justify-content:center}\u001b[39m\n\u001b[31m.modal.show{display:flex}\u001b[39m\n\u001b[31m.modal-box{background:var(--card);border-radius:18px 18px 0 0;width:100%;max-height:85vh;overflow-y:auto;padding:16px 14px calc(16px + env(safe-area-inset-bottom));border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.modal-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\u001b[39m\n\u001b[31m.modal-ttl{font-size:15px;font-weight:800}\u001b[39m\n\u001b[31m.modal-close{font-size:24px;color:var(--muted);background:none;border:none;cursor:pointer}·\u001b[39m\n\u001b[31m/* ── toast ── */\u001b[39m\n\u001b[31m#toast{position:fixed;bottom:calc(var(--bnav-h)+16px);left:12px;right:12px;z-index:1000;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:11px 14px;font-size:13px;font-weight:600;text-align:center;transform:translateY(150px);opacity:0;transition:.25s;pointer-events:none}\u001b[39m\n\u001b[31m#toast.show{transform:translateY(0);opacity:1}\u001b[39m\n\u001b[31m#toast.ok{background:rgba(34,197,94,.18);border-color:var(--g);color:#86efac}\u001b[39m\n\u001b[31m#toast.err{background:rgba(239,68,68,.18);border-color:var(--r);color:#fca5a5}·\u001b[39m\n\u001b[31m/* ── helpers ── */\u001b[39m\n\u001b[31m.spinner{display:inline-block;width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;vertical-align:middle}·\u001b[39m\n\u001b[31m.empty{text-align:center;padding:30px 16px;color:var(--muted);font-size:12.5px}·\u001b[39m\n\u001b[31mtextarea{background:#0b1220;border:1px solid var(--border);color:var(--text);padding:10px;font-family:inherit;font-size:12px;border-radius:6px;resize:vertical;outline:none}·\u001b[39m\n\u001b[31m.kbd{background:rgba(255,255,255,.06);padding:1px 6px;border-radius:4px;font-family:monospace;font-size:11px;color:var(--muted)}\u001b[39m\n\u001b[31m</style>\u001b[39m\n\u001b[31m</head>\u001b[39m\n\u001b[31m<body>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: UPLOAD ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-upload\\\" class=\\\"scr active\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗺 Tour Playground</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Excel → optimised route → tweak</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"loadSaved()\\\" id=\\\"btn-load\\\" style=\\\"display:none\\\">↻ Load</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"location.href='/hub/'\\\">🏡 Hub</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <label class=\\\"dropzone\\\" id=\\\"dropzone\\\" for=\\\"excel-file\\\">\u001b[39m\n\u001b[31m      <input type=\\\"file\\\" id=\\\"excel-file\\\" accept=\\\".xlsx,.xls,.csv\\\">\u001b[39m\n\u001b[31m      <div class=\\\"ico\\\">📊</div>\u001b[39m\n\u001b[31m      <div class=\\\"ttl\\\">Drop Excel here, or tap to choose</div>\u001b[39m\n\u001b[31m      <div class=\\\"sub\\\">Required: <b>store name</b> + <b>address</b><br>Optional: <b>city</b>, <b>lat</b>, <b>lng</b>, <b>phone</b>, anything else.</div>\u001b[39m\n\u001b[31m    </label>·\u001b[39m\n\u001b[31m    <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\">Origin</div>\u001b[39m\n\u001b[31m      <div class=\\\"field\\\">\u001b[39m\n\u001b[31m        <label>Start point</label>\u001b[39m\n\u001b[31m        <input type=\\\"text\\\" id=\\\"inp-origin\\\" value=\\\"Mohali office Punjab\\\" placeholder=\\\"Address or place name\\\">\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div class=\\\"toggle\\\">\u001b[39m\n\u001b[31m        <input type=\\\"checkbox\\\" id=\\\"inp-roundtrip\\\" checked>\u001b[39m\n\u001b[31m        <label for=\\\"inp-roundtrip\\\" style=\\\"cursor:pointer;font-size:13px;font-weight:600\\\">Round-trip (return to start)</label>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"dest-wrap\\\" class=\\\"field\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m        <label>Final destination</label>\u001b[39m\n\u001b[31m        <input type=\\\"text\\\" id=\\\"inp-destination\\\" placeholder=\\\"Where to end if not returning to origin\\\">\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" id=\\\"btn-go\\\" onclick=\\\"parseAndOptimise()\\\" disabled>\u001b[39m\n\u001b[31m      📐 Parse + optimise route\u001b[39m\n\u001b[31m    </button>·\u001b[39m\n\u001b[31m    <div id=\\\"upload-progress\\\" style=\\\"display:none;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;font-size:12px\\\">\u001b[39m\n\u001b[31m        <span id=\\\"prog-label\\\">Geocoding…</span>\u001b[39m\n\u001b[31m        <span id=\\\"prog-count\\\" style=\\\"color:var(--muted)\\\">0 / 0</span>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div class=\\\"progress\\\"><div class=\\\"progress-bar\\\" id=\\\"prog-bar\\\" style=\\\"width:0%\\\"></div></div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div id=\\\"parsed-preview\\\" style=\\\"display:none;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:11px;font-size:12px\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\" style=\\\"margin-bottom:6px\\\">Parsed</div>\u001b[39m\n\u001b[31m      <div id=\\\"parsed-summary\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: EDITOR ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-editor\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"confirmBack()\\\">‹ New</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>Tour Editor</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"editor-sub\\\">— stops</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-2\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"openShare()\\\">📤</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"editor-body\\\">\u001b[39m\n\u001b[31m   <div class=\\\"editor-fixed\\\">·\u001b[39m\n\u001b[31m    <div class=\\\"origin-pill\\\" id=\\\"origin-pill\\\">\u001b[39m\n\u001b[31m      <span class=\\\"ico\\\">📍</span>\u001b[39m\n\u001b[31m      <div class=\\\"meta\\\">\u001b[39m\n\u001b[31m        <div class=\\\"lbl\\\">Start from</div>\u001b[39m\n\u001b[31m        <div class=\\\"val\\\" id=\\\"origin-val\\\">—</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button onclick=\\\"openOriginEdit()\\\">✏️ Change</button>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div id=\\\"origin-edit\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m        <div class=\\\"field\\\">\u001b[39m\n\u001b[31m          <label>New origin (for mid-tour replan)</label>\u001b[39m\n\u001b[31m          <input type=\\\"text\\\" id=\\\"inp-new-origin\\\" placeholder=\\\"Hotel B Mohali, or address…\\\">\u001b[39m\n\u001b[31m          <div style=\\\"font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4\\\">\u001b[39m\n\u001b[31m            Tip: toggle ✗ on completed stops, change origin to where you are now, hit Replan.\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div style=\\\"display:flex;gap:8px\\\">\u001b[39m\n\u001b[31m          <button class=\\\"btn btn-s\\\" onclick=\\\"closeOriginEdit()\\\" style=\\\"flex:1\\\">Cancel</button>\u001b[39m\n\u001b[31m          <button class=\\\"btn btn-p\\\" onclick=\\\"applyOriginAndReplan()\\\" style=\\\"flex:2\\\">🔄 Replan from here</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"top-actions\\\" id=\\\"top-actions\\\">\u001b[39m\n\u001b[31m      <div class=\\\"top-totals\\\">\u001b[39m\n\u001b[31m        <div class=\\\"top-totals-big\\\" id=\\\"top-totals-big\\\">— km · — min<span class=\\\"recalc\\\" id=\\\"recalc\\\">↻</span></div>\u001b[39m\n\u001b[31m        <div class=\\\"top-totals-delta\\\" id=\\\"top-totals-delta\\\">vs proposed: —</div>\u001b[39m\n\u001b[31m        <div class=\\\"reopt-needed\\\" id=\\\"reopt-needed\\\">⚠ Stops changed — tap 🔁 Re-optim to refresh the route plan</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"top-share\\\" onclick=\\\"openShare()\\\">📤 Share</button>\u001b[39m\n\u001b[31m      <button class=\\\"top-reopt\\\" onclick=\\\"replanAll()\\\">🔁 Re-optim</button>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"map-wrap\\\" id=\\\"map-wrap\\\">\u001b[39m\n\u001b[31m      <div class=\\\"map-hdr\\\">\u001b[39m\n\u001b[31m        <div class=\\\"ttl\\\">🗺️ Route preview <span style=\\\"opacity:.6;font-weight:400\\\">(straight-line)</span></div>\u001b[39m\n\u001b[31m        <button class=\\\"toggle\\\" id=\\\"map-toggle\\\" onclick=\\\"toggleMap()\\\" aria-label=\\\"Toggle map\\\">▾</button>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"tour-map\\\"></div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"section-title\\\" style=\\\"margin-top:2px\\\">Stops (drag ≡ to reorder · scroll ↕ to see all)</div>\u001b[39m\n\u001b[31m   </div><!-- /editor-fixed -->·\u001b[39m\n\u001b[31m   <div class=\\\"editor-scroll\\\" id=\\\"editor-scroll\\\">\u001b[39m\n\u001b[31m    <div id=\\\"tiles\\\" class=\\\"tiles\\\"></div>\u001b[39m\n\u001b[31m   </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── MODAL: SHARE ── -->\u001b[39m\n\u001b[31m<div class=\\\"modal\\\" id=\\\"modal-share\\\">\u001b[39m\n\u001b[31m  <div class=\\\"modal-box\\\">\u001b[39m\n\u001b[31m    <div class=\\\"modal-hdr\\\">\u001b[39m\n\u001b[31m      <div class=\\\"modal-ttl\\\">📤 Share / Export</div>\u001b[39m\n\u001b[31m      <button class=\\\"modal-close\\\" onclick=\\\"closeShare()\\\">×</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"share-summary\\\" style=\\\"background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:10px;font-size:12px;line-height:1.6;white-space:pre-wrap;font-family:monospace\\\"></div>\u001b[39m\n\u001b[31m    <div style=\\\"margin-top:12px;display:grid;gap:8px\\\">\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"openInGoogleMaps()\\\">🗺️ Open route in Google Maps</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"shareWhatsApp()\\\">💬 WhatsApp</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"copyText()\\\">📋 Copy text</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"downloadCsv()\\\">📥 Download CSV</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"saveLocal()\\\">💾 Save to this device</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<div id=\\\"toast\\\"></div>·\u001b[39m\n\u001b[31m<script>\u001b[39m\n\u001b[31m'use strict';·\u001b[39m\n\u001b[31mconst CACHE_VER = 'tour-pg-v11';\u001b[39m\n\u001b[31mconst PROXY     = '/tour-pg-proxy';·\u001b[39m\n\u001b[31m// ── State ────────────────────────────────────────────\u001b[39m\n\u001b[31mlet session   = null;\u001b[39m\n\u001b[31mlet myId      = '';\u001b[39m\n\u001b[31mlet myName    = '';\u001b[39m\n\u001b[31mlet state     = newState();\u001b[39m\n\u001b[31mlet _recalcTimer = null;\u001b[39m\n\u001b[31mlet _sortable    = null;·\u001b[39m\n\u001b[31mfunction newState() {\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    origin:     { text: 'Mohali office Punjab', location: null, provider: null },\u001b[39m\n\u001b[31m    destination:null,\u001b[39m\n\u001b[31m    roundTrip:  true,\u001b[39m\n\u001b[31m    stops:      [],          // { id, name, city, address, phone, location, included, legDistance_m, legDuration_s, provider, raw, geocodeErr }\u001b[39m\n\u001b[31m    proposedOrder: null,     // array of stop IDs in optimise-time order\u001b[39m\n\u001b[31m    baseTotal: { distance_m: 0, duration_s: 0 },\u001b[39m\n\u001b[31m    currentTotal: { distance_m: 0, duration_s: 0 },\u001b[39m\n\u001b[31m    lastOptimisation: null,  // 'tomtom-computeBestOrder' | 'matrix-nn-2opt'\u001b[39m\n\u001b[31m    needsReoptimize: false,  // true after include/exclude toggle → totals may be stale; prompt user to tap Re-optim\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction uid(prefix='st') { return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,6)}`; }·\u001b[39m\n\u001b[31m// ── Auth ─────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction init() {\u001b[39m\n\u001b[31m  registerSW();\u001b[39m\n\u001b[31m  const raw = localStorage.getItem('lm360-session');\u001b[39m\n\u001b[31m  // Bounce to Hub with ?next= so user lands back here after login (Hub honours\u001b[39m\n\u001b[31m  // same-origin relative paths). Pattern documented in\u001b[39m\n\u001b[31m  // feedback_hub_next_redirect.md memory file — all gated PWAs should follow.\u001b[39m\n\u001b[31m  const nextParam = '?next=' + encodeURIComponent(location.pathname + location.search);\u001b[39m\n\u001b[31m  if (!raw) { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  try { session = JSON.parse(raw); } catch { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  if (Date.now() - (session.loginAt ? new Date(session.loginAt).getTime() : 0) > 12*3600*1000) { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  myId   = session.empId || '';\u001b[39m\n\u001b[31m  myName = session.name  || myId;\u001b[39m\n\u001b[31m  const userText = `👤 <b>${escHtml(myName)}</b>`;\u001b[39m\n\u001b[31m  document.getElementById('user-pill').innerHTML   = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill-2').innerHTML = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill').style.display   = '';\u001b[39m\n\u001b[31m  document.getElementById('user-pill-2').style.display = '';·\u001b[39m\n\u001b[31m  // Load-saved chip if we have a saved tour\u001b[39m\n\u001b[31m  if (localStorage.getItem('tour-pg-saved')) document.getElementById('btn-load').style.display = '';·\u001b[39m\n\u001b[31m  // Wire UI\u001b[39m\n\u001b[31m  document.getElementById('inp-roundtrip').addEventListener('change', e => {\u001b[39m\n\u001b[31m    document.getElementById('dest-wrap').style.display = e.target.checked ? 'none' : '';\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('dragover', e => { e.preventDefault(); e.currentTarget.classList.add('drag'); });\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('dragleave', e => e.currentTarget.classList.remove('drag'));\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('drop', e => {\u001b[39m\n\u001b[31m    e.preventDefault();\u001b[39m\n\u001b[31m    e.currentTarget.classList.remove('drag');\u001b[39m\n\u001b[31m    const f = e.dataTransfer.files[0]; if (f) handleFile(f);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  document.getElementById('excel-file').addEventListener('change', e => {\u001b[39m\n\u001b[31m    const f = e.target.files[0]; if (f) handleFile(f);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction hubAuth() {\u001b[39m\n\u001b[31m  return 'Hub ' + btoa(JSON.stringify(session));\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function api(path, body) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, {\u001b[39m\n\u001b[31m    method: 'POST',\u001b[39m\n\u001b[31m    headers: { 'Authorization': hubAuth(), 'Content-Type': 'application/json' },\u001b[39m\n\u001b[31m    body: JSON.stringify(body),\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (r.status === 401) {\u001b[39m\n\u001b[31m    toast('Session expired — refreshing to re-login', 'err');\u001b[39m\n\u001b[31m    const nextParam = '?next=' + encodeURIComponent(location.pathname + location.search);\u001b[39m\n\u001b[31m    setTimeout(() => location.href = '/hub/' + nextParam, 1500);\u001b[39m\n\u001b[31m    throw new Error('unauthorized');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!r.ok) {\u001b[39m\n\u001b[31m    const txt = await r.text().catch(() => '');\u001b[39m\n\u001b[31m    throw new Error(`${path}: ${r.status} ${txt.slice(0, 100)}`);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return r.json();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Excel parsing + column detection ────────────────\u001b[39m\n\u001b[31mlet _parsedCols = null;   // exposed for the column-remap UI\u001b[39m\n\u001b[31mlet _parsedRows = null;\u001b[39m\n\u001b[31mfunction handleFile(file) {\u001b[39m\n\u001b[31m  const reader = new FileReader();\u001b[39m\n\u001b[31m  reader.onload = (e) => {\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      const wb = XLSX.read(e.target.result, { type: 'array' });\u001b[39m\n\u001b[31m      const ws = wb.Sheets[wb.SheetNames[0]];\u001b[39m\n\u001b[31m      const rows = XLSX.utils.sheet_to_json(ws, { defval: '' });\u001b[39m\n\u001b[31m      if (!rows.length) throw new Error('Empty sheet');\u001b[39m\n\u001b[31m      _parsedRows = rows;\u001b[39m\n\u001b[31m      _parsedCols = detectColumns(rows[0]);\u001b[39m\n\u001b[31m      if (!_parsedCols.name && !_parsedCols.address && !_parsedCols.city) throw new Error('Missing required columns (need at least one of: name, address, city)');\u001b[39m\n\u001b[31m      rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m      renderParsedPreview();\u001b[39m\n\u001b[31m      document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m      toast('Excel parsed ✓', 'ok');\u001b[39m\n\u001b[31m    } catch (err) {\u001b[39m\n\u001b[31m      toast('Excel error: ' + err.message, 'err');\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  reader.readAsArrayBuffer(file);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction rebuildStopsFromParsedRows() {\u001b[39m\n\u001b[31m  state.stops = _parsedRows.map((row, idx) => buildStop(row, _parsedCols, idx)).filter(s => s);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderParsedPreview() {\u001b[39m\n\u001b[31m  const skip = _parsedRows.length - state.stops.length;\u001b[39m\n\u001b[31m  const allCols = Object.keys(_parsedRows[0]);\u001b[39m\n\u001b[31m  // For each role, show:  <role>: <selected col name>  with a tiny dropdown to override\u001b[39m\n\u001b[31m  const ROLES = ['name','city','address','lat','lng','phone','notes'];\u001b[39m\n\u001b[31m  const sample = _parsedRows[0];\u001b[39m\n\u001b[31m  const previewVal = (key) => {\u001b[39m\n\u001b[31m    const v = sample[key]; if (v == null || v === '') return '';\u001b[39m\n\u001b[31m    return String(v).slice(0, 40);\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  // Column order (per user request): YOUR Excel column first, then the PWA field\u001b[39m\n\u001b[31m  // it maps to, then a sample value. Equal-ish widths via <colgroup>; the wrapper\u001b[39m\n\u001b[31m  // scrolls horizontally so long header names are never clipped on narrow phones.\u001b[39m\n\u001b[31m  let rows = '<div class=\\\"map-table-wrap\\\"><table class=\\\"map-table\\\">' +\u001b[39m\n\u001b[31m    '<colgroup><col class=\\\"c-src\\\"><col class=\\\"c-field\\\"><col class=\\\"c-sample\\\"></colgroup>' +\u001b[39m\n\u001b[31m    '<thead><tr><th>Your file column</th><th>→ PWA field</th><th>Sample value</th></tr></thead><tbody>';\u001b[39m\n\u001b[31m  for (const role of ROLES) {\u001b[39m\n\u001b[31m    const sel  = _parsedCols[role];\u001b[39m\n\u001b[31m    const need = role === 'name';\u001b[39m\n\u001b[31m    const opts = `<option value=\\\"\\\">— ignore —</option>` +\u001b[39m\n\u001b[31m      allCols.map(c => `<option value=\\\"${escHtml(c)}\\\" ${sel === c ? 'selected' : ''}>${escHtml(c)}</option>`).join('');\u001b[39m\n\u001b[31m    const previewText = sel ? escHtml(previewVal(sel)) : '';\u001b[39m\n\u001b[31m    const tip = !sel && need ? `<span style=\\\"color:var(--y);font-weight:700\\\">⚠ no match — uses city or \\\"Stop #N\\\"</span>` : '';\u001b[39m\n\u001b[31m    rows += `<tr>\u001b[39m\n\u001b[31m      <td><select onchange=\\\"remapColumn('${role}', this.value)\\\">${opts}</select></td>\u001b[39m\n\u001b[31m      <td class=\\\"map-field\\\">${role}${need?' <span style=\\\"color:var(--y)\\\">*</span>':''}</td>\u001b[39m\n\u001b[31m      <td class=\\\"map-sample\\\">${previewText}${tip}</td>\u001b[39m\n\u001b[31m    </tr>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  rows += '</tbody></table></div>';\u001b[39m\n\u001b[31m  document.getElementById('parsed-preview').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('parsed-summary').innerHTML =\u001b[39m\n\u001b[31m    `<div style=\\\"margin-bottom:6px\\\"><b>${state.stops.length}</b> stops detected` +\u001b[39m\n\u001b[31m    `${skip > 0 ? ` · ${skip} skipped (no name/city/address)` : ''}</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:10.5px;color:var(--muted);margin-bottom:6px\\\">If detection is wrong, pick the correct column from the dropdowns:</div>` +\u001b[39m\n\u001b[31m    rows;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction remapColumn(role, newCol) {\u001b[39m\n\u001b[31m  if (!_parsedCols) return;\u001b[39m\n\u001b[31m  _parsedCols[role] = newCol || null;\u001b[39m\n\u001b[31m  rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction normaliseColName(s) {\u001b[39m\n\u001b[31m  return String(s).toLowerCase().replace(/[^a-z]/g, '');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction detectColumns(sampleRow) {\u001b[39m\n\u001b[31m  const map = { name:null, city:null, address:null, lat:null, lng:null, phone:null, notes:null, others:[] };\u001b[39m\n\u001b[31m  const keys = Object.keys(sampleRow);\u001b[39m\n\u001b[31m  // Broadened aliases so most user-provided Excel files map sensibly without a UI:\u001b[39m\n\u001b[31m  //   name  ← store / shop / outlet / party / dealer / firm / customer / business /\u001b[39m\n\u001b[31m  //           company / vendor / agency (and \\\"<thing> name\\\")\u001b[39m\n\u001b[31m  //   addr  ← address / fulladdress / location / loc / addr\u001b[39m\n\u001b[31m  //   city  ← city / town / district / location (only if no address column took it)\u001b[39m\n\u001b[31m  for (const k of keys) {\u001b[39m\n\u001b[31m    const n = normaliseColName(k);\u001b[39m\n\u001b[31m    if (!map.name && /^(storename|name|store|client|outlet|party(name)?|dealer(name)?|firm(name)?|customer(name)?|shop(name)?|business(name)?|companyname|company|vendor(name)?|agency(name)?|merchant|chain|sitename|site)$/.test(n)) { map.name = k; continue; }\u001b[39m\n\u001b[31m    if (!map.address && /^(address|fulladdress|streetaddress|addr|location|loc|street)$/.test(n)) { map.address = k; continue; }\u001b[39m\n\u001b[31m    if (!map.city && /^(city|town|district|cityname|locality|area|region)$/.test(n)) { map.city = k; continue; }\u001b[39m\n\u001b[31m    if (!map.lat && /^(lat|latitude|y)$/.test(n)) { map.lat = k; continue; }\u001b[39m\n\u001b[31m    if (!map.lng && /^(lng|lon|long|longitude|x)$/.test(n)) { map.lng = k; continue; }\u001b[39m\n\u001b[31m    if (!map.phone && /^(phone(no)?|mobile(no)?|contact(no)?|tel(no)?|whatsapp|wa|cell)$/.test(n)) { map.phone = k; continue; }\u001b[39m\n\u001b[31m    if (!map.notes && /^(notes?|remarks?|comments?|description|desc|memo)$/.test(n)) { map.notes = k; continue; }\u001b[39m\n\u001b[31m    map.others.push(k);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return map;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction buildStop(row, cols, idx) {\u001b[39m\n\u001b[31m  const name    = cols.name    ? String(row[cols.name]   || '').trim() : '';\u001b[39m\n\u001b[31m  const address = cols.address ? String(row[cols.address]|| '').trim() : '';\u001b[39m\n\u001b[31m  const city    = cols.city    ? String(row[cols.city]   || '').trim() : '';\u001b[39m\n\u001b[31m  const phone   = cols.phone   ? String(row[cols.phone]  || '').trim() : '';\u001b[39m\n\u001b[31m  const notes   = cols.notes   ? String(row[cols.notes]  || '').trim() : '';\u001b[39m\n\u001b[31m  if (!name && !address && !city) return null;\u001b[39m\n\u001b[31m  // pre-resolved coords?\u001b[39m\n\u001b[31m  let location = null;\u001b[39m\n\u001b[31m  if (cols.lat && cols.lng) {\u001b[39m\n\u001b[31m    const lat = parseFloat(row[cols.lat]);\u001b[39m\n\u001b[31m    const lng = parseFloat(row[cols.lng]);\u001b[39m\n\u001b[31m    if (!isNaN(lat) && !isNaN(lng) && lat !== 0 && lng !== 0) location = { lat, lng };\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const fullAddr = [address, city].filter(Boolean).join(', ');\u001b[39m\n\u001b[31m  // Display name fallback hierarchy (NEVER truncated address — that confused users):\u001b[39m\n\u001b[31m  //   1. Explicit name column value\u001b[39m\n\u001b[31m  //   2. City + index (so excluded/duplicate cities stay distinguishable)\u001b[39m\n\u001b[31m  //   3. \\\"Stop #N\\\"\u001b[39m\n\u001b[31m  let displayName;\u001b[39m\n\u001b[31m  if (name) displayName = name;\u001b[39m\n\u001b[31m  else if (city) displayName = `${city} (stop ${idx + 1})`;\u001b[39m\n\u001b[31m  else displayName = `Stop ${idx + 1}`;\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    id:           uid('st'),\u001b[39m\n\u001b[31m    name:         displayName,\u001b[39m\n\u001b[31m    hasExplicitName: !!name,         // for UI to flag \\\"name auto-derived\\\"\u001b[39m\n\u001b[31m    city, phone, notes,\u001b[39m\n\u001b[31m    address:      fullAddr || name || '',\u001b[39m\n\u001b[31m    location,\u001b[39m\n\u001b[31m    provider:     location ? 'excel' : null,\u001b[39m\n\u001b[31m    included:     true,\u001b[39m\n\u001b[31m    legDistance_m: 0,\u001b[39m\n\u001b[31m    legDuration_s: 0,\u001b[39m\n\u001b[31m    geocodeErr:   null,\u001b[39m\n\u001b[31m    raw:          { ...row },\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Geocode + optimise ──────────────────────────────\u001b[39m\n\u001b[31masync function parseAndOptimise() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('Parse an Excel first', 'err');\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = true;\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = '';\u001b[39m\n\u001b[31m  state.origin.text  = document.getElementById('inp-origin').value.trim() || 'Mohali office Punjab';\u001b[39m\n\u001b[31m  state.roundTrip    = document.getElementById('inp-roundtrip').checked;\u001b[39m\n\u001b[31m  state.destination  = state.roundTrip ? null : (document.getElementById('inp-destination').value.trim() || null);\u001b[39m\n\u001b[31m  // Geocode origin first\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: state.origin.text });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin.location = og.location;\u001b[39m\n\u001b[31m    state.origin.provider = og.provider;\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    setProgress(0, 0, '');\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('Origin geocode failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Geocode each stop (skip if pre-resolved). Sequential to avoid rate limits.\u001b[39m\n\u001b[31m  const total = state.stops.length;\u001b[39m\n\u001b[31m  let done = 0;\u001b[39m\n\u001b[31m  for (const s of state.stops) {\u001b[39m\n\u001b[31m    if (s.location) { done++; setProgress(done, total, 'Pre-geocoded'); continue; }\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      const r = await api('/geocode', { address: s.address });\u001b[39m\n\u001b[31m      if (r.ok && r.location) {\u001b[39m\n\u001b[31m        s.location = r.location;\u001b[39m\n\u001b[31m        s.provider = r.provider;\u001b[39m\n\u001b[31m      } else {\u001b[39m\n\u001b[31m        s.geocodeErr = r.error || 'No match';\u001b[39m\n\u001b[31m      }\u001b[39m\n\u001b[31m    } catch (e) {\u001b[39m\n\u001b[31m      s.geocodeErr = e.message;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    done++;\u001b[39m\n\u001b[31m    setProgress(done, total, `Geocoding ${s.name.slice(0, 30)}…`);\u001b[39m\n\u001b[31m    // Tiny delay to be polite to the provider\u001b[39m\n\u001b[31m    await sleep(80);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Filter out failed-geocode stops\u001b[39m\n\u001b[31m  const good = state.stops.filter(s => s.location && !s.geocodeErr);\u001b[39m\n\u001b[31m  const bad  = state.stops.filter(s => !s.location || s.geocodeErr);\u001b[39m\n\u001b[31m  if (!good.length) {\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('No stops could be geocoded', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (bad.length) toast(`${bad.length} stop(s) failed geocode (excluded)`, 'err');\u001b[39m\n\u001b[31m  state.stops = good;·\u001b[39m\n\u001b[31m  // Optimise\u001b[39m\n\u001b[31m  setProgress(total, total, 'Optimising route…');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const opt = await api('/optimize', {\u001b[39m\n\u001b[31m      origin:    locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:     state.stops.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:  state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!opt.ok) throw new Error(opt.error || 'optimise failed');\u001b[39m\n\u001b[31m    // Apply order\u001b[39m\n\u001b[31m    const order = opt.waypoint_order || state.stops.map((_, i) => i);\u001b[39m\n\u001b[31m    state.stops = order.map(i => state.stops[i]);\u001b[39m\n\u001b[31m    state.proposedOrder = state.stops.map(s => s.id);\u001b[39m\n\u001b[31m    state.baseTotal     = { distance_m: opt.total_distance_m, duration_s: opt.total_duration_s };\u001b[39m\n\u001b[31m    state.currentTotal  = { ...state.baseTotal };\u001b[39m\n\u001b[31m    state.lastOptimisation = opt.optimisation;\u001b[39m\n\u001b[31m    state.needsReoptimize  = false;   // fresh optimise — flag cleared\u001b[39m\n\u001b[31m    // Map legs to stops (route returned len = stops + 1 (round-trip) or stops + 1 (one-way))\u001b[39m\n\u001b[31m    if (Array.isArray(opt.legs) && opt.legs.length >= state.stops.length) {\u001b[39m\n\u001b[31m      state.stops.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = opt.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('Optimise failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m  showScreen('s-editor');\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction setProgress(done, total, label) {\u001b[39m\n\u001b[31m  const pct = total ? Math.round(done/total*100) : 0;\u001b[39m\n\u001b[31m  document.getElementById('prog-bar').style.width   = pct + '%';\u001b[39m\n\u001b[31m  document.getElementById('prog-count').textContent = `${done} / ${total}`;\u001b[39m\n\u001b[31m  document.getElementById('prog-label').textContent = label;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction locStr(loc) { return loc ? `${loc.lat},${loc.lng}` : null; }\u001b[39m\n\u001b[31mconst sleep = ms => new Promise(r => setTimeout(r, ms));·\u001b[39m\n\u001b[31m// ── Editor screen ───────────────────────────────────\u001b[39m\n\u001b[31mfunction renderEditor() {\u001b[39m\n\u001b[31m  restoreMapPref();\u001b[39m\n\u001b[31m  document.getElementById('origin-val').textContent = state.origin.text;\u001b[39m\n\u001b[31m  document.getElementById('editor-sub').textContent =\u001b[39m\n\u001b[31m    `${state.stops.filter(s => s.included).length} of ${state.stops.length} included` +\u001b[39m\n\u001b[31m    (state.lastOptimisation ? ` · ${state.lastOptimisation}` : '');\u001b[39m\n\u001b[31m  const cont = document.getElementById('tiles');\u001b[39m\n\u001b[31m  cont.innerHTML = '';\u001b[39m\n\u001b[31m  state.stops.forEach((s, idx) => cont.appendChild(renderTile(s, idx)));\u001b[39m\n\u001b[31m  if (_sortable) _sortable.destroy();\u001b[39m\n\u001b[31m  _sortable = Sortable.create(cont, {\u001b[39m\n\u001b[31m    handle: '.grip',\u001b[39m\n\u001b[31m    animation: 150,\u001b[39m\n\u001b[31m    onEnd: (evt) => {\u001b[39m\n\u001b[31m      const moved = state.stops.splice(evt.oldIndex, 1)[0];\u001b[39m\n\u001b[31m      state.stops.splice(evt.newIndex, 0, moved);\u001b[39m\n\u001b[31m      renderEditor();\u001b[39m\n\u001b[31m      scheduleRecalc();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  updateTotalsBar();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderTile(s, idx) {\u001b[39m\n\u001b[31m  const el = document.createElement('div');\u001b[39m\n\u001b[31m  el.className = 'tile' + (s.included ? '' : ' excluded') + (s.geocodeErr ? ' err' : '');\u001b[39m\n\u001b[31m  el.dataset.id = s.id;\u001b[39m\n\u001b[31m  const providerBadge = s.provider ? `<span class=\\\"badge-provider\\\">${escHtml(s.provider)}</span>` : '';\u001b[39m\n\u001b[31m  const leg = s.legDistance_m > 0\u001b[39m\n\u001b[31m    ? `${(s.legDistance_m/1000).toFixed(1)} km · ${Math.round(s.legDuration_s/60)} min from prev`\u001b[39m\n\u001b[31m    : '';\u001b[39m\n\u001b[31m  // Per UX rule: Name + City are the primary display. Address is shown\u001b[39m\n\u001b[31m  // smaller below as supporting context. Falls back gracefully when city\u001b[39m\n\u001b[31m  // is missing.\u001b[39m\n\u001b[31m  const cityLine = s.city || '—';\u001b[39m\n\u001b[31m  const addrLine = s.address && s.address !== s.city ? s.address : '';\u001b[39m\n\u001b[31m  el.innerHTML = `\u001b[39m\n\u001b[31m    <span class=\\\"grip\\\">≡</span>\u001b[39m\n\u001b[31m    <span class=\\\"ord\\\">${idx + 1}</span>\u001b[39m\n\u001b[31m    <div class=\\\"info\\\">\u001b[39m\n\u001b[31m      <div class=\\\"name\\\">${escHtml(s.name)} ${providerBadge}</div>\u001b[39m\n\u001b[31m      <div class=\\\"city\\\">${escHtml(cityLine)}</div>\u001b[39m\n\u001b[31m      ${addrLine ? `<div class=\\\"addr\\\">${escHtml(addrLine)}</div>` : ''}\u001b[39m\n\u001b[31m      ${leg ? `<div class=\\\"leg\\\">${escHtml(leg)}</div>` : ''}\u001b[39m\n\u001b[31m      ${s.geocodeErr ? `<div class=\\\"leg\\\" style=\\\"color:var(--r)\\\">⚠ ${escHtml(s.geocodeErr)}</div>` : ''}\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"incl ${s.included ? 'on' : 'off'}\\\" onclick=\\\"toggleInclude('${s.id}')\\\" title=\\\"Include / exclude\\\">\u001b[39m\n\u001b[31m      ${s.included ? '✓' : '✗'}\u001b[39m\n\u001b[31m    </button>`;\u001b[39m\n\u001b[31m  return el;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleInclude(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id); if (!s) return;\u001b[39m\n\u001b[31m  s.included = !s.included;\u001b[39m\n\u001b[31m  // The SET of stops just changed — the existing route may no longer be optimal.\u001b[39m\n\u001b[31m  // Surface a banner + pulse the Re-optim button so the user knows to refresh.\u001b[39m\n\u001b[31m  // recalcRoute still fires (debounced) to update the cost of the CURRENT order,\u001b[39m\n\u001b[31m  // but that number is just informational until the user re-optimises.\u001b[39m\n\u001b[31m  state.needsReoptimize = true;\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  scheduleRecalc();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction scheduleRecalc() {\u001b[39m\n\u001b[31m  clearTimeout(_recalcTimer);\u001b[39m\n\u001b[31m  document.getElementById('recalc').classList.add('show');\u001b[39m\n\u001b[31m  _recalcTimer = setTimeout(recalcRoute, 400);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function recalcRoute() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  if (!included.length) {\u001b[39m\n\u001b[31m    state.currentTotal = { distance_m: 0, duration_s: 0 };\u001b[39m\n\u001b[31m    document.getElementById('recalc').classList.remove('show');\u001b[39m\n\u001b[31m    updateTotalsBar();\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/route', {\u001b[39m\n\u001b[31m      origin:      locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:       included.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:   state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!r.ok) throw new Error(r.error || 'route failed');\u001b[39m\n\u001b[31m    state.currentTotal = { distance_m: r.total_distance_m, duration_s: r.total_duration_s };\u001b[39m\n\u001b[31m    // Map legs back to tiles (legs index 0 = origin→stop0, n = lastStop→dest)\u001b[39m\n\u001b[31m    if (Array.isArray(r.legs)) {\u001b[39m\n\u001b[31m      included.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = r.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    // Clear leg info on excluded stops\u001b[39m\n\u001b[31m    state.stops.filter(s => !s.included).forEach(s => { s.legDistance_m = 0; s.legDuration_s = 0; });\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Recalc failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  } finally {\u001b[39m\n\u001b[31m    document.getElementById('recalc').classList.remove('show');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateTotalsBar() {\u001b[39m\n\u001b[31m  // Totals now live ONLY in the top-actions card (the sticky bottom bar was\u001b[39m\n\u001b[31m  // removed after user feedback that it duplicated the top info 2026-06-04).\u001b[39m\n\u001b[31m  // The big-text element holds the inline recalc spinner as a child <span>.\u001b[39m\n\u001b[31m  const km  = (state.currentTotal.distance_m / 1000).toFixed(1);\u001b[39m\n\u001b[31m  const min = Math.round(state.currentTotal.duration_s / 60);\u001b[39m\n\u001b[31m  const hrs = Math.floor(min / 60), mm = min % 60;\u001b[39m\n\u001b[31m  const timeStr = hrs > 0 ? `${hrs}h ${mm}min` : `${mm} min`;\u001b[39m\n\u001b[31m  const totalsText = `${km} km · ${timeStr}`;\u001b[39m\n\u001b[31m  const bigEl  = document.getElementById('top-totals-big');\u001b[39m\n\u001b[31m  const recalc = document.getElementById('recalc');\u001b[39m\n\u001b[31m  bigEl.textContent = totalsText;\u001b[39m\n\u001b[31m  if (recalc) bigEl.appendChild(document.createTextNode(' '));\u001b[39m\n\u001b[31m  if (recalc) bigEl.appendChild(recalc);\u001b[39m\n\u001b[31m  // Delta vs base\u001b[39m\n\u001b[31m  const baseKm   = state.baseTotal.distance_m / 1000;\u001b[39m\n\u001b[31m  const curKm    = state.currentTotal.distance_m / 1000;\u001b[39m\n\u001b[31m  const deltaKm  = curKm - baseKm;\u001b[39m\n\u001b[31m  const deltaMin = Math.round((state.currentTotal.duration_s - state.baseTotal.duration_s) / 60);\u001b[39m\n\u001b[31m  const dT = document.getElementById('top-totals-delta');\u001b[39m\n\u001b[31m  let deltaText, deltaClass;\u001b[39m\n\u001b[31m  if (Math.abs(deltaKm) < 0.05) {\u001b[39m\n\u001b[31m    deltaText  = 'matches proposed route';\u001b[39m\n\u001b[31m    deltaClass = '';\u001b[39m\n\u001b[31m  } else {\u001b[39m\n\u001b[31m    const sign = deltaKm > 0 ? '+' : '';\u001b[39m\n\u001b[31m    deltaText  = `vs proposed: ${sign}${deltaKm.toFixed(1)} km · ${sign}${deltaMin} min`;\u001b[39m\n\u001b[31m    deltaClass = deltaKm < 0 ? 'better' : 'worse';\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  dT.textContent = deltaText;\u001b[39m\n\u001b[31m  dT.className   = 'top-totals-delta ' + deltaClass;·\u001b[39m\n\u001b[31m  // ── Re-optim-needed banner + pulse + stale styling ──\u001b[39m\n\u001b[31m  // Surfaces a clear message whenever the user has added/removed stops since\u001b[39m\n\u001b[31m  // the last /optimize call. The current-order cost is still shown (so the\u001b[39m\n\u001b[31m  // user sees \\\"what this order costs\\\"), but it's struck-through and the\u001b[39m\n\u001b[31m  // Re-optim button pulses to draw attention.\u001b[39m\n\u001b[31m  const needsReopt = !!state.needsReoptimize;\u001b[39m\n\u001b[31m  document.getElementById('reopt-needed').classList.toggle('show', needsReopt);\u001b[39m\n\u001b[31m  bigEl.classList.toggle('stale', needsReopt);\u001b[39m\n\u001b[31m  dT.classList.toggle('stale', needsReopt);\u001b[39m\n\u001b[31m  document.querySelector('.top-reopt')?.classList.toggle('pulse', needsReopt);·\u001b[39m\n\u001b[31m  // Re-render inline map preview to reflect current order/include flags.\u001b[39m\n\u001b[31m  // Debounced via setTimeout 0 so multiple updates in the same tick coalesce.\u001b[39m\n\u001b[31m  scheduleMapRender();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Inline route preview (Leaflet + free OSM tiles) ──────────────\u001b[39m\n\u001b[31m// Renders origin + stops + straight-line polyline inside the editor screen.\u001b[39m\n\u001b[31m// \\\"Straight-line\\\" because the proxy only returns leg distances/durations,\u001b[39m\n\u001b[31m// not the per-leg point arrays from TomTom — this preview is enough for\u001b[39m\n\u001b[31m// orientation; \\\"Open in Google Maps\\\" gives the user the real road geometry.\u001b[39m\n\u001b[31mlet _leafletMap   = null;\u001b[39m\n\u001b[31mlet _leafletLayer = null;\u001b[39m\n\u001b[31mlet _mapRenderTimer = null;·\u001b[39m\n\u001b[31mfunction ensureMap() {\u001b[39m\n\u001b[31m  if (_leafletMap) return _leafletMap;\u001b[39m\n\u001b[31m  if (typeof L === 'undefined') return null;   // CDN not ready yet\u001b[39m\n\u001b[31m  const el = document.getElementById('tour-map');\u001b[39m\n\u001b[31m  if (!el || !el.offsetParent) return null;    // container not visible yet\u001b[39m\n\u001b[31m  _leafletMap = L.map(el, { attributionControl: false, zoomControl: true, preferCanvas: true });\u001b[39m\n\u001b[31m  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18 })\u001b[39m\n\u001b[31m    .addTo(_leafletMap);\u001b[39m\n\u001b[31m  L.control.attribution({ position: 'bottomright', prefix: false })\u001b[39m\n\u001b[31m    .addAttribution('© OpenStreetMap contributors')\u001b[39m\n\u001b[31m    .addTo(_leafletMap);\u001b[39m\n\u001b[31m  return _leafletMap;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderMap() {\u001b[39m\n\u001b[31m  const map = ensureMap();\u001b[39m\n\u001b[31m  if (!map) return;\u001b[39m\n\u001b[31m  if (_leafletLayer) { map.removeLayer(_leafletLayer); _leafletLayer = null; }\u001b[39m\n\u001b[31m  const layers = [];\u001b[39m\n\u001b[31m  const linePts = [];\u001b[39m\n\u001b[31m  // Origin marker (always first in line)\u001b[39m\n\u001b[31m  if (state.origin?.location) {\u001b[39m\n\u001b[31m    const ll = [state.origin.location.lat, state.origin.location.lng];\u001b[39m\n\u001b[31m    layers.push(L.marker(ll, { icon: L.divIcon({\u001b[39m\n\u001b[31m      className: 'tour-pin origin', html: '<div>🏁</div>',\u001b[39m\n\u001b[31m      iconSize: [28, 28], iconAnchor: [14, 14],\u001b[39m\n\u001b[31m    })}).bindTooltip('Start: ' + (state.origin.text || 'origin'), { direction: 'top' }));\u001b[39m\n\u001b[31m    linePts.push(ll);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Stops in current visit order\u001b[39m\n\u001b[31m  let visitNum = 0;\u001b[39m\n\u001b[31m  state.stops.forEach(s => {\u001b[39m\n\u001b[31m    if (!s.location) return;\u001b[39m\n\u001b[31m    const ll = [s.location.lat, s.location.lng];\u001b[39m\n\u001b[31m    const included = s.included;\u001b[39m\n\u001b[31m    if (included) visitNum++;\u001b[39m\n\u001b[31m    layers.push(L.marker(ll, { icon: L.divIcon({\u001b[39m\n\u001b[31m      className: 'tour-pin' + (included ? '' : ' out'),\u001b[39m\n\u001b[31m      html: `<div>${included ? visitNum : '·'}</div>`,\u001b[39m\n\u001b[31m      iconSize: [26, 26], iconAnchor: [13, 13],\u001b[39m\n\u001b[31m    })}).bindTooltip(\u001b[39m\n\u001b[31m      (included ? `${visitNum}. ` : '✗ ') + s.name + (s.city ? ` · ${s.city}` : ''),\u001b[39m\n\u001b[31m      { direction: 'top' }\u001b[39m\n\u001b[31m    ));\u001b[39m\n\u001b[31m    if (included) linePts.push(ll);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  // Close the loop on round-trip\u001b[39m\n\u001b[31m  if (state.roundTrip && state.origin?.location) {\u001b[39m\n\u001b[31m    linePts.push([state.origin.location.lat, state.origin.location.lng]);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (linePts.length >= 2) {\u001b[39m\n\u001b[31m    layers.push(L.polyline(linePts, {\u001b[39m\n\u001b[31m      color: '#0ea5e9', weight: 3, opacity: 0.85, dashArray: '6 4',\u001b[39m\n\u001b[31m    }));\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  _leafletLayer = L.layerGroup(layers).addTo(map);\u001b[39m\n\u001b[31m  if (linePts.length) {\u001b[39m\n\u001b[31m    try { map.fitBounds(L.latLngBounds(linePts), { padding: [30, 30], maxZoom: 13 }); }\u001b[39m\n\u001b[31m    catch (_) {}\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction scheduleMapRender() {\u001b[39m\n\u001b[31m  if (_mapRenderTimer) return;\u001b[39m\n\u001b[31m  _mapRenderTimer = setTimeout(() => { _mapRenderTimer = null; renderMap(); }, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleMap() {\u001b[39m\n\u001b[31m  const wrap = document.getElementById('map-wrap');\u001b[39m\n\u001b[31m  const tog  = document.getElementById('map-toggle');\u001b[39m\n\u001b[31m  const open = !wrap.classList.toggle('collapsed');\u001b[39m\n\u001b[31m  tog.textContent = open ? '▾' : '▴';\u001b[39m\n\u001b[31m  try { localStorage.setItem('tour-pg-map-open', open ? '1' : '0'); } catch (_) {}\u001b[39m\n\u001b[31m  if (open && _leafletMap) {\u001b[39m\n\u001b[31m    // After expansion the container size changed — Leaflet must recompute\u001b[39m\n\u001b[31m    setTimeout(() => { _leafletMap.invalidateSize(); renderMap(); }, 220);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction restoreMapPref() {\u001b[39m\n\u001b[31m  // Default collapsed — user must explicitly expand once for it to stick open.\u001b[39m\n\u001b[31m  let open = false;\u001b[39m\n\u001b[31m  try { open = localStorage.getItem('tour-pg-map-open') === '1'; } catch (_) {}\u001b[39m\n\u001b[31m  const wrap = document.getElementById('map-wrap');\u001b[39m\n\u001b[31m  const tog  = document.getElementById('map-toggle');\u001b[39m\n\u001b[31m  wrap.classList.toggle('collapsed', !open);\u001b[39m\n\u001b[31m  tog.textContent = open ? '▾' : '▴';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Origin edit / mid-tour replan ────────────────────\u001b[39m\n\u001b[31mfunction openOriginEdit() {\u001b[39m\n\u001b[31m  document.getElementById('origin-edit').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('inp-new-origin').value = state.origin.text;\u001b[39m\n\u001b[31m  document.getElementById('inp-new-origin').focus();\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction closeOriginEdit() {\u001b[39m\n\u001b[31m  document.getElementById('origin-edit').style.display = 'none';\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31masync function applyOriginAndReplan() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('inp-new-origin').value.trim();\u001b[39m\n\u001b[31m  if (!txt) return toast('Enter an origin', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: txt });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin = { text: txt, location: og.location, provider: og.provider };\u001b[39m\n\u001b[31m    closeOriginEdit();\u001b[39m\n\u001b[31m    await replanAll();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Origin failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function replanAll() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  if (!included.length) return toast('No stops included', 'err');\u001b[39m\n\u001b[31m  toast('Optimising…', 'ok');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const opt = await api('/optimize', {\u001b[39m\n\u001b[31m      origin:      locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:       included.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:   state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!opt.ok) throw new Error(opt.error || 'optimise failed');\u001b[39m\n\u001b[31m    const order = opt.waypoint_order || included.map((_, i) => i);\u001b[39m\n\u001b[31m    const reordered = order.map(i => included[i]);\u001b[39m\n\u001b[31m    // KEEP EXCLUDED STOPS IN THEIR EXISTING POSITIONS (per user feedback\u001b[39m\n\u001b[31m    // 2026-06-04). Excluded slots stay put; reordered-included stops fill\u001b[39m\n\u001b[31m    // the gaps. Previously appended at end (confusing) → then dropped\u001b[39m\n\u001b[31m    // (lossy) → final: stay where they are. User can re-include to put\u001b[39m\n\u001b[31m    // them back into the optimised route.\u001b[39m\n\u001b[31m    const newStops = [];\u001b[39m\n\u001b[31m    let r = 0;\u001b[39m\n\u001b[31m    for (const s of state.stops) {\u001b[39m\n\u001b[31m      if (s.included) newStops.push(reordered[r++]);\u001b[39m\n\u001b[31m      else            newStops.push(s);\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    state.stops = newStops;\u001b[39m\n\u001b[31m    state.proposedOrder = state.stops.map(s => s.id);\u001b[39m\n\u001b[31m    state.baseTotal     = { distance_m: opt.total_distance_m, duration_s: opt.total_duration_s };\u001b[39m\n\u001b[31m    state.currentTotal  = { ...state.baseTotal };\u001b[39m\n\u001b[31m    state.lastOptimisation = opt.optimisation;\u001b[39m\n\u001b[31m    state.needsReoptimize  = false;   // re-optim done — banner cleared\u001b[39m\n\u001b[31m    if (Array.isArray(opt.legs)) {\u001b[39m\n\u001b[31m      reordered.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = opt.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Re-optimised ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Replan failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Share / export ──────────────────────────────────\u001b[39m\n\u001b[31mfunction openShare() {\u001b[39m\n\u001b[31m  const km   = (state.currentTotal.distance_m / 1000).toFixed(1);\u001b[39m\n\u001b[31m  const min  = Math.round(state.currentTotal.duration_s / 60);\u001b[39m\n\u001b[31m  const hrs  = Math.floor(min / 60), mm = min % 60;\u001b[39m\n\u001b[31m  const timeStr = hrs > 0 ? `${hrs}h ${mm}min` : `${mm} min`;\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  let txt = `🚗 Tour plan (${new Date().toLocaleDateString('en-IN')})\\\\n`;\u001b[39m\n\u001b[31m  txt += `From: ${state.origin.text}\\\\n`;\u001b[39m\n\u001b[31m  txt += `${included.length} stops · ${km} km · ${timeStr}\\\\n`;\u001b[39m\n\u001b[31m  if (state.roundTrip) txt += `Round-trip\\\\n\\\\n`;\u001b[39m\n\u001b[31m  else                  txt += `One-way → ${state.destination || included[included.length-1]?.address || ''}\\\\n\\\\n`;\u001b[39m\n\u001b[31m  included.forEach((s, i) => {\u001b[39m\n\u001b[31m    const legKm  = (s.legDistance_m/1000).toFixed(1);\u001b[39m\n\u001b[31m    const legMin = Math.round(s.legDuration_s/60);\u001b[39m\n\u001b[31m    txt += `${i+1}. ${s.name}`;\u001b[39m\n\u001b[31m    if (s.city) txt += ` · ${s.city}`;\u001b[39m\n\u001b[31m    if (s.legDistance_m > 0) txt += `  (+${legKm}km · ${legMin}min)`;\u001b[39m\n\u001b[31m    if (s.phone) txt += `  ${s.phone}`;\u001b[39m\n\u001b[31m    txt += `\\\\n`;\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (state.roundTrip) txt += `↩ Return to ${state.origin.text}`;\u001b[39m\n\u001b[31m  document.getElementById('share-summary').textContent = txt;\u001b[39m\n\u001b[31m  document.getElementById('modal-share').classList.add('show');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction closeShare() {\u001b[39m\n\u001b[31m  document.getElementById('modal-share').classList.remove('show');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction shareWhatsApp() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('share-summary').textContent;\u001b[39m\n\u001b[31m  const url = `https://wa.me/?text=${encodeURIComponent(txt)}`;\u001b[39m\n\u001b[31m  window.open(url, '_blank');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Hands off the optimised tour to Google Maps Directions (free, zero infra).\u001b[39m\n\u001b[31m// Uses path-style URL `dir/A/B/C/...` because it accepts many more stops than\u001b[39m\n\u001b[31m// the `api=1` Directions URL (which Google caps at 9 waypoints). Each segment\u001b[39m\n\u001b[31m// is \\\"lat,lng\\\" for geocoded points (most reliable). If the user picked a\u001b[39m\n\u001b[31m// text destination on a one-way tour we URL-encode it as the last segment.\u001b[39m\n\u001b[31mfunction openInGoogleMaps() {\u001b[39m\n\u001b[31m  if (!state.origin?.location) { toast('Origin not yet geocoded', 'err'); return; }\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included && s.location);\u001b[39m\n\u001b[31m  if (included.length === 0)   { toast('No included stops to view', 'err'); return; }\u001b[39m\n\u001b[31m  const fmt = (loc) => `${loc.lat.toFixed(6)},${loc.lng.toFixed(6)}`;\u001b[39m\n\u001b[31m  const segs = [ fmt(state.origin.location) ];\u001b[39m\n\u001b[31m  included.forEach(s => segs.push(fmt(s.location)));\u001b[39m\n\u001b[31m  if (state.roundTrip) {\u001b[39m\n\u001b[31m    segs.push(fmt(state.origin.location));\u001b[39m\n\u001b[31m  } else if (state.destination) {\u001b[39m\n\u001b[31m    segs.push(encodeURIComponent(state.destination));\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // travelmode and dir_action=navigate auto-open in driving directions\u001b[39m\n\u001b[31m  const url = `https://www.google.com/maps/dir/${segs.join('/')}/?travelmode=driving`;\u001b[39m\n\u001b[31m  window.open(url, '_blank', 'noopener');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function copyText() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('share-summary').textContent;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await navigator.clipboard.writeText(txt);\u001b[39m\n\u001b[31m    toast('Copied ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Copy failed', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction downloadCsv() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  const rows = [['#','Name','City','Address','Phone','Lat','Lng','Leg km','Leg min','Provider']];\u001b[39m\n\u001b[31m  included.forEach((s, i) => {\u001b[39m\n\u001b[31m    rows.push([\u001b[39m\n\u001b[31m      i + 1,\u001b[39m\n\u001b[31m      s.name, s.city, s.address, s.phone,\u001b[39m\n\u001b[31m      s.location?.lat ?? '', s.location?.lng ?? '',\u001b[39m\n\u001b[31m      (s.legDistance_m/1000).toFixed(2),\u001b[39m\n\u001b[31m      Math.round(s.legDuration_s/60),\u001b[39m\n\u001b[31m      s.provider || '',\u001b[39m\n\u001b[31m    ]);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  const csv = rows.map(r => r.map(c => `\\\"${String(c).replace(/\\\"/g, '\\\"\\\"')}\\\"`).join(',')).join('\\\\n');\u001b[39m\n\u001b[31m  const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });\u001b[39m\n\u001b[31m  const url = URL.createObjectURL(blob);\u001b[39m\n\u001b[31m  const a = document.createElement('a');\u001b[39m\n\u001b[31m  a.href = url; a.download = `tour-${new Date().toISOString().slice(0,10)}.csv`;\u001b[39m\n\u001b[31m  document.body.appendChild(a); a.click(); a.remove();\u001b[39m\n\u001b[31m  URL.revokeObjectURL(url);\u001b[39m\n\u001b[31m  toast('CSV downloaded ✓', 'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction saveLocal() {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    localStorage.setItem('tour-pg-saved', JSON.stringify({\u001b[39m\n\u001b[31m      saved_at: new Date().toISOString(),\u001b[39m\n\u001b[31m      state,\u001b[39m\n\u001b[31m    }));\u001b[39m\n\u001b[31m    toast('Saved ✓', 'ok');\u001b[39m\n\u001b[31m    document.getElementById('btn-load').style.display = '';\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed (storage full?)', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction loadSaved() {\u001b[39m\n\u001b[31m  const raw = localStorage.getItem('tour-pg-saved');\u001b[39m\n\u001b[31m  if (!raw) return toast('No saved tour', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const { state: s } = JSON.parse(raw);\u001b[39m\n\u001b[31m    state = s;\u001b[39m\n\u001b[31m    showScreen('s-editor');\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Loaded ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Load failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Navigation ──────────────────────────────────────\u001b[39m\n\u001b[31mfunction confirmBack() {\u001b[39m\n\u001b[31m  if (!confirm('Start a new tour? Current state will be cleared (use Save first if you want to keep it).')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m  document.getElementById('parsed-preview').style.display = 'none';\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = true;\u001b[39m\n\u001b[31m  document.getElementById('excel-file').value = '';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showScreen(id) {\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById(id).classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Utils ───────────────────────────────────────────\u001b[39m\n\u001b[31mfunction escHtml(s) { if (s == null) return ''; return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\\\"/g,'&quot;'); }\u001b[39m\n\u001b[31mlet _toastTimer;\u001b[39m\n\u001b[31mfunction toast(msg, type='') {\u001b[39m\n\u001b[31m  const el = document.getElementById('toast');\u001b[39m\n\u001b[31m  el.textContent = msg; el.className = 'show ' + type;\u001b[39m\n\u001b[31m  clearTimeout(_toastTimer);\u001b[39m\n\u001b[31m  _toastTimer = setTimeout(() => el.className = '', 3200);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction registerSW() {\u001b[39m\n\u001b[31m  if (!('serviceWorker' in navigator)) return;\u001b[39m\n\u001b[31m  const sw = `\u001b[39m\n\u001b[31mconst C='${CACHE_VER}';\u001b[39m\n\u001b[31mself.addEventListener('install', e => self.skipWaiting());\u001b[39m\n\u001b[31mself.addEventListener('activate', e => {\u001b[39m\n\u001b[31m  e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== C).map(k => caches.delete(k)))));\u001b[39m\n\u001b[31m  self.clients.claim();\u001b[39m\n\u001b[31m});\u001b[39m\n\u001b[31mself.addEventListener('fetch', e => {\u001b[39m\n\u001b[31m  if (e.request.method !== 'GET') return;\u001b[39m\n\u001b[31m  const url = new URL(e.request.url);\u001b[39m\n\u001b[31m  if (url.pathname.startsWith('/tour-pg-proxy')) return;\u001b[39m\n\u001b[31m  if (url.pathname.startsWith('/tour-pg/')) {\u001b[39m\n\u001b[31m    e.respondWith(\u001b[39m\n\u001b[31m      caches.open(C).then(c =>\u001b[39m\n\u001b[31m        c.match(e.request).then(r => r || fetch(e.request).then(f => { c.put(e.request, f.clone()); return f; }))\u001b[39m\n\u001b[31m      )\u001b[39m\n\u001b[31m    );\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m});`;\u001b[39m\n\u001b[31m  const blob = new Blob([sw], { type: 'application/javascript' });\u001b[39m\n\u001b[31m  navigator.serviceWorker.register(URL.createObjectURL(blob), { scope: '/tour-pg/' }).catch(() => {});\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31minit();\u001b[39m\n\u001b[31m</script>·\u001b[39m\n\u001b[31m</body>\u001b[39m\n\u001b[31m</html>\u001b[39m\n\u001b[31m\"\u001b[39m",
                        "stack": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoContain\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // indexOf\u001b[22m\n\nExpected substring: \u001b[32m\"tour-pg-v10\"\u001b[39m\nReceived string:    \u001b[31m\"<!DOCTYPE html>\u001b[39m\n\u001b[31m<html lang=\\\"en\\\">\u001b[39m\n\u001b[31m<head>\u001b[39m\n\u001b[31m<meta charset=\\\"UTF-8\\\">\u001b[39m\n\u001b[31m<meta name=\\\"viewport\\\" content=\\\"width=device-width,initial-scale=1,viewport-fit=cover\\\">\u001b[39m\n\u001b[31m<title>Tour Planner Playground</title>\u001b[39m\n\u001b[31m<link rel=\\\"manifest\\\" href=\\\"manifest.json\\\">\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"/shared/safe-bottom.css\\\">\u001b[39m\n\u001b[31m<meta name=\\\"theme-color\\\" content=\\\"#0a0a0a\\\">\u001b[39m\n\u001b[31m<link rel=\\\"preconnect\\\" href=\\\"https://fonts.googleapis.com\\\">\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&display=swap\\\">\u001b[39m\n\u001b[31m<script src=\\\"https://cdn.sheetjs.com/xlsx-0.20.2/package/dist/xlsx.full.min.js\\\"></script>\u001b[39m\n\u001b[31m<script src=\\\"https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js\\\"></script>\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.css\\\" crossorigin=\\\"\\\">\u001b[39m\n\u001b[31m<script src=\\\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.js\\\" crossorigin=\\\"\\\" defer></script>\u001b[39m\n\u001b[31m<style>\u001b[39m\n\u001b[31m:root{\u001b[39m\n\u001b[31m  --bg:#0a0a0a;--card:#141414;--card2:#1a1a1a;--border:#262626;--text:#f5f5f5;--muted:#a3a3a3;\u001b[39m\n\u001b[31m  --accent:#0ea5e9;--accent2:#0284c7;--g:#22c55e;--r:#ef4444;--y:#eab308;--p:#a855f7;\u001b[39m\n\u001b[31m  --rad:12px;--rads:8px;--bnav-h:64px;\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31m*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}\u001b[39m\n\u001b[31mbody{background:var(--bg);color:var(--text);font-family:'DM Sans',sans-serif;min-height:100vh;overscroll-behavior-y:none}\u001b[39m\n\u001b[31mbutton{font-family:inherit;cursor:pointer;border:none;background:none;color:inherit}\u001b[39m\n\u001b[31ma{color:inherit;text-decoration:none}·\u001b[39m\n\u001b[31m.scr{display:none;flex-direction:column;min-height:100vh;padding-bottom:calc(var(--bnav-h) + env(safe-area-inset-bottom))}\u001b[39m\n\u001b[31m.scr.active{display:flex}·\u001b[39m\n\u001b[31m.hdr{background:var(--card);border-bottom:1px solid var(--border);padding:12px 14px;display:flex;align-items:center;gap:8px;position:sticky;top:0;z-index:10}\u001b[39m\n\u001b[31m.hdr h1{font-size:15px;font-weight:800;flex:1;min-width:0}\u001b[39m\n\u001b[31m.hdr-sub{font-size:11px;color:var(--muted)}\u001b[39m\n\u001b[31m.hdr-btn{background:rgba(255,255,255,.08);border:1px solid var(--border);font-size:12px;font-weight:700;padding:5px 10px;border-radius:14px;cursor:pointer;white-space:nowrap}\u001b[39m\n\u001b[31m.hdr-btn:active{background:rgba(255,255,255,.16)}\u001b[39m\n\u001b[31m.user-pill{background:rgba(255,255,255,.1);border:1px solid var(--border);color:var(--muted);font-size:10px;font-weight:600;padding:3px 8px;border-radius:11px}\u001b[39m\n\u001b[31m.user-pill b{color:var(--text)}·\u001b[39m\n\u001b[31m.body{flex:1;padding:14px;display:flex;flex-direction:column;gap:12px}·\u001b[39m\n\u001b[31m/* ── editor: header + controls + route preview stay put; only the stops list scrolls ── */\u001b[39m\n\u001b[31m#s-editor.scr{height:100vh;height:100dvh;min-height:0;padding-bottom:0;overflow:hidden}\u001b[39m\n\u001b[31m.editor-body{flex:1;min-height:0;display:flex;flex-direction:column;padding:14px 14px 0}\u001b[39m\n\u001b[31m.editor-fixed{display:flex;flex-direction:column;gap:12px;flex-shrink:0}\u001b[39m\n\u001b[31m.editor-scroll{flex:1;min-height:0;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:2px 0 calc(32px + env(safe-area-inset-bottom));margin-top:6px;scrollbar-gutter:stable}\u001b[39m\n\u001b[31m/* visible scrollbar so it's obvious the stops list is independently scrollable */\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar{width:8px}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-thumb{background:#3a3a3a;border-radius:4px}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-thumb:active{background:#525252}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-track{background:transparent}·\u001b[39m\n\u001b[31m/* ── column-mapping table (parsed preview): scroll horizontally if it doesn't fit ── */\u001b[39m\n\u001b[31m.map-table-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--border);border-radius:6px}\u001b[39m\n\u001b[31m.map-table{border-collapse:collapse;table-layout:fixed;width:100%;min-width:440px;font-size:11px}\u001b[39m\n\u001b[31m.map-table th{text-align:left;padding:6px 8px;color:var(--muted);font-weight:800;font-size:9.5px;text-transform:uppercase;letter-spacing:.3px;background:var(--card2);position:sticky;top:0}\u001b[39m\n\u001b[31m.map-table td{padding:6px 8px;vertical-align:middle}\u001b[39m\n\u001b[31m.map-table tr+tr td,.map-table tbody tr td{border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.map-table col.c-src{width:40%}.map-table col.c-field{width:28%}.map-table col.c-sample{width:32%}\u001b[39m\n\u001b[31m.map-table select{background:#0b1220;border:1px solid var(--border);color:var(--text);padding:6px 7px;border-radius:4px;font-size:11px;width:100%}\u001b[39m\n\u001b[31m.map-field{font-weight:800;color:var(--text);white-space:nowrap}\u001b[39m\n\u001b[31m.map-sample{color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}·\u001b[39m\n\u001b[31m/* ── upload ── */\u001b[39m\n\u001b[31m.dropzone{background:var(--card);border:2px dashed var(--border);border-radius:var(--rad);padding:28px 16px;text-align:center;cursor:pointer;transition:.15s}\u001b[39m\n\u001b[31m.dropzone:hover,.dropzone.drag{border-color:var(--accent);background:#0e1726}\u001b[39m\n\u001b[31m.dropzone .ico{font-size:42px;margin-bottom:8px}\u001b[39m\n\u001b[31m.dropzone .ttl{font-weight:800;font-size:15px;margin-bottom:4px}\u001b[39m\n\u001b[31m.dropzone .sub{font-size:11.5px;color:var(--muted);line-height:1.4}\u001b[39m\n\u001b[31m.dropzone input[type=file]{display:none}·\u001b[39m\n\u001b[31m.section-title{font-size:11px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.4px}·\u001b[39m\n\u001b[31m.form-card{background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:12px;display:flex;flex-direction:column;gap:10px}\u001b[39m\n\u001b[31m.field{display:flex;flex-direction:column;gap:3px}\u001b[39m\n\u001b[31m.field label{font-size:11px;font-weight:600;color:var(--muted)}\u001b[39m\n\u001b[31m.field input,.field select{background:#0b1220;border:1px solid var(--border);border-radius:6px;color:var(--text);padding:9px 11px;font-size:13.5px;font-family:inherit;outline:none}\u001b[39m\n\u001b[31m.field input:focus,.field select:focus{border-color:var(--accent)}\u001b[39m\n\u001b[31m.toggle{display:flex;align-items:center;gap:8px;font-size:13px}\u001b[39m\n\u001b[31m.toggle input{accent-color:var(--accent);width:18px;height:18px}·\u001b[39m\n\u001b[31m.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;border-radius:var(--rads);padding:12px 14px;font-size:14px;font-weight:800;cursor:pointer;width:100%;transition:.12s}\u001b[39m\n\u001b[31m.btn-p{background:var(--accent);color:#fff}\u001b[39m\n\u001b[31m.btn-p:active{background:var(--accent2)}\u001b[39m\n\u001b[31m.btn-p:disabled{opacity:.4;cursor:default}\u001b[39m\n\u001b[31m.btn-s{background:var(--card);color:var(--text);border:1px solid var(--border)}\u001b[39m\n\u001b[31m.btn-r{background:rgba(239,68,68,.15);color:#fca5a5;border:1px solid rgba(239,68,68,.3)}·\u001b[39m\n\u001b[31m/* ── progress ── */\u001b[39m\n\u001b[31m.progress{background:#0b1220;border-radius:8px;height:10px;overflow:hidden;border:1px solid var(--border)}\u001b[39m\n\u001b[31m.progress-bar{height:100%;background:linear-gradient(90deg,var(--accent),var(--g));transition:.2s}·\u001b[39m\n\u001b[31m/* ── tiles ── */\u001b[39m\n\u001b[31m.origin-pill{background:linear-gradient(135deg,#0c2e3a,#062a3a);border:1px solid var(--accent);border-radius:var(--rad);padding:11px 13px;display:flex;align-items:center;gap:10px}\u001b[39m\n\u001b[31m.origin-pill .ico{font-size:18px}\u001b[39m\n\u001b[31m.origin-pill .meta{flex:1;min-width:0}\u001b[39m\n\u001b[31m.origin-pill .lbl{font-size:10.5px;color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.4px}\u001b[39m\n\u001b[31m.origin-pill .val{font-size:13.5px;font-weight:700;margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.origin-pill button{background:rgba(255,255,255,.08);border-radius:14px;padding:5px 10px;font-size:11px;font-weight:700;color:var(--accent)}·\u001b[39m\n\u001b[31m.tiles{display:flex;flex-direction:column;gap:6px}\u001b[39m\n\u001b[31m.tile{background:var(--card);border:1px solid var(--border);border-radius:var(--rads);padding:9px 10px;display:flex;align-items:center;gap:10px;transition:.12s;touch-action:none}\u001b[39m\n\u001b[31m.tile.sortable-ghost{opacity:.35;background:var(--card2)}\u001b[39m\n\u001b[31m.tile.sortable-chosen{transform:scale(.98);box-shadow:0 4px 20px rgba(14,165,233,.25)}\u001b[39m\n\u001b[31m.tile.excluded{opacity:.4;background:#0c0c0c}\u001b[39m\n\u001b[31m.tile.excluded .name,.tile.excluded .city{text-decoration:line-through}\u001b[39m\n\u001b[31m.tile.err{border-color:var(--r);background:#1f0a0a}\u001b[39m\n\u001b[31m.tile .grip{font-size:18px;color:var(--muted);cursor:grab;padding:4px;line-height:1}\u001b[39m\n\u001b[31m.tile .grip:active{cursor:grabbing}\u001b[39m\n\u001b[31m.tile .ord{background:var(--accent);color:#fff;width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11.5px;font-weight:800;flex-shrink:0}\u001b[39m\n\u001b[31m.tile.excluded .ord{background:#444}\u001b[39m\n\u001b[31m.tile .info{flex:1;min-width:0}\u001b[39m\n\u001b[31m.tile .name{font-size:13.5px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .city{font-size:11.5px;color:#cbd5e1;font-weight:600;margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .addr{font-size:9.5px;color:var(--muted);margin-top:1px;line-height:1.3;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-style:italic;opacity:.75}\u001b[39m\n\u001b[31m.tile .leg{font-size:10.5px;color:var(--accent2);font-weight:600;margin-top:2px}\u001b[39m\n\u001b[31m.tile.excluded .leg{display:none}\u001b[39m\n\u001b[31m.tile .badge-provider{display:inline-block;font-size:8.5px;padding:1px 5px;border-radius:8px;background:rgba(255,255,255,.08);color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.3px;margin-left:4px;vertical-align:middle}\u001b[39m\n\u001b[31m.tile .incl{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:50%;border:2px solid var(--border);font-size:16px;font-weight:800}\u001b[39m\n\u001b[31m.tile .incl.on{background:var(--g);border-color:var(--g);color:#fff}\u001b[39m\n\u001b[31m.tile .incl.off{background:#0c0c0c;color:var(--r);border-color:var(--r)}·\u001b[39m\n\u001b[31m/* ── prominent top action bar (KM, Share, Re-optimise) ── */\u001b[39m\n\u001b[31m.top-actions{background:linear-gradient(135deg,#0c2e3a,#08111c);border:1px solid var(--accent);border-radius:var(--rad);padding:10px;display:grid;grid-template-columns:1fr auto auto;gap:8px;align-items:center;margin-bottom:6px}\u001b[39m\n\u001b[31m.top-totals{min-width:0}\u001b[39m\n\u001b[31m.top-totals-big{font-size:18px;font-weight:900;line-height:1.05;letter-spacing:.3px}\u001b[39m\n\u001b[31m.top-totals-delta{font-size:11px;color:var(--muted);margin-top:2px}\u001b[39m\n\u001b[31m.top-totals-delta.better{color:var(--g)}\u001b[39m\n\u001b[31m.top-totals-delta.worse{color:var(--y)}\u001b[39m\n\u001b[31m.top-actions button{padding:10px 12px;font-size:12px;font-weight:800;border-radius:8px;border:none;cursor:pointer;display:flex;align-items:center;gap:5px;white-space:nowrap}\u001b[39m\n\u001b[31m.top-share{background:#f59e0b;color:#000}\u001b[39m\n\u001b[31m.top-share:active{background:#d97706}\u001b[39m\n\u001b[31m.top-reopt{background:var(--accent);color:#fff}\u001b[39m\n\u001b[31m.top-reopt:active{background:var(--accent2)}\u001b[39m\n\u001b[31m/* ── Re-optimisation needed (after include/exclude toggle) ── */\u001b[39m\n\u001b[31m.reopt-needed{font-size:11px;font-weight:700;color:#fbbf24;background:rgba(234,179,8,.12);border-left:3px solid #fbbf24;padding:5px 8px;border-radius:4px;margin-top:5px;display:none;line-height:1.3}\u001b[39m\n\u001b[31m.reopt-needed.show{display:block}·\u001b[39m\n\u001b[31m/* ── inline route preview (Leaflet + OSM) ── */\u001b[39m\n\u001b[31m.map-wrap{margin:8px 0 0;background:var(--card);border:1px solid var(--border);border-radius:var(--rads);overflow:hidden}\u001b[39m\n\u001b[31m.map-hdr{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;font-size:12px;color:var(--muted);background:var(--card2);border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.map-hdr .ttl{display:flex;align-items:center;gap:6px;font-weight:600}\u001b[39m\n\u001b[31m.map-hdr .toggle{font-size:14px;color:var(--text);background:none;border:none;cursor:pointer;padding:0 4px}\u001b[39m\n\u001b[31m#tour-map{height:260px;width:100%;background:#1a1a1a;transition:height .2s ease}\u001b[39m\n\u001b[31m.map-wrap.collapsed #tour-map{height:0}\u001b[39m\n\u001b[31m.map-wrap.collapsed .map-hdr{border-bottom:none}\u001b[39m\n\u001b[31m.tour-pin{background:transparent;border:none}\u001b[39m\n\u001b[31m.tour-pin > div{background:#0ea5e9;color:#fff;border-radius:50%;width:26px;height:26px;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:12px;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,.45);font-family:'DM Sans',sans-serif}\u001b[39m\n\u001b[31m.tour-pin.origin > div{background:#22c55e;font-size:14px}\u001b[39m\n\u001b[31m.tour-pin.out > div{background:#6b7280;opacity:.55}\u001b[39m\n\u001b[31m.leaflet-tooltip{background:#141414;color:#f5f5f5;border:1px solid #262626;font-size:11px;padding:3px 6px;font-family:'DM Sans',sans-serif;box-shadow:0 1px 6px rgba(0,0,0,.4)}\u001b[39m\n\u001b[31m.leaflet-tooltip-top:before,.leaflet-tooltip-bottom:before,.leaflet-tooltip-left:before,.leaflet-tooltip-right:before{border-top-color:#262626;border-bottom-color:#262626;border-left-color:#262626;border-right-color:#262626}\u001b[39m\n\u001b[31m.leaflet-control-attribution{background:rgba(20,20,20,.85)!important;color:#a3a3a3!important;font-size:9px!important}\u001b[39m\n\u001b[31m.leaflet-control-attribution a{color:#0ea5e9!important}\u001b[39m\n\u001b[31m.top-reopt.pulse{animation:pulseReopt 1.4s ease-in-out infinite;box-shadow:0 0 0 0 rgba(14,165,233,.6)}\u001b[39m\n\u001b[31m@keyframes pulseReopt{0%{box-shadow:0 0 0 0 rgba(14,165,233,.7)}50%{box-shadow:0 0 0 8px rgba(14,165,233,0)}100%{box-shadow:0 0 0 0 rgba(14,165,233,0)}}\u001b[39m\n\u001b[31m/* Totals dim when stale to make it visually obvious */\u001b[39m\n\u001b[31m.top-totals-big.stale, .top-totals-delta.stale{opacity:.5;text-decoration:line-through;text-decoration-color:rgba(251,191,36,.6)}·\u001b[39m\n\u001b[31m/* recalc spinner that appears inline next to the top totals during recompute */\u001b[39m\n\u001b[31m.recalc{display:none;font-size:11px;color:var(--accent);margin-left:6px;vertical-align:middle}\u001b[39m\n\u001b[31m.recalc.show{display:inline-block;animation:spin 1s linear infinite}\u001b[39m\n\u001b[31m@keyframes spin{to{transform:rotate(360deg)}}·\u001b[39m\n\u001b[31m/* ── share modal ── */\u001b[39m\n\u001b[31m.modal{position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:200;display:none;align-items:flex-end;justify-content:center}\u001b[39m\n\u001b[31m.modal.show{display:flex}\u001b[39m\n\u001b[31m.modal-box{background:var(--card);border-radius:18px 18px 0 0;width:100%;max-height:85vh;overflow-y:auto;padding:16px 14px calc(16px + env(safe-area-inset-bottom));border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.modal-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\u001b[39m\n\u001b[31m.modal-ttl{font-size:15px;font-weight:800}\u001b[39m\n\u001b[31m.modal-close{font-size:24px;color:var(--muted);background:none;border:none;cursor:pointer}·\u001b[39m\n\u001b[31m/* ── toast ── */\u001b[39m\n\u001b[31m#toast{position:fixed;bottom:calc(var(--bnav-h)+16px);left:12px;right:12px;z-index:1000;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:11px 14px;font-size:13px;font-weight:600;text-align:center;transform:translateY(150px);opacity:0;transition:.25s;pointer-events:none}\u001b[39m\n\u001b[31m#toast.show{transform:translateY(0);opacity:1}\u001b[39m\n\u001b[31m#toast.ok{background:rgba(34,197,94,.18);border-color:var(--g);color:#86efac}\u001b[39m\n\u001b[31m#toast.err{background:rgba(239,68,68,.18);border-color:var(--r);color:#fca5a5}·\u001b[39m\n\u001b[31m/* ── helpers ── */\u001b[39m\n\u001b[31m.spinner{display:inline-block;width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;vertical-align:middle}·\u001b[39m\n\u001b[31m.empty{text-align:center;padding:30px 16px;color:var(--muted);font-size:12.5px}·\u001b[39m\n\u001b[31mtextarea{background:#0b1220;border:1px solid var(--border);color:var(--text);padding:10px;font-family:inherit;font-size:12px;border-radius:6px;resize:vertical;outline:none}·\u001b[39m\n\u001b[31m.kbd{background:rgba(255,255,255,.06);padding:1px 6px;border-radius:4px;font-family:monospace;font-size:11px;color:var(--muted)}\u001b[39m\n\u001b[31m</style>\u001b[39m\n\u001b[31m</head>\u001b[39m\n\u001b[31m<body>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: UPLOAD ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-upload\\\" class=\\\"scr active\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗺 Tour Playground</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Excel → optimised route → tweak</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"loadSaved()\\\" id=\\\"btn-load\\\" style=\\\"display:none\\\">↻ Load</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"location.href='/hub/'\\\">🏡 Hub</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <label class=\\\"dropzone\\\" id=\\\"dropzone\\\" for=\\\"excel-file\\\">\u001b[39m\n\u001b[31m      <input type=\\\"file\\\" id=\\\"excel-file\\\" accept=\\\".xlsx,.xls,.csv\\\">\u001b[39m\n\u001b[31m      <div class=\\\"ico\\\">📊</div>\u001b[39m\n\u001b[31m      <div class=\\\"ttl\\\">Drop Excel here, or tap to choose</div>\u001b[39m\n\u001b[31m      <div class=\\\"sub\\\">Required: <b>store name</b> + <b>address</b><br>Optional: <b>city</b>, <b>lat</b>, <b>lng</b>, <b>phone</b>, anything else.</div>\u001b[39m\n\u001b[31m    </label>·\u001b[39m\n\u001b[31m    <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\">Origin</div>\u001b[39m\n\u001b[31m      <div class=\\\"field\\\">\u001b[39m\n\u001b[31m        <label>Start point</label>\u001b[39m\n\u001b[31m        <input type=\\\"text\\\" id=\\\"inp-origin\\\" value=\\\"Mohali office Punjab\\\" placeholder=\\\"Address or place name\\\">\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div class=\\\"toggle\\\">\u001b[39m\n\u001b[31m        <input type=\\\"checkbox\\\" id=\\\"inp-roundtrip\\\" checked>\u001b[39m\n\u001b[31m        <label for=\\\"inp-roundtrip\\\" style=\\\"cursor:pointer;font-size:13px;font-weight:600\\\">Round-trip (return to start)</label>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"dest-wrap\\\" class=\\\"field\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m        <label>Final destination</label>\u001b[39m\n\u001b[31m        <input type=\\\"text\\\" id=\\\"inp-destination\\\" placeholder=\\\"Where to end if not returning to origin\\\">\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" id=\\\"btn-go\\\" onclick=\\\"parseAndOptimise()\\\" disabled>\u001b[39m\n\u001b[31m      📐 Parse + optimise route\u001b[39m\n\u001b[31m    </button>·\u001b[39m\n\u001b[31m    <div id=\\\"upload-progress\\\" style=\\\"display:none;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;font-size:12px\\\">\u001b[39m\n\u001b[31m        <span id=\\\"prog-label\\\">Geocoding…</span>\u001b[39m\n\u001b[31m        <span id=\\\"prog-count\\\" style=\\\"color:var(--muted)\\\">0 / 0</span>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div class=\\\"progress\\\"><div class=\\\"progress-bar\\\" id=\\\"prog-bar\\\" style=\\\"width:0%\\\"></div></div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div id=\\\"parsed-preview\\\" style=\\\"display:none;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:11px;font-size:12px\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\" style=\\\"margin-bottom:6px\\\">Parsed</div>\u001b[39m\n\u001b[31m      <div id=\\\"parsed-summary\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: EDITOR ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-editor\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"confirmBack()\\\">‹ New</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>Tour Editor</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"editor-sub\\\">— stops</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-2\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"openShare()\\\">📤</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"editor-body\\\">\u001b[39m\n\u001b[31m   <div class=\\\"editor-fixed\\\">·\u001b[39m\n\u001b[31m    <div class=\\\"origin-pill\\\" id=\\\"origin-pill\\\">\u001b[39m\n\u001b[31m      <span class=\\\"ico\\\">📍</span>\u001b[39m\n\u001b[31m      <div class=\\\"meta\\\">\u001b[39m\n\u001b[31m        <div class=\\\"lbl\\\">Start from</div>\u001b[39m\n\u001b[31m        <div class=\\\"val\\\" id=\\\"origin-val\\\">—</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button onclick=\\\"openOriginEdit()\\\">✏️ Change</button>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div id=\\\"origin-edit\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m        <div class=\\\"field\\\">\u001b[39m\n\u001b[31m          <label>New origin (for mid-tour replan)</label>\u001b[39m\n\u001b[31m          <input type=\\\"text\\\" id=\\\"inp-new-origin\\\" placeholder=\\\"Hotel B Mohali, or address…\\\">\u001b[39m\n\u001b[31m          <div style=\\\"font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4\\\">\u001b[39m\n\u001b[31m            Tip: toggle ✗ on completed stops, change origin to where you are now, hit Replan.\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div style=\\\"display:flex;gap:8px\\\">\u001b[39m\n\u001b[31m          <button class=\\\"btn btn-s\\\" onclick=\\\"closeOriginEdit()\\\" style=\\\"flex:1\\\">Cancel</button>\u001b[39m\n\u001b[31m          <button class=\\\"btn btn-p\\\" onclick=\\\"applyOriginAndReplan()\\\" style=\\\"flex:2\\\">🔄 Replan from here</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"top-actions\\\" id=\\\"top-actions\\\">\u001b[39m\n\u001b[31m      <div class=\\\"top-totals\\\">\u001b[39m\n\u001b[31m        <div class=\\\"top-totals-big\\\" id=\\\"top-totals-big\\\">— km · — min<span class=\\\"recalc\\\" id=\\\"recalc\\\">↻</span></div>\u001b[39m\n\u001b[31m        <div class=\\\"top-totals-delta\\\" id=\\\"top-totals-delta\\\">vs proposed: —</div>\u001b[39m\n\u001b[31m        <div class=\\\"reopt-needed\\\" id=\\\"reopt-needed\\\">⚠ Stops changed — tap 🔁 Re-optim to refresh the route plan</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"top-share\\\" onclick=\\\"openShare()\\\">📤 Share</button>\u001b[39m\n\u001b[31m      <button class=\\\"top-reopt\\\" onclick=\\\"replanAll()\\\">🔁 Re-optim</button>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"map-wrap\\\" id=\\\"map-wrap\\\">\u001b[39m\n\u001b[31m      <div class=\\\"map-hdr\\\">\u001b[39m\n\u001b[31m        <div class=\\\"ttl\\\">🗺️ Route preview <span style=\\\"opacity:.6;font-weight:400\\\">(straight-line)</span></div>\u001b[39m\n\u001b[31m        <button class=\\\"toggle\\\" id=\\\"map-toggle\\\" onclick=\\\"toggleMap()\\\" aria-label=\\\"Toggle map\\\">▾</button>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"tour-map\\\"></div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"section-title\\\" style=\\\"margin-top:2px\\\">Stops (drag ≡ to reorder · scroll ↕ to see all)</div>\u001b[39m\n\u001b[31m   </div><!-- /editor-fixed -->·\u001b[39m\n\u001b[31m   <div class=\\\"editor-scroll\\\" id=\\\"editor-scroll\\\">\u001b[39m\n\u001b[31m    <div id=\\\"tiles\\\" class=\\\"tiles\\\"></div>\u001b[39m\n\u001b[31m   </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── MODAL: SHARE ── -->\u001b[39m\n\u001b[31m<div class=\\\"modal\\\" id=\\\"modal-share\\\">\u001b[39m\n\u001b[31m  <div class=\\\"modal-box\\\">\u001b[39m\n\u001b[31m    <div class=\\\"modal-hdr\\\">\u001b[39m\n\u001b[31m      <div class=\\\"modal-ttl\\\">📤 Share / Export</div>\u001b[39m\n\u001b[31m      <button class=\\\"modal-close\\\" onclick=\\\"closeShare()\\\">×</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"share-summary\\\" style=\\\"background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:10px;font-size:12px;line-height:1.6;white-space:pre-wrap;font-family:monospace\\\"></div>\u001b[39m\n\u001b[31m    <div style=\\\"margin-top:12px;display:grid;gap:8px\\\">\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"openInGoogleMaps()\\\">🗺️ Open route in Google Maps</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"shareWhatsApp()\\\">💬 WhatsApp</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"copyText()\\\">📋 Copy text</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"downloadCsv()\\\">📥 Download CSV</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"saveLocal()\\\">💾 Save to this device</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<div id=\\\"toast\\\"></div>·\u001b[39m\n\u001b[31m<script>\u001b[39m\n\u001b[31m'use strict';·\u001b[39m\n\u001b[31mconst CACHE_VER = 'tour-pg-v11';\u001b[39m\n\u001b[31mconst PROXY     = '/tour-pg-proxy';·\u001b[39m\n\u001b[31m// ── State ────────────────────────────────────────────\u001b[39m\n\u001b[31mlet session   = null;\u001b[39m\n\u001b[31mlet myId      = '';\u001b[39m\n\u001b[31mlet myName    = '';\u001b[39m\n\u001b[31mlet state     = newState();\u001b[39m\n\u001b[31mlet _recalcTimer = null;\u001b[39m\n\u001b[31mlet _sortable    = null;·\u001b[39m\n\u001b[31mfunction newState() {\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    origin:     { text: 'Mohali office Punjab', location: null, provider: null },\u001b[39m\n\u001b[31m    destination:null,\u001b[39m\n\u001b[31m    roundTrip:  true,\u001b[39m\n\u001b[31m    stops:      [],          // { id, name, city, address, phone, location, included, legDistance_m, legDuration_s, provider, raw, geocodeErr }\u001b[39m\n\u001b[31m    proposedOrder: null,     // array of stop IDs in optimise-time order\u001b[39m\n\u001b[31m    baseTotal: { distance_m: 0, duration_s: 0 },\u001b[39m\n\u001b[31m    currentTotal: { distance_m: 0, duration_s: 0 },\u001b[39m\n\u001b[31m    lastOptimisation: null,  // 'tomtom-computeBestOrder' | 'matrix-nn-2opt'\u001b[39m\n\u001b[31m    needsReoptimize: false,  // true after include/exclude toggle → totals may be stale; prompt user to tap Re-optim\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction uid(prefix='st') { return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,6)}`; }·\u001b[39m\n\u001b[31m// ── Auth ─────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction init() {\u001b[39m\n\u001b[31m  registerSW();\u001b[39m\n\u001b[31m  const raw = localStorage.getItem('lm360-session');\u001b[39m\n\u001b[31m  // Bounce to Hub with ?next= so user lands back here after login (Hub honours\u001b[39m\n\u001b[31m  // same-origin relative paths). Pattern documented in\u001b[39m\n\u001b[31m  // feedback_hub_next_redirect.md memory file — all gated PWAs should follow.\u001b[39m\n\u001b[31m  const nextParam = '?next=' + encodeURIComponent(location.pathname + location.search);\u001b[39m\n\u001b[31m  if (!raw) { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  try { session = JSON.parse(raw); } catch { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  if (Date.now() - (session.loginAt ? new Date(session.loginAt).getTime() : 0) > 12*3600*1000) { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  myId   = session.empId || '';\u001b[39m\n\u001b[31m  myName = session.name  || myId;\u001b[39m\n\u001b[31m  const userText = `👤 <b>${escHtml(myName)}</b>`;\u001b[39m\n\u001b[31m  document.getElementById('user-pill').innerHTML   = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill-2').innerHTML = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill').style.display   = '';\u001b[39m\n\u001b[31m  document.getElementById('user-pill-2').style.display = '';·\u001b[39m\n\u001b[31m  // Load-saved chip if we have a saved tour\u001b[39m\n\u001b[31m  if (localStorage.getItem('tour-pg-saved')) document.getElementById('btn-load').style.display = '';·\u001b[39m\n\u001b[31m  // Wire UI\u001b[39m\n\u001b[31m  document.getElementById('inp-roundtrip').addEventListener('change', e => {\u001b[39m\n\u001b[31m    document.getElementById('dest-wrap').style.display = e.target.checked ? 'none' : '';\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('dragover', e => { e.preventDefault(); e.currentTarget.classList.add('drag'); });\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('dragleave', e => e.currentTarget.classList.remove('drag'));\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('drop', e => {\u001b[39m\n\u001b[31m    e.preventDefault();\u001b[39m\n\u001b[31m    e.currentTarget.classList.remove('drag');\u001b[39m\n\u001b[31m    const f = e.dataTransfer.files[0]; if (f) handleFile(f);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  document.getElementById('excel-file').addEventListener('change', e => {\u001b[39m\n\u001b[31m    const f = e.target.files[0]; if (f) handleFile(f);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction hubAuth() {\u001b[39m\n\u001b[31m  return 'Hub ' + btoa(JSON.stringify(session));\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function api(path, body) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, {\u001b[39m\n\u001b[31m    method: 'POST',\u001b[39m\n\u001b[31m    headers: { 'Authorization': hubAuth(), 'Content-Type': 'application/json' },\u001b[39m\n\u001b[31m    body: JSON.stringify(body),\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (r.status === 401) {\u001b[39m\n\u001b[31m    toast('Session expired — refreshing to re-login', 'err');\u001b[39m\n\u001b[31m    const nextParam = '?next=' + encodeURIComponent(location.pathname + location.search);\u001b[39m\n\u001b[31m    setTimeout(() => location.href = '/hub/' + nextParam, 1500);\u001b[39m\n\u001b[31m    throw new Error('unauthorized');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!r.ok) {\u001b[39m\n\u001b[31m    const txt = await r.text().catch(() => '');\u001b[39m\n\u001b[31m    throw new Error(`${path}: ${r.status} ${txt.slice(0, 100)}`);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return r.json();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Excel parsing + column detection ────────────────\u001b[39m\n\u001b[31mlet _parsedCols = null;   // exposed for the column-remap UI\u001b[39m\n\u001b[31mlet _parsedRows = null;\u001b[39m\n\u001b[31mfunction handleFile(file) {\u001b[39m\n\u001b[31m  const reader = new FileReader();\u001b[39m\n\u001b[31m  reader.onload = (e) => {\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      const wb = XLSX.read(e.target.result, { type: 'array' });\u001b[39m\n\u001b[31m      const ws = wb.Sheets[wb.SheetNames[0]];\u001b[39m\n\u001b[31m      const rows = XLSX.utils.sheet_to_json(ws, { defval: '' });\u001b[39m\n\u001b[31m      if (!rows.length) throw new Error('Empty sheet');\u001b[39m\n\u001b[31m      _parsedRows = rows;\u001b[39m\n\u001b[31m      _parsedCols = detectColumns(rows[0]);\u001b[39m\n\u001b[31m      if (!_parsedCols.name && !_parsedCols.address && !_parsedCols.city) throw new Error('Missing required columns (need at least one of: name, address, city)');\u001b[39m\n\u001b[31m      rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m      renderParsedPreview();\u001b[39m\n\u001b[31m      document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m      toast('Excel parsed ✓', 'ok');\u001b[39m\n\u001b[31m    } catch (err) {\u001b[39m\n\u001b[31m      toast('Excel error: ' + err.message, 'err');\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  reader.readAsArrayBuffer(file);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction rebuildStopsFromParsedRows() {\u001b[39m\n\u001b[31m  state.stops = _parsedRows.map((row, idx) => buildStop(row, _parsedCols, idx)).filter(s => s);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderParsedPreview() {\u001b[39m\n\u001b[31m  const skip = _parsedRows.length - state.stops.length;\u001b[39m\n\u001b[31m  const allCols = Object.keys(_parsedRows[0]);\u001b[39m\n\u001b[31m  // For each role, show:  <role>: <selected col name>  with a tiny dropdown to override\u001b[39m\n\u001b[31m  const ROLES = ['name','city','address','lat','lng','phone','notes'];\u001b[39m\n\u001b[31m  const sample = _parsedRows[0];\u001b[39m\n\u001b[31m  const previewVal = (key) => {\u001b[39m\n\u001b[31m    const v = sample[key]; if (v == null || v === '') return '';\u001b[39m\n\u001b[31m    return String(v).slice(0, 40);\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  // Column order (per user request): YOUR Excel column first, then the PWA field\u001b[39m\n\u001b[31m  // it maps to, then a sample value. Equal-ish widths via <colgroup>; the wrapper\u001b[39m\n\u001b[31m  // scrolls horizontally so long header names are never clipped on narrow phones.\u001b[39m\n\u001b[31m  let rows = '<div class=\\\"map-table-wrap\\\"><table class=\\\"map-table\\\">' +\u001b[39m\n\u001b[31m    '<colgroup><col class=\\\"c-src\\\"><col class=\\\"c-field\\\"><col class=\\\"c-sample\\\"></colgroup>' +\u001b[39m\n\u001b[31m    '<thead><tr><th>Your file column</th><th>→ PWA field</th><th>Sample value</th></tr></thead><tbody>';\u001b[39m\n\u001b[31m  for (const role of ROLES) {\u001b[39m\n\u001b[31m    const sel  = _parsedCols[role];\u001b[39m\n\u001b[31m    const need = role === 'name';\u001b[39m\n\u001b[31m    const opts = `<option value=\\\"\\\">— ignore —</option>` +\u001b[39m\n\u001b[31m      allCols.map(c => `<option value=\\\"${escHtml(c)}\\\" ${sel === c ? 'selected' : ''}>${escHtml(c)}</option>`).join('');\u001b[39m\n\u001b[31m    const previewText = sel ? escHtml(previewVal(sel)) : '';\u001b[39m\n\u001b[31m    const tip = !sel && need ? `<span style=\\\"color:var(--y);font-weight:700\\\">⚠ no match — uses city or \\\"Stop #N\\\"</span>` : '';\u001b[39m\n\u001b[31m    rows += `<tr>\u001b[39m\n\u001b[31m      <td><select onchange=\\\"remapColumn('${role}', this.value)\\\">${opts}</select></td>\u001b[39m\n\u001b[31m      <td class=\\\"map-field\\\">${role}${need?' <span style=\\\"color:var(--y)\\\">*</span>':''}</td>\u001b[39m\n\u001b[31m      <td class=\\\"map-sample\\\">${previewText}${tip}</td>\u001b[39m\n\u001b[31m    </tr>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  rows += '</tbody></table></div>';\u001b[39m\n\u001b[31m  document.getElementById('parsed-preview').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('parsed-summary').innerHTML =\u001b[39m\n\u001b[31m    `<div style=\\\"margin-bottom:6px\\\"><b>${state.stops.length}</b> stops detected` +\u001b[39m\n\u001b[31m    `${skip > 0 ? ` · ${skip} skipped (no name/city/address)` : ''}</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:10.5px;color:var(--muted);margin-bottom:6px\\\">If detection is wrong, pick the correct column from the dropdowns:</div>` +\u001b[39m\n\u001b[31m    rows;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction remapColumn(role, newCol) {\u001b[39m\n\u001b[31m  if (!_parsedCols) return;\u001b[39m\n\u001b[31m  _parsedCols[role] = newCol || null;\u001b[39m\n\u001b[31m  rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction normaliseColName(s) {\u001b[39m\n\u001b[31m  return String(s).toLowerCase().replace(/[^a-z]/g, '');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction detectColumns(sampleRow) {\u001b[39m\n\u001b[31m  const map = { name:null, city:null, address:null, lat:null, lng:null, phone:null, notes:null, others:[] };\u001b[39m\n\u001b[31m  const keys = Object.keys(sampleRow);\u001b[39m\n\u001b[31m  // Broadened aliases so most user-provided Excel files map sensibly without a UI:\u001b[39m\n\u001b[31m  //   name  ← store / shop / outlet / party / dealer / firm / customer / business /\u001b[39m\n\u001b[31m  //           company / vendor / agency (and \\\"<thing> name\\\")\u001b[39m\n\u001b[31m  //   addr  ← address / fulladdress / location / loc / addr\u001b[39m\n\u001b[31m  //   city  ← city / town / district / location (only if no address column took it)\u001b[39m\n\u001b[31m  for (const k of keys) {\u001b[39m\n\u001b[31m    const n = normaliseColName(k);\u001b[39m\n\u001b[31m    if (!map.name && /^(storename|name|store|client|outlet|party(name)?|dealer(name)?|firm(name)?|customer(name)?|shop(name)?|business(name)?|companyname|company|vendor(name)?|agency(name)?|merchant|chain|sitename|site)$/.test(n)) { map.name = k; continue; }\u001b[39m\n\u001b[31m    if (!map.address && /^(address|fulladdress|streetaddress|addr|location|loc|street)$/.test(n)) { map.address = k; continue; }\u001b[39m\n\u001b[31m    if (!map.city && /^(city|town|district|cityname|locality|area|region)$/.test(n)) { map.city = k; continue; }\u001b[39m\n\u001b[31m    if (!map.lat && /^(lat|latitude|y)$/.test(n)) { map.lat = k; continue; }\u001b[39m\n\u001b[31m    if (!map.lng && /^(lng|lon|long|longitude|x)$/.test(n)) { map.lng = k; continue; }\u001b[39m\n\u001b[31m    if (!map.phone && /^(phone(no)?|mobile(no)?|contact(no)?|tel(no)?|whatsapp|wa|cell)$/.test(n)) { map.phone = k; continue; }\u001b[39m\n\u001b[31m    if (!map.notes && /^(notes?|remarks?|comments?|description|desc|memo)$/.test(n)) { map.notes = k; continue; }\u001b[39m\n\u001b[31m    map.others.push(k);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return map;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction buildStop(row, cols, idx) {\u001b[39m\n\u001b[31m  const name    = cols.name    ? String(row[cols.name]   || '').trim() : '';\u001b[39m\n\u001b[31m  const address = cols.address ? String(row[cols.address]|| '').trim() : '';\u001b[39m\n\u001b[31m  const city    = cols.city    ? String(row[cols.city]   || '').trim() : '';\u001b[39m\n\u001b[31m  const phone   = cols.phone   ? String(row[cols.phone]  || '').trim() : '';\u001b[39m\n\u001b[31m  const notes   = cols.notes   ? String(row[cols.notes]  || '').trim() : '';\u001b[39m\n\u001b[31m  if (!name && !address && !city) return null;\u001b[39m\n\u001b[31m  // pre-resolved coords?\u001b[39m\n\u001b[31m  let location = null;\u001b[39m\n\u001b[31m  if (cols.lat && cols.lng) {\u001b[39m\n\u001b[31m    const lat = parseFloat(row[cols.lat]);\u001b[39m\n\u001b[31m    const lng = parseFloat(row[cols.lng]);\u001b[39m\n\u001b[31m    if (!isNaN(lat) && !isNaN(lng) && lat !== 0 && lng !== 0) location = { lat, lng };\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const fullAddr = [address, city].filter(Boolean).join(', ');\u001b[39m\n\u001b[31m  // Display name fallback hierarchy (NEVER truncated address — that confused users):\u001b[39m\n\u001b[31m  //   1. Explicit name column value\u001b[39m\n\u001b[31m  //   2. City + index (so excluded/duplicate cities stay distinguishable)\u001b[39m\n\u001b[31m  //   3. \\\"Stop #N\\\"\u001b[39m\n\u001b[31m  let displayName;\u001b[39m\n\u001b[31m  if (name) displayName = name;\u001b[39m\n\u001b[31m  else if (city) displayName = `${city} (stop ${idx + 1})`;\u001b[39m\n\u001b[31m  else displayName = `Stop ${idx + 1}`;\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    id:           uid('st'),\u001b[39m\n\u001b[31m    name:         displayName,\u001b[39m\n\u001b[31m    hasExplicitName: !!name,         // for UI to flag \\\"name auto-derived\\\"\u001b[39m\n\u001b[31m    city, phone, notes,\u001b[39m\n\u001b[31m    address:      fullAddr || name || '',\u001b[39m\n\u001b[31m    location,\u001b[39m\n\u001b[31m    provider:     location ? 'excel' : null,\u001b[39m\n\u001b[31m    included:     true,\u001b[39m\n\u001b[31m    legDistance_m: 0,\u001b[39m\n\u001b[31m    legDuration_s: 0,\u001b[39m\n\u001b[31m    geocodeErr:   null,\u001b[39m\n\u001b[31m    raw:          { ...row },\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Geocode + optimise ──────────────────────────────\u001b[39m\n\u001b[31masync function parseAndOptimise() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('Parse an Excel first', 'err');\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = true;\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = '';\u001b[39m\n\u001b[31m  state.origin.text  = document.getElementById('inp-origin').value.trim() || 'Mohali office Punjab';\u001b[39m\n\u001b[31m  state.roundTrip    = document.getElementById('inp-roundtrip').checked;\u001b[39m\n\u001b[31m  state.destination  = state.roundTrip ? null : (document.getElementById('inp-destination').value.trim() || null);\u001b[39m\n\u001b[31m  // Geocode origin first\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: state.origin.text });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin.location = og.location;\u001b[39m\n\u001b[31m    state.origin.provider = og.provider;\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    setProgress(0, 0, '');\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('Origin geocode failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Geocode each stop (skip if pre-resolved). Sequential to avoid rate limits.\u001b[39m\n\u001b[31m  const total = state.stops.length;\u001b[39m\n\u001b[31m  let done = 0;\u001b[39m\n\u001b[31m  for (const s of state.stops) {\u001b[39m\n\u001b[31m    if (s.location) { done++; setProgress(done, total, 'Pre-geocoded'); continue; }\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      const r = await api('/geocode', { address: s.address });\u001b[39m\n\u001b[31m      if (r.ok && r.location) {\u001b[39m\n\u001b[31m        s.location = r.location;\u001b[39m\n\u001b[31m        s.provider = r.provider;\u001b[39m\n\u001b[31m      } else {\u001b[39m\n\u001b[31m        s.geocodeErr = r.error || 'No match';\u001b[39m\n\u001b[31m      }\u001b[39m\n\u001b[31m    } catch (e) {\u001b[39m\n\u001b[31m      s.geocodeErr = e.message;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    done++;\u001b[39m\n\u001b[31m    setProgress(done, total, `Geocoding ${s.name.slice(0, 30)}…`);\u001b[39m\n\u001b[31m    // Tiny delay to be polite to the provider\u001b[39m\n\u001b[31m    await sleep(80);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Filter out failed-geocode stops\u001b[39m\n\u001b[31m  const good = state.stops.filter(s => s.location && !s.geocodeErr);\u001b[39m\n\u001b[31m  const bad  = state.stops.filter(s => !s.location || s.geocodeErr);\u001b[39m\n\u001b[31m  if (!good.length) {\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('No stops could be geocoded', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (bad.length) toast(`${bad.length} stop(s) failed geocode (excluded)`, 'err');\u001b[39m\n\u001b[31m  state.stops = good;·\u001b[39m\n\u001b[31m  // Optimise\u001b[39m\n\u001b[31m  setProgress(total, total, 'Optimising route…');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const opt = await api('/optimize', {\u001b[39m\n\u001b[31m      origin:    locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:     state.stops.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:  state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!opt.ok) throw new Error(opt.error || 'optimise failed');\u001b[39m\n\u001b[31m    // Apply order\u001b[39m\n\u001b[31m    const order = opt.waypoint_order || state.stops.map((_, i) => i);\u001b[39m\n\u001b[31m    state.stops = order.map(i => state.stops[i]);\u001b[39m\n\u001b[31m    state.proposedOrder = state.stops.map(s => s.id);\u001b[39m\n\u001b[31m    state.baseTotal     = { distance_m: opt.total_distance_m, duration_s: opt.total_duration_s };\u001b[39m\n\u001b[31m    state.currentTotal  = { ...state.baseTotal };\u001b[39m\n\u001b[31m    state.lastOptimisation = opt.optimisation;\u001b[39m\n\u001b[31m    state.needsReoptimize  = false;   // fresh optimise — flag cleared\u001b[39m\n\u001b[31m    // Map legs to stops (route returned len = stops + 1 (round-trip) or stops + 1 (one-way))\u001b[39m\n\u001b[31m    if (Array.isArray(opt.legs) && opt.legs.length >= state.stops.length) {\u001b[39m\n\u001b[31m      state.stops.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = opt.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('Optimise failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m  showScreen('s-editor');\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction setProgress(done, total, label) {\u001b[39m\n\u001b[31m  const pct = total ? Math.round(done/total*100) : 0;\u001b[39m\n\u001b[31m  document.getElementById('prog-bar').style.width   = pct + '%';\u001b[39m\n\u001b[31m  document.getElementById('prog-count').textContent = `${done} / ${total}`;\u001b[39m\n\u001b[31m  document.getElementById('prog-label').textContent = label;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction locStr(loc) { return loc ? `${loc.lat},${loc.lng}` : null; }\u001b[39m\n\u001b[31mconst sleep = ms => new Promise(r => setTimeout(r, ms));·\u001b[39m\n\u001b[31m// ── Editor screen ───────────────────────────────────\u001b[39m\n\u001b[31mfunction renderEditor() {\u001b[39m\n\u001b[31m  restoreMapPref();\u001b[39m\n\u001b[31m  document.getElementById('origin-val').textContent = state.origin.text;\u001b[39m\n\u001b[31m  document.getElementById('editor-sub').textContent =\u001b[39m\n\u001b[31m    `${state.stops.filter(s => s.included).length} of ${state.stops.length} included` +\u001b[39m\n\u001b[31m    (state.lastOptimisation ? ` · ${state.lastOptimisation}` : '');\u001b[39m\n\u001b[31m  const cont = document.getElementById('tiles');\u001b[39m\n\u001b[31m  cont.innerHTML = '';\u001b[39m\n\u001b[31m  state.stops.forEach((s, idx) => cont.appendChild(renderTile(s, idx)));\u001b[39m\n\u001b[31m  if (_sortable) _sortable.destroy();\u001b[39m\n\u001b[31m  _sortable = Sortable.create(cont, {\u001b[39m\n\u001b[31m    handle: '.grip',\u001b[39m\n\u001b[31m    animation: 150,\u001b[39m\n\u001b[31m    onEnd: (evt) => {\u001b[39m\n\u001b[31m      const moved = state.stops.splice(evt.oldIndex, 1)[0];\u001b[39m\n\u001b[31m      state.stops.splice(evt.newIndex, 0, moved);\u001b[39m\n\u001b[31m      renderEditor();\u001b[39m\n\u001b[31m      scheduleRecalc();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  updateTotalsBar();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderTile(s, idx) {\u001b[39m\n\u001b[31m  const el = document.createElement('div');\u001b[39m\n\u001b[31m  el.className = 'tile' + (s.included ? '' : ' excluded') + (s.geocodeErr ? ' err' : '');\u001b[39m\n\u001b[31m  el.dataset.id = s.id;\u001b[39m\n\u001b[31m  const providerBadge = s.provider ? `<span class=\\\"badge-provider\\\">${escHtml(s.provider)}</span>` : '';\u001b[39m\n\u001b[31m  const leg = s.legDistance_m > 0\u001b[39m\n\u001b[31m    ? `${(s.legDistance_m/1000).toFixed(1)} km · ${Math.round(s.legDuration_s/60)} min from prev`\u001b[39m\n\u001b[31m    : '';\u001b[39m\n\u001b[31m  // Per UX rule: Name + City are the primary display. Address is shown\u001b[39m\n\u001b[31m  // smaller below as supporting context. Falls back gracefully when city\u001b[39m\n\u001b[31m  // is missing.\u001b[39m\n\u001b[31m  const cityLine = s.city || '—';\u001b[39m\n\u001b[31m  const addrLine = s.address && s.address !== s.city ? s.address : '';\u001b[39m\n\u001b[31m  el.innerHTML = `\u001b[39m\n\u001b[31m    <span class=\\\"grip\\\">≡</span>\u001b[39m\n\u001b[31m    <span class=\\\"ord\\\">${idx + 1}</span>\u001b[39m\n\u001b[31m    <div class=\\\"info\\\">\u001b[39m\n\u001b[31m      <div class=\\\"name\\\">${escHtml(s.name)} ${providerBadge}</div>\u001b[39m\n\u001b[31m      <div class=\\\"city\\\">${escHtml(cityLine)}</div>\u001b[39m\n\u001b[31m      ${addrLine ? `<div class=\\\"addr\\\">${escHtml(addrLine)}</div>` : ''}\u001b[39m\n\u001b[31m      ${leg ? `<div class=\\\"leg\\\">${escHtml(leg)}</div>` : ''}\u001b[39m\n\u001b[31m      ${s.geocodeErr ? `<div class=\\\"leg\\\" style=\\\"color:var(--r)\\\">⚠ ${escHtml(s.geocodeErr)}</div>` : ''}\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"incl ${s.included ? 'on' : 'off'}\\\" onclick=\\\"toggleInclude('${s.id}')\\\" title=\\\"Include / exclude\\\">\u001b[39m\n\u001b[31m      ${s.included ? '✓' : '✗'}\u001b[39m\n\u001b[31m    </button>`;\u001b[39m\n\u001b[31m  return el;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleInclude(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id); if (!s) return;\u001b[39m\n\u001b[31m  s.included = !s.included;\u001b[39m\n\u001b[31m  // The SET of stops just changed — the existing route may no longer be optimal.\u001b[39m\n\u001b[31m  // Surface a banner + pulse the Re-optim button so the user knows to refresh.\u001b[39m\n\u001b[31m  // recalcRoute still fires (debounced) to update the cost of the CURRENT order,\u001b[39m\n\u001b[31m  // but that number is just informational until the user re-optimises.\u001b[39m\n\u001b[31m  state.needsReoptimize = true;\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  scheduleRecalc();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction scheduleRecalc() {\u001b[39m\n\u001b[31m  clearTimeout(_recalcTimer);\u001b[39m\n\u001b[31m  document.getElementById('recalc').classList.add('show');\u001b[39m\n\u001b[31m  _recalcTimer = setTimeout(recalcRoute, 400);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function recalcRoute() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  if (!included.length) {\u001b[39m\n\u001b[31m    state.currentTotal = { distance_m: 0, duration_s: 0 };\u001b[39m\n\u001b[31m    document.getElementById('recalc').classList.remove('show');\u001b[39m\n\u001b[31m    updateTotalsBar();\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/route', {\u001b[39m\n\u001b[31m      origin:      locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:       included.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:   state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!r.ok) throw new Error(r.error || 'route failed');\u001b[39m\n\u001b[31m    state.currentTotal = { distance_m: r.total_distance_m, duration_s: r.total_duration_s };\u001b[39m\n\u001b[31m    // Map legs back to tiles (legs index 0 = origin→stop0, n = lastStop→dest)\u001b[39m\n\u001b[31m    if (Array.isArray(r.legs)) {\u001b[39m\n\u001b[31m      included.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = r.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    // Clear leg info on excluded stops\u001b[39m\n\u001b[31m    state.stops.filter(s => !s.included).forEach(s => { s.legDistance_m = 0; s.legDuration_s = 0; });\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Recalc failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  } finally {\u001b[39m\n\u001b[31m    document.getElementById('recalc').classList.remove('show');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateTotalsBar() {\u001b[39m\n\u001b[31m  // Totals now live ONLY in the top-actions card (the sticky bottom bar was\u001b[39m\n\u001b[31m  // removed after user feedback that it duplicated the top info 2026-06-04).\u001b[39m\n\u001b[31m  // The big-text element holds the inline recalc spinner as a child <span>.\u001b[39m\n\u001b[31m  const km  = (state.currentTotal.distance_m / 1000).toFixed(1);\u001b[39m\n\u001b[31m  const min = Math.round(state.currentTotal.duration_s / 60);\u001b[39m\n\u001b[31m  const hrs = Math.floor(min / 60), mm = min % 60;\u001b[39m\n\u001b[31m  const timeStr = hrs > 0 ? `${hrs}h ${mm}min` : `${mm} min`;\u001b[39m\n\u001b[31m  const totalsText = `${km} km · ${timeStr}`;\u001b[39m\n\u001b[31m  const bigEl  = document.getElementById('top-totals-big');\u001b[39m\n\u001b[31m  const recalc = document.getElementById('recalc');\u001b[39m\n\u001b[31m  bigEl.textContent = totalsText;\u001b[39m\n\u001b[31m  if (recalc) bigEl.appendChild(document.createTextNode(' '));\u001b[39m\n\u001b[31m  if (recalc) bigEl.appendChild(recalc);\u001b[39m\n\u001b[31m  // Delta vs base\u001b[39m\n\u001b[31m  const baseKm   = state.baseTotal.distance_m / 1000;\u001b[39m\n\u001b[31m  const curKm    = state.currentTotal.distance_m / 1000;\u001b[39m\n\u001b[31m  const deltaKm  = curKm - baseKm;\u001b[39m\n\u001b[31m  const deltaMin = Math.round((state.currentTotal.duration_s - state.baseTotal.duration_s) / 60);\u001b[39m\n\u001b[31m  const dT = document.getElementById('top-totals-delta');\u001b[39m\n\u001b[31m  let deltaText, deltaClass;\u001b[39m\n\u001b[31m  if (Math.abs(deltaKm) < 0.05) {\u001b[39m\n\u001b[31m    deltaText  = 'matches proposed route';\u001b[39m\n\u001b[31m    deltaClass = '';\u001b[39m\n\u001b[31m  } else {\u001b[39m\n\u001b[31m    const sign = deltaKm > 0 ? '+' : '';\u001b[39m\n\u001b[31m    deltaText  = `vs proposed: ${sign}${deltaKm.toFixed(1)} km · ${sign}${deltaMin} min`;\u001b[39m\n\u001b[31m    deltaClass = deltaKm < 0 ? 'better' : 'worse';\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  dT.textContent = deltaText;\u001b[39m\n\u001b[31m  dT.className   = 'top-totals-delta ' + deltaClass;·\u001b[39m\n\u001b[31m  // ── Re-optim-needed banner + pulse + stale styling ──\u001b[39m\n\u001b[31m  // Surfaces a clear message whenever the user has added/removed stops since\u001b[39m\n\u001b[31m  // the last /optimize call. The current-order cost is still shown (so the\u001b[39m\n\u001b[31m  // user sees \\\"what this order costs\\\"), but it's struck-through and the\u001b[39m\n\u001b[31m  // Re-optim button pulses to draw attention.\u001b[39m\n\u001b[31m  const needsReopt = !!state.needsReoptimize;\u001b[39m\n\u001b[31m  document.getElementById('reopt-needed').classList.toggle('show', needsReopt);\u001b[39m\n\u001b[31m  bigEl.classList.toggle('stale', needsReopt);\u001b[39m\n\u001b[31m  dT.classList.toggle('stale', needsReopt);\u001b[39m\n\u001b[31m  document.querySelector('.top-reopt')?.classList.toggle('pulse', needsReopt);·\u001b[39m\n\u001b[31m  // Re-render inline map preview to reflect current order/include flags.\u001b[39m\n\u001b[31m  // Debounced via setTimeout 0 so multiple updates in the same tick coalesce.\u001b[39m\n\u001b[31m  scheduleMapRender();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Inline route preview (Leaflet + free OSM tiles) ──────────────\u001b[39m\n\u001b[31m// Renders origin + stops + straight-line polyline inside the editor screen.\u001b[39m\n\u001b[31m// \\\"Straight-line\\\" because the proxy only returns leg distances/durations,\u001b[39m\n\u001b[31m// not the per-leg point arrays from TomTom — this preview is enough for\u001b[39m\n\u001b[31m// orientation; \\\"Open in Google Maps\\\" gives the user the real road geometry.\u001b[39m\n\u001b[31mlet _leafletMap   = null;\u001b[39m\n\u001b[31mlet _leafletLayer = null;\u001b[39m\n\u001b[31mlet _mapRenderTimer = null;·\u001b[39m\n\u001b[31mfunction ensureMap() {\u001b[39m\n\u001b[31m  if (_leafletMap) return _leafletMap;\u001b[39m\n\u001b[31m  if (typeof L === 'undefined') return null;   // CDN not ready yet\u001b[39m\n\u001b[31m  const el = document.getElementById('tour-map');\u001b[39m\n\u001b[31m  if (!el || !el.offsetParent) return null;    // container not visible yet\u001b[39m\n\u001b[31m  _leafletMap = L.map(el, { attributionControl: false, zoomControl: true, preferCanvas: true });\u001b[39m\n\u001b[31m  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18 })\u001b[39m\n\u001b[31m    .addTo(_leafletMap);\u001b[39m\n\u001b[31m  L.control.attribution({ position: 'bottomright', prefix: false })\u001b[39m\n\u001b[31m    .addAttribution('© OpenStreetMap contributors')\u001b[39m\n\u001b[31m    .addTo(_leafletMap);\u001b[39m\n\u001b[31m  return _leafletMap;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderMap() {\u001b[39m\n\u001b[31m  const map = ensureMap();\u001b[39m\n\u001b[31m  if (!map) return;\u001b[39m\n\u001b[31m  if (_leafletLayer) { map.removeLayer(_leafletLayer); _leafletLayer = null; }\u001b[39m\n\u001b[31m  const layers = [];\u001b[39m\n\u001b[31m  const linePts = [];\u001b[39m\n\u001b[31m  // Origin marker (always first in line)\u001b[39m\n\u001b[31m  if (state.origin?.location) {\u001b[39m\n\u001b[31m    const ll = [state.origin.location.lat, state.origin.location.lng];\u001b[39m\n\u001b[31m    layers.push(L.marker(ll, { icon: L.divIcon({\u001b[39m\n\u001b[31m      className: 'tour-pin origin', html: '<div>🏁</div>',\u001b[39m\n\u001b[31m      iconSize: [28, 28], iconAnchor: [14, 14],\u001b[39m\n\u001b[31m    })}).bindTooltip('Start: ' + (state.origin.text || 'origin'), { direction: 'top' }));\u001b[39m\n\u001b[31m    linePts.push(ll);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Stops in current visit order\u001b[39m\n\u001b[31m  let visitNum = 0;\u001b[39m\n\u001b[31m  state.stops.forEach(s => {\u001b[39m\n\u001b[31m    if (!s.location) return;\u001b[39m\n\u001b[31m    const ll = [s.location.lat, s.location.lng];\u001b[39m\n\u001b[31m    const included = s.included;\u001b[39m\n\u001b[31m    if (included) visitNum++;\u001b[39m\n\u001b[31m    layers.push(L.marker(ll, { icon: L.divIcon({\u001b[39m\n\u001b[31m      className: 'tour-pin' + (included ? '' : ' out'),\u001b[39m\n\u001b[31m      html: `<div>${included ? visitNum : '·'}</div>`,\u001b[39m\n\u001b[31m      iconSize: [26, 26], iconAnchor: [13, 13],\u001b[39m\n\u001b[31m    })}).bindTooltip(\u001b[39m\n\u001b[31m      (included ? `${visitNum}. ` : '✗ ') + s.name + (s.city ? ` · ${s.city}` : ''),\u001b[39m\n\u001b[31m      { direction: 'top' }\u001b[39m\n\u001b[31m    ));\u001b[39m\n\u001b[31m    if (included) linePts.push(ll);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  // Close the loop on round-trip\u001b[39m\n\u001b[31m  if (state.roundTrip && state.origin?.location) {\u001b[39m\n\u001b[31m    linePts.push([state.origin.location.lat, state.origin.location.lng]);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (linePts.length >= 2) {\u001b[39m\n\u001b[31m    layers.push(L.polyline(linePts, {\u001b[39m\n\u001b[31m      color: '#0ea5e9', weight: 3, opacity: 0.85, dashArray: '6 4',\u001b[39m\n\u001b[31m    }));\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  _leafletLayer = L.layerGroup(layers).addTo(map);\u001b[39m\n\u001b[31m  if (linePts.length) {\u001b[39m\n\u001b[31m    try { map.fitBounds(L.latLngBounds(linePts), { padding: [30, 30], maxZoom: 13 }); }\u001b[39m\n\u001b[31m    catch (_) {}\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction scheduleMapRender() {\u001b[39m\n\u001b[31m  if (_mapRenderTimer) return;\u001b[39m\n\u001b[31m  _mapRenderTimer = setTimeout(() => { _mapRenderTimer = null; renderMap(); }, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleMap() {\u001b[39m\n\u001b[31m  const wrap = document.getElementById('map-wrap');\u001b[39m\n\u001b[31m  const tog  = document.getElementById('map-toggle');\u001b[39m\n\u001b[31m  const open = !wrap.classList.toggle('collapsed');\u001b[39m\n\u001b[31m  tog.textContent = open ? '▾' : '▴';\u001b[39m\n\u001b[31m  try { localStorage.setItem('tour-pg-map-open', open ? '1' : '0'); } catch (_) {}\u001b[39m\n\u001b[31m  if (open && _leafletMap) {\u001b[39m\n\u001b[31m    // After expansion the container size changed — Leaflet must recompute\u001b[39m\n\u001b[31m    setTimeout(() => { _leafletMap.invalidateSize(); renderMap(); }, 220);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction restoreMapPref() {\u001b[39m\n\u001b[31m  // Default collapsed — user must explicitly expand once for it to stick open.\u001b[39m\n\u001b[31m  let open = false;\u001b[39m\n\u001b[31m  try { open = localStorage.getItem('tour-pg-map-open') === '1'; } catch (_) {}\u001b[39m\n\u001b[31m  const wrap = document.getElementById('map-wrap');\u001b[39m\n\u001b[31m  const tog  = document.getElementById('map-toggle');\u001b[39m\n\u001b[31m  wrap.classList.toggle('collapsed', !open);\u001b[39m\n\u001b[31m  tog.textContent = open ? '▾' : '▴';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Origin edit / mid-tour replan ────────────────────\u001b[39m\n\u001b[31mfunction openOriginEdit() {\u001b[39m\n\u001b[31m  document.getElementById('origin-edit').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('inp-new-origin').value = state.origin.text;\u001b[39m\n\u001b[31m  document.getElementById('inp-new-origin').focus();\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction closeOriginEdit() {\u001b[39m\n\u001b[31m  document.getElementById('origin-edit').style.display = 'none';\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31masync function applyOriginAndReplan() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('inp-new-origin').value.trim();\u001b[39m\n\u001b[31m  if (!txt) return toast('Enter an origin', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: txt });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin = { text: txt, location: og.location, provider: og.provider };\u001b[39m\n\u001b[31m    closeOriginEdit();\u001b[39m\n\u001b[31m    await replanAll();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Origin failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function replanAll() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  if (!included.length) return toast('No stops included', 'err');\u001b[39m\n\u001b[31m  toast('Optimising…', 'ok');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const opt = await api('/optimize', {\u001b[39m\n\u001b[31m      origin:      locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:       included.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:   state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!opt.ok) throw new Error(opt.error || 'optimise failed');\u001b[39m\n\u001b[31m    const order = opt.waypoint_order || included.map((_, i) => i);\u001b[39m\n\u001b[31m    const reordered = order.map(i => included[i]);\u001b[39m\n\u001b[31m    // KEEP EXCLUDED STOPS IN THEIR EXISTING POSITIONS (per user feedback\u001b[39m\n\u001b[31m    // 2026-06-04). Excluded slots stay put; reordered-included stops fill\u001b[39m\n\u001b[31m    // the gaps. Previously appended at end (confusing) → then dropped\u001b[39m\n\u001b[31m    // (lossy) → final: stay where they are. User can re-include to put\u001b[39m\n\u001b[31m    // them back into the optimised route.\u001b[39m\n\u001b[31m    const newStops = [];\u001b[39m\n\u001b[31m    let r = 0;\u001b[39m\n\u001b[31m    for (const s of state.stops) {\u001b[39m\n\u001b[31m      if (s.included) newStops.push(reordered[r++]);\u001b[39m\n\u001b[31m      else            newStops.push(s);\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    state.stops = newStops;\u001b[39m\n\u001b[31m    state.proposedOrder = state.stops.map(s => s.id);\u001b[39m\n\u001b[31m    state.baseTotal     = { distance_m: opt.total_distance_m, duration_s: opt.total_duration_s };\u001b[39m\n\u001b[31m    state.currentTotal  = { ...state.baseTotal };\u001b[39m\n\u001b[31m    state.lastOptimisation = opt.optimisation;\u001b[39m\n\u001b[31m    state.needsReoptimize  = false;   // re-optim done — banner cleared\u001b[39m\n\u001b[31m    if (Array.isArray(opt.legs)) {\u001b[39m\n\u001b[31m      reordered.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = opt.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Re-optimised ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Replan failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Share / export ──────────────────────────────────\u001b[39m\n\u001b[31mfunction openShare() {\u001b[39m\n\u001b[31m  const km   = (state.currentTotal.distance_m / 1000).toFixed(1);\u001b[39m\n\u001b[31m  const min  = Math.round(state.currentTotal.duration_s / 60);\u001b[39m\n\u001b[31m  const hrs  = Math.floor(min / 60), mm = min % 60;\u001b[39m\n\u001b[31m  const timeStr = hrs > 0 ? `${hrs}h ${mm}min` : `${mm} min`;\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  let txt = `🚗 Tour plan (${new Date().toLocaleDateString('en-IN')})\\\\n`;\u001b[39m\n\u001b[31m  txt += `From: ${state.origin.text}\\\\n`;\u001b[39m\n\u001b[31m  txt += `${included.length} stops · ${km} km · ${timeStr}\\\\n`;\u001b[39m\n\u001b[31m  if (state.roundTrip) txt += `Round-trip\\\\n\\\\n`;\u001b[39m\n\u001b[31m  else                  txt += `One-way → ${state.destination || included[included.length-1]?.address || ''}\\\\n\\\\n`;\u001b[39m\n\u001b[31m  included.forEach((s, i) => {\u001b[39m\n\u001b[31m    const legKm  = (s.legDistance_m/1000).toFixed(1);\u001b[39m\n\u001b[31m    const legMin = Math.round(s.legDuration_s/60);\u001b[39m\n\u001b[31m    txt += `${i+1}. ${s.name}`;\u001b[39m\n\u001b[31m    if (s.city) txt += ` · ${s.city}`;\u001b[39m\n\u001b[31m    if (s.legDistance_m > 0) txt += `  (+${legKm}km · ${legMin}min)`;\u001b[39m\n\u001b[31m    if (s.phone) txt += `  ${s.phone}`;\u001b[39m\n\u001b[31m    txt += `\\\\n`;\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (state.roundTrip) txt += `↩ Return to ${state.origin.text}`;\u001b[39m\n\u001b[31m  document.getElementById('share-summary').textContent = txt;\u001b[39m\n\u001b[31m  document.getElementById('modal-share').classList.add('show');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction closeShare() {\u001b[39m\n\u001b[31m  document.getElementById('modal-share').classList.remove('show');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction shareWhatsApp() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('share-summary').textContent;\u001b[39m\n\u001b[31m  const url = `https://wa.me/?text=${encodeURIComponent(txt)}`;\u001b[39m\n\u001b[31m  window.open(url, '_blank');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Hands off the optimised tour to Google Maps Directions (free, zero infra).\u001b[39m\n\u001b[31m// Uses path-style URL `dir/A/B/C/...` because it accepts many more stops than\u001b[39m\n\u001b[31m// the `api=1` Directions URL (which Google caps at 9 waypoints). Each segment\u001b[39m\n\u001b[31m// is \\\"lat,lng\\\" for geocoded points (most reliable). If the user picked a\u001b[39m\n\u001b[31m// text destination on a one-way tour we URL-encode it as the last segment.\u001b[39m\n\u001b[31mfunction openInGoogleMaps() {\u001b[39m\n\u001b[31m  if (!state.origin?.location) { toast('Origin not yet geocoded', 'err'); return; }\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included && s.location);\u001b[39m\n\u001b[31m  if (included.length === 0)   { toast('No included stops to view', 'err'); return; }\u001b[39m\n\u001b[31m  const fmt = (loc) => `${loc.lat.toFixed(6)},${loc.lng.toFixed(6)}`;\u001b[39m\n\u001b[31m  const segs = [ fmt(state.origin.location) ];\u001b[39m\n\u001b[31m  included.forEach(s => segs.push(fmt(s.location)));\u001b[39m\n\u001b[31m  if (state.roundTrip) {\u001b[39m\n\u001b[31m    segs.push(fmt(state.origin.location));\u001b[39m\n\u001b[31m  } else if (state.destination) {\u001b[39m\n\u001b[31m    segs.push(encodeURIComponent(state.destination));\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // travelmode and dir_action=navigate auto-open in driving directions\u001b[39m\n\u001b[31m  const url = `https://www.google.com/maps/dir/${segs.join('/')}/?travelmode=driving`;\u001b[39m\n\u001b[31m  window.open(url, '_blank', 'noopener');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function copyText() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('share-summary').textContent;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await navigator.clipboard.writeText(txt);\u001b[39m\n\u001b[31m    toast('Copied ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Copy failed', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction downloadCsv() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  const rows = [['#','Name','City','Address','Phone','Lat','Lng','Leg km','Leg min','Provider']];\u001b[39m\n\u001b[31m  included.forEach((s, i) => {\u001b[39m\n\u001b[31m    rows.push([\u001b[39m\n\u001b[31m      i + 1,\u001b[39m\n\u001b[31m      s.name, s.city, s.address, s.phone,\u001b[39m\n\u001b[31m      s.location?.lat ?? '', s.location?.lng ?? '',\u001b[39m\n\u001b[31m      (s.legDistance_m/1000).toFixed(2),\u001b[39m\n\u001b[31m      Math.round(s.legDuration_s/60),\u001b[39m\n\u001b[31m      s.provider || '',\u001b[39m\n\u001b[31m    ]);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  const csv = rows.map(r => r.map(c => `\\\"${String(c).replace(/\\\"/g, '\\\"\\\"')}\\\"`).join(',')).join('\\\\n');\u001b[39m\n\u001b[31m  const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });\u001b[39m\n\u001b[31m  const url = URL.createObjectURL(blob);\u001b[39m\n\u001b[31m  const a = document.createElement('a');\u001b[39m\n\u001b[31m  a.href = url; a.download = `tour-${new Date().toISOString().slice(0,10)}.csv`;\u001b[39m\n\u001b[31m  document.body.appendChild(a); a.click(); a.remove();\u001b[39m\n\u001b[31m  URL.revokeObjectURL(url);\u001b[39m\n\u001b[31m  toast('CSV downloaded ✓', 'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction saveLocal() {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    localStorage.setItem('tour-pg-saved', JSON.stringify({\u001b[39m\n\u001b[31m      saved_at: new Date().toISOString(),\u001b[39m\n\u001b[31m      state,\u001b[39m\n\u001b[31m    }));\u001b[39m\n\u001b[31m    toast('Saved ✓', 'ok');\u001b[39m\n\u001b[31m    document.getElementById('btn-load').style.display = '';\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed (storage full?)', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction loadSaved() {\u001b[39m\n\u001b[31m  const raw = localStorage.getItem('tour-pg-saved');\u001b[39m\n\u001b[31m  if (!raw) return toast('No saved tour', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const { state: s } = JSON.parse(raw);\u001b[39m\n\u001b[31m    state = s;\u001b[39m\n\u001b[31m    showScreen('s-editor');\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Loaded ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Load failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Navigation ──────────────────────────────────────\u001b[39m\n\u001b[31mfunction confirmBack() {\u001b[39m\n\u001b[31m  if (!confirm('Start a new tour? Current state will be cleared (use Save first if you want to keep it).')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m  document.getElementById('parsed-preview').style.display = 'none';\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = true;\u001b[39m\n\u001b[31m  document.getElementById('excel-file').value = '';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showScreen(id) {\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById(id).classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Utils ───────────────────────────────────────────\u001b[39m\n\u001b[31mfunction escHtml(s) { if (s == null) return ''; return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\\\"/g,'&quot;'); }\u001b[39m\n\u001b[31mlet _toastTimer;\u001b[39m\n\u001b[31mfunction toast(msg, type='') {\u001b[39m\n\u001b[31m  const el = document.getElementById('toast');\u001b[39m\n\u001b[31m  el.textContent = msg; el.className = 'show ' + type;\u001b[39m\n\u001b[31m  clearTimeout(_toastTimer);\u001b[39m\n\u001b[31m  _toastTimer = setTimeout(() => el.className = '', 3200);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction registerSW() {\u001b[39m\n\u001b[31m  if (!('serviceWorker' in navigator)) return;\u001b[39m\n\u001b[31m  const sw = `\u001b[39m\n\u001b[31mconst C='${CACHE_VER}';\u001b[39m\n\u001b[31mself.addEventListener('install', e => self.skipWaiting());\u001b[39m\n\u001b[31mself.addEventListener('activate', e => {\u001b[39m\n\u001b[31m  e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== C).map(k => caches.delete(k)))));\u001b[39m\n\u001b[31m  self.clients.claim();\u001b[39m\n\u001b[31m});\u001b[39m\n\u001b[31mself.addEventListener('fetch', e => {\u001b[39m\n\u001b[31m  if (e.request.method !== 'GET') return;\u001b[39m\n\u001b[31m  const url = new URL(e.request.url);\u001b[39m\n\u001b[31m  if (url.pathname.startsWith('/tour-pg-proxy')) return;\u001b[39m\n\u001b[31m  if (url.pathname.startsWith('/tour-pg/')) {\u001b[39m\n\u001b[31m    e.respondWith(\u001b[39m\n\u001b[31m      caches.open(C).then(c =>\u001b[39m\n\u001b[31m        c.match(e.request).then(r => r || fetch(e.request).then(f => { c.put(e.request, f.clone()); return f; }))\u001b[39m\n\u001b[31m      )\u001b[39m\n\u001b[31m    );\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m});`;\u001b[39m\n\u001b[31m  const blob = new Blob([sw], { type: 'application/javascript' });\u001b[39m\n\u001b[31m  navigator.serviceWorker.register(URL.createObjectURL(blob), { scope: '/tour-pg/' }).catch(() => {});\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31minit();\u001b[39m\n\u001b[31m</script>·\u001b[39m\n\u001b[31m</body>\u001b[39m\n\u001b[31m</html>\u001b[39m\n\u001b[31m\"\u001b[39m\n    at /var/www/360lm/tests/tour_pg.spec.js:54:18",
                        "location": {
                          "file": "/var/www/360lm/tests/tour_pg.spec.js",
                          "column": 18,
                          "line": 54
                        },
                        "snippet": "  52 |     const ctx = await request.newContext();\n  53 |     const html = await (await ctx.get(BASE_URL)).text();\n> 54 |     expect(html).toContain('tour-pg-v10');\n     |                  ^\n  55 |     expect(html).toContain('Tour Playground');\n  56 |     await ctx.dispose();\n  57 |   });"
                      },
                      "errors": [
                        {
                          "location": {
                            "file": "/var/www/360lm/tests/tour_pg.spec.js",
                            "column": 18,
                            "line": 54
                          },
                          "message": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoContain\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // indexOf\u001b[22m\n\nExpected substring: \u001b[32m\"tour-pg-v10\"\u001b[39m\nReceived string:    \u001b[31m\"<!DOCTYPE html>\u001b[39m\n\u001b[31m<html lang=\\\"en\\\">\u001b[39m\n\u001b[31m<head>\u001b[39m\n\u001b[31m<meta charset=\\\"UTF-8\\\">\u001b[39m\n\u001b[31m<meta name=\\\"viewport\\\" content=\\\"width=device-width,initial-scale=1,viewport-fit=cover\\\">\u001b[39m\n\u001b[31m<title>Tour Planner Playground</title>\u001b[39m\n\u001b[31m<link rel=\\\"manifest\\\" href=\\\"manifest.json\\\">\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"/shared/safe-bottom.css\\\">\u001b[39m\n\u001b[31m<meta name=\\\"theme-color\\\" content=\\\"#0a0a0a\\\">\u001b[39m\n\u001b[31m<link rel=\\\"preconnect\\\" href=\\\"https://fonts.googleapis.com\\\">\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&display=swap\\\">\u001b[39m\n\u001b[31m<script src=\\\"https://cdn.sheetjs.com/xlsx-0.20.2/package/dist/xlsx.full.min.js\\\"></script>\u001b[39m\n\u001b[31m<script src=\\\"https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js\\\"></script>\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.css\\\" crossorigin=\\\"\\\">\u001b[39m\n\u001b[31m<script src=\\\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.js\\\" crossorigin=\\\"\\\" defer></script>\u001b[39m\n\u001b[31m<style>\u001b[39m\n\u001b[31m:root{\u001b[39m\n\u001b[31m  --bg:#0a0a0a;--card:#141414;--card2:#1a1a1a;--border:#262626;--text:#f5f5f5;--muted:#a3a3a3;\u001b[39m\n\u001b[31m  --accent:#0ea5e9;--accent2:#0284c7;--g:#22c55e;--r:#ef4444;--y:#eab308;--p:#a855f7;\u001b[39m\n\u001b[31m  --rad:12px;--rads:8px;--bnav-h:64px;\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31m*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}\u001b[39m\n\u001b[31mbody{background:var(--bg);color:var(--text);font-family:'DM Sans',sans-serif;min-height:100vh;overscroll-behavior-y:none}\u001b[39m\n\u001b[31mbutton{font-family:inherit;cursor:pointer;border:none;background:none;color:inherit}\u001b[39m\n\u001b[31ma{color:inherit;text-decoration:none}·\u001b[39m\n\u001b[31m.scr{display:none;flex-direction:column;min-height:100vh;padding-bottom:calc(var(--bnav-h) + env(safe-area-inset-bottom))}\u001b[39m\n\u001b[31m.scr.active{display:flex}·\u001b[39m\n\u001b[31m.hdr{background:var(--card);border-bottom:1px solid var(--border);padding:12px 14px;display:flex;align-items:center;gap:8px;position:sticky;top:0;z-index:10}\u001b[39m\n\u001b[31m.hdr h1{font-size:15px;font-weight:800;flex:1;min-width:0}\u001b[39m\n\u001b[31m.hdr-sub{font-size:11px;color:var(--muted)}\u001b[39m\n\u001b[31m.hdr-btn{background:rgba(255,255,255,.08);border:1px solid var(--border);font-size:12px;font-weight:700;padding:5px 10px;border-radius:14px;cursor:pointer;white-space:nowrap}\u001b[39m\n\u001b[31m.hdr-btn:active{background:rgba(255,255,255,.16)}\u001b[39m\n\u001b[31m.user-pill{background:rgba(255,255,255,.1);border:1px solid var(--border);color:var(--muted);font-size:10px;font-weight:600;padding:3px 8px;border-radius:11px}\u001b[39m\n\u001b[31m.user-pill b{color:var(--text)}·\u001b[39m\n\u001b[31m.body{flex:1;padding:14px;display:flex;flex-direction:column;gap:12px}·\u001b[39m\n\u001b[31m/* ── editor: header + controls + route preview stay put; only the stops list scrolls ── */\u001b[39m\n\u001b[31m#s-editor.scr{height:100vh;height:100dvh;min-height:0;padding-bottom:0;overflow:hidden}\u001b[39m\n\u001b[31m.editor-body{flex:1;min-height:0;display:flex;flex-direction:column;padding:14px 14px 0}\u001b[39m\n\u001b[31m.editor-fixed{display:flex;flex-direction:column;gap:12px;flex-shrink:0}\u001b[39m\n\u001b[31m.editor-scroll{flex:1;min-height:0;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:2px 0 calc(32px + env(safe-area-inset-bottom));margin-top:6px;scrollbar-gutter:stable}\u001b[39m\n\u001b[31m/* visible scrollbar so it's obvious the stops list is independently scrollable */\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar{width:8px}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-thumb{background:#3a3a3a;border-radius:4px}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-thumb:active{background:#525252}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-track{background:transparent}·\u001b[39m\n\u001b[31m/* ── column-mapping table (parsed preview): scroll horizontally if it doesn't fit ── */\u001b[39m\n\u001b[31m.map-table-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--border);border-radius:6px}\u001b[39m\n\u001b[31m.map-table{border-collapse:collapse;table-layout:fixed;width:100%;min-width:440px;font-size:11px}\u001b[39m\n\u001b[31m.map-table th{text-align:left;padding:6px 8px;color:var(--muted);font-weight:800;font-size:9.5px;text-transform:uppercase;letter-spacing:.3px;background:var(--card2);position:sticky;top:0}\u001b[39m\n\u001b[31m.map-table td{padding:6px 8px;vertical-align:middle}\u001b[39m\n\u001b[31m.map-table tr+tr td,.map-table tbody tr td{border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.map-table col.c-src{width:40%}.map-table col.c-field{width:28%}.map-table col.c-sample{width:32%}\u001b[39m\n\u001b[31m.map-table select{background:#0b1220;border:1px solid var(--border);color:var(--text);padding:6px 7px;border-radius:4px;font-size:11px;width:100%}\u001b[39m\n\u001b[31m.map-field{font-weight:800;color:var(--text);white-space:nowrap}\u001b[39m\n\u001b[31m.map-sample{color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}·\u001b[39m\n\u001b[31m/* ── upload ── */\u001b[39m\n\u001b[31m.dropzone{background:var(--card);border:2px dashed var(--border);border-radius:var(--rad);padding:28px 16px;text-align:center;cursor:pointer;transition:.15s}\u001b[39m\n\u001b[31m.dropzone:hover,.dropzone.drag{border-color:var(--accent);background:#0e1726}\u001b[39m\n\u001b[31m.dropzone .ico{font-size:42px;margin-bottom:8px}\u001b[39m\n\u001b[31m.dropzone .ttl{font-weight:800;font-size:15px;margin-bottom:4px}\u001b[39m\n\u001b[31m.dropzone .sub{font-size:11.5px;color:var(--muted);line-height:1.4}\u001b[39m\n\u001b[31m.dropzone input[type=file]{display:none}·\u001b[39m\n\u001b[31m.section-title{font-size:11px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.4px}·\u001b[39m\n\u001b[31m.form-card{background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:12px;display:flex;flex-direction:column;gap:10px}\u001b[39m\n\u001b[31m.field{display:flex;flex-direction:column;gap:3px}\u001b[39m\n\u001b[31m.field label{font-size:11px;font-weight:600;color:var(--muted)}\u001b[39m\n\u001b[31m.field input,.field select{background:#0b1220;border:1px solid var(--border);border-radius:6px;color:var(--text);padding:9px 11px;font-size:13.5px;font-family:inherit;outline:none}\u001b[39m\n\u001b[31m.field input:focus,.field select:focus{border-color:var(--accent)}\u001b[39m\n\u001b[31m.toggle{display:flex;align-items:center;gap:8px;font-size:13px}\u001b[39m\n\u001b[31m.toggle input{accent-color:var(--accent);width:18px;height:18px}·\u001b[39m\n\u001b[31m.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;border-radius:var(--rads);padding:12px 14px;font-size:14px;font-weight:800;cursor:pointer;width:100%;transition:.12s}\u001b[39m\n\u001b[31m.btn-p{background:var(--accent);color:#fff}\u001b[39m\n\u001b[31m.btn-p:active{background:var(--accent2)}\u001b[39m\n\u001b[31m.btn-p:disabled{opacity:.4;cursor:default}\u001b[39m\n\u001b[31m.btn-s{background:var(--card);color:var(--text);border:1px solid var(--border)}\u001b[39m\n\u001b[31m.btn-r{background:rgba(239,68,68,.15);color:#fca5a5;border:1px solid rgba(239,68,68,.3)}·\u001b[39m\n\u001b[31m/* ── progress ── */\u001b[39m\n\u001b[31m.progress{background:#0b1220;border-radius:8px;height:10px;overflow:hidden;border:1px solid var(--border)}\u001b[39m\n\u001b[31m.progress-bar{height:100%;background:linear-gradient(90deg,var(--accent),var(--g));transition:.2s}·\u001b[39m\n\u001b[31m/* ── tiles ── */\u001b[39m\n\u001b[31m.origin-pill{background:linear-gradient(135deg,#0c2e3a,#062a3a);border:1px solid var(--accent);border-radius:var(--rad);padding:11px 13px;display:flex;align-items:center;gap:10px}\u001b[39m\n\u001b[31m.origin-pill .ico{font-size:18px}\u001b[39m\n\u001b[31m.origin-pill .meta{flex:1;min-width:0}\u001b[39m\n\u001b[31m.origin-pill .lbl{font-size:10.5px;color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.4px}\u001b[39m\n\u001b[31m.origin-pill .val{font-size:13.5px;font-weight:700;margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.origin-pill button{background:rgba(255,255,255,.08);border-radius:14px;padding:5px 10px;font-size:11px;font-weight:700;color:var(--accent)}·\u001b[39m\n\u001b[31m.tiles{display:flex;flex-direction:column;gap:6px}\u001b[39m\n\u001b[31m.tile{background:var(--card);border:1px solid var(--border);border-radius:var(--rads);padding:9px 10px;display:flex;align-items:center;gap:10px;transition:.12s;touch-action:none}\u001b[39m\n\u001b[31m.tile.sortable-ghost{opacity:.35;background:var(--card2)}\u001b[39m\n\u001b[31m.tile.sortable-chosen{transform:scale(.98);box-shadow:0 4px 20px rgba(14,165,233,.25)}\u001b[39m\n\u001b[31m.tile.excluded{opacity:.4;background:#0c0c0c}\u001b[39m\n\u001b[31m.tile.excluded .name,.tile.excluded .city{text-decoration:line-through}\u001b[39m\n\u001b[31m.tile.err{border-color:var(--r);background:#1f0a0a}\u001b[39m\n\u001b[31m.tile .grip{font-size:18px;color:var(--muted);cursor:grab;padding:4px;line-height:1}\u001b[39m\n\u001b[31m.tile .grip:active{cursor:grabbing}\u001b[39m\n\u001b[31m.tile .ord{background:var(--accent);color:#fff;width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11.5px;font-weight:800;flex-shrink:0}\u001b[39m\n\u001b[31m.tile.excluded .ord{background:#444}\u001b[39m\n\u001b[31m.tile .info{flex:1;min-width:0}\u001b[39m\n\u001b[31m.tile .name{font-size:13.5px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .city{font-size:11.5px;color:#cbd5e1;font-weight:600;margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .addr{font-size:9.5px;color:var(--muted);margin-top:1px;line-height:1.3;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-style:italic;opacity:.75}\u001b[39m\n\u001b[31m.tile .leg{font-size:10.5px;color:var(--accent2);font-weight:600;margin-top:2px}\u001b[39m\n\u001b[31m.tile.excluded .leg{display:none}\u001b[39m\n\u001b[31m.tile .badge-provider{display:inline-block;font-size:8.5px;padding:1px 5px;border-radius:8px;background:rgba(255,255,255,.08);color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.3px;margin-left:4px;vertical-align:middle}\u001b[39m\n\u001b[31m.tile .incl{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:50%;border:2px solid var(--border);font-size:16px;font-weight:800}\u001b[39m\n\u001b[31m.tile .incl.on{background:var(--g);border-color:var(--g);color:#fff}\u001b[39m\n\u001b[31m.tile .incl.off{background:#0c0c0c;color:var(--r);border-color:var(--r)}·\u001b[39m\n\u001b[31m/* ── prominent top action bar (KM, Share, Re-optimise) ── */\u001b[39m\n\u001b[31m.top-actions{background:linear-gradient(135deg,#0c2e3a,#08111c);border:1px solid var(--accent);border-radius:var(--rad);padding:10px;display:grid;grid-template-columns:1fr auto auto;gap:8px;align-items:center;margin-bottom:6px}\u001b[39m\n\u001b[31m.top-totals{min-width:0}\u001b[39m\n\u001b[31m.top-totals-big{font-size:18px;font-weight:900;line-height:1.05;letter-spacing:.3px}\u001b[39m\n\u001b[31m.top-totals-delta{font-size:11px;color:var(--muted);margin-top:2px}\u001b[39m\n\u001b[31m.top-totals-delta.better{color:var(--g)}\u001b[39m\n\u001b[31m.top-totals-delta.worse{color:var(--y)}\u001b[39m\n\u001b[31m.top-actions button{padding:10px 12px;font-size:12px;font-weight:800;border-radius:8px;border:none;cursor:pointer;display:flex;align-items:center;gap:5px;white-space:nowrap}\u001b[39m\n\u001b[31m.top-share{background:#f59e0b;color:#000}\u001b[39m\n\u001b[31m.top-share:active{background:#d97706}\u001b[39m\n\u001b[31m.top-reopt{background:var(--accent);color:#fff}\u001b[39m\n\u001b[31m.top-reopt:active{background:var(--accent2)}\u001b[39m\n\u001b[31m/* ── Re-optimisation needed (after include/exclude toggle) ── */\u001b[39m\n\u001b[31m.reopt-needed{font-size:11px;font-weight:700;color:#fbbf24;background:rgba(234,179,8,.12);border-left:3px solid #fbbf24;padding:5px 8px;border-radius:4px;margin-top:5px;display:none;line-height:1.3}\u001b[39m\n\u001b[31m.reopt-needed.show{display:block}·\u001b[39m\n\u001b[31m/* ── inline route preview (Leaflet + OSM) ── */\u001b[39m\n\u001b[31m.map-wrap{margin:8px 0 0;background:var(--card);border:1px solid var(--border);border-radius:var(--rads);overflow:hidden}\u001b[39m\n\u001b[31m.map-hdr{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;font-size:12px;color:var(--muted);background:var(--card2);border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.map-hdr .ttl{display:flex;align-items:center;gap:6px;font-weight:600}\u001b[39m\n\u001b[31m.map-hdr .toggle{font-size:14px;color:var(--text);background:none;border:none;cursor:pointer;padding:0 4px}\u001b[39m\n\u001b[31m#tour-map{height:260px;width:100%;background:#1a1a1a;transition:height .2s ease}\u001b[39m\n\u001b[31m.map-wrap.collapsed #tour-map{height:0}\u001b[39m\n\u001b[31m.map-wrap.collapsed .map-hdr{border-bottom:none}\u001b[39m\n\u001b[31m.tour-pin{background:transparent;border:none}\u001b[39m\n\u001b[31m.tour-pin > div{background:#0ea5e9;color:#fff;border-radius:50%;width:26px;height:26px;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:12px;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,.45);font-family:'DM Sans',sans-serif}\u001b[39m\n\u001b[31m.tour-pin.origin > div{background:#22c55e;font-size:14px}\u001b[39m\n\u001b[31m.tour-pin.out > div{background:#6b7280;opacity:.55}\u001b[39m\n\u001b[31m.leaflet-tooltip{background:#141414;color:#f5f5f5;border:1px solid #262626;font-size:11px;padding:3px 6px;font-family:'DM Sans',sans-serif;box-shadow:0 1px 6px rgba(0,0,0,.4)}\u001b[39m\n\u001b[31m.leaflet-tooltip-top:before,.leaflet-tooltip-bottom:before,.leaflet-tooltip-left:before,.leaflet-tooltip-right:before{border-top-color:#262626;border-bottom-color:#262626;border-left-color:#262626;border-right-color:#262626}\u001b[39m\n\u001b[31m.leaflet-control-attribution{background:rgba(20,20,20,.85)!important;color:#a3a3a3!important;font-size:9px!important}\u001b[39m\n\u001b[31m.leaflet-control-attribution a{color:#0ea5e9!important}\u001b[39m\n\u001b[31m.top-reopt.pulse{animation:pulseReopt 1.4s ease-in-out infinite;box-shadow:0 0 0 0 rgba(14,165,233,.6)}\u001b[39m\n\u001b[31m@keyframes pulseReopt{0%{box-shadow:0 0 0 0 rgba(14,165,233,.7)}50%{box-shadow:0 0 0 8px rgba(14,165,233,0)}100%{box-shadow:0 0 0 0 rgba(14,165,233,0)}}\u001b[39m\n\u001b[31m/* Totals dim when stale to make it visually obvious */\u001b[39m\n\u001b[31m.top-totals-big.stale, .top-totals-delta.stale{opacity:.5;text-decoration:line-through;text-decoration-color:rgba(251,191,36,.6)}·\u001b[39m\n\u001b[31m/* recalc spinner that appears inline next to the top totals during recompute */\u001b[39m\n\u001b[31m.recalc{display:none;font-size:11px;color:var(--accent);margin-left:6px;vertical-align:middle}\u001b[39m\n\u001b[31m.recalc.show{display:inline-block;animation:spin 1s linear infinite}\u001b[39m\n\u001b[31m@keyframes spin{to{transform:rotate(360deg)}}·\u001b[39m\n\u001b[31m/* ── share modal ── */\u001b[39m\n\u001b[31m.modal{position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:200;display:none;align-items:flex-end;justify-content:center}\u001b[39m\n\u001b[31m.modal.show{display:flex}\u001b[39m\n\u001b[31m.modal-box{background:var(--card);border-radius:18px 18px 0 0;width:100%;max-height:85vh;overflow-y:auto;padding:16px 14px calc(16px + env(safe-area-inset-bottom));border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.modal-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\u001b[39m\n\u001b[31m.modal-ttl{font-size:15px;font-weight:800}\u001b[39m\n\u001b[31m.modal-close{font-size:24px;color:var(--muted);background:none;border:none;cursor:pointer}·\u001b[39m\n\u001b[31m/* ── toast ── */\u001b[39m\n\u001b[31m#toast{position:fixed;bottom:calc(var(--bnav-h)+16px);left:12px;right:12px;z-index:1000;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:11px 14px;font-size:13px;font-weight:600;text-align:center;transform:translateY(150px);opacity:0;transition:.25s;pointer-events:none}\u001b[39m\n\u001b[31m#toast.show{transform:translateY(0);opacity:1}\u001b[39m\n\u001b[31m#toast.ok{background:rgba(34,197,94,.18);border-color:var(--g);color:#86efac}\u001b[39m\n\u001b[31m#toast.err{background:rgba(239,68,68,.18);border-color:var(--r);color:#fca5a5}·\u001b[39m\n\u001b[31m/* ── helpers ── */\u001b[39m\n\u001b[31m.spinner{display:inline-block;width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;vertical-align:middle}·\u001b[39m\n\u001b[31m.empty{text-align:center;padding:30px 16px;color:var(--muted);font-size:12.5px}·\u001b[39m\n\u001b[31mtextarea{background:#0b1220;border:1px solid var(--border);color:var(--text);padding:10px;font-family:inherit;font-size:12px;border-radius:6px;resize:vertical;outline:none}·\u001b[39m\n\u001b[31m.kbd{background:rgba(255,255,255,.06);padding:1px 6px;border-radius:4px;font-family:monospace;font-size:11px;color:var(--muted)}\u001b[39m\n\u001b[31m</style>\u001b[39m\n\u001b[31m</head>\u001b[39m\n\u001b[31m<body>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: UPLOAD ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-upload\\\" class=\\\"scr active\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗺 Tour Playground</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Excel → optimised route → tweak</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"loadSaved()\\\" id=\\\"btn-load\\\" style=\\\"display:none\\\">↻ Load</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"location.href='/hub/'\\\">🏡 Hub</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <label class=\\\"dropzone\\\" id=\\\"dropzone\\\" for=\\\"excel-file\\\">\u001b[39m\n\u001b[31m      <input type=\\\"file\\\" id=\\\"excel-file\\\" accept=\\\".xlsx,.xls,.csv\\\">\u001b[39m\n\u001b[31m      <div class=\\\"ico\\\">📊</div>\u001b[39m\n\u001b[31m      <div class=\\\"ttl\\\">Drop Excel here, or tap to choose</div>\u001b[39m\n\u001b[31m      <div class=\\\"sub\\\">Required: <b>store name</b> + <b>address</b><br>Optional: <b>city</b>, <b>lat</b>, <b>lng</b>, <b>phone</b>, anything else.</div>\u001b[39m\n\u001b[31m    </label>·\u001b[39m\n\u001b[31m    <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\">Origin</div>\u001b[39m\n\u001b[31m      <div class=\\\"field\\\">\u001b[39m\n\u001b[31m        <label>Start point</label>\u001b[39m\n\u001b[31m        <input type=\\\"text\\\" id=\\\"inp-origin\\\" value=\\\"Mohali office Punjab\\\" placeholder=\\\"Address or place name\\\">\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div class=\\\"toggle\\\">\u001b[39m\n\u001b[31m        <input type=\\\"checkbox\\\" id=\\\"inp-roundtrip\\\" checked>\u001b[39m\n\u001b[31m        <label for=\\\"inp-roundtrip\\\" style=\\\"cursor:pointer;font-size:13px;font-weight:600\\\">Round-trip (return to start)</label>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"dest-wrap\\\" class=\\\"field\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m        <label>Final destination</label>\u001b[39m\n\u001b[31m        <input type=\\\"text\\\" id=\\\"inp-destination\\\" placeholder=\\\"Where to end if not returning to origin\\\">\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" id=\\\"btn-go\\\" onclick=\\\"parseAndOptimise()\\\" disabled>\u001b[39m\n\u001b[31m      📐 Parse + optimise route\u001b[39m\n\u001b[31m    </button>·\u001b[39m\n\u001b[31m    <div id=\\\"upload-progress\\\" style=\\\"display:none;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;font-size:12px\\\">\u001b[39m\n\u001b[31m        <span id=\\\"prog-label\\\">Geocoding…</span>\u001b[39m\n\u001b[31m        <span id=\\\"prog-count\\\" style=\\\"color:var(--muted)\\\">0 / 0</span>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div class=\\\"progress\\\"><div class=\\\"progress-bar\\\" id=\\\"prog-bar\\\" style=\\\"width:0%\\\"></div></div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div id=\\\"parsed-preview\\\" style=\\\"display:none;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:11px;font-size:12px\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\" style=\\\"margin-bottom:6px\\\">Parsed</div>\u001b[39m\n\u001b[31m      <div id=\\\"parsed-summary\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: EDITOR ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-editor\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"confirmBack()\\\">‹ New</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>Tour Editor</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"editor-sub\\\">— stops</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-2\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"openShare()\\\">📤</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"editor-body\\\">\u001b[39m\n\u001b[31m   <div class=\\\"editor-fixed\\\">·\u001b[39m\n\u001b[31m    <div class=\\\"origin-pill\\\" id=\\\"origin-pill\\\">\u001b[39m\n\u001b[31m      <span class=\\\"ico\\\">📍</span>\u001b[39m\n\u001b[31m      <div class=\\\"meta\\\">\u001b[39m\n\u001b[31m        <div class=\\\"lbl\\\">Start from</div>\u001b[39m\n\u001b[31m        <div class=\\\"val\\\" id=\\\"origin-val\\\">—</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button onclick=\\\"openOriginEdit()\\\">✏️ Change</button>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div id=\\\"origin-edit\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m        <div class=\\\"field\\\">\u001b[39m\n\u001b[31m          <label>New origin (for mid-tour replan)</label>\u001b[39m\n\u001b[31m          <input type=\\\"text\\\" id=\\\"inp-new-origin\\\" placeholder=\\\"Hotel B Mohali, or address…\\\">\u001b[39m\n\u001b[31m          <div style=\\\"font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4\\\">\u001b[39m\n\u001b[31m            Tip: toggle ✗ on completed stops, change origin to where you are now, hit Replan.\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div style=\\\"display:flex;gap:8px\\\">\u001b[39m\n\u001b[31m          <button class=\\\"btn btn-s\\\" onclick=\\\"closeOriginEdit()\\\" style=\\\"flex:1\\\">Cancel</button>\u001b[39m\n\u001b[31m          <button class=\\\"btn btn-p\\\" onclick=\\\"applyOriginAndReplan()\\\" style=\\\"flex:2\\\">🔄 Replan from here</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"top-actions\\\" id=\\\"top-actions\\\">\u001b[39m\n\u001b[31m      <div class=\\\"top-totals\\\">\u001b[39m\n\u001b[31m        <div class=\\\"top-totals-big\\\" id=\\\"top-totals-big\\\">— km · — min<span class=\\\"recalc\\\" id=\\\"recalc\\\">↻</span></div>\u001b[39m\n\u001b[31m        <div class=\\\"top-totals-delta\\\" id=\\\"top-totals-delta\\\">vs proposed: —</div>\u001b[39m\n\u001b[31m        <div class=\\\"reopt-needed\\\" id=\\\"reopt-needed\\\">⚠ Stops changed — tap 🔁 Re-optim to refresh the route plan</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"top-share\\\" onclick=\\\"openShare()\\\">📤 Share</button>\u001b[39m\n\u001b[31m      <button class=\\\"top-reopt\\\" onclick=\\\"replanAll()\\\">🔁 Re-optim</button>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"map-wrap\\\" id=\\\"map-wrap\\\">\u001b[39m\n\u001b[31m      <div class=\\\"map-hdr\\\">\u001b[39m\n\u001b[31m        <div class=\\\"ttl\\\">🗺️ Route preview <span style=\\\"opacity:.6;font-weight:400\\\">(straight-line)</span></div>\u001b[39m\n\u001b[31m        <button class=\\\"toggle\\\" id=\\\"map-toggle\\\" onclick=\\\"toggleMap()\\\" aria-label=\\\"Toggle map\\\">▾</button>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"tour-map\\\"></div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"section-title\\\" style=\\\"margin-top:2px\\\">Stops (drag ≡ to reorder · scroll ↕ to see all)</div>\u001b[39m\n\u001b[31m   </div><!-- /editor-fixed -->·\u001b[39m\n\u001b[31m   <div class=\\\"editor-scroll\\\" id=\\\"editor-scroll\\\">\u001b[39m\n\u001b[31m    <div id=\\\"tiles\\\" class=\\\"tiles\\\"></div>\u001b[39m\n\u001b[31m   </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── MODAL: SHARE ── -->\u001b[39m\n\u001b[31m<div class=\\\"modal\\\" id=\\\"modal-share\\\">\u001b[39m\n\u001b[31m  <div class=\\\"modal-box\\\">\u001b[39m\n\u001b[31m    <div class=\\\"modal-hdr\\\">\u001b[39m\n\u001b[31m      <div class=\\\"modal-ttl\\\">📤 Share / Export</div>\u001b[39m\n\u001b[31m      <button class=\\\"modal-close\\\" onclick=\\\"closeShare()\\\">×</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"share-summary\\\" style=\\\"background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:10px;font-size:12px;line-height:1.6;white-space:pre-wrap;font-family:monospace\\\"></div>\u001b[39m\n\u001b[31m    <div style=\\\"margin-top:12px;display:grid;gap:8px\\\">\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"openInGoogleMaps()\\\">🗺️ Open route in Google Maps</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"shareWhatsApp()\\\">💬 WhatsApp</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"copyText()\\\">📋 Copy text</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"downloadCsv()\\\">📥 Download CSV</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"saveLocal()\\\">💾 Save to this device</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<div id=\\\"toast\\\"></div>·\u001b[39m\n\u001b[31m<script>\u001b[39m\n\u001b[31m'use strict';·\u001b[39m\n\u001b[31mconst CACHE_VER = 'tour-pg-v11';\u001b[39m\n\u001b[31mconst PROXY     = '/tour-pg-proxy';·\u001b[39m\n\u001b[31m// ── State ────────────────────────────────────────────\u001b[39m\n\u001b[31mlet session   = null;\u001b[39m\n\u001b[31mlet myId      = '';\u001b[39m\n\u001b[31mlet myName    = '';\u001b[39m\n\u001b[31mlet state     = newState();\u001b[39m\n\u001b[31mlet _recalcTimer = null;\u001b[39m\n\u001b[31mlet _sortable    = null;·\u001b[39m\n\u001b[31mfunction newState() {\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    origin:     { text: 'Mohali office Punjab', location: null, provider: null },\u001b[39m\n\u001b[31m    destination:null,\u001b[39m\n\u001b[31m    roundTrip:  true,\u001b[39m\n\u001b[31m    stops:      [],          // { id, name, city, address, phone, location, included, legDistance_m, legDuration_s, provider, raw, geocodeErr }\u001b[39m\n\u001b[31m    proposedOrder: null,     // array of stop IDs in optimise-time order\u001b[39m\n\u001b[31m    baseTotal: { distance_m: 0, duration_s: 0 },\u001b[39m\n\u001b[31m    currentTotal: { distance_m: 0, duration_s: 0 },\u001b[39m\n\u001b[31m    lastOptimisation: null,  // 'tomtom-computeBestOrder' | 'matrix-nn-2opt'\u001b[39m\n\u001b[31m    needsReoptimize: false,  // true after include/exclude toggle → totals may be stale; prompt user to tap Re-optim\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction uid(prefix='st') { return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,6)}`; }·\u001b[39m\n\u001b[31m// ── Auth ─────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction init() {\u001b[39m\n\u001b[31m  registerSW();\u001b[39m\n\u001b[31m  const raw = localStorage.getItem('lm360-session');\u001b[39m\n\u001b[31m  // Bounce to Hub with ?next= so user lands back here after login (Hub honours\u001b[39m\n\u001b[31m  // same-origin relative paths). Pattern documented in\u001b[39m\n\u001b[31m  // feedback_hub_next_redirect.md memory file — all gated PWAs should follow.\u001b[39m\n\u001b[31m  const nextParam = '?next=' + encodeURIComponent(location.pathname + location.search);\u001b[39m\n\u001b[31m  if (!raw) { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  try { session = JSON.parse(raw); } catch { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  if (Date.now() - (session.loginAt ? new Date(session.loginAt).getTime() : 0) > 12*3600*1000) { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  myId   = session.empId || '';\u001b[39m\n\u001b[31m  myName = session.name  || myId;\u001b[39m\n\u001b[31m  const userText = `👤 <b>${escHtml(myName)}</b>`;\u001b[39m\n\u001b[31m  document.getElementById('user-pill').innerHTML   = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill-2').innerHTML = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill').style.display   = '';\u001b[39m\n\u001b[31m  document.getElementById('user-pill-2').style.display = '';·\u001b[39m\n\u001b[31m  // Load-saved chip if we have a saved tour\u001b[39m\n\u001b[31m  if (localStorage.getItem('tour-pg-saved')) document.getElementById('btn-load').style.display = '';·\u001b[39m\n\u001b[31m  // Wire UI\u001b[39m\n\u001b[31m  document.getElementById('inp-roundtrip').addEventListener('change', e => {\u001b[39m\n\u001b[31m    document.getElementById('dest-wrap').style.display = e.target.checked ? 'none' : '';\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('dragover', e => { e.preventDefault(); e.currentTarget.classList.add('drag'); });\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('dragleave', e => e.currentTarget.classList.remove('drag'));\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('drop', e => {\u001b[39m\n\u001b[31m    e.preventDefault();\u001b[39m\n\u001b[31m    e.currentTarget.classList.remove('drag');\u001b[39m\n\u001b[31m    const f = e.dataTransfer.files[0]; if (f) handleFile(f);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  document.getElementById('excel-file').addEventListener('change', e => {\u001b[39m\n\u001b[31m    const f = e.target.files[0]; if (f) handleFile(f);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction hubAuth() {\u001b[39m\n\u001b[31m  return 'Hub ' + btoa(JSON.stringify(session));\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function api(path, body) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, {\u001b[39m\n\u001b[31m    method: 'POST',\u001b[39m\n\u001b[31m    headers: { 'Authorization': hubAuth(), 'Content-Type': 'application/json' },\u001b[39m\n\u001b[31m    body: JSON.stringify(body),\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (r.status === 401) {\u001b[39m\n\u001b[31m    toast('Session expired — refreshing to re-login', 'err');\u001b[39m\n\u001b[31m    const nextParam = '?next=' + encodeURIComponent(location.pathname + location.search);\u001b[39m\n\u001b[31m    setTimeout(() => location.href = '/hub/' + nextParam, 1500);\u001b[39m\n\u001b[31m    throw new Error('unauthorized');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!r.ok) {\u001b[39m\n\u001b[31m    const txt = await r.text().catch(() => '');\u001b[39m\n\u001b[31m    throw new Error(`${path}: ${r.status} ${txt.slice(0, 100)}`);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return r.json();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Excel parsing + column detection ────────────────\u001b[39m\n\u001b[31mlet _parsedCols = null;   // exposed for the column-remap UI\u001b[39m\n\u001b[31mlet _parsedRows = null;\u001b[39m\n\u001b[31mfunction handleFile(file) {\u001b[39m\n\u001b[31m  const reader = new FileReader();\u001b[39m\n\u001b[31m  reader.onload = (e) => {\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      const wb = XLSX.read(e.target.result, { type: 'array' });\u001b[39m\n\u001b[31m      const ws = wb.Sheets[wb.SheetNames[0]];\u001b[39m\n\u001b[31m      const rows = XLSX.utils.sheet_to_json(ws, { defval: '' });\u001b[39m\n\u001b[31m      if (!rows.length) throw new Error('Empty sheet');\u001b[39m\n\u001b[31m      _parsedRows = rows;\u001b[39m\n\u001b[31m      _parsedCols = detectColumns(rows[0]);\u001b[39m\n\u001b[31m      if (!_parsedCols.name && !_parsedCols.address && !_parsedCols.city) throw new Error('Missing required columns (need at least one of: name, address, city)');\u001b[39m\n\u001b[31m      rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m      renderParsedPreview();\u001b[39m\n\u001b[31m      document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m      toast('Excel parsed ✓', 'ok');\u001b[39m\n\u001b[31m    } catch (err) {\u001b[39m\n\u001b[31m      toast('Excel error: ' + err.message, 'err');\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  reader.readAsArrayBuffer(file);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction rebuildStopsFromParsedRows() {\u001b[39m\n\u001b[31m  state.stops = _parsedRows.map((row, idx) => buildStop(row, _parsedCols, idx)).filter(s => s);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderParsedPreview() {\u001b[39m\n\u001b[31m  const skip = _parsedRows.length - state.stops.length;\u001b[39m\n\u001b[31m  const allCols = Object.keys(_parsedRows[0]);\u001b[39m\n\u001b[31m  // For each role, show:  <role>: <selected col name>  with a tiny dropdown to override\u001b[39m\n\u001b[31m  const ROLES = ['name','city','address','lat','lng','phone','notes'];\u001b[39m\n\u001b[31m  const sample = _parsedRows[0];\u001b[39m\n\u001b[31m  const previewVal = (key) => {\u001b[39m\n\u001b[31m    const v = sample[key]; if (v == null || v === '') return '';\u001b[39m\n\u001b[31m    return String(v).slice(0, 40);\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  // Column order (per user request): YOUR Excel column first, then the PWA field\u001b[39m\n\u001b[31m  // it maps to, then a sample value. Equal-ish widths via <colgroup>; the wrapper\u001b[39m\n\u001b[31m  // scrolls horizontally so long header names are never clipped on narrow phones.\u001b[39m\n\u001b[31m  let rows = '<div class=\\\"map-table-wrap\\\"><table class=\\\"map-table\\\">' +\u001b[39m\n\u001b[31m    '<colgroup><col class=\\\"c-src\\\"><col class=\\\"c-field\\\"><col class=\\\"c-sample\\\"></colgroup>' +\u001b[39m\n\u001b[31m    '<thead><tr><th>Your file column</th><th>→ PWA field</th><th>Sample value</th></tr></thead><tbody>';\u001b[39m\n\u001b[31m  for (const role of ROLES) {\u001b[39m\n\u001b[31m    const sel  = _parsedCols[role];\u001b[39m\n\u001b[31m    const need = role === 'name';\u001b[39m\n\u001b[31m    const opts = `<option value=\\\"\\\">— ignore —</option>` +\u001b[39m\n\u001b[31m      allCols.map(c => `<option value=\\\"${escHtml(c)}\\\" ${sel === c ? 'selected' : ''}>${escHtml(c)}</option>`).join('');\u001b[39m\n\u001b[31m    const previewText = sel ? escHtml(previewVal(sel)) : '';\u001b[39m\n\u001b[31m    const tip = !sel && need ? `<span style=\\\"color:var(--y);font-weight:700\\\">⚠ no match — uses city or \\\"Stop #N\\\"</span>` : '';\u001b[39m\n\u001b[31m    rows += `<tr>\u001b[39m\n\u001b[31m      <td><select onchange=\\\"remapColumn('${role}', this.value)\\\">${opts}</select></td>\u001b[39m\n\u001b[31m      <td class=\\\"map-field\\\">${role}${need?' <span style=\\\"color:var(--y)\\\">*</span>':''}</td>\u001b[39m\n\u001b[31m      <td class=\\\"map-sample\\\">${previewText}${tip}</td>\u001b[39m\n\u001b[31m    </tr>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  rows += '</tbody></table></div>';\u001b[39m\n\u001b[31m  document.getElementById('parsed-preview').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('parsed-summary').innerHTML =\u001b[39m\n\u001b[31m    `<div style=\\\"margin-bottom:6px\\\"><b>${state.stops.length}</b> stops detected` +\u001b[39m\n\u001b[31m    `${skip > 0 ? ` · ${skip} skipped (no name/city/address)` : ''}</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:10.5px;color:var(--muted);margin-bottom:6px\\\">If detection is wrong, pick the correct column from the dropdowns:</div>` +\u001b[39m\n\u001b[31m    rows;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction remapColumn(role, newCol) {\u001b[39m\n\u001b[31m  if (!_parsedCols) return;\u001b[39m\n\u001b[31m  _parsedCols[role] = newCol || null;\u001b[39m\n\u001b[31m  rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction normaliseColName(s) {\u001b[39m\n\u001b[31m  return String(s).toLowerCase().replace(/[^a-z]/g, '');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction detectColumns(sampleRow) {\u001b[39m\n\u001b[31m  const map = { name:null, city:null, address:null, lat:null, lng:null, phone:null, notes:null, others:[] };\u001b[39m\n\u001b[31m  const keys = Object.keys(sampleRow);\u001b[39m\n\u001b[31m  // Broadened aliases so most user-provided Excel files map sensibly without a UI:\u001b[39m\n\u001b[31m  //   name  ← store / shop / outlet / party / dealer / firm / customer / business /\u001b[39m\n\u001b[31m  //           company / vendor / agency (and \\\"<thing> name\\\")\u001b[39m\n\u001b[31m  //   addr  ← address / fulladdress / location / loc / addr\u001b[39m\n\u001b[31m  //   city  ← city / town / district / location (only if no address column took it)\u001b[39m\n\u001b[31m  for (const k of keys) {\u001b[39m\n\u001b[31m    const n = normaliseColName(k);\u001b[39m\n\u001b[31m    if (!map.name && /^(storename|name|store|client|outlet|party(name)?|dealer(name)?|firm(name)?|customer(name)?|shop(name)?|business(name)?|companyname|company|vendor(name)?|agency(name)?|merchant|chain|sitename|site)$/.test(n)) { map.name = k; continue; }\u001b[39m\n\u001b[31m    if (!map.address && /^(address|fulladdress|streetaddress|addr|location|loc|street)$/.test(n)) { map.address = k; continue; }\u001b[39m\n\u001b[31m    if (!map.city && /^(city|town|district|cityname|locality|area|region)$/.test(n)) { map.city = k; continue; }\u001b[39m\n\u001b[31m    if (!map.lat && /^(lat|latitude|y)$/.test(n)) { map.lat = k; continue; }\u001b[39m\n\u001b[31m    if (!map.lng && /^(lng|lon|long|longitude|x)$/.test(n)) { map.lng = k; continue; }\u001b[39m\n\u001b[31m    if (!map.phone && /^(phone(no)?|mobile(no)?|contact(no)?|tel(no)?|whatsapp|wa|cell)$/.test(n)) { map.phone = k; continue; }\u001b[39m\n\u001b[31m    if (!map.notes && /^(notes?|remarks?|comments?|description|desc|memo)$/.test(n)) { map.notes = k; continue; }\u001b[39m\n\u001b[31m    map.others.push(k);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return map;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction buildStop(row, cols, idx) {\u001b[39m\n\u001b[31m  const name    = cols.name    ? String(row[cols.name]   || '').trim() : '';\u001b[39m\n\u001b[31m  const address = cols.address ? String(row[cols.address]|| '').trim() : '';\u001b[39m\n\u001b[31m  const city    = cols.city    ? String(row[cols.city]   || '').trim() : '';\u001b[39m\n\u001b[31m  const phone   = cols.phone   ? String(row[cols.phone]  || '').trim() : '';\u001b[39m\n\u001b[31m  const notes   = cols.notes   ? String(row[cols.notes]  || '').trim() : '';\u001b[39m\n\u001b[31m  if (!name && !address && !city) return null;\u001b[39m\n\u001b[31m  // pre-resolved coords?\u001b[39m\n\u001b[31m  let location = null;\u001b[39m\n\u001b[31m  if (cols.lat && cols.lng) {\u001b[39m\n\u001b[31m    const lat = parseFloat(row[cols.lat]);\u001b[39m\n\u001b[31m    const lng = parseFloat(row[cols.lng]);\u001b[39m\n\u001b[31m    if (!isNaN(lat) && !isNaN(lng) && lat !== 0 && lng !== 0) location = { lat, lng };\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const fullAddr = [address, city].filter(Boolean).join(', ');\u001b[39m\n\u001b[31m  // Display name fallback hierarchy (NEVER truncated address — that confused users):\u001b[39m\n\u001b[31m  //   1. Explicit name column value\u001b[39m\n\u001b[31m  //   2. City + index (so excluded/duplicate cities stay distinguishable)\u001b[39m\n\u001b[31m  //   3. \\\"Stop #N\\\"\u001b[39m\n\u001b[31m  let displayName;\u001b[39m\n\u001b[31m  if (name) displayName = name;\u001b[39m\n\u001b[31m  else if (city) displayName = `${city} (stop ${idx + 1})`;\u001b[39m\n\u001b[31m  else displayName = `Stop ${idx + 1}`;\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    id:           uid('st'),\u001b[39m\n\u001b[31m    name:         displayName,\u001b[39m\n\u001b[31m    hasExplicitName: !!name,         // for UI to flag \\\"name auto-derived\\\"\u001b[39m\n\u001b[31m    city, phone, notes,\u001b[39m\n\u001b[31m    address:      fullAddr || name || '',\u001b[39m\n\u001b[31m    location,\u001b[39m\n\u001b[31m    provider:     location ? 'excel' : null,\u001b[39m\n\u001b[31m    included:     true,\u001b[39m\n\u001b[31m    legDistance_m: 0,\u001b[39m\n\u001b[31m    legDuration_s: 0,\u001b[39m\n\u001b[31m    geocodeErr:   null,\u001b[39m\n\u001b[31m    raw:          { ...row },\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Geocode + optimise ──────────────────────────────\u001b[39m\n\u001b[31masync function parseAndOptimise() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('Parse an Excel first', 'err');\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = true;\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = '';\u001b[39m\n\u001b[31m  state.origin.text  = document.getElementById('inp-origin').value.trim() || 'Mohali office Punjab';\u001b[39m\n\u001b[31m  state.roundTrip    = document.getElementById('inp-roundtrip').checked;\u001b[39m\n\u001b[31m  state.destination  = state.roundTrip ? null : (document.getElementById('inp-destination').value.trim() || null);\u001b[39m\n\u001b[31m  // Geocode origin first\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: state.origin.text });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin.location = og.location;\u001b[39m\n\u001b[31m    state.origin.provider = og.provider;\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    setProgress(0, 0, '');\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('Origin geocode failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Geocode each stop (skip if pre-resolved). Sequential to avoid rate limits.\u001b[39m\n\u001b[31m  const total = state.stops.length;\u001b[39m\n\u001b[31m  let done = 0;\u001b[39m\n\u001b[31m  for (const s of state.stops) {\u001b[39m\n\u001b[31m    if (s.location) { done++; setProgress(done, total, 'Pre-geocoded'); continue; }\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      const r = await api('/geocode', { address: s.address });\u001b[39m\n\u001b[31m      if (r.ok && r.location) {\u001b[39m\n\u001b[31m        s.location = r.location;\u001b[39m\n\u001b[31m        s.provider = r.provider;\u001b[39m\n\u001b[31m      } else {\u001b[39m\n\u001b[31m        s.geocodeErr = r.error || 'No match';\u001b[39m\n\u001b[31m      }\u001b[39m\n\u001b[31m    } catch (e) {\u001b[39m\n\u001b[31m      s.geocodeErr = e.message;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    done++;\u001b[39m\n\u001b[31m    setProgress(done, total, `Geocoding ${s.name.slice(0, 30)}…`);\u001b[39m\n\u001b[31m    // Tiny delay to be polite to the provider\u001b[39m\n\u001b[31m    await sleep(80);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Filter out failed-geocode stops\u001b[39m\n\u001b[31m  const good = state.stops.filter(s => s.location && !s.geocodeErr);\u001b[39m\n\u001b[31m  const bad  = state.stops.filter(s => !s.location || s.geocodeErr);\u001b[39m\n\u001b[31m  if (!good.length) {\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('No stops could be geocoded', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (bad.length) toast(`${bad.length} stop(s) failed geocode (excluded)`, 'err');\u001b[39m\n\u001b[31m  state.stops = good;·\u001b[39m\n\u001b[31m  // Optimise\u001b[39m\n\u001b[31m  setProgress(total, total, 'Optimising route…');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const opt = await api('/optimize', {\u001b[39m\n\u001b[31m      origin:    locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:     state.stops.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:  state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!opt.ok) throw new Error(opt.error || 'optimise failed');\u001b[39m\n\u001b[31m    // Apply order\u001b[39m\n\u001b[31m    const order = opt.waypoint_order || state.stops.map((_, i) => i);\u001b[39m\n\u001b[31m    state.stops = order.map(i => state.stops[i]);\u001b[39m\n\u001b[31m    state.proposedOrder = state.stops.map(s => s.id);\u001b[39m\n\u001b[31m    state.baseTotal     = { distance_m: opt.total_distance_m, duration_s: opt.total_duration_s };\u001b[39m\n\u001b[31m    state.currentTotal  = { ...state.baseTotal };\u001b[39m\n\u001b[31m    state.lastOptimisation = opt.optimisation;\u001b[39m\n\u001b[31m    state.needsReoptimize  = false;   // fresh optimise — flag cleared\u001b[39m\n\u001b[31m    // Map legs to stops (route returned len = stops + 1 (round-trip) or stops + 1 (one-way))\u001b[39m\n\u001b[31m    if (Array.isArray(opt.legs) && opt.legs.length >= state.stops.length) {\u001b[39m\n\u001b[31m      state.stops.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = opt.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('Optimise failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m  showScreen('s-editor');\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction setProgress(done, total, label) {\u001b[39m\n\u001b[31m  const pct = total ? Math.round(done/total*100) : 0;\u001b[39m\n\u001b[31m  document.getElementById('prog-bar').style.width   = pct + '%';\u001b[39m\n\u001b[31m  document.getElementById('prog-count').textContent = `${done} / ${total}`;\u001b[39m\n\u001b[31m  document.getElementById('prog-label').textContent = label;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction locStr(loc) { return loc ? `${loc.lat},${loc.lng}` : null; }\u001b[39m\n\u001b[31mconst sleep = ms => new Promise(r => setTimeout(r, ms));·\u001b[39m\n\u001b[31m// ── Editor screen ───────────────────────────────────\u001b[39m\n\u001b[31mfunction renderEditor() {\u001b[39m\n\u001b[31m  restoreMapPref();\u001b[39m\n\u001b[31m  document.getElementById('origin-val').textContent = state.origin.text;\u001b[39m\n\u001b[31m  document.getElementById('editor-sub').textContent =\u001b[39m\n\u001b[31m    `${state.stops.filter(s => s.included).length} of ${state.stops.length} included` +\u001b[39m\n\u001b[31m    (state.lastOptimisation ? ` · ${state.lastOptimisation}` : '');\u001b[39m\n\u001b[31m  const cont = document.getElementById('tiles');\u001b[39m\n\u001b[31m  cont.innerHTML = '';\u001b[39m\n\u001b[31m  state.stops.forEach((s, idx) => cont.appendChild(renderTile(s, idx)));\u001b[39m\n\u001b[31m  if (_sortable) _sortable.destroy();\u001b[39m\n\u001b[31m  _sortable = Sortable.create(cont, {\u001b[39m\n\u001b[31m    handle: '.grip',\u001b[39m\n\u001b[31m    animation: 150,\u001b[39m\n\u001b[31m    onEnd: (evt) => {\u001b[39m\n\u001b[31m      const moved = state.stops.splice(evt.oldIndex, 1)[0];\u001b[39m\n\u001b[31m      state.stops.splice(evt.newIndex, 0, moved);\u001b[39m\n\u001b[31m      renderEditor();\u001b[39m\n\u001b[31m      scheduleRecalc();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  updateTotalsBar();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderTile(s, idx) {\u001b[39m\n\u001b[31m  const el = document.createElement('div');\u001b[39m\n\u001b[31m  el.className = 'tile' + (s.included ? '' : ' excluded') + (s.geocodeErr ? ' err' : '');\u001b[39m\n\u001b[31m  el.dataset.id = s.id;\u001b[39m\n\u001b[31m  const providerBadge = s.provider ? `<span class=\\\"badge-provider\\\">${escHtml(s.provider)}</span>` : '';\u001b[39m\n\u001b[31m  const leg = s.legDistance_m > 0\u001b[39m\n\u001b[31m    ? `${(s.legDistance_m/1000).toFixed(1)} km · ${Math.round(s.legDuration_s/60)} min from prev`\u001b[39m\n\u001b[31m    : '';\u001b[39m\n\u001b[31m  // Per UX rule: Name + City are the primary display. Address is shown\u001b[39m\n\u001b[31m  // smaller below as supporting context. Falls back gracefully when city\u001b[39m\n\u001b[31m  // is missing.\u001b[39m\n\u001b[31m  const cityLine = s.city || '—';\u001b[39m\n\u001b[31m  const addrLine = s.address && s.address !== s.city ? s.address : '';\u001b[39m\n\u001b[31m  el.innerHTML = `\u001b[39m\n\u001b[31m    <span class=\\\"grip\\\">≡</span>\u001b[39m\n\u001b[31m    <span class=\\\"ord\\\">${idx + 1}</span>\u001b[39m\n\u001b[31m    <div class=\\\"info\\\">\u001b[39m\n\u001b[31m      <div class=\\\"name\\\">${escHtml(s.name)} ${providerBadge}</div>\u001b[39m\n\u001b[31m      <div class=\\\"city\\\">${escHtml(cityLine)}</div>\u001b[39m\n\u001b[31m      ${addrLine ? `<div class=\\\"addr\\\">${escHtml(addrLine)}</div>` : ''}\u001b[39m\n\u001b[31m      ${leg ? `<div class=\\\"leg\\\">${escHtml(leg)}</div>` : ''}\u001b[39m\n\u001b[31m      ${s.geocodeErr ? `<div class=\\\"leg\\\" style=\\\"color:var(--r)\\\">⚠ ${escHtml(s.geocodeErr)}</div>` : ''}\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"incl ${s.included ? 'on' : 'off'}\\\" onclick=\\\"toggleInclude('${s.id}')\\\" title=\\\"Include / exclude\\\">\u001b[39m\n\u001b[31m      ${s.included ? '✓' : '✗'}\u001b[39m\n\u001b[31m    </button>`;\u001b[39m\n\u001b[31m  return el;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleInclude(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id); if (!s) return;\u001b[39m\n\u001b[31m  s.included = !s.included;\u001b[39m\n\u001b[31m  // The SET of stops just changed — the existing route may no longer be optimal.\u001b[39m\n\u001b[31m  // Surface a banner + pulse the Re-optim button so the user knows to refresh.\u001b[39m\n\u001b[31m  // recalcRoute still fires (debounced) to update the cost of the CURRENT order,\u001b[39m\n\u001b[31m  // but that number is just informational until the user re-optimises.\u001b[39m\n\u001b[31m  state.needsReoptimize = true;\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  scheduleRecalc();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction scheduleRecalc() {\u001b[39m\n\u001b[31m  clearTimeout(_recalcTimer);\u001b[39m\n\u001b[31m  document.getElementById('recalc').classList.add('show');\u001b[39m\n\u001b[31m  _recalcTimer = setTimeout(recalcRoute, 400);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function recalcRoute() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  if (!included.length) {\u001b[39m\n\u001b[31m    state.currentTotal = { distance_m: 0, duration_s: 0 };\u001b[39m\n\u001b[31m    document.getElementById('recalc').classList.remove('show');\u001b[39m\n\u001b[31m    updateTotalsBar();\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/route', {\u001b[39m\n\u001b[31m      origin:      locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:       included.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:   state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!r.ok) throw new Error(r.error || 'route failed');\u001b[39m\n\u001b[31m    state.currentTotal = { distance_m: r.total_distance_m, duration_s: r.total_duration_s };\u001b[39m\n\u001b[31m    // Map legs back to tiles (legs index 0 = origin→stop0, n = lastStop→dest)\u001b[39m\n\u001b[31m    if (Array.isArray(r.legs)) {\u001b[39m\n\u001b[31m      included.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = r.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    // Clear leg info on excluded stops\u001b[39m\n\u001b[31m    state.stops.filter(s => !s.included).forEach(s => { s.legDistance_m = 0; s.legDuration_s = 0; });\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Recalc failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  } finally {\u001b[39m\n\u001b[31m    document.getElementById('recalc').classList.remove('show');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateTotalsBar() {\u001b[39m\n\u001b[31m  // Totals now live ONLY in the top-actions card (the sticky bottom bar was\u001b[39m\n\u001b[31m  // removed after user feedback that it duplicated the top info 2026-06-04).\u001b[39m\n\u001b[31m  // The big-text element holds the inline recalc spinner as a child <span>.\u001b[39m\n\u001b[31m  const km  = (state.currentTotal.distance_m / 1000).toFixed(1);\u001b[39m\n\u001b[31m  const min = Math.round(state.currentTotal.duration_s / 60);\u001b[39m\n\u001b[31m  const hrs = Math.floor(min / 60), mm = min % 60;\u001b[39m\n\u001b[31m  const timeStr = hrs > 0 ? `${hrs}h ${mm}min` : `${mm} min`;\u001b[39m\n\u001b[31m  const totalsText = `${km} km · ${timeStr}`;\u001b[39m\n\u001b[31m  const bigEl  = document.getElementById('top-totals-big');\u001b[39m\n\u001b[31m  const recalc = document.getElementById('recalc');\u001b[39m\n\u001b[31m  bigEl.textContent = totalsText;\u001b[39m\n\u001b[31m  if (recalc) bigEl.appendChild(document.createTextNode(' '));\u001b[39m\n\u001b[31m  if (recalc) bigEl.appendChild(recalc);\u001b[39m\n\u001b[31m  // Delta vs base\u001b[39m\n\u001b[31m  const baseKm   = state.baseTotal.distance_m / 1000;\u001b[39m\n\u001b[31m  const curKm    = state.currentTotal.distance_m / 1000;\u001b[39m\n\u001b[31m  const deltaKm  = curKm - baseKm;\u001b[39m\n\u001b[31m  const deltaMin = Math.round((state.currentTotal.duration_s - state.baseTotal.duration_s) / 60);\u001b[39m\n\u001b[31m  const dT = document.getElementById('top-totals-delta');\u001b[39m\n\u001b[31m  let deltaText, deltaClass;\u001b[39m\n\u001b[31m  if (Math.abs(deltaKm) < 0.05) {\u001b[39m\n\u001b[31m    deltaText  = 'matches proposed route';\u001b[39m\n\u001b[31m    deltaClass = '';\u001b[39m\n\u001b[31m  } else {\u001b[39m\n\u001b[31m    const sign = deltaKm > 0 ? '+' : '';\u001b[39m\n\u001b[31m    deltaText  = `vs proposed: ${sign}${deltaKm.toFixed(1)} km · ${sign}${deltaMin} min`;\u001b[39m\n\u001b[31m    deltaClass = deltaKm < 0 ? 'better' : 'worse';\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  dT.textContent = deltaText;\u001b[39m\n\u001b[31m  dT.className   = 'top-totals-delta ' + deltaClass;·\u001b[39m\n\u001b[31m  // ── Re-optim-needed banner + pulse + stale styling ──\u001b[39m\n\u001b[31m  // Surfaces a clear message whenever the user has added/removed stops since\u001b[39m\n\u001b[31m  // the last /optimize call. The current-order cost is still shown (so the\u001b[39m\n\u001b[31m  // user sees \\\"what this order costs\\\"), but it's struck-through and the\u001b[39m\n\u001b[31m  // Re-optim button pulses to draw attention.\u001b[39m\n\u001b[31m  const needsReopt = !!state.needsReoptimize;\u001b[39m\n\u001b[31m  document.getElementById('reopt-needed').classList.toggle('show', needsReopt);\u001b[39m\n\u001b[31m  bigEl.classList.toggle('stale', needsReopt);\u001b[39m\n\u001b[31m  dT.classList.toggle('stale', needsReopt);\u001b[39m\n\u001b[31m  document.querySelector('.top-reopt')?.classList.toggle('pulse', needsReopt);·\u001b[39m\n\u001b[31m  // Re-render inline map preview to reflect current order/include flags.\u001b[39m\n\u001b[31m  // Debounced via setTimeout 0 so multiple updates in the same tick coalesce.\u001b[39m\n\u001b[31m  scheduleMapRender();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Inline route preview (Leaflet + free OSM tiles) ──────────────\u001b[39m\n\u001b[31m// Renders origin + stops + straight-line polyline inside the editor screen.\u001b[39m\n\u001b[31m// \\\"Straight-line\\\" because the proxy only returns leg distances/durations,\u001b[39m\n\u001b[31m// not the per-leg point arrays from TomTom — this preview is enough for\u001b[39m\n\u001b[31m// orientation; \\\"Open in Google Maps\\\" gives the user the real road geometry.\u001b[39m\n\u001b[31mlet _leafletMap   = null;\u001b[39m\n\u001b[31mlet _leafletLayer = null;\u001b[39m\n\u001b[31mlet _mapRenderTimer = null;·\u001b[39m\n\u001b[31mfunction ensureMap() {\u001b[39m\n\u001b[31m  if (_leafletMap) return _leafletMap;\u001b[39m\n\u001b[31m  if (typeof L === 'undefined') return null;   // CDN not ready yet\u001b[39m\n\u001b[31m  const el = document.getElementById('tour-map');\u001b[39m\n\u001b[31m  if (!el || !el.offsetParent) return null;    // container not visible yet\u001b[39m\n\u001b[31m  _leafletMap = L.map(el, { attributionControl: false, zoomControl: true, preferCanvas: true });\u001b[39m\n\u001b[31m  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18 })\u001b[39m\n\u001b[31m    .addTo(_leafletMap);\u001b[39m\n\u001b[31m  L.control.attribution({ position: 'bottomright', prefix: false })\u001b[39m\n\u001b[31m    .addAttribution('© OpenStreetMap contributors')\u001b[39m\n\u001b[31m    .addTo(_leafletMap);\u001b[39m\n\u001b[31m  return _leafletMap;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderMap() {\u001b[39m\n\u001b[31m  const map = ensureMap();\u001b[39m\n\u001b[31m  if (!map) return;\u001b[39m\n\u001b[31m  if (_leafletLayer) { map.removeLayer(_leafletLayer); _leafletLayer = null; }\u001b[39m\n\u001b[31m  const layers = [];\u001b[39m\n\u001b[31m  const linePts = [];\u001b[39m\n\u001b[31m  // Origin marker (always first in line)\u001b[39m\n\u001b[31m  if (state.origin?.location) {\u001b[39m\n\u001b[31m    const ll = [state.origin.location.lat, state.origin.location.lng];\u001b[39m\n\u001b[31m    layers.push(L.marker(ll, { icon: L.divIcon({\u001b[39m\n\u001b[31m      className: 'tour-pin origin', html: '<div>🏁</div>',\u001b[39m\n\u001b[31m      iconSize: [28, 28], iconAnchor: [14, 14],\u001b[39m\n\u001b[31m    })}).bindTooltip('Start: ' + (state.origin.text || 'origin'), { direction: 'top' }));\u001b[39m\n\u001b[31m    linePts.push(ll);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Stops in current visit order\u001b[39m\n\u001b[31m  let visitNum = 0;\u001b[39m\n\u001b[31m  state.stops.forEach(s => {\u001b[39m\n\u001b[31m    if (!s.location) return;\u001b[39m\n\u001b[31m    const ll = [s.location.lat, s.location.lng];\u001b[39m\n\u001b[31m    const included = s.included;\u001b[39m\n\u001b[31m    if (included) visitNum++;\u001b[39m\n\u001b[31m    layers.push(L.marker(ll, { icon: L.divIcon({\u001b[39m\n\u001b[31m      className: 'tour-pin' + (included ? '' : ' out'),\u001b[39m\n\u001b[31m      html: `<div>${included ? visitNum : '·'}</div>`,\u001b[39m\n\u001b[31m      iconSize: [26, 26], iconAnchor: [13, 13],\u001b[39m\n\u001b[31m    })}).bindTooltip(\u001b[39m\n\u001b[31m      (included ? `${visitNum}. ` : '✗ ') + s.name + (s.city ? ` · ${s.city}` : ''),\u001b[39m\n\u001b[31m      { direction: 'top' }\u001b[39m\n\u001b[31m    ));\u001b[39m\n\u001b[31m    if (included) linePts.push(ll);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  // Close the loop on round-trip\u001b[39m\n\u001b[31m  if (state.roundTrip && state.origin?.location) {\u001b[39m\n\u001b[31m    linePts.push([state.origin.location.lat, state.origin.location.lng]);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (linePts.length >= 2) {\u001b[39m\n\u001b[31m    layers.push(L.polyline(linePts, {\u001b[39m\n\u001b[31m      color: '#0ea5e9', weight: 3, opacity: 0.85, dashArray: '6 4',\u001b[39m\n\u001b[31m    }));\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  _leafletLayer = L.layerGroup(layers).addTo(map);\u001b[39m\n\u001b[31m  if (linePts.length) {\u001b[39m\n\u001b[31m    try { map.fitBounds(L.latLngBounds(linePts), { padding: [30, 30], maxZoom: 13 }); }\u001b[39m\n\u001b[31m    catch (_) {}\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction scheduleMapRender() {\u001b[39m\n\u001b[31m  if (_mapRenderTimer) return;\u001b[39m\n\u001b[31m  _mapRenderTimer = setTimeout(() => { _mapRenderTimer = null; renderMap(); }, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleMap() {\u001b[39m\n\u001b[31m  const wrap = document.getElementById('map-wrap');\u001b[39m\n\u001b[31m  const tog  = document.getElementById('map-toggle');\u001b[39m\n\u001b[31m  const open = !wrap.classList.toggle('collapsed');\u001b[39m\n\u001b[31m  tog.textContent = open ? '▾' : '▴';\u001b[39m\n\u001b[31m  try { localStorage.setItem('tour-pg-map-open', open ? '1' : '0'); } catch (_) {}\u001b[39m\n\u001b[31m  if (open && _leafletMap) {\u001b[39m\n\u001b[31m    // After expansion the container size changed — Leaflet must recompute\u001b[39m\n\u001b[31m    setTimeout(() => { _leafletMap.invalidateSize(); renderMap(); }, 220);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction restoreMapPref() {\u001b[39m\n\u001b[31m  // Default collapsed — user must explicitly expand once for it to stick open.\u001b[39m\n\u001b[31m  let open = false;\u001b[39m\n\u001b[31m  try { open = localStorage.getItem('tour-pg-map-open') === '1'; } catch (_) {}\u001b[39m\n\u001b[31m  const wrap = document.getElementById('map-wrap');\u001b[39m\n\u001b[31m  const tog  = document.getElementById('map-toggle');\u001b[39m\n\u001b[31m  wrap.classList.toggle('collapsed', !open);\u001b[39m\n\u001b[31m  tog.textContent = open ? '▾' : '▴';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Origin edit / mid-tour replan ────────────────────\u001b[39m\n\u001b[31mfunction openOriginEdit() {\u001b[39m\n\u001b[31m  document.getElementById('origin-edit').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('inp-new-origin').value = state.origin.text;\u001b[39m\n\u001b[31m  document.getElementById('inp-new-origin').focus();\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction closeOriginEdit() {\u001b[39m\n\u001b[31m  document.getElementById('origin-edit').style.display = 'none';\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31masync function applyOriginAndReplan() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('inp-new-origin').value.trim();\u001b[39m\n\u001b[31m  if (!txt) return toast('Enter an origin', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: txt });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin = { text: txt, location: og.location, provider: og.provider };\u001b[39m\n\u001b[31m    closeOriginEdit();\u001b[39m\n\u001b[31m    await replanAll();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Origin failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function replanAll() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  if (!included.length) return toast('No stops included', 'err');\u001b[39m\n\u001b[31m  toast('Optimising…', 'ok');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const opt = await api('/optimize', {\u001b[39m\n\u001b[31m      origin:      locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:       included.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:   state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!opt.ok) throw new Error(opt.error || 'optimise failed');\u001b[39m\n\u001b[31m    const order = opt.waypoint_order || included.map((_, i) => i);\u001b[39m\n\u001b[31m    const reordered = order.map(i => included[i]);\u001b[39m\n\u001b[31m    // KEEP EXCLUDED STOPS IN THEIR EXISTING POSITIONS (per user feedback\u001b[39m\n\u001b[31m    // 2026-06-04). Excluded slots stay put; reordered-included stops fill\u001b[39m\n\u001b[31m    // the gaps. Previously appended at end (confusing) → then dropped\u001b[39m\n\u001b[31m    // (lossy) → final: stay where they are. User can re-include to put\u001b[39m\n\u001b[31m    // them back into the optimised route.\u001b[39m\n\u001b[31m    const newStops = [];\u001b[39m\n\u001b[31m    let r = 0;\u001b[39m\n\u001b[31m    for (const s of state.stops) {\u001b[39m\n\u001b[31m      if (s.included) newStops.push(reordered[r++]);\u001b[39m\n\u001b[31m      else            newStops.push(s);\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    state.stops = newStops;\u001b[39m\n\u001b[31m    state.proposedOrder = state.stops.map(s => s.id);\u001b[39m\n\u001b[31m    state.baseTotal     = { distance_m: opt.total_distance_m, duration_s: opt.total_duration_s };\u001b[39m\n\u001b[31m    state.currentTotal  = { ...state.baseTotal };\u001b[39m\n\u001b[31m    state.lastOptimisation = opt.optimisation;\u001b[39m\n\u001b[31m    state.needsReoptimize  = false;   // re-optim done — banner cleared\u001b[39m\n\u001b[31m    if (Array.isArray(opt.legs)) {\u001b[39m\n\u001b[31m      reordered.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = opt.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Re-optimised ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Replan failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Share / export ──────────────────────────────────\u001b[39m\n\u001b[31mfunction openShare() {\u001b[39m\n\u001b[31m  const km   = (state.currentTotal.distance_m / 1000).toFixed(1);\u001b[39m\n\u001b[31m  const min  = Math.round(state.currentTotal.duration_s / 60);\u001b[39m\n\u001b[31m  const hrs  = Math.floor(min / 60), mm = min % 60;\u001b[39m\n\u001b[31m  const timeStr = hrs > 0 ? `${hrs}h ${mm}min` : `${mm} min`;\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  let txt = `🚗 Tour plan (${new Date().toLocaleDateString('en-IN')})\\\\n`;\u001b[39m\n\u001b[31m  txt += `From: ${state.origin.text}\\\\n`;\u001b[39m\n\u001b[31m  txt += `${included.length} stops · ${km} km · ${timeStr}\\\\n`;\u001b[39m\n\u001b[31m  if (state.roundTrip) txt += `Round-trip\\\\n\\\\n`;\u001b[39m\n\u001b[31m  else                  txt += `One-way → ${state.destination || included[included.length-1]?.address || ''}\\\\n\\\\n`;\u001b[39m\n\u001b[31m  included.forEach((s, i) => {\u001b[39m\n\u001b[31m    const legKm  = (s.legDistance_m/1000).toFixed(1);\u001b[39m\n\u001b[31m    const legMin = Math.round(s.legDuration_s/60);\u001b[39m\n\u001b[31m    txt += `${i+1}. ${s.name}`;\u001b[39m\n\u001b[31m    if (s.city) txt += ` · ${s.city}`;\u001b[39m\n\u001b[31m    if (s.legDistance_m > 0) txt += `  (+${legKm}km · ${legMin}min)`;\u001b[39m\n\u001b[31m    if (s.phone) txt += `  ${s.phone}`;\u001b[39m\n\u001b[31m    txt += `\\\\n`;\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (state.roundTrip) txt += `↩ Return to ${state.origin.text}`;\u001b[39m\n\u001b[31m  document.getElementById('share-summary').textContent = txt;\u001b[39m\n\u001b[31m  document.getElementById('modal-share').classList.add('show');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction closeShare() {\u001b[39m\n\u001b[31m  document.getElementById('modal-share').classList.remove('show');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction shareWhatsApp() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('share-summary').textContent;\u001b[39m\n\u001b[31m  const url = `https://wa.me/?text=${encodeURIComponent(txt)}`;\u001b[39m\n\u001b[31m  window.open(url, '_blank');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Hands off the optimised tour to Google Maps Directions (free, zero infra).\u001b[39m\n\u001b[31m// Uses path-style URL `dir/A/B/C/...` because it accepts many more stops than\u001b[39m\n\u001b[31m// the `api=1` Directions URL (which Google caps at 9 waypoints). Each segment\u001b[39m\n\u001b[31m// is \\\"lat,lng\\\" for geocoded points (most reliable). If the user picked a\u001b[39m\n\u001b[31m// text destination on a one-way tour we URL-encode it as the last segment.\u001b[39m\n\u001b[31mfunction openInGoogleMaps() {\u001b[39m\n\u001b[31m  if (!state.origin?.location) { toast('Origin not yet geocoded', 'err'); return; }\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included && s.location);\u001b[39m\n\u001b[31m  if (included.length === 0)   { toast('No included stops to view', 'err'); return; }\u001b[39m\n\u001b[31m  const fmt = (loc) => `${loc.lat.toFixed(6)},${loc.lng.toFixed(6)}`;\u001b[39m\n\u001b[31m  const segs = [ fmt(state.origin.location) ];\u001b[39m\n\u001b[31m  included.forEach(s => segs.push(fmt(s.location)));\u001b[39m\n\u001b[31m  if (state.roundTrip) {\u001b[39m\n\u001b[31m    segs.push(fmt(state.origin.location));\u001b[39m\n\u001b[31m  } else if (state.destination) {\u001b[39m\n\u001b[31m    segs.push(encodeURIComponent(state.destination));\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // travelmode and dir_action=navigate auto-open in driving directions\u001b[39m\n\u001b[31m  const url = `https://www.google.com/maps/dir/${segs.join('/')}/?travelmode=driving`;\u001b[39m\n\u001b[31m  window.open(url, '_blank', 'noopener');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function copyText() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('share-summary').textContent;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await navigator.clipboard.writeText(txt);\u001b[39m\n\u001b[31m    toast('Copied ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Copy failed', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction downloadCsv() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  const rows = [['#','Name','City','Address','Phone','Lat','Lng','Leg km','Leg min','Provider']];\u001b[39m\n\u001b[31m  included.forEach((s, i) => {\u001b[39m\n\u001b[31m    rows.push([\u001b[39m\n\u001b[31m      i + 1,\u001b[39m\n\u001b[31m      s.name, s.city, s.address, s.phone,\u001b[39m\n\u001b[31m      s.location?.lat ?? '', s.location?.lng ?? '',\u001b[39m\n\u001b[31m      (s.legDistance_m/1000).toFixed(2),\u001b[39m\n\u001b[31m      Math.round(s.legDuration_s/60),\u001b[39m\n\u001b[31m      s.provider || '',\u001b[39m\n\u001b[31m    ]);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  const csv = rows.map(r => r.map(c => `\\\"${String(c).replace(/\\\"/g, '\\\"\\\"')}\\\"`).join(',')).join('\\\\n');\u001b[39m\n\u001b[31m  const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });\u001b[39m\n\u001b[31m  const url = URL.createObjectURL(blob);\u001b[39m\n\u001b[31m  const a = document.createElement('a');\u001b[39m\n\u001b[31m  a.href = url; a.download = `tour-${new Date().toISOString().slice(0,10)}.csv`;\u001b[39m\n\u001b[31m  document.body.appendChild(a); a.click(); a.remove();\u001b[39m\n\u001b[31m  URL.revokeObjectURL(url);\u001b[39m\n\u001b[31m  toast('CSV downloaded ✓', 'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction saveLocal() {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    localStorage.setItem('tour-pg-saved', JSON.stringify({\u001b[39m\n\u001b[31m      saved_at: new Date().toISOString(),\u001b[39m\n\u001b[31m      state,\u001b[39m\n\u001b[31m    }));\u001b[39m\n\u001b[31m    toast('Saved ✓', 'ok');\u001b[39m\n\u001b[31m    document.getElementById('btn-load').style.display = '';\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed (storage full?)', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction loadSaved() {\u001b[39m\n\u001b[31m  const raw = localStorage.getItem('tour-pg-saved');\u001b[39m\n\u001b[31m  if (!raw) return toast('No saved tour', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const { state: s } = JSON.parse(raw);\u001b[39m\n\u001b[31m    state = s;\u001b[39m\n\u001b[31m    showScreen('s-editor');\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Loaded ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Load failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Navigation ──────────────────────────────────────\u001b[39m\n\u001b[31mfunction confirmBack() {\u001b[39m\n\u001b[31m  if (!confirm('Start a new tour? Current state will be cleared (use Save first if you want to keep it).')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m  document.getElementById('parsed-preview').style.display = 'none';\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = true;\u001b[39m\n\u001b[31m  document.getElementById('excel-file').value = '';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showScreen(id) {\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById(id).classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Utils ───────────────────────────────────────────\u001b[39m\n\u001b[31mfunction escHtml(s) { if (s == null) return ''; return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\\\"/g,'&quot;'); }\u001b[39m\n\u001b[31mlet _toastTimer;\u001b[39m\n\u001b[31mfunction toast(msg, type='') {\u001b[39m\n\u001b[31m  const el = document.getElementById('toast');\u001b[39m\n\u001b[31m  el.textContent = msg; el.className = 'show ' + type;\u001b[39m\n\u001b[31m  clearTimeout(_toastTimer);\u001b[39m\n\u001b[31m  _toastTimer = setTimeout(() => el.className = '', 3200);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction registerSW() {\u001b[39m\n\u001b[31m  if (!('serviceWorker' in navigator)) return;\u001b[39m\n\u001b[31m  const sw = `\u001b[39m\n\u001b[31mconst C='${CACHE_VER}';\u001b[39m\n\u001b[31mself.addEventListener('install', e => self.skipWaiting());\u001b[39m\n\u001b[31mself.addEventListener('activate', e => {\u001b[39m\n\u001b[31m  e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== C).map(k => caches.delete(k)))));\u001b[39m\n\u001b[31m  self.clients.claim();\u001b[39m\n\u001b[31m});\u001b[39m\n\u001b[31mself.addEventListener('fetch', e => {\u001b[39m\n\u001b[31m  if (e.request.method !== 'GET') return;\u001b[39m\n\u001b[31m  const url = new URL(e.request.url);\u001b[39m\n\u001b[31m  if (url.pathname.startsWith('/tour-pg-proxy')) return;\u001b[39m\n\u001b[31m  if (url.pathname.startsWith('/tour-pg/')) {\u001b[39m\n\u001b[31m    e.respondWith(\u001b[39m\n\u001b[31m      caches.open(C).then(c =>\u001b[39m\n\u001b[31m        c.match(e.request).then(r => r || fetch(e.request).then(f => { c.put(e.request, f.clone()); return f; }))\u001b[39m\n\u001b[31m      )\u001b[39m\n\u001b[31m    );\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m});`;\u001b[39m\n\u001b[31m  const blob = new Blob([sw], { type: 'application/javascript' });\u001b[39m\n\u001b[31m  navigator.serviceWorker.register(URL.createObjectURL(blob), { scope: '/tour-pg/' }).catch(() => {});\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31minit();\u001b[39m\n\u001b[31m</script>·\u001b[39m\n\u001b[31m</body>\u001b[39m\n\u001b[31m</html>\u001b[39m\n\u001b[31m\"\u001b[39m\n\n  52 |     const ctx = await request.newContext();\n  53 |     const html = await (await ctx.get(BASE_URL)).text();\n> 54 |     expect(html).toContain('tour-pg-v10');\n     |                  ^\n  55 |     expect(html).toContain('Tour Playground');\n  56 |     await ctx.dispose();\n  57 |   });\n    at /var/www/360lm/tests/tour_pg.spec.js:54:18"
                        }
                      ],
                      "stdout": [],
                      "stderr": [],
                      "retry": 1,
                      "startTime": "2026-06-05T09:15:10.084Z",
                      "annotations": [],
                      "attachments": [
                        {
                          "name": "error-context",
                          "contentType": "text/markdown",
                          "path": "/var/www/360lm/test-results/tour_pg-Tour-Playground-—--22e4b-ries-tour-pg-v10-SW-version-android-chrome-retry1/error-context.md"
                        }
                      ],
                      "errorLocation": {
                        "file": "/var/www/360lm/tests/tour_pg.spec.js",
                        "column": 18,
                        "line": 54
                      }
                    }
                  ],
                  "status": "unexpected"
                }
              ],
              "id": "c6527e00e898397fb4a5-e6eebf7ed6ab960b90bd",
              "file": "tour_pg.spec.js",
              "line": 51,
              "column": 3
            },
            {
              "title": "proxy /health returns ok",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "android-chrome",
                  "projectName": "android-chrome",
                  "results": [
                    {
                      "workerIndex": 2,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 86,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:11.755Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-4f4987b0ee72f032f49f",
              "file": "tour_pg.spec.js",
              "line": 59,
              "column": 3
            },
            {
              "title": "page returns 200",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "desktop-chrome",
                  "projectName": "desktop-chrome",
                  "results": [
                    {
                      "workerIndex": 3,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 928,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:27.913Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-9dbe1966c7dd5452b371",
              "file": "tour_pg.spec.js",
              "line": 32,
              "column": 3
            },
            {
              "title": "manifest.json returns 200",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "desktop-chrome",
                  "projectName": "desktop-chrome",
                  "results": [
                    {
                      "workerIndex": 3,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 362,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:29.152Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-c7fda584a64f77dd8b37",
              "file": "tour_pg.spec.js",
              "line": 37,
              "column": 3
            },
            {
              "title": "sample.xlsx is downloadable",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "desktop-chrome",
                  "projectName": "desktop-chrome",
                  "results": [
                    {
                      "workerIndex": 3,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 105,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:29.565Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-8620fb99767dbe91366c",
              "file": "tour_pg.spec.js",
              "line": 42,
              "column": 3
            },
            {
              "title": "HTML carries tour-pg-v10 SW version",
              "ok": false,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "desktop-chrome",
                  "projectName": "desktop-chrome",
                  "results": [
                    {
                      "workerIndex": 3,
                      "parallelIndex": 0,
                      "status": "failed",
                      "duration": 72,
                      "error": {
                        "message": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoContain\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // indexOf\u001b[22m\n\nExpected substring: \u001b[32m\"tour-pg-v10\"\u001b[39m\nReceived string:    \u001b[31m\"<!DOCTYPE html>\u001b[39m\n\u001b[31m<html lang=\\\"en\\\">\u001b[39m\n\u001b[31m<head>\u001b[39m\n\u001b[31m<meta charset=\\\"UTF-8\\\">\u001b[39m\n\u001b[31m<meta name=\\\"viewport\\\" content=\\\"width=device-width,initial-scale=1,viewport-fit=cover\\\">\u001b[39m\n\u001b[31m<title>Tour Planner Playground</title>\u001b[39m\n\u001b[31m<link rel=\\\"manifest\\\" href=\\\"manifest.json\\\">\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"/shared/safe-bottom.css\\\">\u001b[39m\n\u001b[31m<meta name=\\\"theme-color\\\" content=\\\"#0a0a0a\\\">\u001b[39m\n\u001b[31m<link rel=\\\"preconnect\\\" href=\\\"https://fonts.googleapis.com\\\">\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&display=swap\\\">\u001b[39m\n\u001b[31m<script src=\\\"https://cdn.sheetjs.com/xlsx-0.20.2/package/dist/xlsx.full.min.js\\\"></script>\u001b[39m\n\u001b[31m<script src=\\\"https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js\\\"></script>\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.css\\\" crossorigin=\\\"\\\">\u001b[39m\n\u001b[31m<script src=\\\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.js\\\" crossorigin=\\\"\\\" defer></script>\u001b[39m\n\u001b[31m<style>\u001b[39m\n\u001b[31m:root{\u001b[39m\n\u001b[31m  --bg:#0a0a0a;--card:#141414;--card2:#1a1a1a;--border:#262626;--text:#f5f5f5;--muted:#a3a3a3;\u001b[39m\n\u001b[31m  --accent:#0ea5e9;--accent2:#0284c7;--g:#22c55e;--r:#ef4444;--y:#eab308;--p:#a855f7;\u001b[39m\n\u001b[31m  --rad:12px;--rads:8px;--bnav-h:64px;\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31m*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}\u001b[39m\n\u001b[31mbody{background:var(--bg);color:var(--text);font-family:'DM Sans',sans-serif;min-height:100vh;overscroll-behavior-y:none}\u001b[39m\n\u001b[31mbutton{font-family:inherit;cursor:pointer;border:none;background:none;color:inherit}\u001b[39m\n\u001b[31ma{color:inherit;text-decoration:none}·\u001b[39m\n\u001b[31m.scr{display:none;flex-direction:column;min-height:100vh;padding-bottom:calc(var(--bnav-h) + env(safe-area-inset-bottom))}\u001b[39m\n\u001b[31m.scr.active{display:flex}·\u001b[39m\n\u001b[31m.hdr{background:var(--card);border-bottom:1px solid var(--border);padding:12px 14px;display:flex;align-items:center;gap:8px;position:sticky;top:0;z-index:10}\u001b[39m\n\u001b[31m.hdr h1{font-size:15px;font-weight:800;flex:1;min-width:0}\u001b[39m\n\u001b[31m.hdr-sub{font-size:11px;color:var(--muted)}\u001b[39m\n\u001b[31m.hdr-btn{background:rgba(255,255,255,.08);border:1px solid var(--border);font-size:12px;font-weight:700;padding:5px 10px;border-radius:14px;cursor:pointer;white-space:nowrap}\u001b[39m\n\u001b[31m.hdr-btn:active{background:rgba(255,255,255,.16)}\u001b[39m\n\u001b[31m.user-pill{background:rgba(255,255,255,.1);border:1px solid var(--border);color:var(--muted);font-size:10px;font-weight:600;padding:3px 8px;border-radius:11px}\u001b[39m\n\u001b[31m.user-pill b{color:var(--text)}·\u001b[39m\n\u001b[31m.body{flex:1;padding:14px;display:flex;flex-direction:column;gap:12px}·\u001b[39m\n\u001b[31m/* ── editor: header + controls + route preview stay put; only the stops list scrolls ── */\u001b[39m\n\u001b[31m#s-editor.scr{height:100vh;height:100dvh;min-height:0;padding-bottom:0;overflow:hidden}\u001b[39m\n\u001b[31m.editor-body{flex:1;min-height:0;display:flex;flex-direction:column;padding:14px 14px 0}\u001b[39m\n\u001b[31m.editor-fixed{display:flex;flex-direction:column;gap:12px;flex-shrink:0}\u001b[39m\n\u001b[31m.editor-scroll{flex:1;min-height:0;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:2px 0 calc(32px + env(safe-area-inset-bottom));margin-top:6px;scrollbar-gutter:stable}\u001b[39m\n\u001b[31m/* visible scrollbar so it's obvious the stops list is independently scrollable */\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar{width:8px}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-thumb{background:#3a3a3a;border-radius:4px}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-thumb:active{background:#525252}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-track{background:transparent}·\u001b[39m\n\u001b[31m/* ── column-mapping table (parsed preview): scroll horizontally if it doesn't fit ── */\u001b[39m\n\u001b[31m.map-table-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--border);border-radius:6px}\u001b[39m\n\u001b[31m.map-table{border-collapse:collapse;table-layout:fixed;width:100%;min-width:440px;font-size:11px}\u001b[39m\n\u001b[31m.map-table th{text-align:left;padding:6px 8px;color:var(--muted);font-weight:800;font-size:9.5px;text-transform:uppercase;letter-spacing:.3px;background:var(--card2);position:sticky;top:0}\u001b[39m\n\u001b[31m.map-table td{padding:6px 8px;vertical-align:middle}\u001b[39m\n\u001b[31m.map-table tr+tr td,.map-table tbody tr td{border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.map-table col.c-src{width:40%}.map-table col.c-field{width:28%}.map-table col.c-sample{width:32%}\u001b[39m\n\u001b[31m.map-table select{background:#0b1220;border:1px solid var(--border);color:var(--text);padding:6px 7px;border-radius:4px;font-size:11px;width:100%}\u001b[39m\n\u001b[31m.map-field{font-weight:800;color:var(--text);white-space:nowrap}\u001b[39m\n\u001b[31m.map-sample{color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}·\u001b[39m\n\u001b[31m/* ── upload ── */\u001b[39m\n\u001b[31m.dropzone{background:var(--card);border:2px dashed var(--border);border-radius:var(--rad);padding:28px 16px;text-align:center;cursor:pointer;transition:.15s}\u001b[39m\n\u001b[31m.dropzone:hover,.dropzone.drag{border-color:var(--accent);background:#0e1726}\u001b[39m\n\u001b[31m.dropzone .ico{font-size:42px;margin-bottom:8px}\u001b[39m\n\u001b[31m.dropzone .ttl{font-weight:800;font-size:15px;margin-bottom:4px}\u001b[39m\n\u001b[31m.dropzone .sub{font-size:11.5px;color:var(--muted);line-height:1.4}\u001b[39m\n\u001b[31m.dropzone input[type=file]{display:none}·\u001b[39m\n\u001b[31m.section-title{font-size:11px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.4px}·\u001b[39m\n\u001b[31m.form-card{background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:12px;display:flex;flex-direction:column;gap:10px}\u001b[39m\n\u001b[31m.field{display:flex;flex-direction:column;gap:3px}\u001b[39m\n\u001b[31m.field label{font-size:11px;font-weight:600;color:var(--muted)}\u001b[39m\n\u001b[31m.field input,.field select{background:#0b1220;border:1px solid var(--border);border-radius:6px;color:var(--text);padding:9px 11px;font-size:13.5px;font-family:inherit;outline:none}\u001b[39m\n\u001b[31m.field input:focus,.field select:focus{border-color:var(--accent)}\u001b[39m\n\u001b[31m.toggle{display:flex;align-items:center;gap:8px;font-size:13px}\u001b[39m\n\u001b[31m.toggle input{accent-color:var(--accent);width:18px;height:18px}·\u001b[39m\n\u001b[31m.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;border-radius:var(--rads);padding:12px 14px;font-size:14px;font-weight:800;cursor:pointer;width:100%;transition:.12s}\u001b[39m\n\u001b[31m.btn-p{background:var(--accent);color:#fff}\u001b[39m\n\u001b[31m.btn-p:active{background:var(--accent2)}\u001b[39m\n\u001b[31m.btn-p:disabled{opacity:.4;cursor:default}\u001b[39m\n\u001b[31m.btn-s{background:var(--card);color:var(--text);border:1px solid var(--border)}\u001b[39m\n\u001b[31m.btn-r{background:rgba(239,68,68,.15);color:#fca5a5;border:1px solid rgba(239,68,68,.3)}·\u001b[39m\n\u001b[31m/* ── progress ── */\u001b[39m\n\u001b[31m.progress{background:#0b1220;border-radius:8px;height:10px;overflow:hidden;border:1px solid var(--border)}\u001b[39m\n\u001b[31m.progress-bar{height:100%;background:linear-gradient(90deg,var(--accent),var(--g));transition:.2s}·\u001b[39m\n\u001b[31m/* ── tiles ── */\u001b[39m\n\u001b[31m.origin-pill{background:linear-gradient(135deg,#0c2e3a,#062a3a);border:1px solid var(--accent);border-radius:var(--rad);padding:11px 13px;display:flex;align-items:center;gap:10px}\u001b[39m\n\u001b[31m.origin-pill .ico{font-size:18px}\u001b[39m\n\u001b[31m.origin-pill .meta{flex:1;min-width:0}\u001b[39m\n\u001b[31m.origin-pill .lbl{font-size:10.5px;color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.4px}\u001b[39m\n\u001b[31m.origin-pill .val{font-size:13.5px;font-weight:700;margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.origin-pill button{background:rgba(255,255,255,.08);border-radius:14px;padding:5px 10px;font-size:11px;font-weight:700;color:var(--accent)}·\u001b[39m\n\u001b[31m.tiles{display:flex;flex-direction:column;gap:6px}\u001b[39m\n\u001b[31m.tile{background:var(--card);border:1px solid var(--border);border-radius:var(--rads);padding:9px 10px;display:flex;align-items:center;gap:10px;transition:.12s;touch-action:none}\u001b[39m\n\u001b[31m.tile.sortable-ghost{opacity:.35;background:var(--card2)}\u001b[39m\n\u001b[31m.tile.sortable-chosen{transform:scale(.98);box-shadow:0 4px 20px rgba(14,165,233,.25)}\u001b[39m\n\u001b[31m.tile.excluded{opacity:.4;background:#0c0c0c}\u001b[39m\n\u001b[31m.tile.excluded .name,.tile.excluded .city{text-decoration:line-through}\u001b[39m\n\u001b[31m.tile.err{border-color:var(--r);background:#1f0a0a}\u001b[39m\n\u001b[31m.tile .grip{font-size:18px;color:var(--muted);cursor:grab;padding:4px;line-height:1}\u001b[39m\n\u001b[31m.tile .grip:active{cursor:grabbing}\u001b[39m\n\u001b[31m.tile .ord{background:var(--accent);color:#fff;width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11.5px;font-weight:800;flex-shrink:0}\u001b[39m\n\u001b[31m.tile.excluded .ord{background:#444}\u001b[39m\n\u001b[31m.tile .info{flex:1;min-width:0}\u001b[39m\n\u001b[31m.tile .name{font-size:13.5px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .city{font-size:11.5px;color:#cbd5e1;font-weight:600;margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .addr{font-size:9.5px;color:var(--muted);margin-top:1px;line-height:1.3;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-style:italic;opacity:.75}\u001b[39m\n\u001b[31m.tile .leg{font-size:10.5px;color:var(--accent2);font-weight:600;margin-top:2px}\u001b[39m\n\u001b[31m.tile.excluded .leg{display:none}\u001b[39m\n\u001b[31m.tile .badge-provider{display:inline-block;font-size:8.5px;padding:1px 5px;border-radius:8px;background:rgba(255,255,255,.08);color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.3px;margin-left:4px;vertical-align:middle}\u001b[39m\n\u001b[31m.tile .incl{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:50%;border:2px solid var(--border);font-size:16px;font-weight:800}\u001b[39m\n\u001b[31m.tile .incl.on{background:var(--g);border-color:var(--g);color:#fff}\u001b[39m\n\u001b[31m.tile .incl.off{background:#0c0c0c;color:var(--r);border-color:var(--r)}·\u001b[39m\n\u001b[31m/* ── prominent top action bar (KM, Share, Re-optimise) ── */\u001b[39m\n\u001b[31m.top-actions{background:linear-gradient(135deg,#0c2e3a,#08111c);border:1px solid var(--accent);border-radius:var(--rad);padding:10px;display:grid;grid-template-columns:1fr auto auto;gap:8px;align-items:center;margin-bottom:6px}\u001b[39m\n\u001b[31m.top-totals{min-width:0}\u001b[39m\n\u001b[31m.top-totals-big{font-size:18px;font-weight:900;line-height:1.05;letter-spacing:.3px}\u001b[39m\n\u001b[31m.top-totals-delta{font-size:11px;color:var(--muted);margin-top:2px}\u001b[39m\n\u001b[31m.top-totals-delta.better{color:var(--g)}\u001b[39m\n\u001b[31m.top-totals-delta.worse{color:var(--y)}\u001b[39m\n\u001b[31m.top-actions button{padding:10px 12px;font-size:12px;font-weight:800;border-radius:8px;border:none;cursor:pointer;display:flex;align-items:center;gap:5px;white-space:nowrap}\u001b[39m\n\u001b[31m.top-share{background:#f59e0b;color:#000}\u001b[39m\n\u001b[31m.top-share:active{background:#d97706}\u001b[39m\n\u001b[31m.top-reopt{background:var(--accent);color:#fff}\u001b[39m\n\u001b[31m.top-reopt:active{background:var(--accent2)}\u001b[39m\n\u001b[31m/* ── Re-optimisation needed (after include/exclude toggle) ── */\u001b[39m\n\u001b[31m.reopt-needed{font-size:11px;font-weight:700;color:#fbbf24;background:rgba(234,179,8,.12);border-left:3px solid #fbbf24;padding:5px 8px;border-radius:4px;margin-top:5px;display:none;line-height:1.3}\u001b[39m\n\u001b[31m.reopt-needed.show{display:block}·\u001b[39m\n\u001b[31m/* ── inline route preview (Leaflet + OSM) ── */\u001b[39m\n\u001b[31m.map-wrap{margin:8px 0 0;background:var(--card);border:1px solid var(--border);border-radius:var(--rads);overflow:hidden}\u001b[39m\n\u001b[31m.map-hdr{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;font-size:12px;color:var(--muted);background:var(--card2);border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.map-hdr .ttl{display:flex;align-items:center;gap:6px;font-weight:600}\u001b[39m\n\u001b[31m.map-hdr .toggle{font-size:14px;color:var(--text);background:none;border:none;cursor:pointer;padding:0 4px}\u001b[39m\n\u001b[31m#tour-map{height:260px;width:100%;background:#1a1a1a;transition:height .2s ease}\u001b[39m\n\u001b[31m.map-wrap.collapsed #tour-map{height:0}\u001b[39m\n\u001b[31m.map-wrap.collapsed .map-hdr{border-bottom:none}\u001b[39m\n\u001b[31m.tour-pin{background:transparent;border:none}\u001b[39m\n\u001b[31m.tour-pin > div{background:#0ea5e9;color:#fff;border-radius:50%;width:26px;height:26px;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:12px;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,.45);font-family:'DM Sans',sans-serif}\u001b[39m\n\u001b[31m.tour-pin.origin > div{background:#22c55e;font-size:14px}\u001b[39m\n\u001b[31m.tour-pin.out > div{background:#6b7280;opacity:.55}\u001b[39m\n\u001b[31m.leaflet-tooltip{background:#141414;color:#f5f5f5;border:1px solid #262626;font-size:11px;padding:3px 6px;font-family:'DM Sans',sans-serif;box-shadow:0 1px 6px rgba(0,0,0,.4)}\u001b[39m\n\u001b[31m.leaflet-tooltip-top:before,.leaflet-tooltip-bottom:before,.leaflet-tooltip-left:before,.leaflet-tooltip-right:before{border-top-color:#262626;border-bottom-color:#262626;border-left-color:#262626;border-right-color:#262626}\u001b[39m\n\u001b[31m.leaflet-control-attribution{background:rgba(20,20,20,.85)!important;color:#a3a3a3!important;font-size:9px!important}\u001b[39m\n\u001b[31m.leaflet-control-attribution a{color:#0ea5e9!important}\u001b[39m\n\u001b[31m.top-reopt.pulse{animation:pulseReopt 1.4s ease-in-out infinite;box-shadow:0 0 0 0 rgba(14,165,233,.6)}\u001b[39m\n\u001b[31m@keyframes pulseReopt{0%{box-shadow:0 0 0 0 rgba(14,165,233,.7)}50%{box-shadow:0 0 0 8px rgba(14,165,233,0)}100%{box-shadow:0 0 0 0 rgba(14,165,233,0)}}\u001b[39m\n\u001b[31m/* Totals dim when stale to make it visually obvious */\u001b[39m\n\u001b[31m.top-totals-big.stale, .top-totals-delta.stale{opacity:.5;text-decoration:line-through;text-decoration-color:rgba(251,191,36,.6)}·\u001b[39m\n\u001b[31m/* recalc spinner that appears inline next to the top totals during recompute */\u001b[39m\n\u001b[31m.recalc{display:none;font-size:11px;color:var(--accent);margin-left:6px;vertical-align:middle}\u001b[39m\n\u001b[31m.recalc.show{display:inline-block;animation:spin 1s linear infinite}\u001b[39m\n\u001b[31m@keyframes spin{to{transform:rotate(360deg)}}·\u001b[39m\n\u001b[31m/* ── share modal ── */\u001b[39m\n\u001b[31m.modal{position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:200;display:none;align-items:flex-end;justify-content:center}\u001b[39m\n\u001b[31m.modal.show{display:flex}\u001b[39m\n\u001b[31m.modal-box{background:var(--card);border-radius:18px 18px 0 0;width:100%;max-height:85vh;overflow-y:auto;padding:16px 14px calc(16px + env(safe-area-inset-bottom));border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.modal-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\u001b[39m\n\u001b[31m.modal-ttl{font-size:15px;font-weight:800}\u001b[39m\n\u001b[31m.modal-close{font-size:24px;color:var(--muted);background:none;border:none;cursor:pointer}·\u001b[39m\n\u001b[31m/* ── toast ── */\u001b[39m\n\u001b[31m#toast{position:fixed;bottom:calc(var(--bnav-h)+16px);left:12px;right:12px;z-index:1000;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:11px 14px;font-size:13px;font-weight:600;text-align:center;transform:translateY(150px);opacity:0;transition:.25s;pointer-events:none}\u001b[39m\n\u001b[31m#toast.show{transform:translateY(0);opacity:1}\u001b[39m\n\u001b[31m#toast.ok{background:rgba(34,197,94,.18);border-color:var(--g);color:#86efac}\u001b[39m\n\u001b[31m#toast.err{background:rgba(239,68,68,.18);border-color:var(--r);color:#fca5a5}·\u001b[39m\n\u001b[31m/* ── helpers ── */\u001b[39m\n\u001b[31m.spinner{display:inline-block;width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;vertical-align:middle}·\u001b[39m\n\u001b[31m.empty{text-align:center;padding:30px 16px;color:var(--muted);font-size:12.5px}·\u001b[39m\n\u001b[31mtextarea{background:#0b1220;border:1px solid var(--border);color:var(--text);padding:10px;font-family:inherit;font-size:12px;border-radius:6px;resize:vertical;outline:none}·\u001b[39m\n\u001b[31m.kbd{background:rgba(255,255,255,.06);padding:1px 6px;border-radius:4px;font-family:monospace;font-size:11px;color:var(--muted)}\u001b[39m\n\u001b[31m</style>\u001b[39m\n\u001b[31m</head>\u001b[39m\n\u001b[31m<body>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: UPLOAD ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-upload\\\" class=\\\"scr active\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗺 Tour Playground</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Excel → optimised route → tweak</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"loadSaved()\\\" id=\\\"btn-load\\\" style=\\\"display:none\\\">↻ Load</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"location.href='/hub/'\\\">🏡 Hub</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <label class=\\\"dropzone\\\" id=\\\"dropzone\\\" for=\\\"excel-file\\\">\u001b[39m\n\u001b[31m      <input type=\\\"file\\\" id=\\\"excel-file\\\" accept=\\\".xlsx,.xls,.csv\\\">\u001b[39m\n\u001b[31m      <div class=\\\"ico\\\">📊</div>\u001b[39m\n\u001b[31m      <div class=\\\"ttl\\\">Drop Excel here, or tap to choose</div>\u001b[39m\n\u001b[31m      <div class=\\\"sub\\\">Required: <b>store name</b> + <b>address</b><br>Optional: <b>city</b>, <b>lat</b>, <b>lng</b>, <b>phone</b>, anything else.</div>\u001b[39m\n\u001b[31m    </label>·\u001b[39m\n\u001b[31m    <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\">Origin</div>\u001b[39m\n\u001b[31m      <div class=\\\"field\\\">\u001b[39m\n\u001b[31m        <label>Start point</label>\u001b[39m\n\u001b[31m        <input type=\\\"text\\\" id=\\\"inp-origin\\\" value=\\\"Mohali office Punjab\\\" placeholder=\\\"Address or place name\\\">\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div class=\\\"toggle\\\">\u001b[39m\n\u001b[31m        <input type=\\\"checkbox\\\" id=\\\"inp-roundtrip\\\" checked>\u001b[39m\n\u001b[31m        <label for=\\\"inp-roundtrip\\\" style=\\\"cursor:pointer;font-size:13px;font-weight:600\\\">Round-trip (return to start)</label>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"dest-wrap\\\" class=\\\"field\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m        <label>Final destination</label>\u001b[39m\n\u001b[31m        <input type=\\\"text\\\" id=\\\"inp-destination\\\" placeholder=\\\"Where to end if not returning to origin\\\">\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" id=\\\"btn-go\\\" onclick=\\\"parseAndOptimise()\\\" disabled>\u001b[39m\n\u001b[31m      📐 Parse + optimise route\u001b[39m\n\u001b[31m    </button>·\u001b[39m\n\u001b[31m    <div id=\\\"upload-progress\\\" style=\\\"display:none;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;font-size:12px\\\">\u001b[39m\n\u001b[31m        <span id=\\\"prog-label\\\">Geocoding…</span>\u001b[39m\n\u001b[31m        <span id=\\\"prog-count\\\" style=\\\"color:var(--muted)\\\">0 / 0</span>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div class=\\\"progress\\\"><div class=\\\"progress-bar\\\" id=\\\"prog-bar\\\" style=\\\"width:0%\\\"></div></div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div id=\\\"parsed-preview\\\" style=\\\"display:none;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:11px;font-size:12px\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\" style=\\\"margin-bottom:6px\\\">Parsed</div>\u001b[39m\n\u001b[31m      <div id=\\\"parsed-summary\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: EDITOR ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-editor\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"confirmBack()\\\">‹ New</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>Tour Editor</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"editor-sub\\\">— stops</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-2\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"openShare()\\\">📤</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"editor-body\\\">\u001b[39m\n\u001b[31m   <div class=\\\"editor-fixed\\\">·\u001b[39m\n\u001b[31m    <div class=\\\"origin-pill\\\" id=\\\"origin-pill\\\">\u001b[39m\n\u001b[31m      <span class=\\\"ico\\\">📍</span>\u001b[39m\n\u001b[31m      <div class=\\\"meta\\\">\u001b[39m\n\u001b[31m        <div class=\\\"lbl\\\">Start from</div>\u001b[39m\n\u001b[31m        <div class=\\\"val\\\" id=\\\"origin-val\\\">—</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button onclick=\\\"openOriginEdit()\\\">✏️ Change</button>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div id=\\\"origin-edit\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m        <div class=\\\"field\\\">\u001b[39m\n\u001b[31m          <label>New origin (for mid-tour replan)</label>\u001b[39m\n\u001b[31m          <input type=\\\"text\\\" id=\\\"inp-new-origin\\\" placeholder=\\\"Hotel B Mohali, or address…\\\">\u001b[39m\n\u001b[31m          <div style=\\\"font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4\\\">\u001b[39m\n\u001b[31m            Tip: toggle ✗ on completed stops, change origin to where you are now, hit Replan.\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div style=\\\"display:flex;gap:8px\\\">\u001b[39m\n\u001b[31m          <button class=\\\"btn btn-s\\\" onclick=\\\"closeOriginEdit()\\\" style=\\\"flex:1\\\">Cancel</button>\u001b[39m\n\u001b[31m          <button class=\\\"btn btn-p\\\" onclick=\\\"applyOriginAndReplan()\\\" style=\\\"flex:2\\\">🔄 Replan from here</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"top-actions\\\" id=\\\"top-actions\\\">\u001b[39m\n\u001b[31m      <div class=\\\"top-totals\\\">\u001b[39m\n\u001b[31m        <div class=\\\"top-totals-big\\\" id=\\\"top-totals-big\\\">— km · — min<span class=\\\"recalc\\\" id=\\\"recalc\\\">↻</span></div>\u001b[39m\n\u001b[31m        <div class=\\\"top-totals-delta\\\" id=\\\"top-totals-delta\\\">vs proposed: —</div>\u001b[39m\n\u001b[31m        <div class=\\\"reopt-needed\\\" id=\\\"reopt-needed\\\">⚠ Stops changed — tap 🔁 Re-optim to refresh the route plan</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"top-share\\\" onclick=\\\"openShare()\\\">📤 Share</button>\u001b[39m\n\u001b[31m      <button class=\\\"top-reopt\\\" onclick=\\\"replanAll()\\\">🔁 Re-optim</button>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"map-wrap\\\" id=\\\"map-wrap\\\">\u001b[39m\n\u001b[31m      <div class=\\\"map-hdr\\\">\u001b[39m\n\u001b[31m        <div class=\\\"ttl\\\">🗺️ Route preview <span style=\\\"opacity:.6;font-weight:400\\\">(straight-line)</span></div>\u001b[39m\n\u001b[31m        <button class=\\\"toggle\\\" id=\\\"map-toggle\\\" onclick=\\\"toggleMap()\\\" aria-label=\\\"Toggle map\\\">▾</button>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"tour-map\\\"></div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"section-title\\\" style=\\\"margin-top:2px\\\">Stops (drag ≡ to reorder · scroll ↕ to see all)</div>\u001b[39m\n\u001b[31m   </div><!-- /editor-fixed -->·\u001b[39m\n\u001b[31m   <div class=\\\"editor-scroll\\\" id=\\\"editor-scroll\\\">\u001b[39m\n\u001b[31m    <div id=\\\"tiles\\\" class=\\\"tiles\\\"></div>\u001b[39m\n\u001b[31m   </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── MODAL: SHARE ── -->\u001b[39m\n\u001b[31m<div class=\\\"modal\\\" id=\\\"modal-share\\\">\u001b[39m\n\u001b[31m  <div class=\\\"modal-box\\\">\u001b[39m\n\u001b[31m    <div class=\\\"modal-hdr\\\">\u001b[39m\n\u001b[31m      <div class=\\\"modal-ttl\\\">📤 Share / Export</div>\u001b[39m\n\u001b[31m      <button class=\\\"modal-close\\\" onclick=\\\"closeShare()\\\">×</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"share-summary\\\" style=\\\"background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:10px;font-size:12px;line-height:1.6;white-space:pre-wrap;font-family:monospace\\\"></div>\u001b[39m\n\u001b[31m    <div style=\\\"margin-top:12px;display:grid;gap:8px\\\">\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"openInGoogleMaps()\\\">🗺️ Open route in Google Maps</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"shareWhatsApp()\\\">💬 WhatsApp</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"copyText()\\\">📋 Copy text</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"downloadCsv()\\\">📥 Download CSV</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"saveLocal()\\\">💾 Save to this device</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<div id=\\\"toast\\\"></div>·\u001b[39m\n\u001b[31m<script>\u001b[39m\n\u001b[31m'use strict';·\u001b[39m\n\u001b[31mconst CACHE_VER = 'tour-pg-v11';\u001b[39m\n\u001b[31mconst PROXY     = '/tour-pg-proxy';·\u001b[39m\n\u001b[31m// ── State ────────────────────────────────────────────\u001b[39m\n\u001b[31mlet session   = null;\u001b[39m\n\u001b[31mlet myId      = '';\u001b[39m\n\u001b[31mlet myName    = '';\u001b[39m\n\u001b[31mlet state     = newState();\u001b[39m\n\u001b[31mlet _recalcTimer = null;\u001b[39m\n\u001b[31mlet _sortable    = null;·\u001b[39m\n\u001b[31mfunction newState() {\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    origin:     { text: 'Mohali office Punjab', location: null, provider: null },\u001b[39m\n\u001b[31m    destination:null,\u001b[39m\n\u001b[31m    roundTrip:  true,\u001b[39m\n\u001b[31m    stops:      [],          // { id, name, city, address, phone, location, included, legDistance_m, legDuration_s, provider, raw, geocodeErr }\u001b[39m\n\u001b[31m    proposedOrder: null,     // array of stop IDs in optimise-time order\u001b[39m\n\u001b[31m    baseTotal: { distance_m: 0, duration_s: 0 },\u001b[39m\n\u001b[31m    currentTotal: { distance_m: 0, duration_s: 0 },\u001b[39m\n\u001b[31m    lastOptimisation: null,  // 'tomtom-computeBestOrder' | 'matrix-nn-2opt'\u001b[39m\n\u001b[31m    needsReoptimize: false,  // true after include/exclude toggle → totals may be stale; prompt user to tap Re-optim\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction uid(prefix='st') { return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,6)}`; }·\u001b[39m\n\u001b[31m// ── Auth ─────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction init() {\u001b[39m\n\u001b[31m  registerSW();\u001b[39m\n\u001b[31m  const raw = localStorage.getItem('lm360-session');\u001b[39m\n\u001b[31m  // Bounce to Hub with ?next= so user lands back here after login (Hub honours\u001b[39m\n\u001b[31m  // same-origin relative paths). Pattern documented in\u001b[39m\n\u001b[31m  // feedback_hub_next_redirect.md memory file — all gated PWAs should follow.\u001b[39m\n\u001b[31m  const nextParam = '?next=' + encodeURIComponent(location.pathname + location.search);\u001b[39m\n\u001b[31m  if (!raw) { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  try { session = JSON.parse(raw); } catch { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  if (Date.now() - (session.loginAt ? new Date(session.loginAt).getTime() : 0) > 12*3600*1000) { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  myId   = session.empId || '';\u001b[39m\n\u001b[31m  myName = session.name  || myId;\u001b[39m\n\u001b[31m  const userText = `👤 <b>${escHtml(myName)}</b>`;\u001b[39m\n\u001b[31m  document.getElementById('user-pill').innerHTML   = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill-2').innerHTML = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill').style.display   = '';\u001b[39m\n\u001b[31m  document.getElementById('user-pill-2').style.display = '';·\u001b[39m\n\u001b[31m  // Load-saved chip if we have a saved tour\u001b[39m\n\u001b[31m  if (localStorage.getItem('tour-pg-saved')) document.getElementById('btn-load').style.display = '';·\u001b[39m\n\u001b[31m  // Wire UI\u001b[39m\n\u001b[31m  document.getElementById('inp-roundtrip').addEventListener('change', e => {\u001b[39m\n\u001b[31m    document.getElementById('dest-wrap').style.display = e.target.checked ? 'none' : '';\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('dragover', e => { e.preventDefault(); e.currentTarget.classList.add('drag'); });\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('dragleave', e => e.currentTarget.classList.remove('drag'));\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('drop', e => {\u001b[39m\n\u001b[31m    e.preventDefault();\u001b[39m\n\u001b[31m    e.currentTarget.classList.remove('drag');\u001b[39m\n\u001b[31m    const f = e.dataTransfer.files[0]; if (f) handleFile(f);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  document.getElementById('excel-file').addEventListener('change', e => {\u001b[39m\n\u001b[31m    const f = e.target.files[0]; if (f) handleFile(f);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction hubAuth() {\u001b[39m\n\u001b[31m  return 'Hub ' + btoa(JSON.stringify(session));\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function api(path, body) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, {\u001b[39m\n\u001b[31m    method: 'POST',\u001b[39m\n\u001b[31m    headers: { 'Authorization': hubAuth(), 'Content-Type': 'application/json' },\u001b[39m\n\u001b[31m    body: JSON.stringify(body),\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (r.status === 401) {\u001b[39m\n\u001b[31m    toast('Session expired — refreshing to re-login', 'err');\u001b[39m\n\u001b[31m    const nextParam = '?next=' + encodeURIComponent(location.pathname + location.search);\u001b[39m\n\u001b[31m    setTimeout(() => location.href = '/hub/' + nextParam, 1500);\u001b[39m\n\u001b[31m    throw new Error('unauthorized');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!r.ok) {\u001b[39m\n\u001b[31m    const txt = await r.text().catch(() => '');\u001b[39m\n\u001b[31m    throw new Error(`${path}: ${r.status} ${txt.slice(0, 100)}`);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return r.json();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Excel parsing + column detection ────────────────\u001b[39m\n\u001b[31mlet _parsedCols = null;   // exposed for the column-remap UI\u001b[39m\n\u001b[31mlet _parsedRows = null;\u001b[39m\n\u001b[31mfunction handleFile(file) {\u001b[39m\n\u001b[31m  const reader = new FileReader();\u001b[39m\n\u001b[31m  reader.onload = (e) => {\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      const wb = XLSX.read(e.target.result, { type: 'array' });\u001b[39m\n\u001b[31m      const ws = wb.Sheets[wb.SheetNames[0]];\u001b[39m\n\u001b[31m      const rows = XLSX.utils.sheet_to_json(ws, { defval: '' });\u001b[39m\n\u001b[31m      if (!rows.length) throw new Error('Empty sheet');\u001b[39m\n\u001b[31m      _parsedRows = rows;\u001b[39m\n\u001b[31m      _parsedCols = detectColumns(rows[0]);\u001b[39m\n\u001b[31m      if (!_parsedCols.name && !_parsedCols.address && !_parsedCols.city) throw new Error('Missing required columns (need at least one of: name, address, city)');\u001b[39m\n\u001b[31m      rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m      renderParsedPreview();\u001b[39m\n\u001b[31m      document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m      toast('Excel parsed ✓', 'ok');\u001b[39m\n\u001b[31m    } catch (err) {\u001b[39m\n\u001b[31m      toast('Excel error: ' + err.message, 'err');\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  reader.readAsArrayBuffer(file);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction rebuildStopsFromParsedRows() {\u001b[39m\n\u001b[31m  state.stops = _parsedRows.map((row, idx) => buildStop(row, _parsedCols, idx)).filter(s => s);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderParsedPreview() {\u001b[39m\n\u001b[31m  const skip = _parsedRows.length - state.stops.length;\u001b[39m\n\u001b[31m  const allCols = Object.keys(_parsedRows[0]);\u001b[39m\n\u001b[31m  // For each role, show:  <role>: <selected col name>  with a tiny dropdown to override\u001b[39m\n\u001b[31m  const ROLES = ['name','city','address','lat','lng','phone','notes'];\u001b[39m\n\u001b[31m  const sample = _parsedRows[0];\u001b[39m\n\u001b[31m  const previewVal = (key) => {\u001b[39m\n\u001b[31m    const v = sample[key]; if (v == null || v === '') return '';\u001b[39m\n\u001b[31m    return String(v).slice(0, 40);\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  // Column order (per user request): YOUR Excel column first, then the PWA field\u001b[39m\n\u001b[31m  // it maps to, then a sample value. Equal-ish widths via <colgroup>; the wrapper\u001b[39m\n\u001b[31m  // scrolls horizontally so long header names are never clipped on narrow phones.\u001b[39m\n\u001b[31m  let rows = '<div class=\\\"map-table-wrap\\\"><table class=\\\"map-table\\\">' +\u001b[39m\n\u001b[31m    '<colgroup><col class=\\\"c-src\\\"><col class=\\\"c-field\\\"><col class=\\\"c-sample\\\"></colgroup>' +\u001b[39m\n\u001b[31m    '<thead><tr><th>Your file column</th><th>→ PWA field</th><th>Sample value</th></tr></thead><tbody>';\u001b[39m\n\u001b[31m  for (const role of ROLES) {\u001b[39m\n\u001b[31m    const sel  = _parsedCols[role];\u001b[39m\n\u001b[31m    const need = role === 'name';\u001b[39m\n\u001b[31m    const opts = `<option value=\\\"\\\">— ignore —</option>` +\u001b[39m\n\u001b[31m      allCols.map(c => `<option value=\\\"${escHtml(c)}\\\" ${sel === c ? 'selected' : ''}>${escHtml(c)}</option>`).join('');\u001b[39m\n\u001b[31m    const previewText = sel ? escHtml(previewVal(sel)) : '';\u001b[39m\n\u001b[31m    const tip = !sel && need ? `<span style=\\\"color:var(--y);font-weight:700\\\">⚠ no match — uses city or \\\"Stop #N\\\"</span>` : '';\u001b[39m\n\u001b[31m    rows += `<tr>\u001b[39m\n\u001b[31m      <td><select onchange=\\\"remapColumn('${role}', this.value)\\\">${opts}</select></td>\u001b[39m\n\u001b[31m      <td class=\\\"map-field\\\">${role}${need?' <span style=\\\"color:var(--y)\\\">*</span>':''}</td>\u001b[39m\n\u001b[31m      <td class=\\\"map-sample\\\">${previewText}${tip}</td>\u001b[39m\n\u001b[31m    </tr>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  rows += '</tbody></table></div>';\u001b[39m\n\u001b[31m  document.getElementById('parsed-preview').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('parsed-summary').innerHTML =\u001b[39m\n\u001b[31m    `<div style=\\\"margin-bottom:6px\\\"><b>${state.stops.length}</b> stops detected` +\u001b[39m\n\u001b[31m    `${skip > 0 ? ` · ${skip} skipped (no name/city/address)` : ''}</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:10.5px;color:var(--muted);margin-bottom:6px\\\">If detection is wrong, pick the correct column from the dropdowns:</div>` +\u001b[39m\n\u001b[31m    rows;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction remapColumn(role, newCol) {\u001b[39m\n\u001b[31m  if (!_parsedCols) return;\u001b[39m\n\u001b[31m  _parsedCols[role] = newCol || null;\u001b[39m\n\u001b[31m  rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction normaliseColName(s) {\u001b[39m\n\u001b[31m  return String(s).toLowerCase().replace(/[^a-z]/g, '');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction detectColumns(sampleRow) {\u001b[39m\n\u001b[31m  const map = { name:null, city:null, address:null, lat:null, lng:null, phone:null, notes:null, others:[] };\u001b[39m\n\u001b[31m  const keys = Object.keys(sampleRow);\u001b[39m\n\u001b[31m  // Broadened aliases so most user-provided Excel files map sensibly without a UI:\u001b[39m\n\u001b[31m  //   name  ← store / shop / outlet / party / dealer / firm / customer / business /\u001b[39m\n\u001b[31m  //           company / vendor / agency (and \\\"<thing> name\\\")\u001b[39m\n\u001b[31m  //   addr  ← address / fulladdress / location / loc / addr\u001b[39m\n\u001b[31m  //   city  ← city / town / district / location (only if no address column took it)\u001b[39m\n\u001b[31m  for (const k of keys) {\u001b[39m\n\u001b[31m    const n = normaliseColName(k);\u001b[39m\n\u001b[31m    if (!map.name && /^(storename|name|store|client|outlet|party(name)?|dealer(name)?|firm(name)?|customer(name)?|shop(name)?|business(name)?|companyname|company|vendor(name)?|agency(name)?|merchant|chain|sitename|site)$/.test(n)) { map.name = k; continue; }\u001b[39m\n\u001b[31m    if (!map.address && /^(address|fulladdress|streetaddress|addr|location|loc|street)$/.test(n)) { map.address = k; continue; }\u001b[39m\n\u001b[31m    if (!map.city && /^(city|town|district|cityname|locality|area|region)$/.test(n)) { map.city = k; continue; }\u001b[39m\n\u001b[31m    if (!map.lat && /^(lat|latitude|y)$/.test(n)) { map.lat = k; continue; }\u001b[39m\n\u001b[31m    if (!map.lng && /^(lng|lon|long|longitude|x)$/.test(n)) { map.lng = k; continue; }\u001b[39m\n\u001b[31m    if (!map.phone && /^(phone(no)?|mobile(no)?|contact(no)?|tel(no)?|whatsapp|wa|cell)$/.test(n)) { map.phone = k; continue; }\u001b[39m\n\u001b[31m    if (!map.notes && /^(notes?|remarks?|comments?|description|desc|memo)$/.test(n)) { map.notes = k; continue; }\u001b[39m\n\u001b[31m    map.others.push(k);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return map;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction buildStop(row, cols, idx) {\u001b[39m\n\u001b[31m  const name    = cols.name    ? String(row[cols.name]   || '').trim() : '';\u001b[39m\n\u001b[31m  const address = cols.address ? String(row[cols.address]|| '').trim() : '';\u001b[39m\n\u001b[31m  const city    = cols.city    ? String(row[cols.city]   || '').trim() : '';\u001b[39m\n\u001b[31m  const phone   = cols.phone   ? String(row[cols.phone]  || '').trim() : '';\u001b[39m\n\u001b[31m  const notes   = cols.notes   ? String(row[cols.notes]  || '').trim() : '';\u001b[39m\n\u001b[31m  if (!name && !address && !city) return null;\u001b[39m\n\u001b[31m  // pre-resolved coords?\u001b[39m\n\u001b[31m  let location = null;\u001b[39m\n\u001b[31m  if (cols.lat && cols.lng) {\u001b[39m\n\u001b[31m    const lat = parseFloat(row[cols.lat]);\u001b[39m\n\u001b[31m    const lng = parseFloat(row[cols.lng]);\u001b[39m\n\u001b[31m    if (!isNaN(lat) && !isNaN(lng) && lat !== 0 && lng !== 0) location = { lat, lng };\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const fullAddr = [address, city].filter(Boolean).join(', ');\u001b[39m\n\u001b[31m  // Display name fallback hierarchy (NEVER truncated address — that confused users):\u001b[39m\n\u001b[31m  //   1. Explicit name column value\u001b[39m\n\u001b[31m  //   2. City + index (so excluded/duplicate cities stay distinguishable)\u001b[39m\n\u001b[31m  //   3. \\\"Stop #N\\\"\u001b[39m\n\u001b[31m  let displayName;\u001b[39m\n\u001b[31m  if (name) displayName = name;\u001b[39m\n\u001b[31m  else if (city) displayName = `${city} (stop ${idx + 1})`;\u001b[39m\n\u001b[31m  else displayName = `Stop ${idx + 1}`;\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    id:           uid('st'),\u001b[39m\n\u001b[31m    name:         displayName,\u001b[39m\n\u001b[31m    hasExplicitName: !!name,         // for UI to flag \\\"name auto-derived\\\"\u001b[39m\n\u001b[31m    city, phone, notes,\u001b[39m\n\u001b[31m    address:      fullAddr || name || '',\u001b[39m\n\u001b[31m    location,\u001b[39m\n\u001b[31m    provider:     location ? 'excel' : null,\u001b[39m\n\u001b[31m    included:     true,\u001b[39m\n\u001b[31m    legDistance_m: 0,\u001b[39m\n\u001b[31m    legDuration_s: 0,\u001b[39m\n\u001b[31m    geocodeErr:   null,\u001b[39m\n\u001b[31m    raw:          { ...row },\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Geocode + optimise ──────────────────────────────\u001b[39m\n\u001b[31masync function parseAndOptimise() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('Parse an Excel first', 'err');\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = true;\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = '';\u001b[39m\n\u001b[31m  state.origin.text  = document.getElementById('inp-origin').value.trim() || 'Mohali office Punjab';\u001b[39m\n\u001b[31m  state.roundTrip    = document.getElementById('inp-roundtrip').checked;\u001b[39m\n\u001b[31m  state.destination  = state.roundTrip ? null : (document.getElementById('inp-destination').value.trim() || null);\u001b[39m\n\u001b[31m  // Geocode origin first\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: state.origin.text });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin.location = og.location;\u001b[39m\n\u001b[31m    state.origin.provider = og.provider;\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    setProgress(0, 0, '');\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('Origin geocode failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Geocode each stop (skip if pre-resolved). Sequential to avoid rate limits.\u001b[39m\n\u001b[31m  const total = state.stops.length;\u001b[39m\n\u001b[31m  let done = 0;\u001b[39m\n\u001b[31m  for (const s of state.stops) {\u001b[39m\n\u001b[31m    if (s.location) { done++; setProgress(done, total, 'Pre-geocoded'); continue; }\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      const r = await api('/geocode', { address: s.address });\u001b[39m\n\u001b[31m      if (r.ok && r.location) {\u001b[39m\n\u001b[31m        s.location = r.location;\u001b[39m\n\u001b[31m        s.provider = r.provider;\u001b[39m\n\u001b[31m      } else {\u001b[39m\n\u001b[31m        s.geocodeErr = r.error || 'No match';\u001b[39m\n\u001b[31m      }\u001b[39m\n\u001b[31m    } catch (e) {\u001b[39m\n\u001b[31m      s.geocodeErr = e.message;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    done++;\u001b[39m\n\u001b[31m    setProgress(done, total, `Geocoding ${s.name.slice(0, 30)}…`);\u001b[39m\n\u001b[31m    // Tiny delay to be polite to the provider\u001b[39m\n\u001b[31m    await sleep(80);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Filter out failed-geocode stops\u001b[39m\n\u001b[31m  const good = state.stops.filter(s => s.location && !s.geocodeErr);\u001b[39m\n\u001b[31m  const bad  = state.stops.filter(s => !s.location || s.geocodeErr);\u001b[39m\n\u001b[31m  if (!good.length) {\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('No stops could be geocoded', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (bad.length) toast(`${bad.length} stop(s) failed geocode (excluded)`, 'err');\u001b[39m\n\u001b[31m  state.stops = good;·\u001b[39m\n\u001b[31m  // Optimise\u001b[39m\n\u001b[31m  setProgress(total, total, 'Optimising route…');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const opt = await api('/optimize', {\u001b[39m\n\u001b[31m      origin:    locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:     state.stops.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:  state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!opt.ok) throw new Error(opt.error || 'optimise failed');\u001b[39m\n\u001b[31m    // Apply order\u001b[39m\n\u001b[31m    const order = opt.waypoint_order || state.stops.map((_, i) => i);\u001b[39m\n\u001b[31m    state.stops = order.map(i => state.stops[i]);\u001b[39m\n\u001b[31m    state.proposedOrder = state.stops.map(s => s.id);\u001b[39m\n\u001b[31m    state.baseTotal     = { distance_m: opt.total_distance_m, duration_s: opt.total_duration_s };\u001b[39m\n\u001b[31m    state.currentTotal  = { ...state.baseTotal };\u001b[39m\n\u001b[31m    state.lastOptimisation = opt.optimisation;\u001b[39m\n\u001b[31m    state.needsReoptimize  = false;   // fresh optimise — flag cleared\u001b[39m\n\u001b[31m    // Map legs to stops (route returned len = stops + 1 (round-trip) or stops + 1 (one-way))\u001b[39m\n\u001b[31m    if (Array.isArray(opt.legs) && opt.legs.length >= state.stops.length) {\u001b[39m\n\u001b[31m      state.stops.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = opt.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('Optimise failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m  showScreen('s-editor');\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction setProgress(done, total, label) {\u001b[39m\n\u001b[31m  const pct = total ? Math.round(done/total*100) : 0;\u001b[39m\n\u001b[31m  document.getElementById('prog-bar').style.width   = pct + '%';\u001b[39m\n\u001b[31m  document.getElementById('prog-count').textContent = `${done} / ${total}`;\u001b[39m\n\u001b[31m  document.getElementById('prog-label').textContent = label;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction locStr(loc) { return loc ? `${loc.lat},${loc.lng}` : null; }\u001b[39m\n\u001b[31mconst sleep = ms => new Promise(r => setTimeout(r, ms));·\u001b[39m\n\u001b[31m// ── Editor screen ───────────────────────────────────\u001b[39m\n\u001b[31mfunction renderEditor() {\u001b[39m\n\u001b[31m  restoreMapPref();\u001b[39m\n\u001b[31m  document.getElementById('origin-val').textContent = state.origin.text;\u001b[39m\n\u001b[31m  document.getElementById('editor-sub').textContent =\u001b[39m\n\u001b[31m    `${state.stops.filter(s => s.included).length} of ${state.stops.length} included` +\u001b[39m\n\u001b[31m    (state.lastOptimisation ? ` · ${state.lastOptimisation}` : '');\u001b[39m\n\u001b[31m  const cont = document.getElementById('tiles');\u001b[39m\n\u001b[31m  cont.innerHTML = '';\u001b[39m\n\u001b[31m  state.stops.forEach((s, idx) => cont.appendChild(renderTile(s, idx)));\u001b[39m\n\u001b[31m  if (_sortable) _sortable.destroy();\u001b[39m\n\u001b[31m  _sortable = Sortable.create(cont, {\u001b[39m\n\u001b[31m    handle: '.grip',\u001b[39m\n\u001b[31m    animation: 150,\u001b[39m\n\u001b[31m    onEnd: (evt) => {\u001b[39m\n\u001b[31m      const moved = state.stops.splice(evt.oldIndex, 1)[0];\u001b[39m\n\u001b[31m      state.stops.splice(evt.newIndex, 0, moved);\u001b[39m\n\u001b[31m      renderEditor();\u001b[39m\n\u001b[31m      scheduleRecalc();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  updateTotalsBar();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderTile(s, idx) {\u001b[39m\n\u001b[31m  const el = document.createElement('div');\u001b[39m\n\u001b[31m  el.className = 'tile' + (s.included ? '' : ' excluded') + (s.geocodeErr ? ' err' : '');\u001b[39m\n\u001b[31m  el.dataset.id = s.id;\u001b[39m\n\u001b[31m  const providerBadge = s.provider ? `<span class=\\\"badge-provider\\\">${escHtml(s.provider)}</span>` : '';\u001b[39m\n\u001b[31m  const leg = s.legDistance_m > 0\u001b[39m\n\u001b[31m    ? `${(s.legDistance_m/1000).toFixed(1)} km · ${Math.round(s.legDuration_s/60)} min from prev`\u001b[39m\n\u001b[31m    : '';\u001b[39m\n\u001b[31m  // Per UX rule: Name + City are the primary display. Address is shown\u001b[39m\n\u001b[31m  // smaller below as supporting context. Falls back gracefully when city\u001b[39m\n\u001b[31m  // is missing.\u001b[39m\n\u001b[31m  const cityLine = s.city || '—';\u001b[39m\n\u001b[31m  const addrLine = s.address && s.address !== s.city ? s.address : '';\u001b[39m\n\u001b[31m  el.innerHTML = `\u001b[39m\n\u001b[31m    <span class=\\\"grip\\\">≡</span>\u001b[39m\n\u001b[31m    <span class=\\\"ord\\\">${idx + 1}</span>\u001b[39m\n\u001b[31m    <div class=\\\"info\\\">\u001b[39m\n\u001b[31m      <div class=\\\"name\\\">${escHtml(s.name)} ${providerBadge}</div>\u001b[39m\n\u001b[31m      <div class=\\\"city\\\">${escHtml(cityLine)}</div>\u001b[39m\n\u001b[31m      ${addrLine ? `<div class=\\\"addr\\\">${escHtml(addrLine)}</div>` : ''}\u001b[39m\n\u001b[31m      ${leg ? `<div class=\\\"leg\\\">${escHtml(leg)}</div>` : ''}\u001b[39m\n\u001b[31m      ${s.geocodeErr ? `<div class=\\\"leg\\\" style=\\\"color:var(--r)\\\">⚠ ${escHtml(s.geocodeErr)}</div>` : ''}\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"incl ${s.included ? 'on' : 'off'}\\\" onclick=\\\"toggleInclude('${s.id}')\\\" title=\\\"Include / exclude\\\">\u001b[39m\n\u001b[31m      ${s.included ? '✓' : '✗'}\u001b[39m\n\u001b[31m    </button>`;\u001b[39m\n\u001b[31m  return el;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleInclude(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id); if (!s) return;\u001b[39m\n\u001b[31m  s.included = !s.included;\u001b[39m\n\u001b[31m  // The SET of stops just changed — the existing route may no longer be optimal.\u001b[39m\n\u001b[31m  // Surface a banner + pulse the Re-optim button so the user knows to refresh.\u001b[39m\n\u001b[31m  // recalcRoute still fires (debounced) to update the cost of the CURRENT order,\u001b[39m\n\u001b[31m  // but that number is just informational until the user re-optimises.\u001b[39m\n\u001b[31m  state.needsReoptimize = true;\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  scheduleRecalc();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction scheduleRecalc() {\u001b[39m\n\u001b[31m  clearTimeout(_recalcTimer);\u001b[39m\n\u001b[31m  document.getElementById('recalc').classList.add('show');\u001b[39m\n\u001b[31m  _recalcTimer = setTimeout(recalcRoute, 400);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function recalcRoute() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  if (!included.length) {\u001b[39m\n\u001b[31m    state.currentTotal = { distance_m: 0, duration_s: 0 };\u001b[39m\n\u001b[31m    document.getElementById('recalc').classList.remove('show');\u001b[39m\n\u001b[31m    updateTotalsBar();\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/route', {\u001b[39m\n\u001b[31m      origin:      locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:       included.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:   state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!r.ok) throw new Error(r.error || 'route failed');\u001b[39m\n\u001b[31m    state.currentTotal = { distance_m: r.total_distance_m, duration_s: r.total_duration_s };\u001b[39m\n\u001b[31m    // Map legs back to tiles (legs index 0 = origin→stop0, n = lastStop→dest)\u001b[39m\n\u001b[31m    if (Array.isArray(r.legs)) {\u001b[39m\n\u001b[31m      included.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = r.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    // Clear leg info on excluded stops\u001b[39m\n\u001b[31m    state.stops.filter(s => !s.included).forEach(s => { s.legDistance_m = 0; s.legDuration_s = 0; });\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Recalc failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  } finally {\u001b[39m\n\u001b[31m    document.getElementById('recalc').classList.remove('show');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateTotalsBar() {\u001b[39m\n\u001b[31m  // Totals now live ONLY in the top-actions card (the sticky bottom bar was\u001b[39m\n\u001b[31m  // removed after user feedback that it duplicated the top info 2026-06-04).\u001b[39m\n\u001b[31m  // The big-text element holds the inline recalc spinner as a child <span>.\u001b[39m\n\u001b[31m  const km  = (state.currentTotal.distance_m / 1000).toFixed(1);\u001b[39m\n\u001b[31m  const min = Math.round(state.currentTotal.duration_s / 60);\u001b[39m\n\u001b[31m  const hrs = Math.floor(min / 60), mm = min % 60;\u001b[39m\n\u001b[31m  const timeStr = hrs > 0 ? `${hrs}h ${mm}min` : `${mm} min`;\u001b[39m\n\u001b[31m  const totalsText = `${km} km · ${timeStr}`;\u001b[39m\n\u001b[31m  const bigEl  = document.getElementById('top-totals-big');\u001b[39m\n\u001b[31m  const recalc = document.getElementById('recalc');\u001b[39m\n\u001b[31m  bigEl.textContent = totalsText;\u001b[39m\n\u001b[31m  if (recalc) bigEl.appendChild(document.createTextNode(' '));\u001b[39m\n\u001b[31m  if (recalc) bigEl.appendChild(recalc);\u001b[39m\n\u001b[31m  // Delta vs base\u001b[39m\n\u001b[31m  const baseKm   = state.baseTotal.distance_m / 1000;\u001b[39m\n\u001b[31m  const curKm    = state.currentTotal.distance_m / 1000;\u001b[39m\n\u001b[31m  const deltaKm  = curKm - baseKm;\u001b[39m\n\u001b[31m  const deltaMin = Math.round((state.currentTotal.duration_s - state.baseTotal.duration_s) / 60);\u001b[39m\n\u001b[31m  const dT = document.getElementById('top-totals-delta');\u001b[39m\n\u001b[31m  let deltaText, deltaClass;\u001b[39m\n\u001b[31m  if (Math.abs(deltaKm) < 0.05) {\u001b[39m\n\u001b[31m    deltaText  = 'matches proposed route';\u001b[39m\n\u001b[31m    deltaClass = '';\u001b[39m\n\u001b[31m  } else {\u001b[39m\n\u001b[31m    const sign = deltaKm > 0 ? '+' : '';\u001b[39m\n\u001b[31m    deltaText  = `vs proposed: ${sign}${deltaKm.toFixed(1)} km · ${sign}${deltaMin} min`;\u001b[39m\n\u001b[31m    deltaClass = deltaKm < 0 ? 'better' : 'worse';\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  dT.textContent = deltaText;\u001b[39m\n\u001b[31m  dT.className   = 'top-totals-delta ' + deltaClass;·\u001b[39m\n\u001b[31m  // ── Re-optim-needed banner + pulse + stale styling ──\u001b[39m\n\u001b[31m  // Surfaces a clear message whenever the user has added/removed stops since\u001b[39m\n\u001b[31m  // the last /optimize call. The current-order cost is still shown (so the\u001b[39m\n\u001b[31m  // user sees \\\"what this order costs\\\"), but it's struck-through and the\u001b[39m\n\u001b[31m  // Re-optim button pulses to draw attention.\u001b[39m\n\u001b[31m  const needsReopt = !!state.needsReoptimize;\u001b[39m\n\u001b[31m  document.getElementById('reopt-needed').classList.toggle('show', needsReopt);\u001b[39m\n\u001b[31m  bigEl.classList.toggle('stale', needsReopt);\u001b[39m\n\u001b[31m  dT.classList.toggle('stale', needsReopt);\u001b[39m\n\u001b[31m  document.querySelector('.top-reopt')?.classList.toggle('pulse', needsReopt);·\u001b[39m\n\u001b[31m  // Re-render inline map preview to reflect current order/include flags.\u001b[39m\n\u001b[31m  // Debounced via setTimeout 0 so multiple updates in the same tick coalesce.\u001b[39m\n\u001b[31m  scheduleMapRender();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Inline route preview (Leaflet + free OSM tiles) ──────────────\u001b[39m\n\u001b[31m// Renders origin + stops + straight-line polyline inside the editor screen.\u001b[39m\n\u001b[31m// \\\"Straight-line\\\" because the proxy only returns leg distances/durations,\u001b[39m\n\u001b[31m// not the per-leg point arrays from TomTom — this preview is enough for\u001b[39m\n\u001b[31m// orientation; \\\"Open in Google Maps\\\" gives the user the real road geometry.\u001b[39m\n\u001b[31mlet _leafletMap   = null;\u001b[39m\n\u001b[31mlet _leafletLayer = null;\u001b[39m\n\u001b[31mlet _mapRenderTimer = null;·\u001b[39m\n\u001b[31mfunction ensureMap() {\u001b[39m\n\u001b[31m  if (_leafletMap) return _leafletMap;\u001b[39m\n\u001b[31m  if (typeof L === 'undefined') return null;   // CDN not ready yet\u001b[39m\n\u001b[31m  const el = document.getElementById('tour-map');\u001b[39m\n\u001b[31m  if (!el || !el.offsetParent) return null;    // container not visible yet\u001b[39m\n\u001b[31m  _leafletMap = L.map(el, { attributionControl: false, zoomControl: true, preferCanvas: true });\u001b[39m\n\u001b[31m  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18 })\u001b[39m\n\u001b[31m    .addTo(_leafletMap);\u001b[39m\n\u001b[31m  L.control.attribution({ position: 'bottomright', prefix: false })\u001b[39m\n\u001b[31m    .addAttribution('© OpenStreetMap contributors')\u001b[39m\n\u001b[31m    .addTo(_leafletMap);\u001b[39m\n\u001b[31m  return _leafletMap;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderMap() {\u001b[39m\n\u001b[31m  const map = ensureMap();\u001b[39m\n\u001b[31m  if (!map) return;\u001b[39m\n\u001b[31m  if (_leafletLayer) { map.removeLayer(_leafletLayer); _leafletLayer = null; }\u001b[39m\n\u001b[31m  const layers = [];\u001b[39m\n\u001b[31m  const linePts = [];\u001b[39m\n\u001b[31m  // Origin marker (always first in line)\u001b[39m\n\u001b[31m  if (state.origin?.location) {\u001b[39m\n\u001b[31m    const ll = [state.origin.location.lat, state.origin.location.lng];\u001b[39m\n\u001b[31m    layers.push(L.marker(ll, { icon: L.divIcon({\u001b[39m\n\u001b[31m      className: 'tour-pin origin', html: '<div>🏁</div>',\u001b[39m\n\u001b[31m      iconSize: [28, 28], iconAnchor: [14, 14],\u001b[39m\n\u001b[31m    })}).bindTooltip('Start: ' + (state.origin.text || 'origin'), { direction: 'top' }));\u001b[39m\n\u001b[31m    linePts.push(ll);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Stops in current visit order\u001b[39m\n\u001b[31m  let visitNum = 0;\u001b[39m\n\u001b[31m  state.stops.forEach(s => {\u001b[39m\n\u001b[31m    if (!s.location) return;\u001b[39m\n\u001b[31m    const ll = [s.location.lat, s.location.lng];\u001b[39m\n\u001b[31m    const included = s.included;\u001b[39m\n\u001b[31m    if (included) visitNum++;\u001b[39m\n\u001b[31m    layers.push(L.marker(ll, { icon: L.divIcon({\u001b[39m\n\u001b[31m      className: 'tour-pin' + (included ? '' : ' out'),\u001b[39m\n\u001b[31m      html: `<div>${included ? visitNum : '·'}</div>`,\u001b[39m\n\u001b[31m      iconSize: [26, 26], iconAnchor: [13, 13],\u001b[39m\n\u001b[31m    })}).bindTooltip(\u001b[39m\n\u001b[31m      (included ? `${visitNum}. ` : '✗ ') + s.name + (s.city ? ` · ${s.city}` : ''),\u001b[39m\n\u001b[31m      { direction: 'top' }\u001b[39m\n\u001b[31m    ));\u001b[39m\n\u001b[31m    if (included) linePts.push(ll);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  // Close the loop on round-trip\u001b[39m\n\u001b[31m  if (state.roundTrip && state.origin?.location) {\u001b[39m\n\u001b[31m    linePts.push([state.origin.location.lat, state.origin.location.lng]);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (linePts.length >= 2) {\u001b[39m\n\u001b[31m    layers.push(L.polyline(linePts, {\u001b[39m\n\u001b[31m      color: '#0ea5e9', weight: 3, opacity: 0.85, dashArray: '6 4',\u001b[39m\n\u001b[31m    }));\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  _leafletLayer = L.layerGroup(layers).addTo(map);\u001b[39m\n\u001b[31m  if (linePts.length) {\u001b[39m\n\u001b[31m    try { map.fitBounds(L.latLngBounds(linePts), { padding: [30, 30], maxZoom: 13 }); }\u001b[39m\n\u001b[31m    catch (_) {}\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction scheduleMapRender() {\u001b[39m\n\u001b[31m  if (_mapRenderTimer) return;\u001b[39m\n\u001b[31m  _mapRenderTimer = setTimeout(() => { _mapRenderTimer = null; renderMap(); }, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleMap() {\u001b[39m\n\u001b[31m  const wrap = document.getElementById('map-wrap');\u001b[39m\n\u001b[31m  const tog  = document.getElementById('map-toggle');\u001b[39m\n\u001b[31m  const open = !wrap.classList.toggle('collapsed');\u001b[39m\n\u001b[31m  tog.textContent = open ? '▾' : '▴';\u001b[39m\n\u001b[31m  try { localStorage.setItem('tour-pg-map-open', open ? '1' : '0'); } catch (_) {}\u001b[39m\n\u001b[31m  if (open && _leafletMap) {\u001b[39m\n\u001b[31m    // After expansion the container size changed — Leaflet must recompute\u001b[39m\n\u001b[31m    setTimeout(() => { _leafletMap.invalidateSize(); renderMap(); }, 220);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction restoreMapPref() {\u001b[39m\n\u001b[31m  // Default collapsed — user must explicitly expand once for it to stick open.\u001b[39m\n\u001b[31m  let open = false;\u001b[39m\n\u001b[31m  try { open = localStorage.getItem('tour-pg-map-open') === '1'; } catch (_) {}\u001b[39m\n\u001b[31m  const wrap = document.getElementById('map-wrap');\u001b[39m\n\u001b[31m  const tog  = document.getElementById('map-toggle');\u001b[39m\n\u001b[31m  wrap.classList.toggle('collapsed', !open);\u001b[39m\n\u001b[31m  tog.textContent = open ? '▾' : '▴';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Origin edit / mid-tour replan ────────────────────\u001b[39m\n\u001b[31mfunction openOriginEdit() {\u001b[39m\n\u001b[31m  document.getElementById('origin-edit').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('inp-new-origin').value = state.origin.text;\u001b[39m\n\u001b[31m  document.getElementById('inp-new-origin').focus();\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction closeOriginEdit() {\u001b[39m\n\u001b[31m  document.getElementById('origin-edit').style.display = 'none';\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31masync function applyOriginAndReplan() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('inp-new-origin').value.trim();\u001b[39m\n\u001b[31m  if (!txt) return toast('Enter an origin', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: txt });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin = { text: txt, location: og.location, provider: og.provider };\u001b[39m\n\u001b[31m    closeOriginEdit();\u001b[39m\n\u001b[31m    await replanAll();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Origin failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function replanAll() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  if (!included.length) return toast('No stops included', 'err');\u001b[39m\n\u001b[31m  toast('Optimising…', 'ok');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const opt = await api('/optimize', {\u001b[39m\n\u001b[31m      origin:      locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:       included.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:   state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!opt.ok) throw new Error(opt.error || 'optimise failed');\u001b[39m\n\u001b[31m    const order = opt.waypoint_order || included.map((_, i) => i);\u001b[39m\n\u001b[31m    const reordered = order.map(i => included[i]);\u001b[39m\n\u001b[31m    // KEEP EXCLUDED STOPS IN THEIR EXISTING POSITIONS (per user feedback\u001b[39m\n\u001b[31m    // 2026-06-04). Excluded slots stay put; reordered-included stops fill\u001b[39m\n\u001b[31m    // the gaps. Previously appended at end (confusing) → then dropped\u001b[39m\n\u001b[31m    // (lossy) → final: stay where they are. User can re-include to put\u001b[39m\n\u001b[31m    // them back into the optimised route.\u001b[39m\n\u001b[31m    const newStops = [];\u001b[39m\n\u001b[31m    let r = 0;\u001b[39m\n\u001b[31m    for (const s of state.stops) {\u001b[39m\n\u001b[31m      if (s.included) newStops.push(reordered[r++]);\u001b[39m\n\u001b[31m      else            newStops.push(s);\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    state.stops = newStops;\u001b[39m\n\u001b[31m    state.proposedOrder = state.stops.map(s => s.id);\u001b[39m\n\u001b[31m    state.baseTotal     = { distance_m: opt.total_distance_m, duration_s: opt.total_duration_s };\u001b[39m\n\u001b[31m    state.currentTotal  = { ...state.baseTotal };\u001b[39m\n\u001b[31m    state.lastOptimisation = opt.optimisation;\u001b[39m\n\u001b[31m    state.needsReoptimize  = false;   // re-optim done — banner cleared\u001b[39m\n\u001b[31m    if (Array.isArray(opt.legs)) {\u001b[39m\n\u001b[31m      reordered.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = opt.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Re-optimised ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Replan failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Share / export ──────────────────────────────────\u001b[39m\n\u001b[31mfunction openShare() {\u001b[39m\n\u001b[31m  const km   = (state.currentTotal.distance_m / 1000).toFixed(1);\u001b[39m\n\u001b[31m  const min  = Math.round(state.currentTotal.duration_s / 60);\u001b[39m\n\u001b[31m  const hrs  = Math.floor(min / 60), mm = min % 60;\u001b[39m\n\u001b[31m  const timeStr = hrs > 0 ? `${hrs}h ${mm}min` : `${mm} min`;\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  let txt = `🚗 Tour plan (${new Date().toLocaleDateString('en-IN')})\\\\n`;\u001b[39m\n\u001b[31m  txt += `From: ${state.origin.text}\\\\n`;\u001b[39m\n\u001b[31m  txt += `${included.length} stops · ${km} km · ${timeStr}\\\\n`;\u001b[39m\n\u001b[31m  if (state.roundTrip) txt += `Round-trip\\\\n\\\\n`;\u001b[39m\n\u001b[31m  else                  txt += `One-way → ${state.destination || included[included.length-1]?.address || ''}\\\\n\\\\n`;\u001b[39m\n\u001b[31m  included.forEach((s, i) => {\u001b[39m\n\u001b[31m    const legKm  = (s.legDistance_m/1000).toFixed(1);\u001b[39m\n\u001b[31m    const legMin = Math.round(s.legDuration_s/60);\u001b[39m\n\u001b[31m    txt += `${i+1}. ${s.name}`;\u001b[39m\n\u001b[31m    if (s.city) txt += ` · ${s.city}`;\u001b[39m\n\u001b[31m    if (s.legDistance_m > 0) txt += `  (+${legKm}km · ${legMin}min)`;\u001b[39m\n\u001b[31m    if (s.phone) txt += `  ${s.phone}`;\u001b[39m\n\u001b[31m    txt += `\\\\n`;\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (state.roundTrip) txt += `↩ Return to ${state.origin.text}`;\u001b[39m\n\u001b[31m  document.getElementById('share-summary').textContent = txt;\u001b[39m\n\u001b[31m  document.getElementById('modal-share').classList.add('show');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction closeShare() {\u001b[39m\n\u001b[31m  document.getElementById('modal-share').classList.remove('show');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction shareWhatsApp() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('share-summary').textContent;\u001b[39m\n\u001b[31m  const url = `https://wa.me/?text=${encodeURIComponent(txt)}`;\u001b[39m\n\u001b[31m  window.open(url, '_blank');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Hands off the optimised tour to Google Maps Directions (free, zero infra).\u001b[39m\n\u001b[31m// Uses path-style URL `dir/A/B/C/...` because it accepts many more stops than\u001b[39m\n\u001b[31m// the `api=1` Directions URL (which Google caps at 9 waypoints). Each segment\u001b[39m\n\u001b[31m// is \\\"lat,lng\\\" for geocoded points (most reliable). If the user picked a\u001b[39m\n\u001b[31m// text destination on a one-way tour we URL-encode it as the last segment.\u001b[39m\n\u001b[31mfunction openInGoogleMaps() {\u001b[39m\n\u001b[31m  if (!state.origin?.location) { toast('Origin not yet geocoded', 'err'); return; }\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included && s.location);\u001b[39m\n\u001b[31m  if (included.length === 0)   { toast('No included stops to view', 'err'); return; }\u001b[39m\n\u001b[31m  const fmt = (loc) => `${loc.lat.toFixed(6)},${loc.lng.toFixed(6)}`;\u001b[39m\n\u001b[31m  const segs = [ fmt(state.origin.location) ];\u001b[39m\n\u001b[31m  included.forEach(s => segs.push(fmt(s.location)));\u001b[39m\n\u001b[31m  if (state.roundTrip) {\u001b[39m\n\u001b[31m    segs.push(fmt(state.origin.location));\u001b[39m\n\u001b[31m  } else if (state.destination) {\u001b[39m\n\u001b[31m    segs.push(encodeURIComponent(state.destination));\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // travelmode and dir_action=navigate auto-open in driving directions\u001b[39m\n\u001b[31m  const url = `https://www.google.com/maps/dir/${segs.join('/')}/?travelmode=driving`;\u001b[39m\n\u001b[31m  window.open(url, '_blank', 'noopener');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function copyText() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('share-summary').textContent;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await navigator.clipboard.writeText(txt);\u001b[39m\n\u001b[31m    toast('Copied ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Copy failed', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction downloadCsv() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  const rows = [['#','Name','City','Address','Phone','Lat','Lng','Leg km','Leg min','Provider']];\u001b[39m\n\u001b[31m  included.forEach((s, i) => {\u001b[39m\n\u001b[31m    rows.push([\u001b[39m\n\u001b[31m      i + 1,\u001b[39m\n\u001b[31m      s.name, s.city, s.address, s.phone,\u001b[39m\n\u001b[31m      s.location?.lat ?? '', s.location?.lng ?? '',\u001b[39m\n\u001b[31m      (s.legDistance_m/1000).toFixed(2),\u001b[39m\n\u001b[31m      Math.round(s.legDuration_s/60),\u001b[39m\n\u001b[31m      s.provider || '',\u001b[39m\n\u001b[31m    ]);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  const csv = rows.map(r => r.map(c => `\\\"${String(c).replace(/\\\"/g, '\\\"\\\"')}\\\"`).join(',')).join('\\\\n');\u001b[39m\n\u001b[31m  const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });\u001b[39m\n\u001b[31m  const url = URL.createObjectURL(blob);\u001b[39m\n\u001b[31m  const a = document.createElement('a');\u001b[39m\n\u001b[31m  a.href = url; a.download = `tour-${new Date().toISOString().slice(0,10)}.csv`;\u001b[39m\n\u001b[31m  document.body.appendChild(a); a.click(); a.remove();\u001b[39m\n\u001b[31m  URL.revokeObjectURL(url);\u001b[39m\n\u001b[31m  toast('CSV downloaded ✓', 'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction saveLocal() {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    localStorage.setItem('tour-pg-saved', JSON.stringify({\u001b[39m\n\u001b[31m      saved_at: new Date().toISOString(),\u001b[39m\n\u001b[31m      state,\u001b[39m\n\u001b[31m    }));\u001b[39m\n\u001b[31m    toast('Saved ✓', 'ok');\u001b[39m\n\u001b[31m    document.getElementById('btn-load').style.display = '';\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed (storage full?)', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction loadSaved() {\u001b[39m\n\u001b[31m  const raw = localStorage.getItem('tour-pg-saved');\u001b[39m\n\u001b[31m  if (!raw) return toast('No saved tour', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const { state: s } = JSON.parse(raw);\u001b[39m\n\u001b[31m    state = s;\u001b[39m\n\u001b[31m    showScreen('s-editor');\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Loaded ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Load failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Navigation ──────────────────────────────────────\u001b[39m\n\u001b[31mfunction confirmBack() {\u001b[39m\n\u001b[31m  if (!confirm('Start a new tour? Current state will be cleared (use Save first if you want to keep it).')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m  document.getElementById('parsed-preview').style.display = 'none';\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = true;\u001b[39m\n\u001b[31m  document.getElementById('excel-file').value = '';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showScreen(id) {\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById(id).classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Utils ───────────────────────────────────────────\u001b[39m\n\u001b[31mfunction escHtml(s) { if (s == null) return ''; return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\\\"/g,'&quot;'); }\u001b[39m\n\u001b[31mlet _toastTimer;\u001b[39m\n\u001b[31mfunction toast(msg, type='') {\u001b[39m\n\u001b[31m  const el = document.getElementById('toast');\u001b[39m\n\u001b[31m  el.textContent = msg; el.className = 'show ' + type;\u001b[39m\n\u001b[31m  clearTimeout(_toastTimer);\u001b[39m\n\u001b[31m  _toastTimer = setTimeout(() => el.className = '', 3200);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction registerSW() {\u001b[39m\n\u001b[31m  if (!('serviceWorker' in navigator)) return;\u001b[39m\n\u001b[31m  const sw = `\u001b[39m\n\u001b[31mconst C='${CACHE_VER}';\u001b[39m\n\u001b[31mself.addEventListener('install', e => self.skipWaiting());\u001b[39m\n\u001b[31mself.addEventListener('activate', e => {\u001b[39m\n\u001b[31m  e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== C).map(k => caches.delete(k)))));\u001b[39m\n\u001b[31m  self.clients.claim();\u001b[39m\n\u001b[31m});\u001b[39m\n\u001b[31mself.addEventListener('fetch', e => {\u001b[39m\n\u001b[31m  if (e.request.method !== 'GET') return;\u001b[39m\n\u001b[31m  const url = new URL(e.request.url);\u001b[39m\n\u001b[31m  if (url.pathname.startsWith('/tour-pg-proxy')) return;\u001b[39m\n\u001b[31m  if (url.pathname.startsWith('/tour-pg/')) {\u001b[39m\n\u001b[31m    e.respondWith(\u001b[39m\n\u001b[31m      caches.open(C).then(c =>\u001b[39m\n\u001b[31m        c.match(e.request).then(r => r || fetch(e.request).then(f => { c.put(e.request, f.clone()); return f; }))\u001b[39m\n\u001b[31m      )\u001b[39m\n\u001b[31m    );\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m});`;\u001b[39m\n\u001b[31m  const blob = new Blob([sw], { type: 'application/javascript' });\u001b[39m\n\u001b[31m  navigator.serviceWorker.register(URL.createObjectURL(blob), { scope: '/tour-pg/' }).catch(() => {});\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31minit();\u001b[39m\n\u001b[31m</script>·\u001b[39m\n\u001b[31m</body>\u001b[39m\n\u001b[31m</html>\u001b[39m\n\u001b[31m\"\u001b[39m",
                        "stack": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoContain\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // indexOf\u001b[22m\n\nExpected substring: \u001b[32m\"tour-pg-v10\"\u001b[39m\nReceived string:    \u001b[31m\"<!DOCTYPE html>\u001b[39m\n\u001b[31m<html lang=\\\"en\\\">\u001b[39m\n\u001b[31m<head>\u001b[39m\n\u001b[31m<meta charset=\\\"UTF-8\\\">\u001b[39m\n\u001b[31m<meta name=\\\"viewport\\\" content=\\\"width=device-width,initial-scale=1,viewport-fit=cover\\\">\u001b[39m\n\u001b[31m<title>Tour Planner Playground</title>\u001b[39m\n\u001b[31m<link rel=\\\"manifest\\\" href=\\\"manifest.json\\\">\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"/shared/safe-bottom.css\\\">\u001b[39m\n\u001b[31m<meta name=\\\"theme-color\\\" content=\\\"#0a0a0a\\\">\u001b[39m\n\u001b[31m<link rel=\\\"preconnect\\\" href=\\\"https://fonts.googleapis.com\\\">\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&display=swap\\\">\u001b[39m\n\u001b[31m<script src=\\\"https://cdn.sheetjs.com/xlsx-0.20.2/package/dist/xlsx.full.min.js\\\"></script>\u001b[39m\n\u001b[31m<script src=\\\"https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js\\\"></script>\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.css\\\" crossorigin=\\\"\\\">\u001b[39m\n\u001b[31m<script src=\\\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.js\\\" crossorigin=\\\"\\\" defer></script>\u001b[39m\n\u001b[31m<style>\u001b[39m\n\u001b[31m:root{\u001b[39m\n\u001b[31m  --bg:#0a0a0a;--card:#141414;--card2:#1a1a1a;--border:#262626;--text:#f5f5f5;--muted:#a3a3a3;\u001b[39m\n\u001b[31m  --accent:#0ea5e9;--accent2:#0284c7;--g:#22c55e;--r:#ef4444;--y:#eab308;--p:#a855f7;\u001b[39m\n\u001b[31m  --rad:12px;--rads:8px;--bnav-h:64px;\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31m*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}\u001b[39m\n\u001b[31mbody{background:var(--bg);color:var(--text);font-family:'DM Sans',sans-serif;min-height:100vh;overscroll-behavior-y:none}\u001b[39m\n\u001b[31mbutton{font-family:inherit;cursor:pointer;border:none;background:none;color:inherit}\u001b[39m\n\u001b[31ma{color:inherit;text-decoration:none}·\u001b[39m\n\u001b[31m.scr{display:none;flex-direction:column;min-height:100vh;padding-bottom:calc(var(--bnav-h) + env(safe-area-inset-bottom))}\u001b[39m\n\u001b[31m.scr.active{display:flex}·\u001b[39m\n\u001b[31m.hdr{background:var(--card);border-bottom:1px solid var(--border);padding:12px 14px;display:flex;align-items:center;gap:8px;position:sticky;top:0;z-index:10}\u001b[39m\n\u001b[31m.hdr h1{font-size:15px;font-weight:800;flex:1;min-width:0}\u001b[39m\n\u001b[31m.hdr-sub{font-size:11px;color:var(--muted)}\u001b[39m\n\u001b[31m.hdr-btn{background:rgba(255,255,255,.08);border:1px solid var(--border);font-size:12px;font-weight:700;padding:5px 10px;border-radius:14px;cursor:pointer;white-space:nowrap}\u001b[39m\n\u001b[31m.hdr-btn:active{background:rgba(255,255,255,.16)}\u001b[39m\n\u001b[31m.user-pill{background:rgba(255,255,255,.1);border:1px solid var(--border);color:var(--muted);font-size:10px;font-weight:600;padding:3px 8px;border-radius:11px}\u001b[39m\n\u001b[31m.user-pill b{color:var(--text)}·\u001b[39m\n\u001b[31m.body{flex:1;padding:14px;display:flex;flex-direction:column;gap:12px}·\u001b[39m\n\u001b[31m/* ── editor: header + controls + route preview stay put; only the stops list scrolls ── */\u001b[39m\n\u001b[31m#s-editor.scr{height:100vh;height:100dvh;min-height:0;padding-bottom:0;overflow:hidden}\u001b[39m\n\u001b[31m.editor-body{flex:1;min-height:0;display:flex;flex-direction:column;padding:14px 14px 0}\u001b[39m\n\u001b[31m.editor-fixed{display:flex;flex-direction:column;gap:12px;flex-shrink:0}\u001b[39m\n\u001b[31m.editor-scroll{flex:1;min-height:0;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:2px 0 calc(32px + env(safe-area-inset-bottom));margin-top:6px;scrollbar-gutter:stable}\u001b[39m\n\u001b[31m/* visible scrollbar so it's obvious the stops list is independently scrollable */\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar{width:8px}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-thumb{background:#3a3a3a;border-radius:4px}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-thumb:active{background:#525252}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-track{background:transparent}·\u001b[39m\n\u001b[31m/* ── column-mapping table (parsed preview): scroll horizontally if it doesn't fit ── */\u001b[39m\n\u001b[31m.map-table-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--border);border-radius:6px}\u001b[39m\n\u001b[31m.map-table{border-collapse:collapse;table-layout:fixed;width:100%;min-width:440px;font-size:11px}\u001b[39m\n\u001b[31m.map-table th{text-align:left;padding:6px 8px;color:var(--muted);font-weight:800;font-size:9.5px;text-transform:uppercase;letter-spacing:.3px;background:var(--card2);position:sticky;top:0}\u001b[39m\n\u001b[31m.map-table td{padding:6px 8px;vertical-align:middle}\u001b[39m\n\u001b[31m.map-table tr+tr td,.map-table tbody tr td{border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.map-table col.c-src{width:40%}.map-table col.c-field{width:28%}.map-table col.c-sample{width:32%}\u001b[39m\n\u001b[31m.map-table select{background:#0b1220;border:1px solid var(--border);color:var(--text);padding:6px 7px;border-radius:4px;font-size:11px;width:100%}\u001b[39m\n\u001b[31m.map-field{font-weight:800;color:var(--text);white-space:nowrap}\u001b[39m\n\u001b[31m.map-sample{color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}·\u001b[39m\n\u001b[31m/* ── upload ── */\u001b[39m\n\u001b[31m.dropzone{background:var(--card);border:2px dashed var(--border);border-radius:var(--rad);padding:28px 16px;text-align:center;cursor:pointer;transition:.15s}\u001b[39m\n\u001b[31m.dropzone:hover,.dropzone.drag{border-color:var(--accent);background:#0e1726}\u001b[39m\n\u001b[31m.dropzone .ico{font-size:42px;margin-bottom:8px}\u001b[39m\n\u001b[31m.dropzone .ttl{font-weight:800;font-size:15px;margin-bottom:4px}\u001b[39m\n\u001b[31m.dropzone .sub{font-size:11.5px;color:var(--muted);line-height:1.4}\u001b[39m\n\u001b[31m.dropzone input[type=file]{display:none}·\u001b[39m\n\u001b[31m.section-title{font-size:11px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.4px}·\u001b[39m\n\u001b[31m.form-card{background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:12px;display:flex;flex-direction:column;gap:10px}\u001b[39m\n\u001b[31m.field{display:flex;flex-direction:column;gap:3px}\u001b[39m\n\u001b[31m.field label{font-size:11px;font-weight:600;color:var(--muted)}\u001b[39m\n\u001b[31m.field input,.field select{background:#0b1220;border:1px solid var(--border);border-radius:6px;color:var(--text);padding:9px 11px;font-size:13.5px;font-family:inherit;outline:none}\u001b[39m\n\u001b[31m.field input:focus,.field select:focus{border-color:var(--accent)}\u001b[39m\n\u001b[31m.toggle{display:flex;align-items:center;gap:8px;font-size:13px}\u001b[39m\n\u001b[31m.toggle input{accent-color:var(--accent);width:18px;height:18px}·\u001b[39m\n\u001b[31m.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;border-radius:var(--rads);padding:12px 14px;font-size:14px;font-weight:800;cursor:pointer;width:100%;transition:.12s}\u001b[39m\n\u001b[31m.btn-p{background:var(--accent);color:#fff}\u001b[39m\n\u001b[31m.btn-p:active{background:var(--accent2)}\u001b[39m\n\u001b[31m.btn-p:disabled{opacity:.4;cursor:default}\u001b[39m\n\u001b[31m.btn-s{background:var(--card);color:var(--text);border:1px solid var(--border)}\u001b[39m\n\u001b[31m.btn-r{background:rgba(239,68,68,.15);color:#fca5a5;border:1px solid rgba(239,68,68,.3)}·\u001b[39m\n\u001b[31m/* ── progress ── */\u001b[39m\n\u001b[31m.progress{background:#0b1220;border-radius:8px;height:10px;overflow:hidden;border:1px solid var(--border)}\u001b[39m\n\u001b[31m.progress-bar{height:100%;background:linear-gradient(90deg,var(--accent),var(--g));transition:.2s}·\u001b[39m\n\u001b[31m/* ── tiles ── */\u001b[39m\n\u001b[31m.origin-pill{background:linear-gradient(135deg,#0c2e3a,#062a3a);border:1px solid var(--accent);border-radius:var(--rad);padding:11px 13px;display:flex;align-items:center;gap:10px}\u001b[39m\n\u001b[31m.origin-pill .ico{font-size:18px}\u001b[39m\n\u001b[31m.origin-pill .meta{flex:1;min-width:0}\u001b[39m\n\u001b[31m.origin-pill .lbl{font-size:10.5px;color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.4px}\u001b[39m\n\u001b[31m.origin-pill .val{font-size:13.5px;font-weight:700;margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.origin-pill button{background:rgba(255,255,255,.08);border-radius:14px;padding:5px 10px;font-size:11px;font-weight:700;color:var(--accent)}·\u001b[39m\n\u001b[31m.tiles{display:flex;flex-direction:column;gap:6px}\u001b[39m\n\u001b[31m.tile{background:var(--card);border:1px solid var(--border);border-radius:var(--rads);padding:9px 10px;display:flex;align-items:center;gap:10px;transition:.12s;touch-action:none}\u001b[39m\n\u001b[31m.tile.sortable-ghost{opacity:.35;background:var(--card2)}\u001b[39m\n\u001b[31m.tile.sortable-chosen{transform:scale(.98);box-shadow:0 4px 20px rgba(14,165,233,.25)}\u001b[39m\n\u001b[31m.tile.excluded{opacity:.4;background:#0c0c0c}\u001b[39m\n\u001b[31m.tile.excluded .name,.tile.excluded .city{text-decoration:line-through}\u001b[39m\n\u001b[31m.tile.err{border-color:var(--r);background:#1f0a0a}\u001b[39m\n\u001b[31m.tile .grip{font-size:18px;color:var(--muted);cursor:grab;padding:4px;line-height:1}\u001b[39m\n\u001b[31m.tile .grip:active{cursor:grabbing}\u001b[39m\n\u001b[31m.tile .ord{background:var(--accent);color:#fff;width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11.5px;font-weight:800;flex-shrink:0}\u001b[39m\n\u001b[31m.tile.excluded .ord{background:#444}\u001b[39m\n\u001b[31m.tile .info{flex:1;min-width:0}\u001b[39m\n\u001b[31m.tile .name{font-size:13.5px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .city{font-size:11.5px;color:#cbd5e1;font-weight:600;margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .addr{font-size:9.5px;color:var(--muted);margin-top:1px;line-height:1.3;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-style:italic;opacity:.75}\u001b[39m\n\u001b[31m.tile .leg{font-size:10.5px;color:var(--accent2);font-weight:600;margin-top:2px}\u001b[39m\n\u001b[31m.tile.excluded .leg{display:none}\u001b[39m\n\u001b[31m.tile .badge-provider{display:inline-block;font-size:8.5px;padding:1px 5px;border-radius:8px;background:rgba(255,255,255,.08);color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.3px;margin-left:4px;vertical-align:middle}\u001b[39m\n\u001b[31m.tile .incl{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:50%;border:2px solid var(--border);font-size:16px;font-weight:800}\u001b[39m\n\u001b[31m.tile .incl.on{background:var(--g);border-color:var(--g);color:#fff}\u001b[39m\n\u001b[31m.tile .incl.off{background:#0c0c0c;color:var(--r);border-color:var(--r)}·\u001b[39m\n\u001b[31m/* ── prominent top action bar (KM, Share, Re-optimise) ── */\u001b[39m\n\u001b[31m.top-actions{background:linear-gradient(135deg,#0c2e3a,#08111c);border:1px solid var(--accent);border-radius:var(--rad);padding:10px;display:grid;grid-template-columns:1fr auto auto;gap:8px;align-items:center;margin-bottom:6px}\u001b[39m\n\u001b[31m.top-totals{min-width:0}\u001b[39m\n\u001b[31m.top-totals-big{font-size:18px;font-weight:900;line-height:1.05;letter-spacing:.3px}\u001b[39m\n\u001b[31m.top-totals-delta{font-size:11px;color:var(--muted);margin-top:2px}\u001b[39m\n\u001b[31m.top-totals-delta.better{color:var(--g)}\u001b[39m\n\u001b[31m.top-totals-delta.worse{color:var(--y)}\u001b[39m\n\u001b[31m.top-actions button{padding:10px 12px;font-size:12px;font-weight:800;border-radius:8px;border:none;cursor:pointer;display:flex;align-items:center;gap:5px;white-space:nowrap}\u001b[39m\n\u001b[31m.top-share{background:#f59e0b;color:#000}\u001b[39m\n\u001b[31m.top-share:active{background:#d97706}\u001b[39m\n\u001b[31m.top-reopt{background:var(--accent);color:#fff}\u001b[39m\n\u001b[31m.top-reopt:active{background:var(--accent2)}\u001b[39m\n\u001b[31m/* ── Re-optimisation needed (after include/exclude toggle) ── */\u001b[39m\n\u001b[31m.reopt-needed{font-size:11px;font-weight:700;color:#fbbf24;background:rgba(234,179,8,.12);border-left:3px solid #fbbf24;padding:5px 8px;border-radius:4px;margin-top:5px;display:none;line-height:1.3}\u001b[39m\n\u001b[31m.reopt-needed.show{display:block}·\u001b[39m\n\u001b[31m/* ── inline route preview (Leaflet + OSM) ── */\u001b[39m\n\u001b[31m.map-wrap{margin:8px 0 0;background:var(--card);border:1px solid var(--border);border-radius:var(--rads);overflow:hidden}\u001b[39m\n\u001b[31m.map-hdr{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;font-size:12px;color:var(--muted);background:var(--card2);border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.map-hdr .ttl{display:flex;align-items:center;gap:6px;font-weight:600}\u001b[39m\n\u001b[31m.map-hdr .toggle{font-size:14px;color:var(--text);background:none;border:none;cursor:pointer;padding:0 4px}\u001b[39m\n\u001b[31m#tour-map{height:260px;width:100%;background:#1a1a1a;transition:height .2s ease}\u001b[39m\n\u001b[31m.map-wrap.collapsed #tour-map{height:0}\u001b[39m\n\u001b[31m.map-wrap.collapsed .map-hdr{border-bottom:none}\u001b[39m\n\u001b[31m.tour-pin{background:transparent;border:none}\u001b[39m\n\u001b[31m.tour-pin > div{background:#0ea5e9;color:#fff;border-radius:50%;width:26px;height:26px;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:12px;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,.45);font-family:'DM Sans',sans-serif}\u001b[39m\n\u001b[31m.tour-pin.origin > div{background:#22c55e;font-size:14px}\u001b[39m\n\u001b[31m.tour-pin.out > div{background:#6b7280;opacity:.55}\u001b[39m\n\u001b[31m.leaflet-tooltip{background:#141414;color:#f5f5f5;border:1px solid #262626;font-size:11px;padding:3px 6px;font-family:'DM Sans',sans-serif;box-shadow:0 1px 6px rgba(0,0,0,.4)}\u001b[39m\n\u001b[31m.leaflet-tooltip-top:before,.leaflet-tooltip-bottom:before,.leaflet-tooltip-left:before,.leaflet-tooltip-right:before{border-top-color:#262626;border-bottom-color:#262626;border-left-color:#262626;border-right-color:#262626}\u001b[39m\n\u001b[31m.leaflet-control-attribution{background:rgba(20,20,20,.85)!important;color:#a3a3a3!important;font-size:9px!important}\u001b[39m\n\u001b[31m.leaflet-control-attribution a{color:#0ea5e9!important}\u001b[39m\n\u001b[31m.top-reopt.pulse{animation:pulseReopt 1.4s ease-in-out infinite;box-shadow:0 0 0 0 rgba(14,165,233,.6)}\u001b[39m\n\u001b[31m@keyframes pulseReopt{0%{box-shadow:0 0 0 0 rgba(14,165,233,.7)}50%{box-shadow:0 0 0 8px rgba(14,165,233,0)}100%{box-shadow:0 0 0 0 rgba(14,165,233,0)}}\u001b[39m\n\u001b[31m/* Totals dim when stale to make it visually obvious */\u001b[39m\n\u001b[31m.top-totals-big.stale, .top-totals-delta.stale{opacity:.5;text-decoration:line-through;text-decoration-color:rgba(251,191,36,.6)}·\u001b[39m\n\u001b[31m/* recalc spinner that appears inline next to the top totals during recompute */\u001b[39m\n\u001b[31m.recalc{display:none;font-size:11px;color:var(--accent);margin-left:6px;vertical-align:middle}\u001b[39m\n\u001b[31m.recalc.show{display:inline-block;animation:spin 1s linear infinite}\u001b[39m\n\u001b[31m@keyframes spin{to{transform:rotate(360deg)}}·\u001b[39m\n\u001b[31m/* ── share modal ── */\u001b[39m\n\u001b[31m.modal{position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:200;display:none;align-items:flex-end;justify-content:center}\u001b[39m\n\u001b[31m.modal.show{display:flex}\u001b[39m\n\u001b[31m.modal-box{background:var(--card);border-radius:18px 18px 0 0;width:100%;max-height:85vh;overflow-y:auto;padding:16px 14px calc(16px + env(safe-area-inset-bottom));border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.modal-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\u001b[39m\n\u001b[31m.modal-ttl{font-size:15px;font-weight:800}\u001b[39m\n\u001b[31m.modal-close{font-size:24px;color:var(--muted);background:none;border:none;cursor:pointer}·\u001b[39m\n\u001b[31m/* ── toast ── */\u001b[39m\n\u001b[31m#toast{position:fixed;bottom:calc(var(--bnav-h)+16px);left:12px;right:12px;z-index:1000;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:11px 14px;font-size:13px;font-weight:600;text-align:center;transform:translateY(150px);opacity:0;transition:.25s;pointer-events:none}\u001b[39m\n\u001b[31m#toast.show{transform:translateY(0);opacity:1}\u001b[39m\n\u001b[31m#toast.ok{background:rgba(34,197,94,.18);border-color:var(--g);color:#86efac}\u001b[39m\n\u001b[31m#toast.err{background:rgba(239,68,68,.18);border-color:var(--r);color:#fca5a5}·\u001b[39m\n\u001b[31m/* ── helpers ── */\u001b[39m\n\u001b[31m.spinner{display:inline-block;width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;vertical-align:middle}·\u001b[39m\n\u001b[31m.empty{text-align:center;padding:30px 16px;color:var(--muted);font-size:12.5px}·\u001b[39m\n\u001b[31mtextarea{background:#0b1220;border:1px solid var(--border);color:var(--text);padding:10px;font-family:inherit;font-size:12px;border-radius:6px;resize:vertical;outline:none}·\u001b[39m\n\u001b[31m.kbd{background:rgba(255,255,255,.06);padding:1px 6px;border-radius:4px;font-family:monospace;font-size:11px;color:var(--muted)}\u001b[39m\n\u001b[31m</style>\u001b[39m\n\u001b[31m</head>\u001b[39m\n\u001b[31m<body>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: UPLOAD ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-upload\\\" class=\\\"scr active\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗺 Tour Playground</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Excel → optimised route → tweak</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"loadSaved()\\\" id=\\\"btn-load\\\" style=\\\"display:none\\\">↻ Load</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"location.href='/hub/'\\\">🏡 Hub</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <label class=\\\"dropzone\\\" id=\\\"dropzone\\\" for=\\\"excel-file\\\">\u001b[39m\n\u001b[31m      <input type=\\\"file\\\" id=\\\"excel-file\\\" accept=\\\".xlsx,.xls,.csv\\\">\u001b[39m\n\u001b[31m      <div class=\\\"ico\\\">📊</div>\u001b[39m\n\u001b[31m      <div class=\\\"ttl\\\">Drop Excel here, or tap to choose</div>\u001b[39m\n\u001b[31m      <div class=\\\"sub\\\">Required: <b>store name</b> + <b>address</b><br>Optional: <b>city</b>, <b>lat</b>, <b>lng</b>, <b>phone</b>, anything else.</div>\u001b[39m\n\u001b[31m    </label>·\u001b[39m\n\u001b[31m    <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\">Origin</div>\u001b[39m\n\u001b[31m      <div class=\\\"field\\\">\u001b[39m\n\u001b[31m        <label>Start point</label>\u001b[39m\n\u001b[31m        <input type=\\\"text\\\" id=\\\"inp-origin\\\" value=\\\"Mohali office Punjab\\\" placeholder=\\\"Address or place name\\\">\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div class=\\\"toggle\\\">\u001b[39m\n\u001b[31m        <input type=\\\"checkbox\\\" id=\\\"inp-roundtrip\\\" checked>\u001b[39m\n\u001b[31m        <label for=\\\"inp-roundtrip\\\" style=\\\"cursor:pointer;font-size:13px;font-weight:600\\\">Round-trip (return to start)</label>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"dest-wrap\\\" class=\\\"field\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m        <label>Final destination</label>\u001b[39m\n\u001b[31m        <input type=\\\"text\\\" id=\\\"inp-destination\\\" placeholder=\\\"Where to end if not returning to origin\\\">\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" id=\\\"btn-go\\\" onclick=\\\"parseAndOptimise()\\\" disabled>\u001b[39m\n\u001b[31m      📐 Parse + optimise route\u001b[39m\n\u001b[31m    </button>·\u001b[39m\n\u001b[31m    <div id=\\\"upload-progress\\\" style=\\\"display:none;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;font-size:12px\\\">\u001b[39m\n\u001b[31m        <span id=\\\"prog-label\\\">Geocoding…</span>\u001b[39m\n\u001b[31m        <span id=\\\"prog-count\\\" style=\\\"color:var(--muted)\\\">0 / 0</span>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div class=\\\"progress\\\"><div class=\\\"progress-bar\\\" id=\\\"prog-bar\\\" style=\\\"width:0%\\\"></div></div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div id=\\\"parsed-preview\\\" style=\\\"display:none;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:11px;font-size:12px\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\" style=\\\"margin-bottom:6px\\\">Parsed</div>\u001b[39m\n\u001b[31m      <div id=\\\"parsed-summary\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: EDITOR ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-editor\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"confirmBack()\\\">‹ New</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>Tour Editor</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"editor-sub\\\">— stops</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-2\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"openShare()\\\">📤</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"editor-body\\\">\u001b[39m\n\u001b[31m   <div class=\\\"editor-fixed\\\">·\u001b[39m\n\u001b[31m    <div class=\\\"origin-pill\\\" id=\\\"origin-pill\\\">\u001b[39m\n\u001b[31m      <span class=\\\"ico\\\">📍</span>\u001b[39m\n\u001b[31m      <div class=\\\"meta\\\">\u001b[39m\n\u001b[31m        <div class=\\\"lbl\\\">Start from</div>\u001b[39m\n\u001b[31m        <div class=\\\"val\\\" id=\\\"origin-val\\\">—</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button onclick=\\\"openOriginEdit()\\\">✏️ Change</button>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div id=\\\"origin-edit\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m        <div class=\\\"field\\\">\u001b[39m\n\u001b[31m          <label>New origin (for mid-tour replan)</label>\u001b[39m\n\u001b[31m          <input type=\\\"text\\\" id=\\\"inp-new-origin\\\" placeholder=\\\"Hotel B Mohali, or address…\\\">\u001b[39m\n\u001b[31m          <div style=\\\"font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4\\\">\u001b[39m\n\u001b[31m            Tip: toggle ✗ on completed stops, change origin to where you are now, hit Replan.\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div style=\\\"display:flex;gap:8px\\\">\u001b[39m\n\u001b[31m          <button class=\\\"btn btn-s\\\" onclick=\\\"closeOriginEdit()\\\" style=\\\"flex:1\\\">Cancel</button>\u001b[39m\n\u001b[31m          <button class=\\\"btn btn-p\\\" onclick=\\\"applyOriginAndReplan()\\\" style=\\\"flex:2\\\">🔄 Replan from here</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"top-actions\\\" id=\\\"top-actions\\\">\u001b[39m\n\u001b[31m      <div class=\\\"top-totals\\\">\u001b[39m\n\u001b[31m        <div class=\\\"top-totals-big\\\" id=\\\"top-totals-big\\\">— km · — min<span class=\\\"recalc\\\" id=\\\"recalc\\\">↻</span></div>\u001b[39m\n\u001b[31m        <div class=\\\"top-totals-delta\\\" id=\\\"top-totals-delta\\\">vs proposed: —</div>\u001b[39m\n\u001b[31m        <div class=\\\"reopt-needed\\\" id=\\\"reopt-needed\\\">⚠ Stops changed — tap 🔁 Re-optim to refresh the route plan</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"top-share\\\" onclick=\\\"openShare()\\\">📤 Share</button>\u001b[39m\n\u001b[31m      <button class=\\\"top-reopt\\\" onclick=\\\"replanAll()\\\">🔁 Re-optim</button>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"map-wrap\\\" id=\\\"map-wrap\\\">\u001b[39m\n\u001b[31m      <div class=\\\"map-hdr\\\">\u001b[39m\n\u001b[31m        <div class=\\\"ttl\\\">🗺️ Route preview <span style=\\\"opacity:.6;font-weight:400\\\">(straight-line)</span></div>\u001b[39m\n\u001b[31m        <button class=\\\"toggle\\\" id=\\\"map-toggle\\\" onclick=\\\"toggleMap()\\\" aria-label=\\\"Toggle map\\\">▾</button>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"tour-map\\\"></div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"section-title\\\" style=\\\"margin-top:2px\\\">Stops (drag ≡ to reorder · scroll ↕ to see all)</div>\u001b[39m\n\u001b[31m   </div><!-- /editor-fixed -->·\u001b[39m\n\u001b[31m   <div class=\\\"editor-scroll\\\" id=\\\"editor-scroll\\\">\u001b[39m\n\u001b[31m    <div id=\\\"tiles\\\" class=\\\"tiles\\\"></div>\u001b[39m\n\u001b[31m   </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── MODAL: SHARE ── -->\u001b[39m\n\u001b[31m<div class=\\\"modal\\\" id=\\\"modal-share\\\">\u001b[39m\n\u001b[31m  <div class=\\\"modal-box\\\">\u001b[39m\n\u001b[31m    <div class=\\\"modal-hdr\\\">\u001b[39m\n\u001b[31m      <div class=\\\"modal-ttl\\\">📤 Share / Export</div>\u001b[39m\n\u001b[31m      <button class=\\\"modal-close\\\" onclick=\\\"closeShare()\\\">×</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"share-summary\\\" style=\\\"background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:10px;font-size:12px;line-height:1.6;white-space:pre-wrap;font-family:monospace\\\"></div>\u001b[39m\n\u001b[31m    <div style=\\\"margin-top:12px;display:grid;gap:8px\\\">\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"openInGoogleMaps()\\\">🗺️ Open route in Google Maps</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"shareWhatsApp()\\\">💬 WhatsApp</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"copyText()\\\">📋 Copy text</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"downloadCsv()\\\">📥 Download CSV</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"saveLocal()\\\">💾 Save to this device</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<div id=\\\"toast\\\"></div>·\u001b[39m\n\u001b[31m<script>\u001b[39m\n\u001b[31m'use strict';·\u001b[39m\n\u001b[31mconst CACHE_VER = 'tour-pg-v11';\u001b[39m\n\u001b[31mconst PROXY     = '/tour-pg-proxy';·\u001b[39m\n\u001b[31m// ── State ────────────────────────────────────────────\u001b[39m\n\u001b[31mlet session   = null;\u001b[39m\n\u001b[31mlet myId      = '';\u001b[39m\n\u001b[31mlet myName    = '';\u001b[39m\n\u001b[31mlet state     = newState();\u001b[39m\n\u001b[31mlet _recalcTimer = null;\u001b[39m\n\u001b[31mlet _sortable    = null;·\u001b[39m\n\u001b[31mfunction newState() {\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    origin:     { text: 'Mohali office Punjab', location: null, provider: null },\u001b[39m\n\u001b[31m    destination:null,\u001b[39m\n\u001b[31m    roundTrip:  true,\u001b[39m\n\u001b[31m    stops:      [],          // { id, name, city, address, phone, location, included, legDistance_m, legDuration_s, provider, raw, geocodeErr }\u001b[39m\n\u001b[31m    proposedOrder: null,     // array of stop IDs in optimise-time order\u001b[39m\n\u001b[31m    baseTotal: { distance_m: 0, duration_s: 0 },\u001b[39m\n\u001b[31m    currentTotal: { distance_m: 0, duration_s: 0 },\u001b[39m\n\u001b[31m    lastOptimisation: null,  // 'tomtom-computeBestOrder' | 'matrix-nn-2opt'\u001b[39m\n\u001b[31m    needsReoptimize: false,  // true after include/exclude toggle → totals may be stale; prompt user to tap Re-optim\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction uid(prefix='st') { return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,6)}`; }·\u001b[39m\n\u001b[31m// ── Auth ─────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction init() {\u001b[39m\n\u001b[31m  registerSW();\u001b[39m\n\u001b[31m  const raw = localStorage.getItem('lm360-session');\u001b[39m\n\u001b[31m  // Bounce to Hub with ?next= so user lands back here after login (Hub honours\u001b[39m\n\u001b[31m  // same-origin relative paths). Pattern documented in\u001b[39m\n\u001b[31m  // feedback_hub_next_redirect.md memory file — all gated PWAs should follow.\u001b[39m\n\u001b[31m  const nextParam = '?next=' + encodeURIComponent(location.pathname + location.search);\u001b[39m\n\u001b[31m  if (!raw) { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  try { session = JSON.parse(raw); } catch { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  if (Date.now() - (session.loginAt ? new Date(session.loginAt).getTime() : 0) > 12*3600*1000) { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  myId   = session.empId || '';\u001b[39m\n\u001b[31m  myName = session.name  || myId;\u001b[39m\n\u001b[31m  const userText = `👤 <b>${escHtml(myName)}</b>`;\u001b[39m\n\u001b[31m  document.getElementById('user-pill').innerHTML   = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill-2').innerHTML = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill').style.display   = '';\u001b[39m\n\u001b[31m  document.getElementById('user-pill-2').style.display = '';·\u001b[39m\n\u001b[31m  // Load-saved chip if we have a saved tour\u001b[39m\n\u001b[31m  if (localStorage.getItem('tour-pg-saved')) document.getElementById('btn-load').style.display = '';·\u001b[39m\n\u001b[31m  // Wire UI\u001b[39m\n\u001b[31m  document.getElementById('inp-roundtrip').addEventListener('change', e => {\u001b[39m\n\u001b[31m    document.getElementById('dest-wrap').style.display = e.target.checked ? 'none' : '';\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('dragover', e => { e.preventDefault(); e.currentTarget.classList.add('drag'); });\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('dragleave', e => e.currentTarget.classList.remove('drag'));\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('drop', e => {\u001b[39m\n\u001b[31m    e.preventDefault();\u001b[39m\n\u001b[31m    e.currentTarget.classList.remove('drag');\u001b[39m\n\u001b[31m    const f = e.dataTransfer.files[0]; if (f) handleFile(f);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  document.getElementById('excel-file').addEventListener('change', e => {\u001b[39m\n\u001b[31m    const f = e.target.files[0]; if (f) handleFile(f);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction hubAuth() {\u001b[39m\n\u001b[31m  return 'Hub ' + btoa(JSON.stringify(session));\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function api(path, body) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, {\u001b[39m\n\u001b[31m    method: 'POST',\u001b[39m\n\u001b[31m    headers: { 'Authorization': hubAuth(), 'Content-Type': 'application/json' },\u001b[39m\n\u001b[31m    body: JSON.stringify(body),\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (r.status === 401) {\u001b[39m\n\u001b[31m    toast('Session expired — refreshing to re-login', 'err');\u001b[39m\n\u001b[31m    const nextParam = '?next=' + encodeURIComponent(location.pathname + location.search);\u001b[39m\n\u001b[31m    setTimeout(() => location.href = '/hub/' + nextParam, 1500);\u001b[39m\n\u001b[31m    throw new Error('unauthorized');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!r.ok) {\u001b[39m\n\u001b[31m    const txt = await r.text().catch(() => '');\u001b[39m\n\u001b[31m    throw new Error(`${path}: ${r.status} ${txt.slice(0, 100)}`);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return r.json();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Excel parsing + column detection ────────────────\u001b[39m\n\u001b[31mlet _parsedCols = null;   // exposed for the column-remap UI\u001b[39m\n\u001b[31mlet _parsedRows = null;\u001b[39m\n\u001b[31mfunction handleFile(file) {\u001b[39m\n\u001b[31m  const reader = new FileReader();\u001b[39m\n\u001b[31m  reader.onload = (e) => {\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      const wb = XLSX.read(e.target.result, { type: 'array' });\u001b[39m\n\u001b[31m      const ws = wb.Sheets[wb.SheetNames[0]];\u001b[39m\n\u001b[31m      const rows = XLSX.utils.sheet_to_json(ws, { defval: '' });\u001b[39m\n\u001b[31m      if (!rows.length) throw new Error('Empty sheet');\u001b[39m\n\u001b[31m      _parsedRows = rows;\u001b[39m\n\u001b[31m      _parsedCols = detectColumns(rows[0]);\u001b[39m\n\u001b[31m      if (!_parsedCols.name && !_parsedCols.address && !_parsedCols.city) throw new Error('Missing required columns (need at least one of: name, address, city)');\u001b[39m\n\u001b[31m      rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m      renderParsedPreview();\u001b[39m\n\u001b[31m      document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m      toast('Excel parsed ✓', 'ok');\u001b[39m\n\u001b[31m    } catch (err) {\u001b[39m\n\u001b[31m      toast('Excel error: ' + err.message, 'err');\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  reader.readAsArrayBuffer(file);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction rebuildStopsFromParsedRows() {\u001b[39m\n\u001b[31m  state.stops = _parsedRows.map((row, idx) => buildStop(row, _parsedCols, idx)).filter(s => s);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderParsedPreview() {\u001b[39m\n\u001b[31m  const skip = _parsedRows.length - state.stops.length;\u001b[39m\n\u001b[31m  const allCols = Object.keys(_parsedRows[0]);\u001b[39m\n\u001b[31m  // For each role, show:  <role>: <selected col name>  with a tiny dropdown to override\u001b[39m\n\u001b[31m  const ROLES = ['name','city','address','lat','lng','phone','notes'];\u001b[39m\n\u001b[31m  const sample = _parsedRows[0];\u001b[39m\n\u001b[31m  const previewVal = (key) => {\u001b[39m\n\u001b[31m    const v = sample[key]; if (v == null || v === '') return '';\u001b[39m\n\u001b[31m    return String(v).slice(0, 40);\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  // Column order (per user request): YOUR Excel column first, then the PWA field\u001b[39m\n\u001b[31m  // it maps to, then a sample value. Equal-ish widths via <colgroup>; the wrapper\u001b[39m\n\u001b[31m  // scrolls horizontally so long header names are never clipped on narrow phones.\u001b[39m\n\u001b[31m  let rows = '<div class=\\\"map-table-wrap\\\"><table class=\\\"map-table\\\">' +\u001b[39m\n\u001b[31m    '<colgroup><col class=\\\"c-src\\\"><col class=\\\"c-field\\\"><col class=\\\"c-sample\\\"></colgroup>' +\u001b[39m\n\u001b[31m    '<thead><tr><th>Your file column</th><th>→ PWA field</th><th>Sample value</th></tr></thead><tbody>';\u001b[39m\n\u001b[31m  for (const role of ROLES) {\u001b[39m\n\u001b[31m    const sel  = _parsedCols[role];\u001b[39m\n\u001b[31m    const need = role === 'name';\u001b[39m\n\u001b[31m    const opts = `<option value=\\\"\\\">— ignore —</option>` +\u001b[39m\n\u001b[31m      allCols.map(c => `<option value=\\\"${escHtml(c)}\\\" ${sel === c ? 'selected' : ''}>${escHtml(c)}</option>`).join('');\u001b[39m\n\u001b[31m    const previewText = sel ? escHtml(previewVal(sel)) : '';\u001b[39m\n\u001b[31m    const tip = !sel && need ? `<span style=\\\"color:var(--y);font-weight:700\\\">⚠ no match — uses city or \\\"Stop #N\\\"</span>` : '';\u001b[39m\n\u001b[31m    rows += `<tr>\u001b[39m\n\u001b[31m      <td><select onchange=\\\"remapColumn('${role}', this.value)\\\">${opts}</select></td>\u001b[39m\n\u001b[31m      <td class=\\\"map-field\\\">${role}${need?' <span style=\\\"color:var(--y)\\\">*</span>':''}</td>\u001b[39m\n\u001b[31m      <td class=\\\"map-sample\\\">${previewText}${tip}</td>\u001b[39m\n\u001b[31m    </tr>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  rows += '</tbody></table></div>';\u001b[39m\n\u001b[31m  document.getElementById('parsed-preview').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('parsed-summary').innerHTML =\u001b[39m\n\u001b[31m    `<div style=\\\"margin-bottom:6px\\\"><b>${state.stops.length}</b> stops detected` +\u001b[39m\n\u001b[31m    `${skip > 0 ? ` · ${skip} skipped (no name/city/address)` : ''}</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:10.5px;color:var(--muted);margin-bottom:6px\\\">If detection is wrong, pick the correct column from the dropdowns:</div>` +\u001b[39m\n\u001b[31m    rows;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction remapColumn(role, newCol) {\u001b[39m\n\u001b[31m  if (!_parsedCols) return;\u001b[39m\n\u001b[31m  _parsedCols[role] = newCol || null;\u001b[39m\n\u001b[31m  rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction normaliseColName(s) {\u001b[39m\n\u001b[31m  return String(s).toLowerCase().replace(/[^a-z]/g, '');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction detectColumns(sampleRow) {\u001b[39m\n\u001b[31m  const map = { name:null, city:null, address:null, lat:null, lng:null, phone:null, notes:null, others:[] };\u001b[39m\n\u001b[31m  const keys = Object.keys(sampleRow);\u001b[39m\n\u001b[31m  // Broadened aliases so most user-provided Excel files map sensibly without a UI:\u001b[39m\n\u001b[31m  //   name  ← store / shop / outlet / party / dealer / firm / customer / business /\u001b[39m\n\u001b[31m  //           company / vendor / agency (and \\\"<thing> name\\\")\u001b[39m\n\u001b[31m  //   addr  ← address / fulladdress / location / loc / addr\u001b[39m\n\u001b[31m  //   city  ← city / town / district / location (only if no address column took it)\u001b[39m\n\u001b[31m  for (const k of keys) {\u001b[39m\n\u001b[31m    const n = normaliseColName(k);\u001b[39m\n\u001b[31m    if (!map.name && /^(storename|name|store|client|outlet|party(name)?|dealer(name)?|firm(name)?|customer(name)?|shop(name)?|business(name)?|companyname|company|vendor(name)?|agency(name)?|merchant|chain|sitename|site)$/.test(n)) { map.name = k; continue; }\u001b[39m\n\u001b[31m    if (!map.address && /^(address|fulladdress|streetaddress|addr|location|loc|street)$/.test(n)) { map.address = k; continue; }\u001b[39m\n\u001b[31m    if (!map.city && /^(city|town|district|cityname|locality|area|region)$/.test(n)) { map.city = k; continue; }\u001b[39m\n\u001b[31m    if (!map.lat && /^(lat|latitude|y)$/.test(n)) { map.lat = k; continue; }\u001b[39m\n\u001b[31m    if (!map.lng && /^(lng|lon|long|longitude|x)$/.test(n)) { map.lng = k; continue; }\u001b[39m\n\u001b[31m    if (!map.phone && /^(phone(no)?|mobile(no)?|contact(no)?|tel(no)?|whatsapp|wa|cell)$/.test(n)) { map.phone = k; continue; }\u001b[39m\n\u001b[31m    if (!map.notes && /^(notes?|remarks?|comments?|description|desc|memo)$/.test(n)) { map.notes = k; continue; }\u001b[39m\n\u001b[31m    map.others.push(k);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return map;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction buildStop(row, cols, idx) {\u001b[39m\n\u001b[31m  const name    = cols.name    ? String(row[cols.name]   || '').trim() : '';\u001b[39m\n\u001b[31m  const address = cols.address ? String(row[cols.address]|| '').trim() : '';\u001b[39m\n\u001b[31m  const city    = cols.city    ? String(row[cols.city]   || '').trim() : '';\u001b[39m\n\u001b[31m  const phone   = cols.phone   ? String(row[cols.phone]  || '').trim() : '';\u001b[39m\n\u001b[31m  const notes   = cols.notes   ? String(row[cols.notes]  || '').trim() : '';\u001b[39m\n\u001b[31m  if (!name && !address && !city) return null;\u001b[39m\n\u001b[31m  // pre-resolved coords?\u001b[39m\n\u001b[31m  let location = null;\u001b[39m\n\u001b[31m  if (cols.lat && cols.lng) {\u001b[39m\n\u001b[31m    const lat = parseFloat(row[cols.lat]);\u001b[39m\n\u001b[31m    const lng = parseFloat(row[cols.lng]);\u001b[39m\n\u001b[31m    if (!isNaN(lat) && !isNaN(lng) && lat !== 0 && lng !== 0) location = { lat, lng };\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const fullAddr = [address, city].filter(Boolean).join(', ');\u001b[39m\n\u001b[31m  // Display name fallback hierarchy (NEVER truncated address — that confused users):\u001b[39m\n\u001b[31m  //   1. Explicit name column value\u001b[39m\n\u001b[31m  //   2. City + index (so excluded/duplicate cities stay distinguishable)\u001b[39m\n\u001b[31m  //   3. \\\"Stop #N\\\"\u001b[39m\n\u001b[31m  let displayName;\u001b[39m\n\u001b[31m  if (name) displayName = name;\u001b[39m\n\u001b[31m  else if (city) displayName = `${city} (stop ${idx + 1})`;\u001b[39m\n\u001b[31m  else displayName = `Stop ${idx + 1}`;\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    id:           uid('st'),\u001b[39m\n\u001b[31m    name:         displayName,\u001b[39m\n\u001b[31m    hasExplicitName: !!name,         // for UI to flag \\\"name auto-derived\\\"\u001b[39m\n\u001b[31m    city, phone, notes,\u001b[39m\n\u001b[31m    address:      fullAddr || name || '',\u001b[39m\n\u001b[31m    location,\u001b[39m\n\u001b[31m    provider:     location ? 'excel' : null,\u001b[39m\n\u001b[31m    included:     true,\u001b[39m\n\u001b[31m    legDistance_m: 0,\u001b[39m\n\u001b[31m    legDuration_s: 0,\u001b[39m\n\u001b[31m    geocodeErr:   null,\u001b[39m\n\u001b[31m    raw:          { ...row },\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Geocode + optimise ──────────────────────────────\u001b[39m\n\u001b[31masync function parseAndOptimise() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('Parse an Excel first', 'err');\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = true;\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = '';\u001b[39m\n\u001b[31m  state.origin.text  = document.getElementById('inp-origin').value.trim() || 'Mohali office Punjab';\u001b[39m\n\u001b[31m  state.roundTrip    = document.getElementById('inp-roundtrip').checked;\u001b[39m\n\u001b[31m  state.destination  = state.roundTrip ? null : (document.getElementById('inp-destination').value.trim() || null);\u001b[39m\n\u001b[31m  // Geocode origin first\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: state.origin.text });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin.location = og.location;\u001b[39m\n\u001b[31m    state.origin.provider = og.provider;\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    setProgress(0, 0, '');\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('Origin geocode failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Geocode each stop (skip if pre-resolved). Sequential to avoid rate limits.\u001b[39m\n\u001b[31m  const total = state.stops.length;\u001b[39m\n\u001b[31m  let done = 0;\u001b[39m\n\u001b[31m  for (const s of state.stops) {\u001b[39m\n\u001b[31m    if (s.location) { done++; setProgress(done, total, 'Pre-geocoded'); continue; }\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      const r = await api('/geocode', { address: s.address });\u001b[39m\n\u001b[31m      if (r.ok && r.location) {\u001b[39m\n\u001b[31m        s.location = r.location;\u001b[39m\n\u001b[31m        s.provider = r.provider;\u001b[39m\n\u001b[31m      } else {\u001b[39m\n\u001b[31m        s.geocodeErr = r.error || 'No match';\u001b[39m\n\u001b[31m      }\u001b[39m\n\u001b[31m    } catch (e) {\u001b[39m\n\u001b[31m      s.geocodeErr = e.message;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    done++;\u001b[39m\n\u001b[31m    setProgress(done, total, `Geocoding ${s.name.slice(0, 30)}…`);\u001b[39m\n\u001b[31m    // Tiny delay to be polite to the provider\u001b[39m\n\u001b[31m    await sleep(80);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Filter out failed-geocode stops\u001b[39m\n\u001b[31m  const good = state.stops.filter(s => s.location && !s.geocodeErr);\u001b[39m\n\u001b[31m  const bad  = state.stops.filter(s => !s.location || s.geocodeErr);\u001b[39m\n\u001b[31m  if (!good.length) {\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('No stops could be geocoded', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (bad.length) toast(`${bad.length} stop(s) failed geocode (excluded)`, 'err');\u001b[39m\n\u001b[31m  state.stops = good;·\u001b[39m\n\u001b[31m  // Optimise\u001b[39m\n\u001b[31m  setProgress(total, total, 'Optimising route…');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const opt = await api('/optimize', {\u001b[39m\n\u001b[31m      origin:    locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:     state.stops.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:  state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!opt.ok) throw new Error(opt.error || 'optimise failed');\u001b[39m\n\u001b[31m    // Apply order\u001b[39m\n\u001b[31m    const order = opt.waypoint_order || state.stops.map((_, i) => i);\u001b[39m\n\u001b[31m    state.stops = order.map(i => state.stops[i]);\u001b[39m\n\u001b[31m    state.proposedOrder = state.stops.map(s => s.id);\u001b[39m\n\u001b[31m    state.baseTotal     = { distance_m: opt.total_distance_m, duration_s: opt.total_duration_s };\u001b[39m\n\u001b[31m    state.currentTotal  = { ...state.baseTotal };\u001b[39m\n\u001b[31m    state.lastOptimisation = opt.optimisation;\u001b[39m\n\u001b[31m    state.needsReoptimize  = false;   // fresh optimise — flag cleared\u001b[39m\n\u001b[31m    // Map legs to stops (route returned len = stops + 1 (round-trip) or stops + 1 (one-way))\u001b[39m\n\u001b[31m    if (Array.isArray(opt.legs) && opt.legs.length >= state.stops.length) {\u001b[39m\n\u001b[31m      state.stops.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = opt.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('Optimise failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m  showScreen('s-editor');\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction setProgress(done, total, label) {\u001b[39m\n\u001b[31m  const pct = total ? Math.round(done/total*100) : 0;\u001b[39m\n\u001b[31m  document.getElementById('prog-bar').style.width   = pct + '%';\u001b[39m\n\u001b[31m  document.getElementById('prog-count').textContent = `${done} / ${total}`;\u001b[39m\n\u001b[31m  document.getElementById('prog-label').textContent = label;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction locStr(loc) { return loc ? `${loc.lat},${loc.lng}` : null; }\u001b[39m\n\u001b[31mconst sleep = ms => new Promise(r => setTimeout(r, ms));·\u001b[39m\n\u001b[31m// ── Editor screen ───────────────────────────────────\u001b[39m\n\u001b[31mfunction renderEditor() {\u001b[39m\n\u001b[31m  restoreMapPref();\u001b[39m\n\u001b[31m  document.getElementById('origin-val').textContent = state.origin.text;\u001b[39m\n\u001b[31m  document.getElementById('editor-sub').textContent =\u001b[39m\n\u001b[31m    `${state.stops.filter(s => s.included).length} of ${state.stops.length} included` +\u001b[39m\n\u001b[31m    (state.lastOptimisation ? ` · ${state.lastOptimisation}` : '');\u001b[39m\n\u001b[31m  const cont = document.getElementById('tiles');\u001b[39m\n\u001b[31m  cont.innerHTML = '';\u001b[39m\n\u001b[31m  state.stops.forEach((s, idx) => cont.appendChild(renderTile(s, idx)));\u001b[39m\n\u001b[31m  if (_sortable) _sortable.destroy();\u001b[39m\n\u001b[31m  _sortable = Sortable.create(cont, {\u001b[39m\n\u001b[31m    handle: '.grip',\u001b[39m\n\u001b[31m    animation: 150,\u001b[39m\n\u001b[31m    onEnd: (evt) => {\u001b[39m\n\u001b[31m      const moved = state.stops.splice(evt.oldIndex, 1)[0];\u001b[39m\n\u001b[31m      state.stops.splice(evt.newIndex, 0, moved);\u001b[39m\n\u001b[31m      renderEditor();\u001b[39m\n\u001b[31m      scheduleRecalc();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  updateTotalsBar();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderTile(s, idx) {\u001b[39m\n\u001b[31m  const el = document.createElement('div');\u001b[39m\n\u001b[31m  el.className = 'tile' + (s.included ? '' : ' excluded') + (s.geocodeErr ? ' err' : '');\u001b[39m\n\u001b[31m  el.dataset.id = s.id;\u001b[39m\n\u001b[31m  const providerBadge = s.provider ? `<span class=\\\"badge-provider\\\">${escHtml(s.provider)}</span>` : '';\u001b[39m\n\u001b[31m  const leg = s.legDistance_m > 0\u001b[39m\n\u001b[31m    ? `${(s.legDistance_m/1000).toFixed(1)} km · ${Math.round(s.legDuration_s/60)} min from prev`\u001b[39m\n\u001b[31m    : '';\u001b[39m\n\u001b[31m  // Per UX rule: Name + City are the primary display. Address is shown\u001b[39m\n\u001b[31m  // smaller below as supporting context. Falls back gracefully when city\u001b[39m\n\u001b[31m  // is missing.\u001b[39m\n\u001b[31m  const cityLine = s.city || '—';\u001b[39m\n\u001b[31m  const addrLine = s.address && s.address !== s.city ? s.address : '';\u001b[39m\n\u001b[31m  el.innerHTML = `\u001b[39m\n\u001b[31m    <span class=\\\"grip\\\">≡</span>\u001b[39m\n\u001b[31m    <span class=\\\"ord\\\">${idx + 1}</span>\u001b[39m\n\u001b[31m    <div class=\\\"info\\\">\u001b[39m\n\u001b[31m      <div class=\\\"name\\\">${escHtml(s.name)} ${providerBadge}</div>\u001b[39m\n\u001b[31m      <div class=\\\"city\\\">${escHtml(cityLine)}</div>\u001b[39m\n\u001b[31m      ${addrLine ? `<div class=\\\"addr\\\">${escHtml(addrLine)}</div>` : ''}\u001b[39m\n\u001b[31m      ${leg ? `<div class=\\\"leg\\\">${escHtml(leg)}</div>` : ''}\u001b[39m\n\u001b[31m      ${s.geocodeErr ? `<div class=\\\"leg\\\" style=\\\"color:var(--r)\\\">⚠ ${escHtml(s.geocodeErr)}</div>` : ''}\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"incl ${s.included ? 'on' : 'off'}\\\" onclick=\\\"toggleInclude('${s.id}')\\\" title=\\\"Include / exclude\\\">\u001b[39m\n\u001b[31m      ${s.included ? '✓' : '✗'}\u001b[39m\n\u001b[31m    </button>`;\u001b[39m\n\u001b[31m  return el;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleInclude(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id); if (!s) return;\u001b[39m\n\u001b[31m  s.included = !s.included;\u001b[39m\n\u001b[31m  // The SET of stops just changed — the existing route may no longer be optimal.\u001b[39m\n\u001b[31m  // Surface a banner + pulse the Re-optim button so the user knows to refresh.\u001b[39m\n\u001b[31m  // recalcRoute still fires (debounced) to update the cost of the CURRENT order,\u001b[39m\n\u001b[31m  // but that number is just informational until the user re-optimises.\u001b[39m\n\u001b[31m  state.needsReoptimize = true;\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  scheduleRecalc();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction scheduleRecalc() {\u001b[39m\n\u001b[31m  clearTimeout(_recalcTimer);\u001b[39m\n\u001b[31m  document.getElementById('recalc').classList.add('show');\u001b[39m\n\u001b[31m  _recalcTimer = setTimeout(recalcRoute, 400);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function recalcRoute() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  if (!included.length) {\u001b[39m\n\u001b[31m    state.currentTotal = { distance_m: 0, duration_s: 0 };\u001b[39m\n\u001b[31m    document.getElementById('recalc').classList.remove('show');\u001b[39m\n\u001b[31m    updateTotalsBar();\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/route', {\u001b[39m\n\u001b[31m      origin:      locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:       included.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:   state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!r.ok) throw new Error(r.error || 'route failed');\u001b[39m\n\u001b[31m    state.currentTotal = { distance_m: r.total_distance_m, duration_s: r.total_duration_s };\u001b[39m\n\u001b[31m    // Map legs back to tiles (legs index 0 = origin→stop0, n = lastStop→dest)\u001b[39m\n\u001b[31m    if (Array.isArray(r.legs)) {\u001b[39m\n\u001b[31m      included.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = r.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    // Clear leg info on excluded stops\u001b[39m\n\u001b[31m    state.stops.filter(s => !s.included).forEach(s => { s.legDistance_m = 0; s.legDuration_s = 0; });\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Recalc failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  } finally {\u001b[39m\n\u001b[31m    document.getElementById('recalc').classList.remove('show');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateTotalsBar() {\u001b[39m\n\u001b[31m  // Totals now live ONLY in the top-actions card (the sticky bottom bar was\u001b[39m\n\u001b[31m  // removed after user feedback that it duplicated the top info 2026-06-04).\u001b[39m\n\u001b[31m  // The big-text element holds the inline recalc spinner as a child <span>.\u001b[39m\n\u001b[31m  const km  = (state.currentTotal.distance_m / 1000).toFixed(1);\u001b[39m\n\u001b[31m  const min = Math.round(state.currentTotal.duration_s / 60);\u001b[39m\n\u001b[31m  const hrs = Math.floor(min / 60), mm = min % 60;\u001b[39m\n\u001b[31m  const timeStr = hrs > 0 ? `${hrs}h ${mm}min` : `${mm} min`;\u001b[39m\n\u001b[31m  const totalsText = `${km} km · ${timeStr}`;\u001b[39m\n\u001b[31m  const bigEl  = document.getElementById('top-totals-big');\u001b[39m\n\u001b[31m  const recalc = document.getElementById('recalc');\u001b[39m\n\u001b[31m  bigEl.textContent = totalsText;\u001b[39m\n\u001b[31m  if (recalc) bigEl.appendChild(document.createTextNode(' '));\u001b[39m\n\u001b[31m  if (recalc) bigEl.appendChild(recalc);\u001b[39m\n\u001b[31m  // Delta vs base\u001b[39m\n\u001b[31m  const baseKm   = state.baseTotal.distance_m / 1000;\u001b[39m\n\u001b[31m  const curKm    = state.currentTotal.distance_m / 1000;\u001b[39m\n\u001b[31m  const deltaKm  = curKm - baseKm;\u001b[39m\n\u001b[31m  const deltaMin = Math.round((state.currentTotal.duration_s - state.baseTotal.duration_s) / 60);\u001b[39m\n\u001b[31m  const dT = document.getElementById('top-totals-delta');\u001b[39m\n\u001b[31m  let deltaText, deltaClass;\u001b[39m\n\u001b[31m  if (Math.abs(deltaKm) < 0.05) {\u001b[39m\n\u001b[31m    deltaText  = 'matches proposed route';\u001b[39m\n\u001b[31m    deltaClass = '';\u001b[39m\n\u001b[31m  } else {\u001b[39m\n\u001b[31m    const sign = deltaKm > 0 ? '+' : '';\u001b[39m\n\u001b[31m    deltaText  = `vs proposed: ${sign}${deltaKm.toFixed(1)} km · ${sign}${deltaMin} min`;\u001b[39m\n\u001b[31m    deltaClass = deltaKm < 0 ? 'better' : 'worse';\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  dT.textContent = deltaText;\u001b[39m\n\u001b[31m  dT.className   = 'top-totals-delta ' + deltaClass;·\u001b[39m\n\u001b[31m  // ── Re-optim-needed banner + pulse + stale styling ──\u001b[39m\n\u001b[31m  // Surfaces a clear message whenever the user has added/removed stops since\u001b[39m\n\u001b[31m  // the last /optimize call. The current-order cost is still shown (so the\u001b[39m\n\u001b[31m  // user sees \\\"what this order costs\\\"), but it's struck-through and the\u001b[39m\n\u001b[31m  // Re-optim button pulses to draw attention.\u001b[39m\n\u001b[31m  const needsReopt = !!state.needsReoptimize;\u001b[39m\n\u001b[31m  document.getElementById('reopt-needed').classList.toggle('show', needsReopt);\u001b[39m\n\u001b[31m  bigEl.classList.toggle('stale', needsReopt);\u001b[39m\n\u001b[31m  dT.classList.toggle('stale', needsReopt);\u001b[39m\n\u001b[31m  document.querySelector('.top-reopt')?.classList.toggle('pulse', needsReopt);·\u001b[39m\n\u001b[31m  // Re-render inline map preview to reflect current order/include flags.\u001b[39m\n\u001b[31m  // Debounced via setTimeout 0 so multiple updates in the same tick coalesce.\u001b[39m\n\u001b[31m  scheduleMapRender();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Inline route preview (Leaflet + free OSM tiles) ──────────────\u001b[39m\n\u001b[31m// Renders origin + stops + straight-line polyline inside the editor screen.\u001b[39m\n\u001b[31m// \\\"Straight-line\\\" because the proxy only returns leg distances/durations,\u001b[39m\n\u001b[31m// not the per-leg point arrays from TomTom — this preview is enough for\u001b[39m\n\u001b[31m// orientation; \\\"Open in Google Maps\\\" gives the user the real road geometry.\u001b[39m\n\u001b[31mlet _leafletMap   = null;\u001b[39m\n\u001b[31mlet _leafletLayer = null;\u001b[39m\n\u001b[31mlet _mapRenderTimer = null;·\u001b[39m\n\u001b[31mfunction ensureMap() {\u001b[39m\n\u001b[31m  if (_leafletMap) return _leafletMap;\u001b[39m\n\u001b[31m  if (typeof L === 'undefined') return null;   // CDN not ready yet\u001b[39m\n\u001b[31m  const el = document.getElementById('tour-map');\u001b[39m\n\u001b[31m  if (!el || !el.offsetParent) return null;    // container not visible yet\u001b[39m\n\u001b[31m  _leafletMap = L.map(el, { attributionControl: false, zoomControl: true, preferCanvas: true });\u001b[39m\n\u001b[31m  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18 })\u001b[39m\n\u001b[31m    .addTo(_leafletMap);\u001b[39m\n\u001b[31m  L.control.attribution({ position: 'bottomright', prefix: false })\u001b[39m\n\u001b[31m    .addAttribution('© OpenStreetMap contributors')\u001b[39m\n\u001b[31m    .addTo(_leafletMap);\u001b[39m\n\u001b[31m  return _leafletMap;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderMap() {\u001b[39m\n\u001b[31m  const map = ensureMap();\u001b[39m\n\u001b[31m  if (!map) return;\u001b[39m\n\u001b[31m  if (_leafletLayer) { map.removeLayer(_leafletLayer); _leafletLayer = null; }\u001b[39m\n\u001b[31m  const layers = [];\u001b[39m\n\u001b[31m  const linePts = [];\u001b[39m\n\u001b[31m  // Origin marker (always first in line)\u001b[39m\n\u001b[31m  if (state.origin?.location) {\u001b[39m\n\u001b[31m    const ll = [state.origin.location.lat, state.origin.location.lng];\u001b[39m\n\u001b[31m    layers.push(L.marker(ll, { icon: L.divIcon({\u001b[39m\n\u001b[31m      className: 'tour-pin origin', html: '<div>🏁</div>',\u001b[39m\n\u001b[31m      iconSize: [28, 28], iconAnchor: [14, 14],\u001b[39m\n\u001b[31m    })}).bindTooltip('Start: ' + (state.origin.text || 'origin'), { direction: 'top' }));\u001b[39m\n\u001b[31m    linePts.push(ll);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Stops in current visit order\u001b[39m\n\u001b[31m  let visitNum = 0;\u001b[39m\n\u001b[31m  state.stops.forEach(s => {\u001b[39m\n\u001b[31m    if (!s.location) return;\u001b[39m\n\u001b[31m    const ll = [s.location.lat, s.location.lng];\u001b[39m\n\u001b[31m    const included = s.included;\u001b[39m\n\u001b[31m    if (included) visitNum++;\u001b[39m\n\u001b[31m    layers.push(L.marker(ll, { icon: L.divIcon({\u001b[39m\n\u001b[31m      className: 'tour-pin' + (included ? '' : ' out'),\u001b[39m\n\u001b[31m      html: `<div>${included ? visitNum : '·'}</div>`,\u001b[39m\n\u001b[31m      iconSize: [26, 26], iconAnchor: [13, 13],\u001b[39m\n\u001b[31m    })}).bindTooltip(\u001b[39m\n\u001b[31m      (included ? `${visitNum}. ` : '✗ ') + s.name + (s.city ? ` · ${s.city}` : ''),\u001b[39m\n\u001b[31m      { direction: 'top' }\u001b[39m\n\u001b[31m    ));\u001b[39m\n\u001b[31m    if (included) linePts.push(ll);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  // Close the loop on round-trip\u001b[39m\n\u001b[31m  if (state.roundTrip && state.origin?.location) {\u001b[39m\n\u001b[31m    linePts.push([state.origin.location.lat, state.origin.location.lng]);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (linePts.length >= 2) {\u001b[39m\n\u001b[31m    layers.push(L.polyline(linePts, {\u001b[39m\n\u001b[31m      color: '#0ea5e9', weight: 3, opacity: 0.85, dashArray: '6 4',\u001b[39m\n\u001b[31m    }));\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  _leafletLayer = L.layerGroup(layers).addTo(map);\u001b[39m\n\u001b[31m  if (linePts.length) {\u001b[39m\n\u001b[31m    try { map.fitBounds(L.latLngBounds(linePts), { padding: [30, 30], maxZoom: 13 }); }\u001b[39m\n\u001b[31m    catch (_) {}\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction scheduleMapRender() {\u001b[39m\n\u001b[31m  if (_mapRenderTimer) return;\u001b[39m\n\u001b[31m  _mapRenderTimer = setTimeout(() => { _mapRenderTimer = null; renderMap(); }, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleMap() {\u001b[39m\n\u001b[31m  const wrap = document.getElementById('map-wrap');\u001b[39m\n\u001b[31m  const tog  = document.getElementById('map-toggle');\u001b[39m\n\u001b[31m  const open = !wrap.classList.toggle('collapsed');\u001b[39m\n\u001b[31m  tog.textContent = open ? '▾' : '▴';\u001b[39m\n\u001b[31m  try { localStorage.setItem('tour-pg-map-open', open ? '1' : '0'); } catch (_) {}\u001b[39m\n\u001b[31m  if (open && _leafletMap) {\u001b[39m\n\u001b[31m    // After expansion the container size changed — Leaflet must recompute\u001b[39m\n\u001b[31m    setTimeout(() => { _leafletMap.invalidateSize(); renderMap(); }, 220);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction restoreMapPref() {\u001b[39m\n\u001b[31m  // Default collapsed — user must explicitly expand once for it to stick open.\u001b[39m\n\u001b[31m  let open = false;\u001b[39m\n\u001b[31m  try { open = localStorage.getItem('tour-pg-map-open') === '1'; } catch (_) {}\u001b[39m\n\u001b[31m  const wrap = document.getElementById('map-wrap');\u001b[39m\n\u001b[31m  const tog  = document.getElementById('map-toggle');\u001b[39m\n\u001b[31m  wrap.classList.toggle('collapsed', !open);\u001b[39m\n\u001b[31m  tog.textContent = open ? '▾' : '▴';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Origin edit / mid-tour replan ────────────────────\u001b[39m\n\u001b[31mfunction openOriginEdit() {\u001b[39m\n\u001b[31m  document.getElementById('origin-edit').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('inp-new-origin').value = state.origin.text;\u001b[39m\n\u001b[31m  document.getElementById('inp-new-origin').focus();\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction closeOriginEdit() {\u001b[39m\n\u001b[31m  document.getElementById('origin-edit').style.display = 'none';\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31masync function applyOriginAndReplan() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('inp-new-origin').value.trim();\u001b[39m\n\u001b[31m  if (!txt) return toast('Enter an origin', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: txt });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin = { text: txt, location: og.location, provider: og.provider };\u001b[39m\n\u001b[31m    closeOriginEdit();\u001b[39m\n\u001b[31m    await replanAll();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Origin failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function replanAll() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  if (!included.length) return toast('No stops included', 'err');\u001b[39m\n\u001b[31m  toast('Optimising…', 'ok');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const opt = await api('/optimize', {\u001b[39m\n\u001b[31m      origin:      locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:       included.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:   state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!opt.ok) throw new Error(opt.error || 'optimise failed');\u001b[39m\n\u001b[31m    const order = opt.waypoint_order || included.map((_, i) => i);\u001b[39m\n\u001b[31m    const reordered = order.map(i => included[i]);\u001b[39m\n\u001b[31m    // KEEP EXCLUDED STOPS IN THEIR EXISTING POSITIONS (per user feedback\u001b[39m\n\u001b[31m    // 2026-06-04). Excluded slots stay put; reordered-included stops fill\u001b[39m\n\u001b[31m    // the gaps. Previously appended at end (confusing) → then dropped\u001b[39m\n\u001b[31m    // (lossy) → final: stay where they are. User can re-include to put\u001b[39m\n\u001b[31m    // them back into the optimised route.\u001b[39m\n\u001b[31m    const newStops = [];\u001b[39m\n\u001b[31m    let r = 0;\u001b[39m\n\u001b[31m    for (const s of state.stops) {\u001b[39m\n\u001b[31m      if (s.included) newStops.push(reordered[r++]);\u001b[39m\n\u001b[31m      else            newStops.push(s);\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    state.stops = newStops;\u001b[39m\n\u001b[31m    state.proposedOrder = state.stops.map(s => s.id);\u001b[39m\n\u001b[31m    state.baseTotal     = { distance_m: opt.total_distance_m, duration_s: opt.total_duration_s };\u001b[39m\n\u001b[31m    state.currentTotal  = { ...state.baseTotal };\u001b[39m\n\u001b[31m    state.lastOptimisation = opt.optimisation;\u001b[39m\n\u001b[31m    state.needsReoptimize  = false;   // re-optim done — banner cleared\u001b[39m\n\u001b[31m    if (Array.isArray(opt.legs)) {\u001b[39m\n\u001b[31m      reordered.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = opt.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Re-optimised ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Replan failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Share / export ──────────────────────────────────\u001b[39m\n\u001b[31mfunction openShare() {\u001b[39m\n\u001b[31m  const km   = (state.currentTotal.distance_m / 1000).toFixed(1);\u001b[39m\n\u001b[31m  const min  = Math.round(state.currentTotal.duration_s / 60);\u001b[39m\n\u001b[31m  const hrs  = Math.floor(min / 60), mm = min % 60;\u001b[39m\n\u001b[31m  const timeStr = hrs > 0 ? `${hrs}h ${mm}min` : `${mm} min`;\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  let txt = `🚗 Tour plan (${new Date().toLocaleDateString('en-IN')})\\\\n`;\u001b[39m\n\u001b[31m  txt += `From: ${state.origin.text}\\\\n`;\u001b[39m\n\u001b[31m  txt += `${included.length} stops · ${km} km · ${timeStr}\\\\n`;\u001b[39m\n\u001b[31m  if (state.roundTrip) txt += `Round-trip\\\\n\\\\n`;\u001b[39m\n\u001b[31m  else                  txt += `One-way → ${state.destination || included[included.length-1]?.address || ''}\\\\n\\\\n`;\u001b[39m\n\u001b[31m  included.forEach((s, i) => {\u001b[39m\n\u001b[31m    const legKm  = (s.legDistance_m/1000).toFixed(1);\u001b[39m\n\u001b[31m    const legMin = Math.round(s.legDuration_s/60);\u001b[39m\n\u001b[31m    txt += `${i+1}. ${s.name}`;\u001b[39m\n\u001b[31m    if (s.city) txt += ` · ${s.city}`;\u001b[39m\n\u001b[31m    if (s.legDistance_m > 0) txt += `  (+${legKm}km · ${legMin}min)`;\u001b[39m\n\u001b[31m    if (s.phone) txt += `  ${s.phone}`;\u001b[39m\n\u001b[31m    txt += `\\\\n`;\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (state.roundTrip) txt += `↩ Return to ${state.origin.text}`;\u001b[39m\n\u001b[31m  document.getElementById('share-summary').textContent = txt;\u001b[39m\n\u001b[31m  document.getElementById('modal-share').classList.add('show');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction closeShare() {\u001b[39m\n\u001b[31m  document.getElementById('modal-share').classList.remove('show');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction shareWhatsApp() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('share-summary').textContent;\u001b[39m\n\u001b[31m  const url = `https://wa.me/?text=${encodeURIComponent(txt)}`;\u001b[39m\n\u001b[31m  window.open(url, '_blank');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Hands off the optimised tour to Google Maps Directions (free, zero infra).\u001b[39m\n\u001b[31m// Uses path-style URL `dir/A/B/C/...` because it accepts many more stops than\u001b[39m\n\u001b[31m// the `api=1` Directions URL (which Google caps at 9 waypoints). Each segment\u001b[39m\n\u001b[31m// is \\\"lat,lng\\\" for geocoded points (most reliable). If the user picked a\u001b[39m\n\u001b[31m// text destination on a one-way tour we URL-encode it as the last segment.\u001b[39m\n\u001b[31mfunction openInGoogleMaps() {\u001b[39m\n\u001b[31m  if (!state.origin?.location) { toast('Origin not yet geocoded', 'err'); return; }\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included && s.location);\u001b[39m\n\u001b[31m  if (included.length === 0)   { toast('No included stops to view', 'err'); return; }\u001b[39m\n\u001b[31m  const fmt = (loc) => `${loc.lat.toFixed(6)},${loc.lng.toFixed(6)}`;\u001b[39m\n\u001b[31m  const segs = [ fmt(state.origin.location) ];\u001b[39m\n\u001b[31m  included.forEach(s => segs.push(fmt(s.location)));\u001b[39m\n\u001b[31m  if (state.roundTrip) {\u001b[39m\n\u001b[31m    segs.push(fmt(state.origin.location));\u001b[39m\n\u001b[31m  } else if (state.destination) {\u001b[39m\n\u001b[31m    segs.push(encodeURIComponent(state.destination));\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // travelmode and dir_action=navigate auto-open in driving directions\u001b[39m\n\u001b[31m  const url = `https://www.google.com/maps/dir/${segs.join('/')}/?travelmode=driving`;\u001b[39m\n\u001b[31m  window.open(url, '_blank', 'noopener');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function copyText() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('share-summary').textContent;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await navigator.clipboard.writeText(txt);\u001b[39m\n\u001b[31m    toast('Copied ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Copy failed', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction downloadCsv() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  const rows = [['#','Name','City','Address','Phone','Lat','Lng','Leg km','Leg min','Provider']];\u001b[39m\n\u001b[31m  included.forEach((s, i) => {\u001b[39m\n\u001b[31m    rows.push([\u001b[39m\n\u001b[31m      i + 1,\u001b[39m\n\u001b[31m      s.name, s.city, s.address, s.phone,\u001b[39m\n\u001b[31m      s.location?.lat ?? '', s.location?.lng ?? '',\u001b[39m\n\u001b[31m      (s.legDistance_m/1000).toFixed(2),\u001b[39m\n\u001b[31m      Math.round(s.legDuration_s/60),\u001b[39m\n\u001b[31m      s.provider || '',\u001b[39m\n\u001b[31m    ]);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  const csv = rows.map(r => r.map(c => `\\\"${String(c).replace(/\\\"/g, '\\\"\\\"')}\\\"`).join(',')).join('\\\\n');\u001b[39m\n\u001b[31m  const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });\u001b[39m\n\u001b[31m  const url = URL.createObjectURL(blob);\u001b[39m\n\u001b[31m  const a = document.createElement('a');\u001b[39m\n\u001b[31m  a.href = url; a.download = `tour-${new Date().toISOString().slice(0,10)}.csv`;\u001b[39m\n\u001b[31m  document.body.appendChild(a); a.click(); a.remove();\u001b[39m\n\u001b[31m  URL.revokeObjectURL(url);\u001b[39m\n\u001b[31m  toast('CSV downloaded ✓', 'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction saveLocal() {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    localStorage.setItem('tour-pg-saved', JSON.stringify({\u001b[39m\n\u001b[31m      saved_at: new Date().toISOString(),\u001b[39m\n\u001b[31m      state,\u001b[39m\n\u001b[31m    }));\u001b[39m\n\u001b[31m    toast('Saved ✓', 'ok');\u001b[39m\n\u001b[31m    document.getElementById('btn-load').style.display = '';\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed (storage full?)', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction loadSaved() {\u001b[39m\n\u001b[31m  const raw = localStorage.getItem('tour-pg-saved');\u001b[39m\n\u001b[31m  if (!raw) return toast('No saved tour', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const { state: s } = JSON.parse(raw);\u001b[39m\n\u001b[31m    state = s;\u001b[39m\n\u001b[31m    showScreen('s-editor');\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Loaded ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Load failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Navigation ──────────────────────────────────────\u001b[39m\n\u001b[31mfunction confirmBack() {\u001b[39m\n\u001b[31m  if (!confirm('Start a new tour? Current state will be cleared (use Save first if you want to keep it).')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m  document.getElementById('parsed-preview').style.display = 'none';\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = true;\u001b[39m\n\u001b[31m  document.getElementById('excel-file').value = '';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showScreen(id) {\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById(id).classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Utils ───────────────────────────────────────────\u001b[39m\n\u001b[31mfunction escHtml(s) { if (s == null) return ''; return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\\\"/g,'&quot;'); }\u001b[39m\n\u001b[31mlet _toastTimer;\u001b[39m\n\u001b[31mfunction toast(msg, type='') {\u001b[39m\n\u001b[31m  const el = document.getElementById('toast');\u001b[39m\n\u001b[31m  el.textContent = msg; el.className = 'show ' + type;\u001b[39m\n\u001b[31m  clearTimeout(_toastTimer);\u001b[39m\n\u001b[31m  _toastTimer = setTimeout(() => el.className = '', 3200);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction registerSW() {\u001b[39m\n\u001b[31m  if (!('serviceWorker' in navigator)) return;\u001b[39m\n\u001b[31m  const sw = `\u001b[39m\n\u001b[31mconst C='${CACHE_VER}';\u001b[39m\n\u001b[31mself.addEventListener('install', e => self.skipWaiting());\u001b[39m\n\u001b[31mself.addEventListener('activate', e => {\u001b[39m\n\u001b[31m  e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== C).map(k => caches.delete(k)))));\u001b[39m\n\u001b[31m  self.clients.claim();\u001b[39m\n\u001b[31m});\u001b[39m\n\u001b[31mself.addEventListener('fetch', e => {\u001b[39m\n\u001b[31m  if (e.request.method !== 'GET') return;\u001b[39m\n\u001b[31m  const url = new URL(e.request.url);\u001b[39m\n\u001b[31m  if (url.pathname.startsWith('/tour-pg-proxy')) return;\u001b[39m\n\u001b[31m  if (url.pathname.startsWith('/tour-pg/')) {\u001b[39m\n\u001b[31m    e.respondWith(\u001b[39m\n\u001b[31m      caches.open(C).then(c =>\u001b[39m\n\u001b[31m        c.match(e.request).then(r => r || fetch(e.request).then(f => { c.put(e.request, f.clone()); return f; }))\u001b[39m\n\u001b[31m      )\u001b[39m\n\u001b[31m    );\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m});`;\u001b[39m\n\u001b[31m  const blob = new Blob([sw], { type: 'application/javascript' });\u001b[39m\n\u001b[31m  navigator.serviceWorker.register(URL.createObjectURL(blob), { scope: '/tour-pg/' }).catch(() => {});\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31minit();\u001b[39m\n\u001b[31m</script>·\u001b[39m\n\u001b[31m</body>\u001b[39m\n\u001b[31m</html>\u001b[39m\n\u001b[31m\"\u001b[39m\n    at /var/www/360lm/tests/tour_pg.spec.js:54:18",
                        "location": {
                          "file": "/var/www/360lm/tests/tour_pg.spec.js",
                          "column": 18,
                          "line": 54
                        },
                        "snippet": "  52 |     const ctx = await request.newContext();\n  53 |     const html = await (await ctx.get(BASE_URL)).text();\n> 54 |     expect(html).toContain('tour-pg-v10');\n     |                  ^\n  55 |     expect(html).toContain('Tour Playground');\n  56 |     await ctx.dispose();\n  57 |   });"
                      },
                      "errors": [
                        {
                          "location": {
                            "file": "/var/www/360lm/tests/tour_pg.spec.js",
                            "column": 18,
                            "line": 54
                          },
                          "message": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoContain\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // indexOf\u001b[22m\n\nExpected substring: \u001b[32m\"tour-pg-v10\"\u001b[39m\nReceived string:    \u001b[31m\"<!DOCTYPE html>\u001b[39m\n\u001b[31m<html lang=\\\"en\\\">\u001b[39m\n\u001b[31m<head>\u001b[39m\n\u001b[31m<meta charset=\\\"UTF-8\\\">\u001b[39m\n\u001b[31m<meta name=\\\"viewport\\\" content=\\\"width=device-width,initial-scale=1,viewport-fit=cover\\\">\u001b[39m\n\u001b[31m<title>Tour Planner Playground</title>\u001b[39m\n\u001b[31m<link rel=\\\"manifest\\\" href=\\\"manifest.json\\\">\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"/shared/safe-bottom.css\\\">\u001b[39m\n\u001b[31m<meta name=\\\"theme-color\\\" content=\\\"#0a0a0a\\\">\u001b[39m\n\u001b[31m<link rel=\\\"preconnect\\\" href=\\\"https://fonts.googleapis.com\\\">\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&display=swap\\\">\u001b[39m\n\u001b[31m<script src=\\\"https://cdn.sheetjs.com/xlsx-0.20.2/package/dist/xlsx.full.min.js\\\"></script>\u001b[39m\n\u001b[31m<script src=\\\"https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js\\\"></script>\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.css\\\" crossorigin=\\\"\\\">\u001b[39m\n\u001b[31m<script src=\\\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.js\\\" crossorigin=\\\"\\\" defer></script>\u001b[39m\n\u001b[31m<style>\u001b[39m\n\u001b[31m:root{\u001b[39m\n\u001b[31m  --bg:#0a0a0a;--card:#141414;--card2:#1a1a1a;--border:#262626;--text:#f5f5f5;--muted:#a3a3a3;\u001b[39m\n\u001b[31m  --accent:#0ea5e9;--accent2:#0284c7;--g:#22c55e;--r:#ef4444;--y:#eab308;--p:#a855f7;\u001b[39m\n\u001b[31m  --rad:12px;--rads:8px;--bnav-h:64px;\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31m*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}\u001b[39m\n\u001b[31mbody{background:var(--bg);color:var(--text);font-family:'DM Sans',sans-serif;min-height:100vh;overscroll-behavior-y:none}\u001b[39m\n\u001b[31mbutton{font-family:inherit;cursor:pointer;border:none;background:none;color:inherit}\u001b[39m\n\u001b[31ma{color:inherit;text-decoration:none}·\u001b[39m\n\u001b[31m.scr{display:none;flex-direction:column;min-height:100vh;padding-bottom:calc(var(--bnav-h) + env(safe-area-inset-bottom))}\u001b[39m\n\u001b[31m.scr.active{display:flex}·\u001b[39m\n\u001b[31m.hdr{background:var(--card);border-bottom:1px solid var(--border);padding:12px 14px;display:flex;align-items:center;gap:8px;position:sticky;top:0;z-index:10}\u001b[39m\n\u001b[31m.hdr h1{font-size:15px;font-weight:800;flex:1;min-width:0}\u001b[39m\n\u001b[31m.hdr-sub{font-size:11px;color:var(--muted)}\u001b[39m\n\u001b[31m.hdr-btn{background:rgba(255,255,255,.08);border:1px solid var(--border);font-size:12px;font-weight:700;padding:5px 10px;border-radius:14px;cursor:pointer;white-space:nowrap}\u001b[39m\n\u001b[31m.hdr-btn:active{background:rgba(255,255,255,.16)}\u001b[39m\n\u001b[31m.user-pill{background:rgba(255,255,255,.1);border:1px solid var(--border);color:var(--muted);font-size:10px;font-weight:600;padding:3px 8px;border-radius:11px}\u001b[39m\n\u001b[31m.user-pill b{color:var(--text)}·\u001b[39m\n\u001b[31m.body{flex:1;padding:14px;display:flex;flex-direction:column;gap:12px}·\u001b[39m\n\u001b[31m/* ── editor: header + controls + route preview stay put; only the stops list scrolls ── */\u001b[39m\n\u001b[31m#s-editor.scr{height:100vh;height:100dvh;min-height:0;padding-bottom:0;overflow:hidden}\u001b[39m\n\u001b[31m.editor-body{flex:1;min-height:0;display:flex;flex-direction:column;padding:14px 14px 0}\u001b[39m\n\u001b[31m.editor-fixed{display:flex;flex-direction:column;gap:12px;flex-shrink:0}\u001b[39m\n\u001b[31m.editor-scroll{flex:1;min-height:0;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:2px 0 calc(32px + env(safe-area-inset-bottom));margin-top:6px;scrollbar-gutter:stable}\u001b[39m\n\u001b[31m/* visible scrollbar so it's obvious the stops list is independently scrollable */\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar{width:8px}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-thumb{background:#3a3a3a;border-radius:4px}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-thumb:active{background:#525252}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-track{background:transparent}·\u001b[39m\n\u001b[31m/* ── column-mapping table (parsed preview): scroll horizontally if it doesn't fit ── */\u001b[39m\n\u001b[31m.map-table-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--border);border-radius:6px}\u001b[39m\n\u001b[31m.map-table{border-collapse:collapse;table-layout:fixed;width:100%;min-width:440px;font-size:11px}\u001b[39m\n\u001b[31m.map-table th{text-align:left;padding:6px 8px;color:var(--muted);font-weight:800;font-size:9.5px;text-transform:uppercase;letter-spacing:.3px;background:var(--card2);position:sticky;top:0}\u001b[39m\n\u001b[31m.map-table td{padding:6px 8px;vertical-align:middle}\u001b[39m\n\u001b[31m.map-table tr+tr td,.map-table tbody tr td{border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.map-table col.c-src{width:40%}.map-table col.c-field{width:28%}.map-table col.c-sample{width:32%}\u001b[39m\n\u001b[31m.map-table select{background:#0b1220;border:1px solid var(--border);color:var(--text);padding:6px 7px;border-radius:4px;font-size:11px;width:100%}\u001b[39m\n\u001b[31m.map-field{font-weight:800;color:var(--text);white-space:nowrap}\u001b[39m\n\u001b[31m.map-sample{color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}·\u001b[39m\n\u001b[31m/* ── upload ── */\u001b[39m\n\u001b[31m.dropzone{background:var(--card);border:2px dashed var(--border);border-radius:var(--rad);padding:28px 16px;text-align:center;cursor:pointer;transition:.15s}\u001b[39m\n\u001b[31m.dropzone:hover,.dropzone.drag{border-color:var(--accent);background:#0e1726}\u001b[39m\n\u001b[31m.dropzone .ico{font-size:42px;margin-bottom:8px}\u001b[39m\n\u001b[31m.dropzone .ttl{font-weight:800;font-size:15px;margin-bottom:4px}\u001b[39m\n\u001b[31m.dropzone .sub{font-size:11.5px;color:var(--muted);line-height:1.4}\u001b[39m\n\u001b[31m.dropzone input[type=file]{display:none}·\u001b[39m\n\u001b[31m.section-title{font-size:11px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.4px}·\u001b[39m\n\u001b[31m.form-card{background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:12px;display:flex;flex-direction:column;gap:10px}\u001b[39m\n\u001b[31m.field{display:flex;flex-direction:column;gap:3px}\u001b[39m\n\u001b[31m.field label{font-size:11px;font-weight:600;color:var(--muted)}\u001b[39m\n\u001b[31m.field input,.field select{background:#0b1220;border:1px solid var(--border);border-radius:6px;color:var(--text);padding:9px 11px;font-size:13.5px;font-family:inherit;outline:none}\u001b[39m\n\u001b[31m.field input:focus,.field select:focus{border-color:var(--accent)}\u001b[39m\n\u001b[31m.toggle{display:flex;align-items:center;gap:8px;font-size:13px}\u001b[39m\n\u001b[31m.toggle input{accent-color:var(--accent);width:18px;height:18px}·\u001b[39m\n\u001b[31m.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;border-radius:var(--rads);padding:12px 14px;font-size:14px;font-weight:800;cursor:pointer;width:100%;transition:.12s}\u001b[39m\n\u001b[31m.btn-p{background:var(--accent);color:#fff}\u001b[39m\n\u001b[31m.btn-p:active{background:var(--accent2)}\u001b[39m\n\u001b[31m.btn-p:disabled{opacity:.4;cursor:default}\u001b[39m\n\u001b[31m.btn-s{background:var(--card);color:var(--text);border:1px solid var(--border)}\u001b[39m\n\u001b[31m.btn-r{background:rgba(239,68,68,.15);color:#fca5a5;border:1px solid rgba(239,68,68,.3)}·\u001b[39m\n\u001b[31m/* ── progress ── */\u001b[39m\n\u001b[31m.progress{background:#0b1220;border-radius:8px;height:10px;overflow:hidden;border:1px solid var(--border)}\u001b[39m\n\u001b[31m.progress-bar{height:100%;background:linear-gradient(90deg,var(--accent),var(--g));transition:.2s}·\u001b[39m\n\u001b[31m/* ── tiles ── */\u001b[39m\n\u001b[31m.origin-pill{background:linear-gradient(135deg,#0c2e3a,#062a3a);border:1px solid var(--accent);border-radius:var(--rad);padding:11px 13px;display:flex;align-items:center;gap:10px}\u001b[39m\n\u001b[31m.origin-pill .ico{font-size:18px}\u001b[39m\n\u001b[31m.origin-pill .meta{flex:1;min-width:0}\u001b[39m\n\u001b[31m.origin-pill .lbl{font-size:10.5px;color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.4px}\u001b[39m\n\u001b[31m.origin-pill .val{font-size:13.5px;font-weight:700;margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.origin-pill button{background:rgba(255,255,255,.08);border-radius:14px;padding:5px 10px;font-size:11px;font-weight:700;color:var(--accent)}·\u001b[39m\n\u001b[31m.tiles{display:flex;flex-direction:column;gap:6px}\u001b[39m\n\u001b[31m.tile{background:var(--card);border:1px solid var(--border);border-radius:var(--rads);padding:9px 10px;display:flex;align-items:center;gap:10px;transition:.12s;touch-action:none}\u001b[39m\n\u001b[31m.tile.sortable-ghost{opacity:.35;background:var(--card2)}\u001b[39m\n\u001b[31m.tile.sortable-chosen{transform:scale(.98);box-shadow:0 4px 20px rgba(14,165,233,.25)}\u001b[39m\n\u001b[31m.tile.excluded{opacity:.4;background:#0c0c0c}\u001b[39m\n\u001b[31m.tile.excluded .name,.tile.excluded .city{text-decoration:line-through}\u001b[39m\n\u001b[31m.tile.err{border-color:var(--r);background:#1f0a0a}\u001b[39m\n\u001b[31m.tile .grip{font-size:18px;color:var(--muted);cursor:grab;padding:4px;line-height:1}\u001b[39m\n\u001b[31m.tile .grip:active{cursor:grabbing}\u001b[39m\n\u001b[31m.tile .ord{background:var(--accent);color:#fff;width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11.5px;font-weight:800;flex-shrink:0}\u001b[39m\n\u001b[31m.tile.excluded .ord{background:#444}\u001b[39m\n\u001b[31m.tile .info{flex:1;min-width:0}\u001b[39m\n\u001b[31m.tile .name{font-size:13.5px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .city{font-size:11.5px;color:#cbd5e1;font-weight:600;margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .addr{font-size:9.5px;color:var(--muted);margin-top:1px;line-height:1.3;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-style:italic;opacity:.75}\u001b[39m\n\u001b[31m.tile .leg{font-size:10.5px;color:var(--accent2);font-weight:600;margin-top:2px}\u001b[39m\n\u001b[31m.tile.excluded .leg{display:none}\u001b[39m\n\u001b[31m.tile .badge-provider{display:inline-block;font-size:8.5px;padding:1px 5px;border-radius:8px;background:rgba(255,255,255,.08);color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.3px;margin-left:4px;vertical-align:middle}\u001b[39m\n\u001b[31m.tile .incl{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:50%;border:2px solid var(--border);font-size:16px;font-weight:800}\u001b[39m\n\u001b[31m.tile .incl.on{background:var(--g);border-color:var(--g);color:#fff}\u001b[39m\n\u001b[31m.tile .incl.off{background:#0c0c0c;color:var(--r);border-color:var(--r)}·\u001b[39m\n\u001b[31m/* ── prominent top action bar (KM, Share, Re-optimise) ── */\u001b[39m\n\u001b[31m.top-actions{background:linear-gradient(135deg,#0c2e3a,#08111c);border:1px solid var(--accent);border-radius:var(--rad);padding:10px;display:grid;grid-template-columns:1fr auto auto;gap:8px;align-items:center;margin-bottom:6px}\u001b[39m\n\u001b[31m.top-totals{min-width:0}\u001b[39m\n\u001b[31m.top-totals-big{font-size:18px;font-weight:900;line-height:1.05;letter-spacing:.3px}\u001b[39m\n\u001b[31m.top-totals-delta{font-size:11px;color:var(--muted);margin-top:2px}\u001b[39m\n\u001b[31m.top-totals-delta.better{color:var(--g)}\u001b[39m\n\u001b[31m.top-totals-delta.worse{color:var(--y)}\u001b[39m\n\u001b[31m.top-actions button{padding:10px 12px;font-size:12px;font-weight:800;border-radius:8px;border:none;cursor:pointer;display:flex;align-items:center;gap:5px;white-space:nowrap}\u001b[39m\n\u001b[31m.top-share{background:#f59e0b;color:#000}\u001b[39m\n\u001b[31m.top-share:active{background:#d97706}\u001b[39m\n\u001b[31m.top-reopt{background:var(--accent);color:#fff}\u001b[39m\n\u001b[31m.top-reopt:active{background:var(--accent2)}\u001b[39m\n\u001b[31m/* ── Re-optimisation needed (after include/exclude toggle) ── */\u001b[39m\n\u001b[31m.reopt-needed{font-size:11px;font-weight:700;color:#fbbf24;background:rgba(234,179,8,.12);border-left:3px solid #fbbf24;padding:5px 8px;border-radius:4px;margin-top:5px;display:none;line-height:1.3}\u001b[39m\n\u001b[31m.reopt-needed.show{display:block}·\u001b[39m\n\u001b[31m/* ── inline route preview (Leaflet + OSM) ── */\u001b[39m\n\u001b[31m.map-wrap{margin:8px 0 0;background:var(--card);border:1px solid var(--border);border-radius:var(--rads);overflow:hidden}\u001b[39m\n\u001b[31m.map-hdr{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;font-size:12px;color:var(--muted);background:var(--card2);border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.map-hdr .ttl{display:flex;align-items:center;gap:6px;font-weight:600}\u001b[39m\n\u001b[31m.map-hdr .toggle{font-size:14px;color:var(--text);background:none;border:none;cursor:pointer;padding:0 4px}\u001b[39m\n\u001b[31m#tour-map{height:260px;width:100%;background:#1a1a1a;transition:height .2s ease}\u001b[39m\n\u001b[31m.map-wrap.collapsed #tour-map{height:0}\u001b[39m\n\u001b[31m.map-wrap.collapsed .map-hdr{border-bottom:none}\u001b[39m\n\u001b[31m.tour-pin{background:transparent;border:none}\u001b[39m\n\u001b[31m.tour-pin > div{background:#0ea5e9;color:#fff;border-radius:50%;width:26px;height:26px;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:12px;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,.45);font-family:'DM Sans',sans-serif}\u001b[39m\n\u001b[31m.tour-pin.origin > div{background:#22c55e;font-size:14px}\u001b[39m\n\u001b[31m.tour-pin.out > div{background:#6b7280;opacity:.55}\u001b[39m\n\u001b[31m.leaflet-tooltip{background:#141414;color:#f5f5f5;border:1px solid #262626;font-size:11px;padding:3px 6px;font-family:'DM Sans',sans-serif;box-shadow:0 1px 6px rgba(0,0,0,.4)}\u001b[39m\n\u001b[31m.leaflet-tooltip-top:before,.leaflet-tooltip-bottom:before,.leaflet-tooltip-left:before,.leaflet-tooltip-right:before{border-top-color:#262626;border-bottom-color:#262626;border-left-color:#262626;border-right-color:#262626}\u001b[39m\n\u001b[31m.leaflet-control-attribution{background:rgba(20,20,20,.85)!important;color:#a3a3a3!important;font-size:9px!important}\u001b[39m\n\u001b[31m.leaflet-control-attribution a{color:#0ea5e9!important}\u001b[39m\n\u001b[31m.top-reopt.pulse{animation:pulseReopt 1.4s ease-in-out infinite;box-shadow:0 0 0 0 rgba(14,165,233,.6)}\u001b[39m\n\u001b[31m@keyframes pulseReopt{0%{box-shadow:0 0 0 0 rgba(14,165,233,.7)}50%{box-shadow:0 0 0 8px rgba(14,165,233,0)}100%{box-shadow:0 0 0 0 rgba(14,165,233,0)}}\u001b[39m\n\u001b[31m/* Totals dim when stale to make it visually obvious */\u001b[39m\n\u001b[31m.top-totals-big.stale, .top-totals-delta.stale{opacity:.5;text-decoration:line-through;text-decoration-color:rgba(251,191,36,.6)}·\u001b[39m\n\u001b[31m/* recalc spinner that appears inline next to the top totals during recompute */\u001b[39m\n\u001b[31m.recalc{display:none;font-size:11px;color:var(--accent);margin-left:6px;vertical-align:middle}\u001b[39m\n\u001b[31m.recalc.show{display:inline-block;animation:spin 1s linear infinite}\u001b[39m\n\u001b[31m@keyframes spin{to{transform:rotate(360deg)}}·\u001b[39m\n\u001b[31m/* ── share modal ── */\u001b[39m\n\u001b[31m.modal{position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:200;display:none;align-items:flex-end;justify-content:center}\u001b[39m\n\u001b[31m.modal.show{display:flex}\u001b[39m\n\u001b[31m.modal-box{background:var(--card);border-radius:18px 18px 0 0;width:100%;max-height:85vh;overflow-y:auto;padding:16px 14px calc(16px + env(safe-area-inset-bottom));border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.modal-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\u001b[39m\n\u001b[31m.modal-ttl{font-size:15px;font-weight:800}\u001b[39m\n\u001b[31m.modal-close{font-size:24px;color:var(--muted);background:none;border:none;cursor:pointer}·\u001b[39m\n\u001b[31m/* ── toast ── */\u001b[39m\n\u001b[31m#toast{position:fixed;bottom:calc(var(--bnav-h)+16px);left:12px;right:12px;z-index:1000;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:11px 14px;font-size:13px;font-weight:600;text-align:center;transform:translateY(150px);opacity:0;transition:.25s;pointer-events:none}\u001b[39m\n\u001b[31m#toast.show{transform:translateY(0);opacity:1}\u001b[39m\n\u001b[31m#toast.ok{background:rgba(34,197,94,.18);border-color:var(--g);color:#86efac}\u001b[39m\n\u001b[31m#toast.err{background:rgba(239,68,68,.18);border-color:var(--r);color:#fca5a5}·\u001b[39m\n\u001b[31m/* ── helpers ── */\u001b[39m\n\u001b[31m.spinner{display:inline-block;width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;vertical-align:middle}·\u001b[39m\n\u001b[31m.empty{text-align:center;padding:30px 16px;color:var(--muted);font-size:12.5px}·\u001b[39m\n\u001b[31mtextarea{background:#0b1220;border:1px solid var(--border);color:var(--text);padding:10px;font-family:inherit;font-size:12px;border-radius:6px;resize:vertical;outline:none}·\u001b[39m\n\u001b[31m.kbd{background:rgba(255,255,255,.06);padding:1px 6px;border-radius:4px;font-family:monospace;font-size:11px;color:var(--muted)}\u001b[39m\n\u001b[31m</style>\u001b[39m\n\u001b[31m</head>\u001b[39m\n\u001b[31m<body>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: UPLOAD ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-upload\\\" class=\\\"scr active\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗺 Tour Playground</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Excel → optimised route → tweak</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"loadSaved()\\\" id=\\\"btn-load\\\" style=\\\"display:none\\\">↻ Load</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"location.href='/hub/'\\\">🏡 Hub</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <label class=\\\"dropzone\\\" id=\\\"dropzone\\\" for=\\\"excel-file\\\">\u001b[39m\n\u001b[31m      <input type=\\\"file\\\" id=\\\"excel-file\\\" accept=\\\".xlsx,.xls,.csv\\\">\u001b[39m\n\u001b[31m      <div class=\\\"ico\\\">📊</div>\u001b[39m\n\u001b[31m      <div class=\\\"ttl\\\">Drop Excel here, or tap to choose</div>\u001b[39m\n\u001b[31m      <div class=\\\"sub\\\">Required: <b>store name</b> + <b>address</b><br>Optional: <b>city</b>, <b>lat</b>, <b>lng</b>, <b>phone</b>, anything else.</div>\u001b[39m\n\u001b[31m    </label>·\u001b[39m\n\u001b[31m    <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\">Origin</div>\u001b[39m\n\u001b[31m      <div class=\\\"field\\\">\u001b[39m\n\u001b[31m        <label>Start point</label>\u001b[39m\n\u001b[31m        <input type=\\\"text\\\" id=\\\"inp-origin\\\" value=\\\"Mohali office Punjab\\\" placeholder=\\\"Address or place name\\\">\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div class=\\\"toggle\\\">\u001b[39m\n\u001b[31m        <input type=\\\"checkbox\\\" id=\\\"inp-roundtrip\\\" checked>\u001b[39m\n\u001b[31m        <label for=\\\"inp-roundtrip\\\" style=\\\"cursor:pointer;font-size:13px;font-weight:600\\\">Round-trip (return to start)</label>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"dest-wrap\\\" class=\\\"field\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m        <label>Final destination</label>\u001b[39m\n\u001b[31m        <input type=\\\"text\\\" id=\\\"inp-destination\\\" placeholder=\\\"Where to end if not returning to origin\\\">\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" id=\\\"btn-go\\\" onclick=\\\"parseAndOptimise()\\\" disabled>\u001b[39m\n\u001b[31m      📐 Parse + optimise route\u001b[39m\n\u001b[31m    </button>·\u001b[39m\n\u001b[31m    <div id=\\\"upload-progress\\\" style=\\\"display:none;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;font-size:12px\\\">\u001b[39m\n\u001b[31m        <span id=\\\"prog-label\\\">Geocoding…</span>\u001b[39m\n\u001b[31m        <span id=\\\"prog-count\\\" style=\\\"color:var(--muted)\\\">0 / 0</span>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div class=\\\"progress\\\"><div class=\\\"progress-bar\\\" id=\\\"prog-bar\\\" style=\\\"width:0%\\\"></div></div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div id=\\\"parsed-preview\\\" style=\\\"display:none;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:11px;font-size:12px\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\" style=\\\"margin-bottom:6px\\\">Parsed</div>\u001b[39m\n\u001b[31m      <div id=\\\"parsed-summary\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: EDITOR ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-editor\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"confirmBack()\\\">‹ New</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>Tour Editor</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"editor-sub\\\">— stops</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-2\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"openShare()\\\">📤</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"editor-body\\\">\u001b[39m\n\u001b[31m   <div class=\\\"editor-fixed\\\">·\u001b[39m\n\u001b[31m    <div class=\\\"origin-pill\\\" id=\\\"origin-pill\\\">\u001b[39m\n\u001b[31m      <span class=\\\"ico\\\">📍</span>\u001b[39m\n\u001b[31m      <div class=\\\"meta\\\">\u001b[39m\n\u001b[31m        <div class=\\\"lbl\\\">Start from</div>\u001b[39m\n\u001b[31m        <div class=\\\"val\\\" id=\\\"origin-val\\\">—</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button onclick=\\\"openOriginEdit()\\\">✏️ Change</button>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div id=\\\"origin-edit\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m        <div class=\\\"field\\\">\u001b[39m\n\u001b[31m          <label>New origin (for mid-tour replan)</label>\u001b[39m\n\u001b[31m          <input type=\\\"text\\\" id=\\\"inp-new-origin\\\" placeholder=\\\"Hotel B Mohali, or address…\\\">\u001b[39m\n\u001b[31m          <div style=\\\"font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4\\\">\u001b[39m\n\u001b[31m            Tip: toggle ✗ on completed stops, change origin to where you are now, hit Replan.\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div style=\\\"display:flex;gap:8px\\\">\u001b[39m\n\u001b[31m          <button class=\\\"btn btn-s\\\" onclick=\\\"closeOriginEdit()\\\" style=\\\"flex:1\\\">Cancel</button>\u001b[39m\n\u001b[31m          <button class=\\\"btn btn-p\\\" onclick=\\\"applyOriginAndReplan()\\\" style=\\\"flex:2\\\">🔄 Replan from here</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"top-actions\\\" id=\\\"top-actions\\\">\u001b[39m\n\u001b[31m      <div class=\\\"top-totals\\\">\u001b[39m\n\u001b[31m        <div class=\\\"top-totals-big\\\" id=\\\"top-totals-big\\\">— km · — min<span class=\\\"recalc\\\" id=\\\"recalc\\\">↻</span></div>\u001b[39m\n\u001b[31m        <div class=\\\"top-totals-delta\\\" id=\\\"top-totals-delta\\\">vs proposed: —</div>\u001b[39m\n\u001b[31m        <div class=\\\"reopt-needed\\\" id=\\\"reopt-needed\\\">⚠ Stops changed — tap 🔁 Re-optim to refresh the route plan</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"top-share\\\" onclick=\\\"openShare()\\\">📤 Share</button>\u001b[39m\n\u001b[31m      <button class=\\\"top-reopt\\\" onclick=\\\"replanAll()\\\">🔁 Re-optim</button>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"map-wrap\\\" id=\\\"map-wrap\\\">\u001b[39m\n\u001b[31m      <div class=\\\"map-hdr\\\">\u001b[39m\n\u001b[31m        <div class=\\\"ttl\\\">🗺️ Route preview <span style=\\\"opacity:.6;font-weight:400\\\">(straight-line)</span></div>\u001b[39m\n\u001b[31m        <button class=\\\"toggle\\\" id=\\\"map-toggle\\\" onclick=\\\"toggleMap()\\\" aria-label=\\\"Toggle map\\\">▾</button>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"tour-map\\\"></div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"section-title\\\" style=\\\"margin-top:2px\\\">Stops (drag ≡ to reorder · scroll ↕ to see all)</div>\u001b[39m\n\u001b[31m   </div><!-- /editor-fixed -->·\u001b[39m\n\u001b[31m   <div class=\\\"editor-scroll\\\" id=\\\"editor-scroll\\\">\u001b[39m\n\u001b[31m    <div id=\\\"tiles\\\" class=\\\"tiles\\\"></div>\u001b[39m\n\u001b[31m   </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── MODAL: SHARE ── -->\u001b[39m\n\u001b[31m<div class=\\\"modal\\\" id=\\\"modal-share\\\">\u001b[39m\n\u001b[31m  <div class=\\\"modal-box\\\">\u001b[39m\n\u001b[31m    <div class=\\\"modal-hdr\\\">\u001b[39m\n\u001b[31m      <div class=\\\"modal-ttl\\\">📤 Share / Export</div>\u001b[39m\n\u001b[31m      <button class=\\\"modal-close\\\" onclick=\\\"closeShare()\\\">×</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"share-summary\\\" style=\\\"background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:10px;font-size:12px;line-height:1.6;white-space:pre-wrap;font-family:monospace\\\"></div>\u001b[39m\n\u001b[31m    <div style=\\\"margin-top:12px;display:grid;gap:8px\\\">\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"openInGoogleMaps()\\\">🗺️ Open route in Google Maps</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"shareWhatsApp()\\\">💬 WhatsApp</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"copyText()\\\">📋 Copy text</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"downloadCsv()\\\">📥 Download CSV</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"saveLocal()\\\">💾 Save to this device</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<div id=\\\"toast\\\"></div>·\u001b[39m\n\u001b[31m<script>\u001b[39m\n\u001b[31m'use strict';·\u001b[39m\n\u001b[31mconst CACHE_VER = 'tour-pg-v11';\u001b[39m\n\u001b[31mconst PROXY     = '/tour-pg-proxy';·\u001b[39m\n\u001b[31m// ── State ────────────────────────────────────────────\u001b[39m\n\u001b[31mlet session   = null;\u001b[39m\n\u001b[31mlet myId      = '';\u001b[39m\n\u001b[31mlet myName    = '';\u001b[39m\n\u001b[31mlet state     = newState();\u001b[39m\n\u001b[31mlet _recalcTimer = null;\u001b[39m\n\u001b[31mlet _sortable    = null;·\u001b[39m\n\u001b[31mfunction newState() {\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    origin:     { text: 'Mohali office Punjab', location: null, provider: null },\u001b[39m\n\u001b[31m    destination:null,\u001b[39m\n\u001b[31m    roundTrip:  true,\u001b[39m\n\u001b[31m    stops:      [],          // { id, name, city, address, phone, location, included, legDistance_m, legDuration_s, provider, raw, geocodeErr }\u001b[39m\n\u001b[31m    proposedOrder: null,     // array of stop IDs in optimise-time order\u001b[39m\n\u001b[31m    baseTotal: { distance_m: 0, duration_s: 0 },\u001b[39m\n\u001b[31m    currentTotal: { distance_m: 0, duration_s: 0 },\u001b[39m\n\u001b[31m    lastOptimisation: null,  // 'tomtom-computeBestOrder' | 'matrix-nn-2opt'\u001b[39m\n\u001b[31m    needsReoptimize: false,  // true after include/exclude toggle → totals may be stale; prompt user to tap Re-optim\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction uid(prefix='st') { return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,6)}`; }·\u001b[39m\n\u001b[31m// ── Auth ─────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction init() {\u001b[39m\n\u001b[31m  registerSW();\u001b[39m\n\u001b[31m  const raw = localStorage.getItem('lm360-session');\u001b[39m\n\u001b[31m  // Bounce to Hub with ?next= so user lands back here after login (Hub honours\u001b[39m\n\u001b[31m  // same-origin relative paths). Pattern documented in\u001b[39m\n\u001b[31m  // feedback_hub_next_redirect.md memory file — all gated PWAs should follow.\u001b[39m\n\u001b[31m  const nextParam = '?next=' + encodeURIComponent(location.pathname + location.search);\u001b[39m\n\u001b[31m  if (!raw) { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  try { session = JSON.parse(raw); } catch { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  if (Date.now() - (session.loginAt ? new Date(session.loginAt).getTime() : 0) > 12*3600*1000) { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  myId   = session.empId || '';\u001b[39m\n\u001b[31m  myName = session.name  || myId;\u001b[39m\n\u001b[31m  const userText = `👤 <b>${escHtml(myName)}</b>`;\u001b[39m\n\u001b[31m  document.getElementById('user-pill').innerHTML   = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill-2').innerHTML = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill').style.display   = '';\u001b[39m\n\u001b[31m  document.getElementById('user-pill-2').style.display = '';·\u001b[39m\n\u001b[31m  // Load-saved chip if we have a saved tour\u001b[39m\n\u001b[31m  if (localStorage.getItem('tour-pg-saved')) document.getElementById('btn-load').style.display = '';·\u001b[39m\n\u001b[31m  // Wire UI\u001b[39m\n\u001b[31m  document.getElementById('inp-roundtrip').addEventListener('change', e => {\u001b[39m\n\u001b[31m    document.getElementById('dest-wrap').style.display = e.target.checked ? 'none' : '';\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('dragover', e => { e.preventDefault(); e.currentTarget.classList.add('drag'); });\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('dragleave', e => e.currentTarget.classList.remove('drag'));\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('drop', e => {\u001b[39m\n\u001b[31m    e.preventDefault();\u001b[39m\n\u001b[31m    e.currentTarget.classList.remove('drag');\u001b[39m\n\u001b[31m    const f = e.dataTransfer.files[0]; if (f) handleFile(f);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  document.getElementById('excel-file').addEventListener('change', e => {\u001b[39m\n\u001b[31m    const f = e.target.files[0]; if (f) handleFile(f);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction hubAuth() {\u001b[39m\n\u001b[31m  return 'Hub ' + btoa(JSON.stringify(session));\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function api(path, body) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, {\u001b[39m\n\u001b[31m    method: 'POST',\u001b[39m\n\u001b[31m    headers: { 'Authorization': hubAuth(), 'Content-Type': 'application/json' },\u001b[39m\n\u001b[31m    body: JSON.stringify(body),\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (r.status === 401) {\u001b[39m\n\u001b[31m    toast('Session expired — refreshing to re-login', 'err');\u001b[39m\n\u001b[31m    const nextParam = '?next=' + encodeURIComponent(location.pathname + location.search);\u001b[39m\n\u001b[31m    setTimeout(() => location.href = '/hub/' + nextParam, 1500);\u001b[39m\n\u001b[31m    throw new Error('unauthorized');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!r.ok) {\u001b[39m\n\u001b[31m    const txt = await r.text().catch(() => '');\u001b[39m\n\u001b[31m    throw new Error(`${path}: ${r.status} ${txt.slice(0, 100)}`);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return r.json();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Excel parsing + column detection ────────────────\u001b[39m\n\u001b[31mlet _parsedCols = null;   // exposed for the column-remap UI\u001b[39m\n\u001b[31mlet _parsedRows = null;\u001b[39m\n\u001b[31mfunction handleFile(file) {\u001b[39m\n\u001b[31m  const reader = new FileReader();\u001b[39m\n\u001b[31m  reader.onload = (e) => {\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      const wb = XLSX.read(e.target.result, { type: 'array' });\u001b[39m\n\u001b[31m      const ws = wb.Sheets[wb.SheetNames[0]];\u001b[39m\n\u001b[31m      const rows = XLSX.utils.sheet_to_json(ws, { defval: '' });\u001b[39m\n\u001b[31m      if (!rows.length) throw new Error('Empty sheet');\u001b[39m\n\u001b[31m      _parsedRows = rows;\u001b[39m\n\u001b[31m      _parsedCols = detectColumns(rows[0]);\u001b[39m\n\u001b[31m      if (!_parsedCols.name && !_parsedCols.address && !_parsedCols.city) throw new Error('Missing required columns (need at least one of: name, address, city)');\u001b[39m\n\u001b[31m      rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m      renderParsedPreview();\u001b[39m\n\u001b[31m      document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m      toast('Excel parsed ✓', 'ok');\u001b[39m\n\u001b[31m    } catch (err) {\u001b[39m\n\u001b[31m      toast('Excel error: ' + err.message, 'err');\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  reader.readAsArrayBuffer(file);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction rebuildStopsFromParsedRows() {\u001b[39m\n\u001b[31m  state.stops = _parsedRows.map((row, idx) => buildStop(row, _parsedCols, idx)).filter(s => s);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderParsedPreview() {\u001b[39m\n\u001b[31m  const skip = _parsedRows.length - state.stops.length;\u001b[39m\n\u001b[31m  const allCols = Object.keys(_parsedRows[0]);\u001b[39m\n\u001b[31m  // For each role, show:  <role>: <selected col name>  with a tiny dropdown to override\u001b[39m\n\u001b[31m  const ROLES = ['name','city','address','lat','lng','phone','notes'];\u001b[39m\n\u001b[31m  const sample = _parsedRows[0];\u001b[39m\n\u001b[31m  const previewVal = (key) => {\u001b[39m\n\u001b[31m    const v = sample[key]; if (v == null || v === '') return '';\u001b[39m\n\u001b[31m    return String(v).slice(0, 40);\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  // Column order (per user request): YOUR Excel column first, then the PWA field\u001b[39m\n\u001b[31m  // it maps to, then a sample value. Equal-ish widths via <colgroup>; the wrapper\u001b[39m\n\u001b[31m  // scrolls horizontally so long header names are never clipped on narrow phones.\u001b[39m\n\u001b[31m  let rows = '<div class=\\\"map-table-wrap\\\"><table class=\\\"map-table\\\">' +\u001b[39m\n\u001b[31m    '<colgroup><col class=\\\"c-src\\\"><col class=\\\"c-field\\\"><col class=\\\"c-sample\\\"></colgroup>' +\u001b[39m\n\u001b[31m    '<thead><tr><th>Your file column</th><th>→ PWA field</th><th>Sample value</th></tr></thead><tbody>';\u001b[39m\n\u001b[31m  for (const role of ROLES) {\u001b[39m\n\u001b[31m    const sel  = _parsedCols[role];\u001b[39m\n\u001b[31m    const need = role === 'name';\u001b[39m\n\u001b[31m    const opts = `<option value=\\\"\\\">— ignore —</option>` +\u001b[39m\n\u001b[31m      allCols.map(c => `<option value=\\\"${escHtml(c)}\\\" ${sel === c ? 'selected' : ''}>${escHtml(c)}</option>`).join('');\u001b[39m\n\u001b[31m    const previewText = sel ? escHtml(previewVal(sel)) : '';\u001b[39m\n\u001b[31m    const tip = !sel && need ? `<span style=\\\"color:var(--y);font-weight:700\\\">⚠ no match — uses city or \\\"Stop #N\\\"</span>` : '';\u001b[39m\n\u001b[31m    rows += `<tr>\u001b[39m\n\u001b[31m      <td><select onchange=\\\"remapColumn('${role}', this.value)\\\">${opts}</select></td>\u001b[39m\n\u001b[31m      <td class=\\\"map-field\\\">${role}${need?' <span style=\\\"color:var(--y)\\\">*</span>':''}</td>\u001b[39m\n\u001b[31m      <td class=\\\"map-sample\\\">${previewText}${tip}</td>\u001b[39m\n\u001b[31m    </tr>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  rows += '</tbody></table></div>';\u001b[39m\n\u001b[31m  document.getElementById('parsed-preview').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('parsed-summary').innerHTML =\u001b[39m\n\u001b[31m    `<div style=\\\"margin-bottom:6px\\\"><b>${state.stops.length}</b> stops detected` +\u001b[39m\n\u001b[31m    `${skip > 0 ? ` · ${skip} skipped (no name/city/address)` : ''}</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:10.5px;color:var(--muted);margin-bottom:6px\\\">If detection is wrong, pick the correct column from the dropdowns:</div>` +\u001b[39m\n\u001b[31m    rows;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction remapColumn(role, newCol) {\u001b[39m\n\u001b[31m  if (!_parsedCols) return;\u001b[39m\n\u001b[31m  _parsedCols[role] = newCol || null;\u001b[39m\n\u001b[31m  rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction normaliseColName(s) {\u001b[39m\n\u001b[31m  return String(s).toLowerCase().replace(/[^a-z]/g, '');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction detectColumns(sampleRow) {\u001b[39m\n\u001b[31m  const map = { name:null, city:null, address:null, lat:null, lng:null, phone:null, notes:null, others:[] };\u001b[39m\n\u001b[31m  const keys = Object.keys(sampleRow);\u001b[39m\n\u001b[31m  // Broadened aliases so most user-provided Excel files map sensibly without a UI:\u001b[39m\n\u001b[31m  //   name  ← store / shop / outlet / party / dealer / firm / customer / business /\u001b[39m\n\u001b[31m  //           company / vendor / agency (and \\\"<thing> name\\\")\u001b[39m\n\u001b[31m  //   addr  ← address / fulladdress / location / loc / addr\u001b[39m\n\u001b[31m  //   city  ← city / town / district / location (only if no address column took it)\u001b[39m\n\u001b[31m  for (const k of keys) {\u001b[39m\n\u001b[31m    const n = normaliseColName(k);\u001b[39m\n\u001b[31m    if (!map.name && /^(storename|name|store|client|outlet|party(name)?|dealer(name)?|firm(name)?|customer(name)?|shop(name)?|business(name)?|companyname|company|vendor(name)?|agency(name)?|merchant|chain|sitename|site)$/.test(n)) { map.name = k; continue; }\u001b[39m\n\u001b[31m    if (!map.address && /^(address|fulladdress|streetaddress|addr|location|loc|street)$/.test(n)) { map.address = k; continue; }\u001b[39m\n\u001b[31m    if (!map.city && /^(city|town|district|cityname|locality|area|region)$/.test(n)) { map.city = k; continue; }\u001b[39m\n\u001b[31m    if (!map.lat && /^(lat|latitude|y)$/.test(n)) { map.lat = k; continue; }\u001b[39m\n\u001b[31m    if (!map.lng && /^(lng|lon|long|longitude|x)$/.test(n)) { map.lng = k; continue; }\u001b[39m\n\u001b[31m    if (!map.phone && /^(phone(no)?|mobile(no)?|contact(no)?|tel(no)?|whatsapp|wa|cell)$/.test(n)) { map.phone = k; continue; }\u001b[39m\n\u001b[31m    if (!map.notes && /^(notes?|remarks?|comments?|description|desc|memo)$/.test(n)) { map.notes = k; continue; }\u001b[39m\n\u001b[31m    map.others.push(k);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return map;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction buildStop(row, cols, idx) {\u001b[39m\n\u001b[31m  const name    = cols.name    ? String(row[cols.name]   || '').trim() : '';\u001b[39m\n\u001b[31m  const address = cols.address ? String(row[cols.address]|| '').trim() : '';\u001b[39m\n\u001b[31m  const city    = cols.city    ? String(row[cols.city]   || '').trim() : '';\u001b[39m\n\u001b[31m  const phone   = cols.phone   ? String(row[cols.phone]  || '').trim() : '';\u001b[39m\n\u001b[31m  const notes   = cols.notes   ? String(row[cols.notes]  || '').trim() : '';\u001b[39m\n\u001b[31m  if (!name && !address && !city) return null;\u001b[39m\n\u001b[31m  // pre-resolved coords?\u001b[39m\n\u001b[31m  let location = null;\u001b[39m\n\u001b[31m  if (cols.lat && cols.lng) {\u001b[39m\n\u001b[31m    const lat = parseFloat(row[cols.lat]);\u001b[39m\n\u001b[31m    const lng = parseFloat(row[cols.lng]);\u001b[39m\n\u001b[31m    if (!isNaN(lat) && !isNaN(lng) && lat !== 0 && lng !== 0) location = { lat, lng };\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const fullAddr = [address, city].filter(Boolean).join(', ');\u001b[39m\n\u001b[31m  // Display name fallback hierarchy (NEVER truncated address — that confused users):\u001b[39m\n\u001b[31m  //   1. Explicit name column value\u001b[39m\n\u001b[31m  //   2. City + index (so excluded/duplicate cities stay distinguishable)\u001b[39m\n\u001b[31m  //   3. \\\"Stop #N\\\"\u001b[39m\n\u001b[31m  let displayName;\u001b[39m\n\u001b[31m  if (name) displayName = name;\u001b[39m\n\u001b[31m  else if (city) displayName = `${city} (stop ${idx + 1})`;\u001b[39m\n\u001b[31m  else displayName = `Stop ${idx + 1}`;\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    id:           uid('st'),\u001b[39m\n\u001b[31m    name:         displayName,\u001b[39m\n\u001b[31m    hasExplicitName: !!name,         // for UI to flag \\\"name auto-derived\\\"\u001b[39m\n\u001b[31m    city, phone, notes,\u001b[39m\n\u001b[31m    address:      fullAddr || name || '',\u001b[39m\n\u001b[31m    location,\u001b[39m\n\u001b[31m    provider:     location ? 'excel' : null,\u001b[39m\n\u001b[31m    included:     true,\u001b[39m\n\u001b[31m    legDistance_m: 0,\u001b[39m\n\u001b[31m    legDuration_s: 0,\u001b[39m\n\u001b[31m    geocodeErr:   null,\u001b[39m\n\u001b[31m    raw:          { ...row },\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Geocode + optimise ──────────────────────────────\u001b[39m\n\u001b[31masync function parseAndOptimise() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('Parse an Excel first', 'err');\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = true;\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = '';\u001b[39m\n\u001b[31m  state.origin.text  = document.getElementById('inp-origin').value.trim() || 'Mohali office Punjab';\u001b[39m\n\u001b[31m  state.roundTrip    = document.getElementById('inp-roundtrip').checked;\u001b[39m\n\u001b[31m  state.destination  = state.roundTrip ? null : (document.getElementById('inp-destination').value.trim() || null);\u001b[39m\n\u001b[31m  // Geocode origin first\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: state.origin.text });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin.location = og.location;\u001b[39m\n\u001b[31m    state.origin.provider = og.provider;\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    setProgress(0, 0, '');\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('Origin geocode failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Geocode each stop (skip if pre-resolved). Sequential to avoid rate limits.\u001b[39m\n\u001b[31m  const total = state.stops.length;\u001b[39m\n\u001b[31m  let done = 0;\u001b[39m\n\u001b[31m  for (const s of state.stops) {\u001b[39m\n\u001b[31m    if (s.location) { done++; setProgress(done, total, 'Pre-geocoded'); continue; }\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      const r = await api('/geocode', { address: s.address });\u001b[39m\n\u001b[31m      if (r.ok && r.location) {\u001b[39m\n\u001b[31m        s.location = r.location;\u001b[39m\n\u001b[31m        s.provider = r.provider;\u001b[39m\n\u001b[31m      } else {\u001b[39m\n\u001b[31m        s.geocodeErr = r.error || 'No match';\u001b[39m\n\u001b[31m      }\u001b[39m\n\u001b[31m    } catch (e) {\u001b[39m\n\u001b[31m      s.geocodeErr = e.message;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    done++;\u001b[39m\n\u001b[31m    setProgress(done, total, `Geocoding ${s.name.slice(0, 30)}…`);\u001b[39m\n\u001b[31m    // Tiny delay to be polite to the provider\u001b[39m\n\u001b[31m    await sleep(80);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Filter out failed-geocode stops\u001b[39m\n\u001b[31m  const good = state.stops.filter(s => s.location && !s.geocodeErr);\u001b[39m\n\u001b[31m  const bad  = state.stops.filter(s => !s.location || s.geocodeErr);\u001b[39m\n\u001b[31m  if (!good.length) {\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('No stops could be geocoded', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (bad.length) toast(`${bad.length} stop(s) failed geocode (excluded)`, 'err');\u001b[39m\n\u001b[31m  state.stops = good;·\u001b[39m\n\u001b[31m  // Optimise\u001b[39m\n\u001b[31m  setProgress(total, total, 'Optimising route…');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const opt = await api('/optimize', {\u001b[39m\n\u001b[31m      origin:    locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:     state.stops.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:  state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!opt.ok) throw new Error(opt.error || 'optimise failed');\u001b[39m\n\u001b[31m    // Apply order\u001b[39m\n\u001b[31m    const order = opt.waypoint_order || state.stops.map((_, i) => i);\u001b[39m\n\u001b[31m    state.stops = order.map(i => state.stops[i]);\u001b[39m\n\u001b[31m    state.proposedOrder = state.stops.map(s => s.id);\u001b[39m\n\u001b[31m    state.baseTotal     = { distance_m: opt.total_distance_m, duration_s: opt.total_duration_s };\u001b[39m\n\u001b[31m    state.currentTotal  = { ...state.baseTotal };\u001b[39m\n\u001b[31m    state.lastOptimisation = opt.optimisation;\u001b[39m\n\u001b[31m    state.needsReoptimize  = false;   // fresh optimise — flag cleared\u001b[39m\n\u001b[31m    // Map legs to stops (route returned len = stops + 1 (round-trip) or stops + 1 (one-way))\u001b[39m\n\u001b[31m    if (Array.isArray(opt.legs) && opt.legs.length >= state.stops.length) {\u001b[39m\n\u001b[31m      state.stops.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = opt.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('Optimise failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m  showScreen('s-editor');\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction setProgress(done, total, label) {\u001b[39m\n\u001b[31m  const pct = total ? Math.round(done/total*100) : 0;\u001b[39m\n\u001b[31m  document.getElementById('prog-bar').style.width   = pct + '%';\u001b[39m\n\u001b[31m  document.getElementById('prog-count').textContent = `${done} / ${total}`;\u001b[39m\n\u001b[31m  document.getElementById('prog-label').textContent = label;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction locStr(loc) { return loc ? `${loc.lat},${loc.lng}` : null; }\u001b[39m\n\u001b[31mconst sleep = ms => new Promise(r => setTimeout(r, ms));·\u001b[39m\n\u001b[31m// ── Editor screen ───────────────────────────────────\u001b[39m\n\u001b[31mfunction renderEditor() {\u001b[39m\n\u001b[31m  restoreMapPref();\u001b[39m\n\u001b[31m  document.getElementById('origin-val').textContent = state.origin.text;\u001b[39m\n\u001b[31m  document.getElementById('editor-sub').textContent =\u001b[39m\n\u001b[31m    `${state.stops.filter(s => s.included).length} of ${state.stops.length} included` +\u001b[39m\n\u001b[31m    (state.lastOptimisation ? ` · ${state.lastOptimisation}` : '');\u001b[39m\n\u001b[31m  const cont = document.getElementById('tiles');\u001b[39m\n\u001b[31m  cont.innerHTML = '';\u001b[39m\n\u001b[31m  state.stops.forEach((s, idx) => cont.appendChild(renderTile(s, idx)));\u001b[39m\n\u001b[31m  if (_sortable) _sortable.destroy();\u001b[39m\n\u001b[31m  _sortable = Sortable.create(cont, {\u001b[39m\n\u001b[31m    handle: '.grip',\u001b[39m\n\u001b[31m    animation: 150,\u001b[39m\n\u001b[31m    onEnd: (evt) => {\u001b[39m\n\u001b[31m      const moved = state.stops.splice(evt.oldIndex, 1)[0];\u001b[39m\n\u001b[31m      state.stops.splice(evt.newIndex, 0, moved);\u001b[39m\n\u001b[31m      renderEditor();\u001b[39m\n\u001b[31m      scheduleRecalc();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  updateTotalsBar();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderTile(s, idx) {\u001b[39m\n\u001b[31m  const el = document.createElement('div');\u001b[39m\n\u001b[31m  el.className = 'tile' + (s.included ? '' : ' excluded') + (s.geocodeErr ? ' err' : '');\u001b[39m\n\u001b[31m  el.dataset.id = s.id;\u001b[39m\n\u001b[31m  const providerBadge = s.provider ? `<span class=\\\"badge-provider\\\">${escHtml(s.provider)}</span>` : '';\u001b[39m\n\u001b[31m  const leg = s.legDistance_m > 0\u001b[39m\n\u001b[31m    ? `${(s.legDistance_m/1000).toFixed(1)} km · ${Math.round(s.legDuration_s/60)} min from prev`\u001b[39m\n\u001b[31m    : '';\u001b[39m\n\u001b[31m  // Per UX rule: Name + City are the primary display. Address is shown\u001b[39m\n\u001b[31m  // smaller below as supporting context. Falls back gracefully when city\u001b[39m\n\u001b[31m  // is missing.\u001b[39m\n\u001b[31m  const cityLine = s.city || '—';\u001b[39m\n\u001b[31m  const addrLine = s.address && s.address !== s.city ? s.address : '';\u001b[39m\n\u001b[31m  el.innerHTML = `\u001b[39m\n\u001b[31m    <span class=\\\"grip\\\">≡</span>\u001b[39m\n\u001b[31m    <span class=\\\"ord\\\">${idx + 1}</span>\u001b[39m\n\u001b[31m    <div class=\\\"info\\\">\u001b[39m\n\u001b[31m      <div class=\\\"name\\\">${escHtml(s.name)} ${providerBadge}</div>\u001b[39m\n\u001b[31m      <div class=\\\"city\\\">${escHtml(cityLine)}</div>\u001b[39m\n\u001b[31m      ${addrLine ? `<div class=\\\"addr\\\">${escHtml(addrLine)}</div>` : ''}\u001b[39m\n\u001b[31m      ${leg ? `<div class=\\\"leg\\\">${escHtml(leg)}</div>` : ''}\u001b[39m\n\u001b[31m      ${s.geocodeErr ? `<div class=\\\"leg\\\" style=\\\"color:var(--r)\\\">⚠ ${escHtml(s.geocodeErr)}</div>` : ''}\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"incl ${s.included ? 'on' : 'off'}\\\" onclick=\\\"toggleInclude('${s.id}')\\\" title=\\\"Include / exclude\\\">\u001b[39m\n\u001b[31m      ${s.included ? '✓' : '✗'}\u001b[39m\n\u001b[31m    </button>`;\u001b[39m\n\u001b[31m  return el;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleInclude(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id); if (!s) return;\u001b[39m\n\u001b[31m  s.included = !s.included;\u001b[39m\n\u001b[31m  // The SET of stops just changed — the existing route may no longer be optimal.\u001b[39m\n\u001b[31m  // Surface a banner + pulse the Re-optim button so the user knows to refresh.\u001b[39m\n\u001b[31m  // recalcRoute still fires (debounced) to update the cost of the CURRENT order,\u001b[39m\n\u001b[31m  // but that number is just informational until the user re-optimises.\u001b[39m\n\u001b[31m  state.needsReoptimize = true;\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  scheduleRecalc();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction scheduleRecalc() {\u001b[39m\n\u001b[31m  clearTimeout(_recalcTimer);\u001b[39m\n\u001b[31m  document.getElementById('recalc').classList.add('show');\u001b[39m\n\u001b[31m  _recalcTimer = setTimeout(recalcRoute, 400);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function recalcRoute() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  if (!included.length) {\u001b[39m\n\u001b[31m    state.currentTotal = { distance_m: 0, duration_s: 0 };\u001b[39m\n\u001b[31m    document.getElementById('recalc').classList.remove('show');\u001b[39m\n\u001b[31m    updateTotalsBar();\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/route', {\u001b[39m\n\u001b[31m      origin:      locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:       included.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:   state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!r.ok) throw new Error(r.error || 'route failed');\u001b[39m\n\u001b[31m    state.currentTotal = { distance_m: r.total_distance_m, duration_s: r.total_duration_s };\u001b[39m\n\u001b[31m    // Map legs back to tiles (legs index 0 = origin→stop0, n = lastStop→dest)\u001b[39m\n\u001b[31m    if (Array.isArray(r.legs)) {\u001b[39m\n\u001b[31m      included.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = r.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    // Clear leg info on excluded stops\u001b[39m\n\u001b[31m    state.stops.filter(s => !s.included).forEach(s => { s.legDistance_m = 0; s.legDuration_s = 0; });\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Recalc failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  } finally {\u001b[39m\n\u001b[31m    document.getElementById('recalc').classList.remove('show');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateTotalsBar() {\u001b[39m\n\u001b[31m  // Totals now live ONLY in the top-actions card (the sticky bottom bar was\u001b[39m\n\u001b[31m  // removed after user feedback that it duplicated the top info 2026-06-04).\u001b[39m\n\u001b[31m  // The big-text element holds the inline recalc spinner as a child <span>.\u001b[39m\n\u001b[31m  const km  = (state.currentTotal.distance_m / 1000).toFixed(1);\u001b[39m\n\u001b[31m  const min = Math.round(state.currentTotal.duration_s / 60);\u001b[39m\n\u001b[31m  const hrs = Math.floor(min / 60), mm = min % 60;\u001b[39m\n\u001b[31m  const timeStr = hrs > 0 ? `${hrs}h ${mm}min` : `${mm} min`;\u001b[39m\n\u001b[31m  const totalsText = `${km} km · ${timeStr}`;\u001b[39m\n\u001b[31m  const bigEl  = document.getElementById('top-totals-big');\u001b[39m\n\u001b[31m  const recalc = document.getElementById('recalc');\u001b[39m\n\u001b[31m  bigEl.textContent = totalsText;\u001b[39m\n\u001b[31m  if (recalc) bigEl.appendChild(document.createTextNode(' '));\u001b[39m\n\u001b[31m  if (recalc) bigEl.appendChild(recalc);\u001b[39m\n\u001b[31m  // Delta vs base\u001b[39m\n\u001b[31m  const baseKm   = state.baseTotal.distance_m / 1000;\u001b[39m\n\u001b[31m  const curKm    = state.currentTotal.distance_m / 1000;\u001b[39m\n\u001b[31m  const deltaKm  = curKm - baseKm;\u001b[39m\n\u001b[31m  const deltaMin = Math.round((state.currentTotal.duration_s - state.baseTotal.duration_s) / 60);\u001b[39m\n\u001b[31m  const dT = document.getElementById('top-totals-delta');\u001b[39m\n\u001b[31m  let deltaText, deltaClass;\u001b[39m\n\u001b[31m  if (Math.abs(deltaKm) < 0.05) {\u001b[39m\n\u001b[31m    deltaText  = 'matches proposed route';\u001b[39m\n\u001b[31m    deltaClass = '';\u001b[39m\n\u001b[31m  } else {\u001b[39m\n\u001b[31m    const sign = deltaKm > 0 ? '+' : '';\u001b[39m\n\u001b[31m    deltaText  = `vs proposed: ${sign}${deltaKm.toFixed(1)} km · ${sign}${deltaMin} min`;\u001b[39m\n\u001b[31m    deltaClass = deltaKm < 0 ? 'better' : 'worse';\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  dT.textContent = deltaText;\u001b[39m\n\u001b[31m  dT.className   = 'top-totals-delta ' + deltaClass;·\u001b[39m\n\u001b[31m  // ── Re-optim-needed banner + pulse + stale styling ──\u001b[39m\n\u001b[31m  // Surfaces a clear message whenever the user has added/removed stops since\u001b[39m\n\u001b[31m  // the last /optimize call. The current-order cost is still shown (so the\u001b[39m\n\u001b[31m  // user sees \\\"what this order costs\\\"), but it's struck-through and the\u001b[39m\n\u001b[31m  // Re-optim button pulses to draw attention.\u001b[39m\n\u001b[31m  const needsReopt = !!state.needsReoptimize;\u001b[39m\n\u001b[31m  document.getElementById('reopt-needed').classList.toggle('show', needsReopt);\u001b[39m\n\u001b[31m  bigEl.classList.toggle('stale', needsReopt);\u001b[39m\n\u001b[31m  dT.classList.toggle('stale', needsReopt);\u001b[39m\n\u001b[31m  document.querySelector('.top-reopt')?.classList.toggle('pulse', needsReopt);·\u001b[39m\n\u001b[31m  // Re-render inline map preview to reflect current order/include flags.\u001b[39m\n\u001b[31m  // Debounced via setTimeout 0 so multiple updates in the same tick coalesce.\u001b[39m\n\u001b[31m  scheduleMapRender();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Inline route preview (Leaflet + free OSM tiles) ──────────────\u001b[39m\n\u001b[31m// Renders origin + stops + straight-line polyline inside the editor screen.\u001b[39m\n\u001b[31m// \\\"Straight-line\\\" because the proxy only returns leg distances/durations,\u001b[39m\n\u001b[31m// not the per-leg point arrays from TomTom — this preview is enough for\u001b[39m\n\u001b[31m// orientation; \\\"Open in Google Maps\\\" gives the user the real road geometry.\u001b[39m\n\u001b[31mlet _leafletMap   = null;\u001b[39m\n\u001b[31mlet _leafletLayer = null;\u001b[39m\n\u001b[31mlet _mapRenderTimer = null;·\u001b[39m\n\u001b[31mfunction ensureMap() {\u001b[39m\n\u001b[31m  if (_leafletMap) return _leafletMap;\u001b[39m\n\u001b[31m  if (typeof L === 'undefined') return null;   // CDN not ready yet\u001b[39m\n\u001b[31m  const el = document.getElementById('tour-map');\u001b[39m\n\u001b[31m  if (!el || !el.offsetParent) return null;    // container not visible yet\u001b[39m\n\u001b[31m  _leafletMap = L.map(el, { attributionControl: false, zoomControl: true, preferCanvas: true });\u001b[39m\n\u001b[31m  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18 })\u001b[39m\n\u001b[31m    .addTo(_leafletMap);\u001b[39m\n\u001b[31m  L.control.attribution({ position: 'bottomright', prefix: false })\u001b[39m\n\u001b[31m    .addAttribution('© OpenStreetMap contributors')\u001b[39m\n\u001b[31m    .addTo(_leafletMap);\u001b[39m\n\u001b[31m  return _leafletMap;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderMap() {\u001b[39m\n\u001b[31m  const map = ensureMap();\u001b[39m\n\u001b[31m  if (!map) return;\u001b[39m\n\u001b[31m  if (_leafletLayer) { map.removeLayer(_leafletLayer); _leafletLayer = null; }\u001b[39m\n\u001b[31m  const layers = [];\u001b[39m\n\u001b[31m  const linePts = [];\u001b[39m\n\u001b[31m  // Origin marker (always first in line)\u001b[39m\n\u001b[31m  if (state.origin?.location) {\u001b[39m\n\u001b[31m    const ll = [state.origin.location.lat, state.origin.location.lng];\u001b[39m\n\u001b[31m    layers.push(L.marker(ll, { icon: L.divIcon({\u001b[39m\n\u001b[31m      className: 'tour-pin origin', html: '<div>🏁</div>',\u001b[39m\n\u001b[31m      iconSize: [28, 28], iconAnchor: [14, 14],\u001b[39m\n\u001b[31m    })}).bindTooltip('Start: ' + (state.origin.text || 'origin'), { direction: 'top' }));\u001b[39m\n\u001b[31m    linePts.push(ll);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Stops in current visit order\u001b[39m\n\u001b[31m  let visitNum = 0;\u001b[39m\n\u001b[31m  state.stops.forEach(s => {\u001b[39m\n\u001b[31m    if (!s.location) return;\u001b[39m\n\u001b[31m    const ll = [s.location.lat, s.location.lng];\u001b[39m\n\u001b[31m    const included = s.included;\u001b[39m\n\u001b[31m    if (included) visitNum++;\u001b[39m\n\u001b[31m    layers.push(L.marker(ll, { icon: L.divIcon({\u001b[39m\n\u001b[31m      className: 'tour-pin' + (included ? '' : ' out'),\u001b[39m\n\u001b[31m      html: `<div>${included ? visitNum : '·'}</div>`,\u001b[39m\n\u001b[31m      iconSize: [26, 26], iconAnchor: [13, 13],\u001b[39m\n\u001b[31m    })}).bindTooltip(\u001b[39m\n\u001b[31m      (included ? `${visitNum}. ` : '✗ ') + s.name + (s.city ? ` · ${s.city}` : ''),\u001b[39m\n\u001b[31m      { direction: 'top' }\u001b[39m\n\u001b[31m    ));\u001b[39m\n\u001b[31m    if (included) linePts.push(ll);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  // Close the loop on round-trip\u001b[39m\n\u001b[31m  if (state.roundTrip && state.origin?.location) {\u001b[39m\n\u001b[31m    linePts.push([state.origin.location.lat, state.origin.location.lng]);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (linePts.length >= 2) {\u001b[39m\n\u001b[31m    layers.push(L.polyline(linePts, {\u001b[39m\n\u001b[31m      color: '#0ea5e9', weight: 3, opacity: 0.85, dashArray: '6 4',\u001b[39m\n\u001b[31m    }));\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  _leafletLayer = L.layerGroup(layers).addTo(map);\u001b[39m\n\u001b[31m  if (linePts.length) {\u001b[39m\n\u001b[31m    try { map.fitBounds(L.latLngBounds(linePts), { padding: [30, 30], maxZoom: 13 }); }\u001b[39m\n\u001b[31m    catch (_) {}\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction scheduleMapRender() {\u001b[39m\n\u001b[31m  if (_mapRenderTimer) return;\u001b[39m\n\u001b[31m  _mapRenderTimer = setTimeout(() => { _mapRenderTimer = null; renderMap(); }, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleMap() {\u001b[39m\n\u001b[31m  const wrap = document.getElementById('map-wrap');\u001b[39m\n\u001b[31m  const tog  = document.getElementById('map-toggle');\u001b[39m\n\u001b[31m  const open = !wrap.classList.toggle('collapsed');\u001b[39m\n\u001b[31m  tog.textContent = open ? '▾' : '▴';\u001b[39m\n\u001b[31m  try { localStorage.setItem('tour-pg-map-open', open ? '1' : '0'); } catch (_) {}\u001b[39m\n\u001b[31m  if (open && _leafletMap) {\u001b[39m\n\u001b[31m    // After expansion the container size changed — Leaflet must recompute\u001b[39m\n\u001b[31m    setTimeout(() => { _leafletMap.invalidateSize(); renderMap(); }, 220);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction restoreMapPref() {\u001b[39m\n\u001b[31m  // Default collapsed — user must explicitly expand once for it to stick open.\u001b[39m\n\u001b[31m  let open = false;\u001b[39m\n\u001b[31m  try { open = localStorage.getItem('tour-pg-map-open') === '1'; } catch (_) {}\u001b[39m\n\u001b[31m  const wrap = document.getElementById('map-wrap');\u001b[39m\n\u001b[31m  const tog  = document.getElementById('map-toggle');\u001b[39m\n\u001b[31m  wrap.classList.toggle('collapsed', !open);\u001b[39m\n\u001b[31m  tog.textContent = open ? '▾' : '▴';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Origin edit / mid-tour replan ────────────────────\u001b[39m\n\u001b[31mfunction openOriginEdit() {\u001b[39m\n\u001b[31m  document.getElementById('origin-edit').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('inp-new-origin').value = state.origin.text;\u001b[39m\n\u001b[31m  document.getElementById('inp-new-origin').focus();\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction closeOriginEdit() {\u001b[39m\n\u001b[31m  document.getElementById('origin-edit').style.display = 'none';\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31masync function applyOriginAndReplan() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('inp-new-origin').value.trim();\u001b[39m\n\u001b[31m  if (!txt) return toast('Enter an origin', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: txt });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin = { text: txt, location: og.location, provider: og.provider };\u001b[39m\n\u001b[31m    closeOriginEdit();\u001b[39m\n\u001b[31m    await replanAll();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Origin failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function replanAll() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  if (!included.length) return toast('No stops included', 'err');\u001b[39m\n\u001b[31m  toast('Optimising…', 'ok');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const opt = await api('/optimize', {\u001b[39m\n\u001b[31m      origin:      locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:       included.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:   state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!opt.ok) throw new Error(opt.error || 'optimise failed');\u001b[39m\n\u001b[31m    const order = opt.waypoint_order || included.map((_, i) => i);\u001b[39m\n\u001b[31m    const reordered = order.map(i => included[i]);\u001b[39m\n\u001b[31m    // KEEP EXCLUDED STOPS IN THEIR EXISTING POSITIONS (per user feedback\u001b[39m\n\u001b[31m    // 2026-06-04). Excluded slots stay put; reordered-included stops fill\u001b[39m\n\u001b[31m    // the gaps. Previously appended at end (confusing) → then dropped\u001b[39m\n\u001b[31m    // (lossy) → final: stay where they are. User can re-include to put\u001b[39m\n\u001b[31m    // them back into the optimised route.\u001b[39m\n\u001b[31m    const newStops = [];\u001b[39m\n\u001b[31m    let r = 0;\u001b[39m\n\u001b[31m    for (const s of state.stops) {\u001b[39m\n\u001b[31m      if (s.included) newStops.push(reordered[r++]);\u001b[39m\n\u001b[31m      else            newStops.push(s);\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    state.stops = newStops;\u001b[39m\n\u001b[31m    state.proposedOrder = state.stops.map(s => s.id);\u001b[39m\n\u001b[31m    state.baseTotal     = { distance_m: opt.total_distance_m, duration_s: opt.total_duration_s };\u001b[39m\n\u001b[31m    state.currentTotal  = { ...state.baseTotal };\u001b[39m\n\u001b[31m    state.lastOptimisation = opt.optimisation;\u001b[39m\n\u001b[31m    state.needsReoptimize  = false;   // re-optim done — banner cleared\u001b[39m\n\u001b[31m    if (Array.isArray(opt.legs)) {\u001b[39m\n\u001b[31m      reordered.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = opt.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Re-optimised ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Replan failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Share / export ──────────────────────────────────\u001b[39m\n\u001b[31mfunction openShare() {\u001b[39m\n\u001b[31m  const km   = (state.currentTotal.distance_m / 1000).toFixed(1);\u001b[39m\n\u001b[31m  const min  = Math.round(state.currentTotal.duration_s / 60);\u001b[39m\n\u001b[31m  const hrs  = Math.floor(min / 60), mm = min % 60;\u001b[39m\n\u001b[31m  const timeStr = hrs > 0 ? `${hrs}h ${mm}min` : `${mm} min`;\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  let txt = `🚗 Tour plan (${new Date().toLocaleDateString('en-IN')})\\\\n`;\u001b[39m\n\u001b[31m  txt += `From: ${state.origin.text}\\\\n`;\u001b[39m\n\u001b[31m  txt += `${included.length} stops · ${km} km · ${timeStr}\\\\n`;\u001b[39m\n\u001b[31m  if (state.roundTrip) txt += `Round-trip\\\\n\\\\n`;\u001b[39m\n\u001b[31m  else                  txt += `One-way → ${state.destination || included[included.length-1]?.address || ''}\\\\n\\\\n`;\u001b[39m\n\u001b[31m  included.forEach((s, i) => {\u001b[39m\n\u001b[31m    const legKm  = (s.legDistance_m/1000).toFixed(1);\u001b[39m\n\u001b[31m    const legMin = Math.round(s.legDuration_s/60);\u001b[39m\n\u001b[31m    txt += `${i+1}. ${s.name}`;\u001b[39m\n\u001b[31m    if (s.city) txt += ` · ${s.city}`;\u001b[39m\n\u001b[31m    if (s.legDistance_m > 0) txt += `  (+${legKm}km · ${legMin}min)`;\u001b[39m\n\u001b[31m    if (s.phone) txt += `  ${s.phone}`;\u001b[39m\n\u001b[31m    txt += `\\\\n`;\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (state.roundTrip) txt += `↩ Return to ${state.origin.text}`;\u001b[39m\n\u001b[31m  document.getElementById('share-summary').textContent = txt;\u001b[39m\n\u001b[31m  document.getElementById('modal-share').classList.add('show');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction closeShare() {\u001b[39m\n\u001b[31m  document.getElementById('modal-share').classList.remove('show');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction shareWhatsApp() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('share-summary').textContent;\u001b[39m\n\u001b[31m  const url = `https://wa.me/?text=${encodeURIComponent(txt)}`;\u001b[39m\n\u001b[31m  window.open(url, '_blank');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Hands off the optimised tour to Google Maps Directions (free, zero infra).\u001b[39m\n\u001b[31m// Uses path-style URL `dir/A/B/C/...` because it accepts many more stops than\u001b[39m\n\u001b[31m// the `api=1` Directions URL (which Google caps at 9 waypoints). Each segment\u001b[39m\n\u001b[31m// is \\\"lat,lng\\\" for geocoded points (most reliable). If the user picked a\u001b[39m\n\u001b[31m// text destination on a one-way tour we URL-encode it as the last segment.\u001b[39m\n\u001b[31mfunction openInGoogleMaps() {\u001b[39m\n\u001b[31m  if (!state.origin?.location) { toast('Origin not yet geocoded', 'err'); return; }\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included && s.location);\u001b[39m\n\u001b[31m  if (included.length === 0)   { toast('No included stops to view', 'err'); return; }\u001b[39m\n\u001b[31m  const fmt = (loc) => `${loc.lat.toFixed(6)},${loc.lng.toFixed(6)}`;\u001b[39m\n\u001b[31m  const segs = [ fmt(state.origin.location) ];\u001b[39m\n\u001b[31m  included.forEach(s => segs.push(fmt(s.location)));\u001b[39m\n\u001b[31m  if (state.roundTrip) {\u001b[39m\n\u001b[31m    segs.push(fmt(state.origin.location));\u001b[39m\n\u001b[31m  } else if (state.destination) {\u001b[39m\n\u001b[31m    segs.push(encodeURIComponent(state.destination));\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // travelmode and dir_action=navigate auto-open in driving directions\u001b[39m\n\u001b[31m  const url = `https://www.google.com/maps/dir/${segs.join('/')}/?travelmode=driving`;\u001b[39m\n\u001b[31m  window.open(url, '_blank', 'noopener');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function copyText() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('share-summary').textContent;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await navigator.clipboard.writeText(txt);\u001b[39m\n\u001b[31m    toast('Copied ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Copy failed', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction downloadCsv() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  const rows = [['#','Name','City','Address','Phone','Lat','Lng','Leg km','Leg min','Provider']];\u001b[39m\n\u001b[31m  included.forEach((s, i) => {\u001b[39m\n\u001b[31m    rows.push([\u001b[39m\n\u001b[31m      i + 1,\u001b[39m\n\u001b[31m      s.name, s.city, s.address, s.phone,\u001b[39m\n\u001b[31m      s.location?.lat ?? '', s.location?.lng ?? '',\u001b[39m\n\u001b[31m      (s.legDistance_m/1000).toFixed(2),\u001b[39m\n\u001b[31m      Math.round(s.legDuration_s/60),\u001b[39m\n\u001b[31m      s.provider || '',\u001b[39m\n\u001b[31m    ]);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  const csv = rows.map(r => r.map(c => `\\\"${String(c).replace(/\\\"/g, '\\\"\\\"')}\\\"`).join(',')).join('\\\\n');\u001b[39m\n\u001b[31m  const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });\u001b[39m\n\u001b[31m  const url = URL.createObjectURL(blob);\u001b[39m\n\u001b[31m  const a = document.createElement('a');\u001b[39m\n\u001b[31m  a.href = url; a.download = `tour-${new Date().toISOString().slice(0,10)}.csv`;\u001b[39m\n\u001b[31m  document.body.appendChild(a); a.click(); a.remove();\u001b[39m\n\u001b[31m  URL.revokeObjectURL(url);\u001b[39m\n\u001b[31m  toast('CSV downloaded ✓', 'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction saveLocal() {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    localStorage.setItem('tour-pg-saved', JSON.stringify({\u001b[39m\n\u001b[31m      saved_at: new Date().toISOString(),\u001b[39m\n\u001b[31m      state,\u001b[39m\n\u001b[31m    }));\u001b[39m\n\u001b[31m    toast('Saved ✓', 'ok');\u001b[39m\n\u001b[31m    document.getElementById('btn-load').style.display = '';\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed (storage full?)', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction loadSaved() {\u001b[39m\n\u001b[31m  const raw = localStorage.getItem('tour-pg-saved');\u001b[39m\n\u001b[31m  if (!raw) return toast('No saved tour', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const { state: s } = JSON.parse(raw);\u001b[39m\n\u001b[31m    state = s;\u001b[39m\n\u001b[31m    showScreen('s-editor');\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Loaded ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Load failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Navigation ──────────────────────────────────────\u001b[39m\n\u001b[31mfunction confirmBack() {\u001b[39m\n\u001b[31m  if (!confirm('Start a new tour? Current state will be cleared (use Save first if you want to keep it).')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m  document.getElementById('parsed-preview').style.display = 'none';\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = true;\u001b[39m\n\u001b[31m  document.getElementById('excel-file').value = '';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showScreen(id) {\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById(id).classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Utils ───────────────────────────────────────────\u001b[39m\n\u001b[31mfunction escHtml(s) { if (s == null) return ''; return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\\\"/g,'&quot;'); }\u001b[39m\n\u001b[31mlet _toastTimer;\u001b[39m\n\u001b[31mfunction toast(msg, type='') {\u001b[39m\n\u001b[31m  const el = document.getElementById('toast');\u001b[39m\n\u001b[31m  el.textContent = msg; el.className = 'show ' + type;\u001b[39m\n\u001b[31m  clearTimeout(_toastTimer);\u001b[39m\n\u001b[31m  _toastTimer = setTimeout(() => el.className = '', 3200);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction registerSW() {\u001b[39m\n\u001b[31m  if (!('serviceWorker' in navigator)) return;\u001b[39m\n\u001b[31m  const sw = `\u001b[39m\n\u001b[31mconst C='${CACHE_VER}';\u001b[39m\n\u001b[31mself.addEventListener('install', e => self.skipWaiting());\u001b[39m\n\u001b[31mself.addEventListener('activate', e => {\u001b[39m\n\u001b[31m  e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== C).map(k => caches.delete(k)))));\u001b[39m\n\u001b[31m  self.clients.claim();\u001b[39m\n\u001b[31m});\u001b[39m\n\u001b[31mself.addEventListener('fetch', e => {\u001b[39m\n\u001b[31m  if (e.request.method !== 'GET') return;\u001b[39m\n\u001b[31m  const url = new URL(e.request.url);\u001b[39m\n\u001b[31m  if (url.pathname.startsWith('/tour-pg-proxy')) return;\u001b[39m\n\u001b[31m  if (url.pathname.startsWith('/tour-pg/')) {\u001b[39m\n\u001b[31m    e.respondWith(\u001b[39m\n\u001b[31m      caches.open(C).then(c =>\u001b[39m\n\u001b[31m        c.match(e.request).then(r => r || fetch(e.request).then(f => { c.put(e.request, f.clone()); return f; }))\u001b[39m\n\u001b[31m      )\u001b[39m\n\u001b[31m    );\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m});`;\u001b[39m\n\u001b[31m  const blob = new Blob([sw], { type: 'application/javascript' });\u001b[39m\n\u001b[31m  navigator.serviceWorker.register(URL.createObjectURL(blob), { scope: '/tour-pg/' }).catch(() => {});\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31minit();\u001b[39m\n\u001b[31m</script>·\u001b[39m\n\u001b[31m</body>\u001b[39m\n\u001b[31m</html>\u001b[39m\n\u001b[31m\"\u001b[39m\n\n  52 |     const ctx = await request.newContext();\n  53 |     const html = await (await ctx.get(BASE_URL)).text();\n> 54 |     expect(html).toContain('tour-pg-v10');\n     |                  ^\n  55 |     expect(html).toContain('Tour Playground');\n  56 |     await ctx.dispose();\n  57 |   });\n    at /var/www/360lm/tests/tour_pg.spec.js:54:18"
                        }
                      ],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:29.696Z",
                      "annotations": [],
                      "attachments": [
                        {
                          "name": "error-context",
                          "contentType": "text/markdown",
                          "path": "/var/www/360lm/test-results/tour_pg-Tour-Playground-—--22e4b-ries-tour-pg-v10-SW-version-desktop-chrome/error-context.md"
                        }
                      ],
                      "errorLocation": {
                        "file": "/var/www/360lm/tests/tour_pg.spec.js",
                        "column": 18,
                        "line": 54
                      }
                    },
                    {
                      "workerIndex": 4,
                      "parallelIndex": 0,
                      "status": "failed",
                      "duration": 109,
                      "error": {
                        "message": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoContain\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // indexOf\u001b[22m\n\nExpected substring: \u001b[32m\"tour-pg-v10\"\u001b[39m\nReceived string:    \u001b[31m\"<!DOCTYPE html>\u001b[39m\n\u001b[31m<html lang=\\\"en\\\">\u001b[39m\n\u001b[31m<head>\u001b[39m\n\u001b[31m<meta charset=\\\"UTF-8\\\">\u001b[39m\n\u001b[31m<meta name=\\\"viewport\\\" content=\\\"width=device-width,initial-scale=1,viewport-fit=cover\\\">\u001b[39m\n\u001b[31m<title>Tour Planner Playground</title>\u001b[39m\n\u001b[31m<link rel=\\\"manifest\\\" href=\\\"manifest.json\\\">\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"/shared/safe-bottom.css\\\">\u001b[39m\n\u001b[31m<meta name=\\\"theme-color\\\" content=\\\"#0a0a0a\\\">\u001b[39m\n\u001b[31m<link rel=\\\"preconnect\\\" href=\\\"https://fonts.googleapis.com\\\">\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&display=swap\\\">\u001b[39m\n\u001b[31m<script src=\\\"https://cdn.sheetjs.com/xlsx-0.20.2/package/dist/xlsx.full.min.js\\\"></script>\u001b[39m\n\u001b[31m<script src=\\\"https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js\\\"></script>\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.css\\\" crossorigin=\\\"\\\">\u001b[39m\n\u001b[31m<script src=\\\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.js\\\" crossorigin=\\\"\\\" defer></script>\u001b[39m\n\u001b[31m<style>\u001b[39m\n\u001b[31m:root{\u001b[39m\n\u001b[31m  --bg:#0a0a0a;--card:#141414;--card2:#1a1a1a;--border:#262626;--text:#f5f5f5;--muted:#a3a3a3;\u001b[39m\n\u001b[31m  --accent:#0ea5e9;--accent2:#0284c7;--g:#22c55e;--r:#ef4444;--y:#eab308;--p:#a855f7;\u001b[39m\n\u001b[31m  --rad:12px;--rads:8px;--bnav-h:64px;\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31m*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}\u001b[39m\n\u001b[31mbody{background:var(--bg);color:var(--text);font-family:'DM Sans',sans-serif;min-height:100vh;overscroll-behavior-y:none}\u001b[39m\n\u001b[31mbutton{font-family:inherit;cursor:pointer;border:none;background:none;color:inherit}\u001b[39m\n\u001b[31ma{color:inherit;text-decoration:none}·\u001b[39m\n\u001b[31m.scr{display:none;flex-direction:column;min-height:100vh;padding-bottom:calc(var(--bnav-h) + env(safe-area-inset-bottom))}\u001b[39m\n\u001b[31m.scr.active{display:flex}·\u001b[39m\n\u001b[31m.hdr{background:var(--card);border-bottom:1px solid var(--border);padding:12px 14px;display:flex;align-items:center;gap:8px;position:sticky;top:0;z-index:10}\u001b[39m\n\u001b[31m.hdr h1{font-size:15px;font-weight:800;flex:1;min-width:0}\u001b[39m\n\u001b[31m.hdr-sub{font-size:11px;color:var(--muted)}\u001b[39m\n\u001b[31m.hdr-btn{background:rgba(255,255,255,.08);border:1px solid var(--border);font-size:12px;font-weight:700;padding:5px 10px;border-radius:14px;cursor:pointer;white-space:nowrap}\u001b[39m\n\u001b[31m.hdr-btn:active{background:rgba(255,255,255,.16)}\u001b[39m\n\u001b[31m.user-pill{background:rgba(255,255,255,.1);border:1px solid var(--border);color:var(--muted);font-size:10px;font-weight:600;padding:3px 8px;border-radius:11px}\u001b[39m\n\u001b[31m.user-pill b{color:var(--text)}·\u001b[39m\n\u001b[31m.body{flex:1;padding:14px;display:flex;flex-direction:column;gap:12px}·\u001b[39m\n\u001b[31m/* ── editor: header + controls + route preview stay put; only the stops list scrolls ── */\u001b[39m\n\u001b[31m#s-editor.scr{height:100vh;height:100dvh;min-height:0;padding-bottom:0;overflow:hidden}\u001b[39m\n\u001b[31m.editor-body{flex:1;min-height:0;display:flex;flex-direction:column;padding:14px 14px 0}\u001b[39m\n\u001b[31m.editor-fixed{display:flex;flex-direction:column;gap:12px;flex-shrink:0}\u001b[39m\n\u001b[31m.editor-scroll{flex:1;min-height:0;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:2px 0 calc(32px + env(safe-area-inset-bottom));margin-top:6px;scrollbar-gutter:stable}\u001b[39m\n\u001b[31m/* visible scrollbar so it's obvious the stops list is independently scrollable */\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar{width:8px}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-thumb{background:#3a3a3a;border-radius:4px}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-thumb:active{background:#525252}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-track{background:transparent}·\u001b[39m\n\u001b[31m/* ── column-mapping table (parsed preview): scroll horizontally if it doesn't fit ── */\u001b[39m\n\u001b[31m.map-table-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--border);border-radius:6px}\u001b[39m\n\u001b[31m.map-table{border-collapse:collapse;table-layout:fixed;width:100%;min-width:440px;font-size:11px}\u001b[39m\n\u001b[31m.map-table th{text-align:left;padding:6px 8px;color:var(--muted);font-weight:800;font-size:9.5px;text-transform:uppercase;letter-spacing:.3px;background:var(--card2);position:sticky;top:0}\u001b[39m\n\u001b[31m.map-table td{padding:6px 8px;vertical-align:middle}\u001b[39m\n\u001b[31m.map-table tr+tr td,.map-table tbody tr td{border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.map-table col.c-src{width:40%}.map-table col.c-field{width:28%}.map-table col.c-sample{width:32%}\u001b[39m\n\u001b[31m.map-table select{background:#0b1220;border:1px solid var(--border);color:var(--text);padding:6px 7px;border-radius:4px;font-size:11px;width:100%}\u001b[39m\n\u001b[31m.map-field{font-weight:800;color:var(--text);white-space:nowrap}\u001b[39m\n\u001b[31m.map-sample{color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}·\u001b[39m\n\u001b[31m/* ── upload ── */\u001b[39m\n\u001b[31m.dropzone{background:var(--card);border:2px dashed var(--border);border-radius:var(--rad);padding:28px 16px;text-align:center;cursor:pointer;transition:.15s}\u001b[39m\n\u001b[31m.dropzone:hover,.dropzone.drag{border-color:var(--accent);background:#0e1726}\u001b[39m\n\u001b[31m.dropzone .ico{font-size:42px;margin-bottom:8px}\u001b[39m\n\u001b[31m.dropzone .ttl{font-weight:800;font-size:15px;margin-bottom:4px}\u001b[39m\n\u001b[31m.dropzone .sub{font-size:11.5px;color:var(--muted);line-height:1.4}\u001b[39m\n\u001b[31m.dropzone input[type=file]{display:none}·\u001b[39m\n\u001b[31m.section-title{font-size:11px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.4px}·\u001b[39m\n\u001b[31m.form-card{background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:12px;display:flex;flex-direction:column;gap:10px}\u001b[39m\n\u001b[31m.field{display:flex;flex-direction:column;gap:3px}\u001b[39m\n\u001b[31m.field label{font-size:11px;font-weight:600;color:var(--muted)}\u001b[39m\n\u001b[31m.field input,.field select{background:#0b1220;border:1px solid var(--border);border-radius:6px;color:var(--text);padding:9px 11px;font-size:13.5px;font-family:inherit;outline:none}\u001b[39m\n\u001b[31m.field input:focus,.field select:focus{border-color:var(--accent)}\u001b[39m\n\u001b[31m.toggle{display:flex;align-items:center;gap:8px;font-size:13px}\u001b[39m\n\u001b[31m.toggle input{accent-color:var(--accent);width:18px;height:18px}·\u001b[39m\n\u001b[31m.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;border-radius:var(--rads);padding:12px 14px;font-size:14px;font-weight:800;cursor:pointer;width:100%;transition:.12s}\u001b[39m\n\u001b[31m.btn-p{background:var(--accent);color:#fff}\u001b[39m\n\u001b[31m.btn-p:active{background:var(--accent2)}\u001b[39m\n\u001b[31m.btn-p:disabled{opacity:.4;cursor:default}\u001b[39m\n\u001b[31m.btn-s{background:var(--card);color:var(--text);border:1px solid var(--border)}\u001b[39m\n\u001b[31m.btn-r{background:rgba(239,68,68,.15);color:#fca5a5;border:1px solid rgba(239,68,68,.3)}·\u001b[39m\n\u001b[31m/* ── progress ── */\u001b[39m\n\u001b[31m.progress{background:#0b1220;border-radius:8px;height:10px;overflow:hidden;border:1px solid var(--border)}\u001b[39m\n\u001b[31m.progress-bar{height:100%;background:linear-gradient(90deg,var(--accent),var(--g));transition:.2s}·\u001b[39m\n\u001b[31m/* ── tiles ── */\u001b[39m\n\u001b[31m.origin-pill{background:linear-gradient(135deg,#0c2e3a,#062a3a);border:1px solid var(--accent);border-radius:var(--rad);padding:11px 13px;display:flex;align-items:center;gap:10px}\u001b[39m\n\u001b[31m.origin-pill .ico{font-size:18px}\u001b[39m\n\u001b[31m.origin-pill .meta{flex:1;min-width:0}\u001b[39m\n\u001b[31m.origin-pill .lbl{font-size:10.5px;color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.4px}\u001b[39m\n\u001b[31m.origin-pill .val{font-size:13.5px;font-weight:700;margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.origin-pill button{background:rgba(255,255,255,.08);border-radius:14px;padding:5px 10px;font-size:11px;font-weight:700;color:var(--accent)}·\u001b[39m\n\u001b[31m.tiles{display:flex;flex-direction:column;gap:6px}\u001b[39m\n\u001b[31m.tile{background:var(--card);border:1px solid var(--border);border-radius:var(--rads);padding:9px 10px;display:flex;align-items:center;gap:10px;transition:.12s;touch-action:none}\u001b[39m\n\u001b[31m.tile.sortable-ghost{opacity:.35;background:var(--card2)}\u001b[39m\n\u001b[31m.tile.sortable-chosen{transform:scale(.98);box-shadow:0 4px 20px rgba(14,165,233,.25)}\u001b[39m\n\u001b[31m.tile.excluded{opacity:.4;background:#0c0c0c}\u001b[39m\n\u001b[31m.tile.excluded .name,.tile.excluded .city{text-decoration:line-through}\u001b[39m\n\u001b[31m.tile.err{border-color:var(--r);background:#1f0a0a}\u001b[39m\n\u001b[31m.tile .grip{font-size:18px;color:var(--muted);cursor:grab;padding:4px;line-height:1}\u001b[39m\n\u001b[31m.tile .grip:active{cursor:grabbing}\u001b[39m\n\u001b[31m.tile .ord{background:var(--accent);color:#fff;width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11.5px;font-weight:800;flex-shrink:0}\u001b[39m\n\u001b[31m.tile.excluded .ord{background:#444}\u001b[39m\n\u001b[31m.tile .info{flex:1;min-width:0}\u001b[39m\n\u001b[31m.tile .name{font-size:13.5px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .city{font-size:11.5px;color:#cbd5e1;font-weight:600;margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .addr{font-size:9.5px;color:var(--muted);margin-top:1px;line-height:1.3;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-style:italic;opacity:.75}\u001b[39m\n\u001b[31m.tile .leg{font-size:10.5px;color:var(--accent2);font-weight:600;margin-top:2px}\u001b[39m\n\u001b[31m.tile.excluded .leg{display:none}\u001b[39m\n\u001b[31m.tile .badge-provider{display:inline-block;font-size:8.5px;padding:1px 5px;border-radius:8px;background:rgba(255,255,255,.08);color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.3px;margin-left:4px;vertical-align:middle}\u001b[39m\n\u001b[31m.tile .incl{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:50%;border:2px solid var(--border);font-size:16px;font-weight:800}\u001b[39m\n\u001b[31m.tile .incl.on{background:var(--g);border-color:var(--g);color:#fff}\u001b[39m\n\u001b[31m.tile .incl.off{background:#0c0c0c;color:var(--r);border-color:var(--r)}·\u001b[39m\n\u001b[31m/* ── prominent top action bar (KM, Share, Re-optimise) ── */\u001b[39m\n\u001b[31m.top-actions{background:linear-gradient(135deg,#0c2e3a,#08111c);border:1px solid var(--accent);border-radius:var(--rad);padding:10px;display:grid;grid-template-columns:1fr auto auto;gap:8px;align-items:center;margin-bottom:6px}\u001b[39m\n\u001b[31m.top-totals{min-width:0}\u001b[39m\n\u001b[31m.top-totals-big{font-size:18px;font-weight:900;line-height:1.05;letter-spacing:.3px}\u001b[39m\n\u001b[31m.top-totals-delta{font-size:11px;color:var(--muted);margin-top:2px}\u001b[39m\n\u001b[31m.top-totals-delta.better{color:var(--g)}\u001b[39m\n\u001b[31m.top-totals-delta.worse{color:var(--y)}\u001b[39m\n\u001b[31m.top-actions button{padding:10px 12px;font-size:12px;font-weight:800;border-radius:8px;border:none;cursor:pointer;display:flex;align-items:center;gap:5px;white-space:nowrap}\u001b[39m\n\u001b[31m.top-share{background:#f59e0b;color:#000}\u001b[39m\n\u001b[31m.top-share:active{background:#d97706}\u001b[39m\n\u001b[31m.top-reopt{background:var(--accent);color:#fff}\u001b[39m\n\u001b[31m.top-reopt:active{background:var(--accent2)}\u001b[39m\n\u001b[31m/* ── Re-optimisation needed (after include/exclude toggle) ── */\u001b[39m\n\u001b[31m.reopt-needed{font-size:11px;font-weight:700;color:#fbbf24;background:rgba(234,179,8,.12);border-left:3px solid #fbbf24;padding:5px 8px;border-radius:4px;margin-top:5px;display:none;line-height:1.3}\u001b[39m\n\u001b[31m.reopt-needed.show{display:block}·\u001b[39m\n\u001b[31m/* ── inline route preview (Leaflet + OSM) ── */\u001b[39m\n\u001b[31m.map-wrap{margin:8px 0 0;background:var(--card);border:1px solid var(--border);border-radius:var(--rads);overflow:hidden}\u001b[39m\n\u001b[31m.map-hdr{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;font-size:12px;color:var(--muted);background:var(--card2);border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.map-hdr .ttl{display:flex;align-items:center;gap:6px;font-weight:600}\u001b[39m\n\u001b[31m.map-hdr .toggle{font-size:14px;color:var(--text);background:none;border:none;cursor:pointer;padding:0 4px}\u001b[39m\n\u001b[31m#tour-map{height:260px;width:100%;background:#1a1a1a;transition:height .2s ease}\u001b[39m\n\u001b[31m.map-wrap.collapsed #tour-map{height:0}\u001b[39m\n\u001b[31m.map-wrap.collapsed .map-hdr{border-bottom:none}\u001b[39m\n\u001b[31m.tour-pin{background:transparent;border:none}\u001b[39m\n\u001b[31m.tour-pin > div{background:#0ea5e9;color:#fff;border-radius:50%;width:26px;height:26px;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:12px;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,.45);font-family:'DM Sans',sans-serif}\u001b[39m\n\u001b[31m.tour-pin.origin > div{background:#22c55e;font-size:14px}\u001b[39m\n\u001b[31m.tour-pin.out > div{background:#6b7280;opacity:.55}\u001b[39m\n\u001b[31m.leaflet-tooltip{background:#141414;color:#f5f5f5;border:1px solid #262626;font-size:11px;padding:3px 6px;font-family:'DM Sans',sans-serif;box-shadow:0 1px 6px rgba(0,0,0,.4)}\u001b[39m\n\u001b[31m.leaflet-tooltip-top:before,.leaflet-tooltip-bottom:before,.leaflet-tooltip-left:before,.leaflet-tooltip-right:before{border-top-color:#262626;border-bottom-color:#262626;border-left-color:#262626;border-right-color:#262626}\u001b[39m\n\u001b[31m.leaflet-control-attribution{background:rgba(20,20,20,.85)!important;color:#a3a3a3!important;font-size:9px!important}\u001b[39m\n\u001b[31m.leaflet-control-attribution a{color:#0ea5e9!important}\u001b[39m\n\u001b[31m.top-reopt.pulse{animation:pulseReopt 1.4s ease-in-out infinite;box-shadow:0 0 0 0 rgba(14,165,233,.6)}\u001b[39m\n\u001b[31m@keyframes pulseReopt{0%{box-shadow:0 0 0 0 rgba(14,165,233,.7)}50%{box-shadow:0 0 0 8px rgba(14,165,233,0)}100%{box-shadow:0 0 0 0 rgba(14,165,233,0)}}\u001b[39m\n\u001b[31m/* Totals dim when stale to make it visually obvious */\u001b[39m\n\u001b[31m.top-totals-big.stale, .top-totals-delta.stale{opacity:.5;text-decoration:line-through;text-decoration-color:rgba(251,191,36,.6)}·\u001b[39m\n\u001b[31m/* recalc spinner that appears inline next to the top totals during recompute */\u001b[39m\n\u001b[31m.recalc{display:none;font-size:11px;color:var(--accent);margin-left:6px;vertical-align:middle}\u001b[39m\n\u001b[31m.recalc.show{display:inline-block;animation:spin 1s linear infinite}\u001b[39m\n\u001b[31m@keyframes spin{to{transform:rotate(360deg)}}·\u001b[39m\n\u001b[31m/* ── share modal ── */\u001b[39m\n\u001b[31m.modal{position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:200;display:none;align-items:flex-end;justify-content:center}\u001b[39m\n\u001b[31m.modal.show{display:flex}\u001b[39m\n\u001b[31m.modal-box{background:var(--card);border-radius:18px 18px 0 0;width:100%;max-height:85vh;overflow-y:auto;padding:16px 14px calc(16px + env(safe-area-inset-bottom));border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.modal-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\u001b[39m\n\u001b[31m.modal-ttl{font-size:15px;font-weight:800}\u001b[39m\n\u001b[31m.modal-close{font-size:24px;color:var(--muted);background:none;border:none;cursor:pointer}·\u001b[39m\n\u001b[31m/* ── toast ── */\u001b[39m\n\u001b[31m#toast{position:fixed;bottom:calc(var(--bnav-h)+16px);left:12px;right:12px;z-index:1000;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:11px 14px;font-size:13px;font-weight:600;text-align:center;transform:translateY(150px);opacity:0;transition:.25s;pointer-events:none}\u001b[39m\n\u001b[31m#toast.show{transform:translateY(0);opacity:1}\u001b[39m\n\u001b[31m#toast.ok{background:rgba(34,197,94,.18);border-color:var(--g);color:#86efac}\u001b[39m\n\u001b[31m#toast.err{background:rgba(239,68,68,.18);border-color:var(--r);color:#fca5a5}·\u001b[39m\n\u001b[31m/* ── helpers ── */\u001b[39m\n\u001b[31m.spinner{display:inline-block;width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;vertical-align:middle}·\u001b[39m\n\u001b[31m.empty{text-align:center;padding:30px 16px;color:var(--muted);font-size:12.5px}·\u001b[39m\n\u001b[31mtextarea{background:#0b1220;border:1px solid var(--border);color:var(--text);padding:10px;font-family:inherit;font-size:12px;border-radius:6px;resize:vertical;outline:none}·\u001b[39m\n\u001b[31m.kbd{background:rgba(255,255,255,.06);padding:1px 6px;border-radius:4px;font-family:monospace;font-size:11px;color:var(--muted)}\u001b[39m\n\u001b[31m</style>\u001b[39m\n\u001b[31m</head>\u001b[39m\n\u001b[31m<body>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: UPLOAD ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-upload\\\" class=\\\"scr active\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗺 Tour Playground</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Excel → optimised route → tweak</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"loadSaved()\\\" id=\\\"btn-load\\\" style=\\\"display:none\\\">↻ Load</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"location.href='/hub/'\\\">🏡 Hub</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <label class=\\\"dropzone\\\" id=\\\"dropzone\\\" for=\\\"excel-file\\\">\u001b[39m\n\u001b[31m      <input type=\\\"file\\\" id=\\\"excel-file\\\" accept=\\\".xlsx,.xls,.csv\\\">\u001b[39m\n\u001b[31m      <div class=\\\"ico\\\">📊</div>\u001b[39m\n\u001b[31m      <div class=\\\"ttl\\\">Drop Excel here, or tap to choose</div>\u001b[39m\n\u001b[31m      <div class=\\\"sub\\\">Required: <b>store name</b> + <b>address</b><br>Optional: <b>city</b>, <b>lat</b>, <b>lng</b>, <b>phone</b>, anything else.</div>\u001b[39m\n\u001b[31m    </label>·\u001b[39m\n\u001b[31m    <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\">Origin</div>\u001b[39m\n\u001b[31m      <div class=\\\"field\\\">\u001b[39m\n\u001b[31m        <label>Start point</label>\u001b[39m\n\u001b[31m        <input type=\\\"text\\\" id=\\\"inp-origin\\\" value=\\\"Mohali office Punjab\\\" placeholder=\\\"Address or place name\\\">\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div class=\\\"toggle\\\">\u001b[39m\n\u001b[31m        <input type=\\\"checkbox\\\" id=\\\"inp-roundtrip\\\" checked>\u001b[39m\n\u001b[31m        <label for=\\\"inp-roundtrip\\\" style=\\\"cursor:pointer;font-size:13px;font-weight:600\\\">Round-trip (return to start)</label>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"dest-wrap\\\" class=\\\"field\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m        <label>Final destination</label>\u001b[39m\n\u001b[31m        <input type=\\\"text\\\" id=\\\"inp-destination\\\" placeholder=\\\"Where to end if not returning to origin\\\">\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" id=\\\"btn-go\\\" onclick=\\\"parseAndOptimise()\\\" disabled>\u001b[39m\n\u001b[31m      📐 Parse + optimise route\u001b[39m\n\u001b[31m    </button>·\u001b[39m\n\u001b[31m    <div id=\\\"upload-progress\\\" style=\\\"display:none;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;font-size:12px\\\">\u001b[39m\n\u001b[31m        <span id=\\\"prog-label\\\">Geocoding…</span>\u001b[39m\n\u001b[31m        <span id=\\\"prog-count\\\" style=\\\"color:var(--muted)\\\">0 / 0</span>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div class=\\\"progress\\\"><div class=\\\"progress-bar\\\" id=\\\"prog-bar\\\" style=\\\"width:0%\\\"></div></div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div id=\\\"parsed-preview\\\" style=\\\"display:none;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:11px;font-size:12px\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\" style=\\\"margin-bottom:6px\\\">Parsed</div>\u001b[39m\n\u001b[31m      <div id=\\\"parsed-summary\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: EDITOR ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-editor\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"confirmBack()\\\">‹ New</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>Tour Editor</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"editor-sub\\\">— stops</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-2\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"openShare()\\\">📤</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"editor-body\\\">\u001b[39m\n\u001b[31m   <div class=\\\"editor-fixed\\\">·\u001b[39m\n\u001b[31m    <div class=\\\"origin-pill\\\" id=\\\"origin-pill\\\">\u001b[39m\n\u001b[31m      <span class=\\\"ico\\\">📍</span>\u001b[39m\n\u001b[31m      <div class=\\\"meta\\\">\u001b[39m\n\u001b[31m        <div class=\\\"lbl\\\">Start from</div>\u001b[39m\n\u001b[31m        <div class=\\\"val\\\" id=\\\"origin-val\\\">—</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button onclick=\\\"openOriginEdit()\\\">✏️ Change</button>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div id=\\\"origin-edit\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m        <div class=\\\"field\\\">\u001b[39m\n\u001b[31m          <label>New origin (for mid-tour replan)</label>\u001b[39m\n\u001b[31m          <input type=\\\"text\\\" id=\\\"inp-new-origin\\\" placeholder=\\\"Hotel B Mohali, or address…\\\">\u001b[39m\n\u001b[31m          <div style=\\\"font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4\\\">\u001b[39m\n\u001b[31m            Tip: toggle ✗ on completed stops, change origin to where you are now, hit Replan.\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div style=\\\"display:flex;gap:8px\\\">\u001b[39m\n\u001b[31m          <button class=\\\"btn btn-s\\\" onclick=\\\"closeOriginEdit()\\\" style=\\\"flex:1\\\">Cancel</button>\u001b[39m\n\u001b[31m          <button class=\\\"btn btn-p\\\" onclick=\\\"applyOriginAndReplan()\\\" style=\\\"flex:2\\\">🔄 Replan from here</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"top-actions\\\" id=\\\"top-actions\\\">\u001b[39m\n\u001b[31m      <div class=\\\"top-totals\\\">\u001b[39m\n\u001b[31m        <div class=\\\"top-totals-big\\\" id=\\\"top-totals-big\\\">— km · — min<span class=\\\"recalc\\\" id=\\\"recalc\\\">↻</span></div>\u001b[39m\n\u001b[31m        <div class=\\\"top-totals-delta\\\" id=\\\"top-totals-delta\\\">vs proposed: —</div>\u001b[39m\n\u001b[31m        <div class=\\\"reopt-needed\\\" id=\\\"reopt-needed\\\">⚠ Stops changed — tap 🔁 Re-optim to refresh the route plan</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"top-share\\\" onclick=\\\"openShare()\\\">📤 Share</button>\u001b[39m\n\u001b[31m      <button class=\\\"top-reopt\\\" onclick=\\\"replanAll()\\\">🔁 Re-optim</button>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"map-wrap\\\" id=\\\"map-wrap\\\">\u001b[39m\n\u001b[31m      <div class=\\\"map-hdr\\\">\u001b[39m\n\u001b[31m        <div class=\\\"ttl\\\">🗺️ Route preview <span style=\\\"opacity:.6;font-weight:400\\\">(straight-line)</span></div>\u001b[39m\n\u001b[31m        <button class=\\\"toggle\\\" id=\\\"map-toggle\\\" onclick=\\\"toggleMap()\\\" aria-label=\\\"Toggle map\\\">▾</button>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"tour-map\\\"></div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"section-title\\\" style=\\\"margin-top:2px\\\">Stops (drag ≡ to reorder · scroll ↕ to see all)</div>\u001b[39m\n\u001b[31m   </div><!-- /editor-fixed -->·\u001b[39m\n\u001b[31m   <div class=\\\"editor-scroll\\\" id=\\\"editor-scroll\\\">\u001b[39m\n\u001b[31m    <div id=\\\"tiles\\\" class=\\\"tiles\\\"></div>\u001b[39m\n\u001b[31m   </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── MODAL: SHARE ── -->\u001b[39m\n\u001b[31m<div class=\\\"modal\\\" id=\\\"modal-share\\\">\u001b[39m\n\u001b[31m  <div class=\\\"modal-box\\\">\u001b[39m\n\u001b[31m    <div class=\\\"modal-hdr\\\">\u001b[39m\n\u001b[31m      <div class=\\\"modal-ttl\\\">📤 Share / Export</div>\u001b[39m\n\u001b[31m      <button class=\\\"modal-close\\\" onclick=\\\"closeShare()\\\">×</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"share-summary\\\" style=\\\"background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:10px;font-size:12px;line-height:1.6;white-space:pre-wrap;font-family:monospace\\\"></div>\u001b[39m\n\u001b[31m    <div style=\\\"margin-top:12px;display:grid;gap:8px\\\">\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"openInGoogleMaps()\\\">🗺️ Open route in Google Maps</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"shareWhatsApp()\\\">💬 WhatsApp</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"copyText()\\\">📋 Copy text</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"downloadCsv()\\\">📥 Download CSV</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"saveLocal()\\\">💾 Save to this device</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<div id=\\\"toast\\\"></div>·\u001b[39m\n\u001b[31m<script>\u001b[39m\n\u001b[31m'use strict';·\u001b[39m\n\u001b[31mconst CACHE_VER = 'tour-pg-v11';\u001b[39m\n\u001b[31mconst PROXY     = '/tour-pg-proxy';·\u001b[39m\n\u001b[31m// ── State ────────────────────────────────────────────\u001b[39m\n\u001b[31mlet session   = null;\u001b[39m\n\u001b[31mlet myId      = '';\u001b[39m\n\u001b[31mlet myName    = '';\u001b[39m\n\u001b[31mlet state     = newState();\u001b[39m\n\u001b[31mlet _recalcTimer = null;\u001b[39m\n\u001b[31mlet _sortable    = null;·\u001b[39m\n\u001b[31mfunction newState() {\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    origin:     { text: 'Mohali office Punjab', location: null, provider: null },\u001b[39m\n\u001b[31m    destination:null,\u001b[39m\n\u001b[31m    roundTrip:  true,\u001b[39m\n\u001b[31m    stops:      [],          // { id, name, city, address, phone, location, included, legDistance_m, legDuration_s, provider, raw, geocodeErr }\u001b[39m\n\u001b[31m    proposedOrder: null,     // array of stop IDs in optimise-time order\u001b[39m\n\u001b[31m    baseTotal: { distance_m: 0, duration_s: 0 },\u001b[39m\n\u001b[31m    currentTotal: { distance_m: 0, duration_s: 0 },\u001b[39m\n\u001b[31m    lastOptimisation: null,  // 'tomtom-computeBestOrder' | 'matrix-nn-2opt'\u001b[39m\n\u001b[31m    needsReoptimize: false,  // true after include/exclude toggle → totals may be stale; prompt user to tap Re-optim\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction uid(prefix='st') { return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,6)}`; }·\u001b[39m\n\u001b[31m// ── Auth ─────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction init() {\u001b[39m\n\u001b[31m  registerSW();\u001b[39m\n\u001b[31m  const raw = localStorage.getItem('lm360-session');\u001b[39m\n\u001b[31m  // Bounce to Hub with ?next= so user lands back here after login (Hub honours\u001b[39m\n\u001b[31m  // same-origin relative paths). Pattern documented in\u001b[39m\n\u001b[31m  // feedback_hub_next_redirect.md memory file — all gated PWAs should follow.\u001b[39m\n\u001b[31m  const nextParam = '?next=' + encodeURIComponent(location.pathname + location.search);\u001b[39m\n\u001b[31m  if (!raw) { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  try { session = JSON.parse(raw); } catch { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  if (Date.now() - (session.loginAt ? new Date(session.loginAt).getTime() : 0) > 12*3600*1000) { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  myId   = session.empId || '';\u001b[39m\n\u001b[31m  myName = session.name  || myId;\u001b[39m\n\u001b[31m  const userText = `👤 <b>${escHtml(myName)}</b>`;\u001b[39m\n\u001b[31m  document.getElementById('user-pill').innerHTML   = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill-2').innerHTML = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill').style.display   = '';\u001b[39m\n\u001b[31m  document.getElementById('user-pill-2').style.display = '';·\u001b[39m\n\u001b[31m  // Load-saved chip if we have a saved tour\u001b[39m\n\u001b[31m  if (localStorage.getItem('tour-pg-saved')) document.getElementById('btn-load').style.display = '';·\u001b[39m\n\u001b[31m  // Wire UI\u001b[39m\n\u001b[31m  document.getElementById('inp-roundtrip').addEventListener('change', e => {\u001b[39m\n\u001b[31m    document.getElementById('dest-wrap').style.display = e.target.checked ? 'none' : '';\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('dragover', e => { e.preventDefault(); e.currentTarget.classList.add('drag'); });\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('dragleave', e => e.currentTarget.classList.remove('drag'));\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('drop', e => {\u001b[39m\n\u001b[31m    e.preventDefault();\u001b[39m\n\u001b[31m    e.currentTarget.classList.remove('drag');\u001b[39m\n\u001b[31m    const f = e.dataTransfer.files[0]; if (f) handleFile(f);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  document.getElementById('excel-file').addEventListener('change', e => {\u001b[39m\n\u001b[31m    const f = e.target.files[0]; if (f) handleFile(f);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction hubAuth() {\u001b[39m\n\u001b[31m  return 'Hub ' + btoa(JSON.stringify(session));\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function api(path, body) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, {\u001b[39m\n\u001b[31m    method: 'POST',\u001b[39m\n\u001b[31m    headers: { 'Authorization': hubAuth(), 'Content-Type': 'application/json' },\u001b[39m\n\u001b[31m    body: JSON.stringify(body),\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (r.status === 401) {\u001b[39m\n\u001b[31m    toast('Session expired — refreshing to re-login', 'err');\u001b[39m\n\u001b[31m    const nextParam = '?next=' + encodeURIComponent(location.pathname + location.search);\u001b[39m\n\u001b[31m    setTimeout(() => location.href = '/hub/' + nextParam, 1500);\u001b[39m\n\u001b[31m    throw new Error('unauthorized');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!r.ok) {\u001b[39m\n\u001b[31m    const txt = await r.text().catch(() => '');\u001b[39m\n\u001b[31m    throw new Error(`${path}: ${r.status} ${txt.slice(0, 100)}`);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return r.json();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Excel parsing + column detection ────────────────\u001b[39m\n\u001b[31mlet _parsedCols = null;   // exposed for the column-remap UI\u001b[39m\n\u001b[31mlet _parsedRows = null;\u001b[39m\n\u001b[31mfunction handleFile(file) {\u001b[39m\n\u001b[31m  const reader = new FileReader();\u001b[39m\n\u001b[31m  reader.onload = (e) => {\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      const wb = XLSX.read(e.target.result, { type: 'array' });\u001b[39m\n\u001b[31m      const ws = wb.Sheets[wb.SheetNames[0]];\u001b[39m\n\u001b[31m      const rows = XLSX.utils.sheet_to_json(ws, { defval: '' });\u001b[39m\n\u001b[31m      if (!rows.length) throw new Error('Empty sheet');\u001b[39m\n\u001b[31m      _parsedRows = rows;\u001b[39m\n\u001b[31m      _parsedCols = detectColumns(rows[0]);\u001b[39m\n\u001b[31m      if (!_parsedCols.name && !_parsedCols.address && !_parsedCols.city) throw new Error('Missing required columns (need at least one of: name, address, city)');\u001b[39m\n\u001b[31m      rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m      renderParsedPreview();\u001b[39m\n\u001b[31m      document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m      toast('Excel parsed ✓', 'ok');\u001b[39m\n\u001b[31m    } catch (err) {\u001b[39m\n\u001b[31m      toast('Excel error: ' + err.message, 'err');\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  reader.readAsArrayBuffer(file);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction rebuildStopsFromParsedRows() {\u001b[39m\n\u001b[31m  state.stops = _parsedRows.map((row, idx) => buildStop(row, _parsedCols, idx)).filter(s => s);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderParsedPreview() {\u001b[39m\n\u001b[31m  const skip = _parsedRows.length - state.stops.length;\u001b[39m\n\u001b[31m  const allCols = Object.keys(_parsedRows[0]);\u001b[39m\n\u001b[31m  // For each role, show:  <role>: <selected col name>  with a tiny dropdown to override\u001b[39m\n\u001b[31m  const ROLES = ['name','city','address','lat','lng','phone','notes'];\u001b[39m\n\u001b[31m  const sample = _parsedRows[0];\u001b[39m\n\u001b[31m  const previewVal = (key) => {\u001b[39m\n\u001b[31m    const v = sample[key]; if (v == null || v === '') return '';\u001b[39m\n\u001b[31m    return String(v).slice(0, 40);\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  // Column order (per user request): YOUR Excel column first, then the PWA field\u001b[39m\n\u001b[31m  // it maps to, then a sample value. Equal-ish widths via <colgroup>; the wrapper\u001b[39m\n\u001b[31m  // scrolls horizontally so long header names are never clipped on narrow phones.\u001b[39m\n\u001b[31m  let rows = '<div class=\\\"map-table-wrap\\\"><table class=\\\"map-table\\\">' +\u001b[39m\n\u001b[31m    '<colgroup><col class=\\\"c-src\\\"><col class=\\\"c-field\\\"><col class=\\\"c-sample\\\"></colgroup>' +\u001b[39m\n\u001b[31m    '<thead><tr><th>Your file column</th><th>→ PWA field</th><th>Sample value</th></tr></thead><tbody>';\u001b[39m\n\u001b[31m  for (const role of ROLES) {\u001b[39m\n\u001b[31m    const sel  = _parsedCols[role];\u001b[39m\n\u001b[31m    const need = role === 'name';\u001b[39m\n\u001b[31m    const opts = `<option value=\\\"\\\">— ignore —</option>` +\u001b[39m\n\u001b[31m      allCols.map(c => `<option value=\\\"${escHtml(c)}\\\" ${sel === c ? 'selected' : ''}>${escHtml(c)}</option>`).join('');\u001b[39m\n\u001b[31m    const previewText = sel ? escHtml(previewVal(sel)) : '';\u001b[39m\n\u001b[31m    const tip = !sel && need ? `<span style=\\\"color:var(--y);font-weight:700\\\">⚠ no match — uses city or \\\"Stop #N\\\"</span>` : '';\u001b[39m\n\u001b[31m    rows += `<tr>\u001b[39m\n\u001b[31m      <td><select onchange=\\\"remapColumn('${role}', this.value)\\\">${opts}</select></td>\u001b[39m\n\u001b[31m      <td class=\\\"map-field\\\">${role}${need?' <span style=\\\"color:var(--y)\\\">*</span>':''}</td>\u001b[39m\n\u001b[31m      <td class=\\\"map-sample\\\">${previewText}${tip}</td>\u001b[39m\n\u001b[31m    </tr>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  rows += '</tbody></table></div>';\u001b[39m\n\u001b[31m  document.getElementById('parsed-preview').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('parsed-summary').innerHTML =\u001b[39m\n\u001b[31m    `<div style=\\\"margin-bottom:6px\\\"><b>${state.stops.length}</b> stops detected` +\u001b[39m\n\u001b[31m    `${skip > 0 ? ` · ${skip} skipped (no name/city/address)` : ''}</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:10.5px;color:var(--muted);margin-bottom:6px\\\">If detection is wrong, pick the correct column from the dropdowns:</div>` +\u001b[39m\n\u001b[31m    rows;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction remapColumn(role, newCol) {\u001b[39m\n\u001b[31m  if (!_parsedCols) return;\u001b[39m\n\u001b[31m  _parsedCols[role] = newCol || null;\u001b[39m\n\u001b[31m  rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction normaliseColName(s) {\u001b[39m\n\u001b[31m  return String(s).toLowerCase().replace(/[^a-z]/g, '');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction detectColumns(sampleRow) {\u001b[39m\n\u001b[31m  const map = { name:null, city:null, address:null, lat:null, lng:null, phone:null, notes:null, others:[] };\u001b[39m\n\u001b[31m  const keys = Object.keys(sampleRow);\u001b[39m\n\u001b[31m  // Broadened aliases so most user-provided Excel files map sensibly without a UI:\u001b[39m\n\u001b[31m  //   name  ← store / shop / outlet / party / dealer / firm / customer / business /\u001b[39m\n\u001b[31m  //           company / vendor / agency (and \\\"<thing> name\\\")\u001b[39m\n\u001b[31m  //   addr  ← address / fulladdress / location / loc / addr\u001b[39m\n\u001b[31m  //   city  ← city / town / district / location (only if no address column took it)\u001b[39m\n\u001b[31m  for (const k of keys) {\u001b[39m\n\u001b[31m    const n = normaliseColName(k);\u001b[39m\n\u001b[31m    if (!map.name && /^(storename|name|store|client|outlet|party(name)?|dealer(name)?|firm(name)?|customer(name)?|shop(name)?|business(name)?|companyname|company|vendor(name)?|agency(name)?|merchant|chain|sitename|site)$/.test(n)) { map.name = k; continue; }\u001b[39m\n\u001b[31m    if (!map.address && /^(address|fulladdress|streetaddress|addr|location|loc|street)$/.test(n)) { map.address = k; continue; }\u001b[39m\n\u001b[31m    if (!map.city && /^(city|town|district|cityname|locality|area|region)$/.test(n)) { map.city = k; continue; }\u001b[39m\n\u001b[31m    if (!map.lat && /^(lat|latitude|y)$/.test(n)) { map.lat = k; continue; }\u001b[39m\n\u001b[31m    if (!map.lng && /^(lng|lon|long|longitude|x)$/.test(n)) { map.lng = k; continue; }\u001b[39m\n\u001b[31m    if (!map.phone && /^(phone(no)?|mobile(no)?|contact(no)?|tel(no)?|whatsapp|wa|cell)$/.test(n)) { map.phone = k; continue; }\u001b[39m\n\u001b[31m    if (!map.notes && /^(notes?|remarks?|comments?|description|desc|memo)$/.test(n)) { map.notes = k; continue; }\u001b[39m\n\u001b[31m    map.others.push(k);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return map;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction buildStop(row, cols, idx) {\u001b[39m\n\u001b[31m  const name    = cols.name    ? String(row[cols.name]   || '').trim() : '';\u001b[39m\n\u001b[31m  const address = cols.address ? String(row[cols.address]|| '').trim() : '';\u001b[39m\n\u001b[31m  const city    = cols.city    ? String(row[cols.city]   || '').trim() : '';\u001b[39m\n\u001b[31m  const phone   = cols.phone   ? String(row[cols.phone]  || '').trim() : '';\u001b[39m\n\u001b[31m  const notes   = cols.notes   ? String(row[cols.notes]  || '').trim() : '';\u001b[39m\n\u001b[31m  if (!name && !address && !city) return null;\u001b[39m\n\u001b[31m  // pre-resolved coords?\u001b[39m\n\u001b[31m  let location = null;\u001b[39m\n\u001b[31m  if (cols.lat && cols.lng) {\u001b[39m\n\u001b[31m    const lat = parseFloat(row[cols.lat]);\u001b[39m\n\u001b[31m    const lng = parseFloat(row[cols.lng]);\u001b[39m\n\u001b[31m    if (!isNaN(lat) && !isNaN(lng) && lat !== 0 && lng !== 0) location = { lat, lng };\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const fullAddr = [address, city].filter(Boolean).join(', ');\u001b[39m\n\u001b[31m  // Display name fallback hierarchy (NEVER truncated address — that confused users):\u001b[39m\n\u001b[31m  //   1. Explicit name column value\u001b[39m\n\u001b[31m  //   2. City + index (so excluded/duplicate cities stay distinguishable)\u001b[39m\n\u001b[31m  //   3. \\\"Stop #N\\\"\u001b[39m\n\u001b[31m  let displayName;\u001b[39m\n\u001b[31m  if (name) displayName = name;\u001b[39m\n\u001b[31m  else if (city) displayName = `${city} (stop ${idx + 1})`;\u001b[39m\n\u001b[31m  else displayName = `Stop ${idx + 1}`;\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    id:           uid('st'),\u001b[39m\n\u001b[31m    name:         displayName,\u001b[39m\n\u001b[31m    hasExplicitName: !!name,         // for UI to flag \\\"name auto-derived\\\"\u001b[39m\n\u001b[31m    city, phone, notes,\u001b[39m\n\u001b[31m    address:      fullAddr || name || '',\u001b[39m\n\u001b[31m    location,\u001b[39m\n\u001b[31m    provider:     location ? 'excel' : null,\u001b[39m\n\u001b[31m    included:     true,\u001b[39m\n\u001b[31m    legDistance_m: 0,\u001b[39m\n\u001b[31m    legDuration_s: 0,\u001b[39m\n\u001b[31m    geocodeErr:   null,\u001b[39m\n\u001b[31m    raw:          { ...row },\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Geocode + optimise ──────────────────────────────\u001b[39m\n\u001b[31masync function parseAndOptimise() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('Parse an Excel first', 'err');\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = true;\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = '';\u001b[39m\n\u001b[31m  state.origin.text  = document.getElementById('inp-origin').value.trim() || 'Mohali office Punjab';\u001b[39m\n\u001b[31m  state.roundTrip    = document.getElementById('inp-roundtrip').checked;\u001b[39m\n\u001b[31m  state.destination  = state.roundTrip ? null : (document.getElementById('inp-destination').value.trim() || null);\u001b[39m\n\u001b[31m  // Geocode origin first\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: state.origin.text });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin.location = og.location;\u001b[39m\n\u001b[31m    state.origin.provider = og.provider;\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    setProgress(0, 0, '');\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('Origin geocode failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Geocode each stop (skip if pre-resolved). Sequential to avoid rate limits.\u001b[39m\n\u001b[31m  const total = state.stops.length;\u001b[39m\n\u001b[31m  let done = 0;\u001b[39m\n\u001b[31m  for (const s of state.stops) {\u001b[39m\n\u001b[31m    if (s.location) { done++; setProgress(done, total, 'Pre-geocoded'); continue; }\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      const r = await api('/geocode', { address: s.address });\u001b[39m\n\u001b[31m      if (r.ok && r.location) {\u001b[39m\n\u001b[31m        s.location = r.location;\u001b[39m\n\u001b[31m        s.provider = r.provider;\u001b[39m\n\u001b[31m      } else {\u001b[39m\n\u001b[31m        s.geocodeErr = r.error || 'No match';\u001b[39m\n\u001b[31m      }\u001b[39m\n\u001b[31m    } catch (e) {\u001b[39m\n\u001b[31m      s.geocodeErr = e.message;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    done++;\u001b[39m\n\u001b[31m    setProgress(done, total, `Geocoding ${s.name.slice(0, 30)}…`);\u001b[39m\n\u001b[31m    // Tiny delay to be polite to the provider\u001b[39m\n\u001b[31m    await sleep(80);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Filter out failed-geocode stops\u001b[39m\n\u001b[31m  const good = state.stops.filter(s => s.location && !s.geocodeErr);\u001b[39m\n\u001b[31m  const bad  = state.stops.filter(s => !s.location || s.geocodeErr);\u001b[39m\n\u001b[31m  if (!good.length) {\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('No stops could be geocoded', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (bad.length) toast(`${bad.length} stop(s) failed geocode (excluded)`, 'err');\u001b[39m\n\u001b[31m  state.stops = good;·\u001b[39m\n\u001b[31m  // Optimise\u001b[39m\n\u001b[31m  setProgress(total, total, 'Optimising route…');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const opt = await api('/optimize', {\u001b[39m\n\u001b[31m      origin:    locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:     state.stops.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:  state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!opt.ok) throw new Error(opt.error || 'optimise failed');\u001b[39m\n\u001b[31m    // Apply order\u001b[39m\n\u001b[31m    const order = opt.waypoint_order || state.stops.map((_, i) => i);\u001b[39m\n\u001b[31m    state.stops = order.map(i => state.stops[i]);\u001b[39m\n\u001b[31m    state.proposedOrder = state.stops.map(s => s.id);\u001b[39m\n\u001b[31m    state.baseTotal     = { distance_m: opt.total_distance_m, duration_s: opt.total_duration_s };\u001b[39m\n\u001b[31m    state.currentTotal  = { ...state.baseTotal };\u001b[39m\n\u001b[31m    state.lastOptimisation = opt.optimisation;\u001b[39m\n\u001b[31m    state.needsReoptimize  = false;   // fresh optimise — flag cleared\u001b[39m\n\u001b[31m    // Map legs to stops (route returned len = stops + 1 (round-trip) or stops + 1 (one-way))\u001b[39m\n\u001b[31m    if (Array.isArray(opt.legs) && opt.legs.length >= state.stops.length) {\u001b[39m\n\u001b[31m      state.stops.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = opt.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('Optimise failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m  showScreen('s-editor');\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction setProgress(done, total, label) {\u001b[39m\n\u001b[31m  const pct = total ? Math.round(done/total*100) : 0;\u001b[39m\n\u001b[31m  document.getElementById('prog-bar').style.width   = pct + '%';\u001b[39m\n\u001b[31m  document.getElementById('prog-count').textContent = `${done} / ${total}`;\u001b[39m\n\u001b[31m  document.getElementById('prog-label').textContent = label;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction locStr(loc) { return loc ? `${loc.lat},${loc.lng}` : null; }\u001b[39m\n\u001b[31mconst sleep = ms => new Promise(r => setTimeout(r, ms));·\u001b[39m\n\u001b[31m// ── Editor screen ───────────────────────────────────\u001b[39m\n\u001b[31mfunction renderEditor() {\u001b[39m\n\u001b[31m  restoreMapPref();\u001b[39m\n\u001b[31m  document.getElementById('origin-val').textContent = state.origin.text;\u001b[39m\n\u001b[31m  document.getElementById('editor-sub').textContent =\u001b[39m\n\u001b[31m    `${state.stops.filter(s => s.included).length} of ${state.stops.length} included` +\u001b[39m\n\u001b[31m    (state.lastOptimisation ? ` · ${state.lastOptimisation}` : '');\u001b[39m\n\u001b[31m  const cont = document.getElementById('tiles');\u001b[39m\n\u001b[31m  cont.innerHTML = '';\u001b[39m\n\u001b[31m  state.stops.forEach((s, idx) => cont.appendChild(renderTile(s, idx)));\u001b[39m\n\u001b[31m  if (_sortable) _sortable.destroy();\u001b[39m\n\u001b[31m  _sortable = Sortable.create(cont, {\u001b[39m\n\u001b[31m    handle: '.grip',\u001b[39m\n\u001b[31m    animation: 150,\u001b[39m\n\u001b[31m    onEnd: (evt) => {\u001b[39m\n\u001b[31m      const moved = state.stops.splice(evt.oldIndex, 1)[0];\u001b[39m\n\u001b[31m      state.stops.splice(evt.newIndex, 0, moved);\u001b[39m\n\u001b[31m      renderEditor();\u001b[39m\n\u001b[31m      scheduleRecalc();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  updateTotalsBar();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderTile(s, idx) {\u001b[39m\n\u001b[31m  const el = document.createElement('div');\u001b[39m\n\u001b[31m  el.className = 'tile' + (s.included ? '' : ' excluded') + (s.geocodeErr ? ' err' : '');\u001b[39m\n\u001b[31m  el.dataset.id = s.id;\u001b[39m\n\u001b[31m  const providerBadge = s.provider ? `<span class=\\\"badge-provider\\\">${escHtml(s.provider)}</span>` : '';\u001b[39m\n\u001b[31m  const leg = s.legDistance_m > 0\u001b[39m\n\u001b[31m    ? `${(s.legDistance_m/1000).toFixed(1)} km · ${Math.round(s.legDuration_s/60)} min from prev`\u001b[39m\n\u001b[31m    : '';\u001b[39m\n\u001b[31m  // Per UX rule: Name + City are the primary display. Address is shown\u001b[39m\n\u001b[31m  // smaller below as supporting context. Falls back gracefully when city\u001b[39m\n\u001b[31m  // is missing.\u001b[39m\n\u001b[31m  const cityLine = s.city || '—';\u001b[39m\n\u001b[31m  const addrLine = s.address && s.address !== s.city ? s.address : '';\u001b[39m\n\u001b[31m  el.innerHTML = `\u001b[39m\n\u001b[31m    <span class=\\\"grip\\\">≡</span>\u001b[39m\n\u001b[31m    <span class=\\\"ord\\\">${idx + 1}</span>\u001b[39m\n\u001b[31m    <div class=\\\"info\\\">\u001b[39m\n\u001b[31m      <div class=\\\"name\\\">${escHtml(s.name)} ${providerBadge}</div>\u001b[39m\n\u001b[31m      <div class=\\\"city\\\">${escHtml(cityLine)}</div>\u001b[39m\n\u001b[31m      ${addrLine ? `<div class=\\\"addr\\\">${escHtml(addrLine)}</div>` : ''}\u001b[39m\n\u001b[31m      ${leg ? `<div class=\\\"leg\\\">${escHtml(leg)}</div>` : ''}\u001b[39m\n\u001b[31m      ${s.geocodeErr ? `<div class=\\\"leg\\\" style=\\\"color:var(--r)\\\">⚠ ${escHtml(s.geocodeErr)}</div>` : ''}\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"incl ${s.included ? 'on' : 'off'}\\\" onclick=\\\"toggleInclude('${s.id}')\\\" title=\\\"Include / exclude\\\">\u001b[39m\n\u001b[31m      ${s.included ? '✓' : '✗'}\u001b[39m\n\u001b[31m    </button>`;\u001b[39m\n\u001b[31m  return el;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleInclude(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id); if (!s) return;\u001b[39m\n\u001b[31m  s.included = !s.included;\u001b[39m\n\u001b[31m  // The SET of stops just changed — the existing route may no longer be optimal.\u001b[39m\n\u001b[31m  // Surface a banner + pulse the Re-optim button so the user knows to refresh.\u001b[39m\n\u001b[31m  // recalcRoute still fires (debounced) to update the cost of the CURRENT order,\u001b[39m\n\u001b[31m  // but that number is just informational until the user re-optimises.\u001b[39m\n\u001b[31m  state.needsReoptimize = true;\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  scheduleRecalc();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction scheduleRecalc() {\u001b[39m\n\u001b[31m  clearTimeout(_recalcTimer);\u001b[39m\n\u001b[31m  document.getElementById('recalc').classList.add('show');\u001b[39m\n\u001b[31m  _recalcTimer = setTimeout(recalcRoute, 400);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function recalcRoute() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  if (!included.length) {\u001b[39m\n\u001b[31m    state.currentTotal = { distance_m: 0, duration_s: 0 };\u001b[39m\n\u001b[31m    document.getElementById('recalc').classList.remove('show');\u001b[39m\n\u001b[31m    updateTotalsBar();\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/route', {\u001b[39m\n\u001b[31m      origin:      locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:       included.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:   state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!r.ok) throw new Error(r.error || 'route failed');\u001b[39m\n\u001b[31m    state.currentTotal = { distance_m: r.total_distance_m, duration_s: r.total_duration_s };\u001b[39m\n\u001b[31m    // Map legs back to tiles (legs index 0 = origin→stop0, n = lastStop→dest)\u001b[39m\n\u001b[31m    if (Array.isArray(r.legs)) {\u001b[39m\n\u001b[31m      included.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = r.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    // Clear leg info on excluded stops\u001b[39m\n\u001b[31m    state.stops.filter(s => !s.included).forEach(s => { s.legDistance_m = 0; s.legDuration_s = 0; });\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Recalc failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  } finally {\u001b[39m\n\u001b[31m    document.getElementById('recalc').classList.remove('show');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateTotalsBar() {\u001b[39m\n\u001b[31m  // Totals now live ONLY in the top-actions card (the sticky bottom bar was\u001b[39m\n\u001b[31m  // removed after user feedback that it duplicated the top info 2026-06-04).\u001b[39m\n\u001b[31m  // The big-text element holds the inline recalc spinner as a child <span>.\u001b[39m\n\u001b[31m  const km  = (state.currentTotal.distance_m / 1000).toFixed(1);\u001b[39m\n\u001b[31m  const min = Math.round(state.currentTotal.duration_s / 60);\u001b[39m\n\u001b[31m  const hrs = Math.floor(min / 60), mm = min % 60;\u001b[39m\n\u001b[31m  const timeStr = hrs > 0 ? `${hrs}h ${mm}min` : `${mm} min`;\u001b[39m\n\u001b[31m  const totalsText = `${km} km · ${timeStr}`;\u001b[39m\n\u001b[31m  const bigEl  = document.getElementById('top-totals-big');\u001b[39m\n\u001b[31m  const recalc = document.getElementById('recalc');\u001b[39m\n\u001b[31m  bigEl.textContent = totalsText;\u001b[39m\n\u001b[31m  if (recalc) bigEl.appendChild(document.createTextNode(' '));\u001b[39m\n\u001b[31m  if (recalc) bigEl.appendChild(recalc);\u001b[39m\n\u001b[31m  // Delta vs base\u001b[39m\n\u001b[31m  const baseKm   = state.baseTotal.distance_m / 1000;\u001b[39m\n\u001b[31m  const curKm    = state.currentTotal.distance_m / 1000;\u001b[39m\n\u001b[31m  const deltaKm  = curKm - baseKm;\u001b[39m\n\u001b[31m  const deltaMin = Math.round((state.currentTotal.duration_s - state.baseTotal.duration_s) / 60);\u001b[39m\n\u001b[31m  const dT = document.getElementById('top-totals-delta');\u001b[39m\n\u001b[31m  let deltaText, deltaClass;\u001b[39m\n\u001b[31m  if (Math.abs(deltaKm) < 0.05) {\u001b[39m\n\u001b[31m    deltaText  = 'matches proposed route';\u001b[39m\n\u001b[31m    deltaClass = '';\u001b[39m\n\u001b[31m  } else {\u001b[39m\n\u001b[31m    const sign = deltaKm > 0 ? '+' : '';\u001b[39m\n\u001b[31m    deltaText  = `vs proposed: ${sign}${deltaKm.toFixed(1)} km · ${sign}${deltaMin} min`;\u001b[39m\n\u001b[31m    deltaClass = deltaKm < 0 ? 'better' : 'worse';\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  dT.textContent = deltaText;\u001b[39m\n\u001b[31m  dT.className   = 'top-totals-delta ' + deltaClass;·\u001b[39m\n\u001b[31m  // ── Re-optim-needed banner + pulse + stale styling ──\u001b[39m\n\u001b[31m  // Surfaces a clear message whenever the user has added/removed stops since\u001b[39m\n\u001b[31m  // the last /optimize call. The current-order cost is still shown (so the\u001b[39m\n\u001b[31m  // user sees \\\"what this order costs\\\"), but it's struck-through and the\u001b[39m\n\u001b[31m  // Re-optim button pulses to draw attention.\u001b[39m\n\u001b[31m  const needsReopt = !!state.needsReoptimize;\u001b[39m\n\u001b[31m  document.getElementById('reopt-needed').classList.toggle('show', needsReopt);\u001b[39m\n\u001b[31m  bigEl.classList.toggle('stale', needsReopt);\u001b[39m\n\u001b[31m  dT.classList.toggle('stale', needsReopt);\u001b[39m\n\u001b[31m  document.querySelector('.top-reopt')?.classList.toggle('pulse', needsReopt);·\u001b[39m\n\u001b[31m  // Re-render inline map preview to reflect current order/include flags.\u001b[39m\n\u001b[31m  // Debounced via setTimeout 0 so multiple updates in the same tick coalesce.\u001b[39m\n\u001b[31m  scheduleMapRender();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Inline route preview (Leaflet + free OSM tiles) ──────────────\u001b[39m\n\u001b[31m// Renders origin + stops + straight-line polyline inside the editor screen.\u001b[39m\n\u001b[31m// \\\"Straight-line\\\" because the proxy only returns leg distances/durations,\u001b[39m\n\u001b[31m// not the per-leg point arrays from TomTom — this preview is enough for\u001b[39m\n\u001b[31m// orientation; \\\"Open in Google Maps\\\" gives the user the real road geometry.\u001b[39m\n\u001b[31mlet _leafletMap   = null;\u001b[39m\n\u001b[31mlet _leafletLayer = null;\u001b[39m\n\u001b[31mlet _mapRenderTimer = null;·\u001b[39m\n\u001b[31mfunction ensureMap() {\u001b[39m\n\u001b[31m  if (_leafletMap) return _leafletMap;\u001b[39m\n\u001b[31m  if (typeof L === 'undefined') return null;   // CDN not ready yet\u001b[39m\n\u001b[31m  const el = document.getElementById('tour-map');\u001b[39m\n\u001b[31m  if (!el || !el.offsetParent) return null;    // container not visible yet\u001b[39m\n\u001b[31m  _leafletMap = L.map(el, { attributionControl: false, zoomControl: true, preferCanvas: true });\u001b[39m\n\u001b[31m  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18 })\u001b[39m\n\u001b[31m    .addTo(_leafletMap);\u001b[39m\n\u001b[31m  L.control.attribution({ position: 'bottomright', prefix: false })\u001b[39m\n\u001b[31m    .addAttribution('© OpenStreetMap contributors')\u001b[39m\n\u001b[31m    .addTo(_leafletMap);\u001b[39m\n\u001b[31m  return _leafletMap;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderMap() {\u001b[39m\n\u001b[31m  const map = ensureMap();\u001b[39m\n\u001b[31m  if (!map) return;\u001b[39m\n\u001b[31m  if (_leafletLayer) { map.removeLayer(_leafletLayer); _leafletLayer = null; }\u001b[39m\n\u001b[31m  const layers = [];\u001b[39m\n\u001b[31m  const linePts = [];\u001b[39m\n\u001b[31m  // Origin marker (always first in line)\u001b[39m\n\u001b[31m  if (state.origin?.location) {\u001b[39m\n\u001b[31m    const ll = [state.origin.location.lat, state.origin.location.lng];\u001b[39m\n\u001b[31m    layers.push(L.marker(ll, { icon: L.divIcon({\u001b[39m\n\u001b[31m      className: 'tour-pin origin', html: '<div>🏁</div>',\u001b[39m\n\u001b[31m      iconSize: [28, 28], iconAnchor: [14, 14],\u001b[39m\n\u001b[31m    })}).bindTooltip('Start: ' + (state.origin.text || 'origin'), { direction: 'top' }));\u001b[39m\n\u001b[31m    linePts.push(ll);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Stops in current visit order\u001b[39m\n\u001b[31m  let visitNum = 0;\u001b[39m\n\u001b[31m  state.stops.forEach(s => {\u001b[39m\n\u001b[31m    if (!s.location) return;\u001b[39m\n\u001b[31m    const ll = [s.location.lat, s.location.lng];\u001b[39m\n\u001b[31m    const included = s.included;\u001b[39m\n\u001b[31m    if (included) visitNum++;\u001b[39m\n\u001b[31m    layers.push(L.marker(ll, { icon: L.divIcon({\u001b[39m\n\u001b[31m      className: 'tour-pin' + (included ? '' : ' out'),\u001b[39m\n\u001b[31m      html: `<div>${included ? visitNum : '·'}</div>`,\u001b[39m\n\u001b[31m      iconSize: [26, 26], iconAnchor: [13, 13],\u001b[39m\n\u001b[31m    })}).bindTooltip(\u001b[39m\n\u001b[31m      (included ? `${visitNum}. ` : '✗ ') + s.name + (s.city ? ` · ${s.city}` : ''),\u001b[39m\n\u001b[31m      { direction: 'top' }\u001b[39m\n\u001b[31m    ));\u001b[39m\n\u001b[31m    if (included) linePts.push(ll);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  // Close the loop on round-trip\u001b[39m\n\u001b[31m  if (state.roundTrip && state.origin?.location) {\u001b[39m\n\u001b[31m    linePts.push([state.origin.location.lat, state.origin.location.lng]);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (linePts.length >= 2) {\u001b[39m\n\u001b[31m    layers.push(L.polyline(linePts, {\u001b[39m\n\u001b[31m      color: '#0ea5e9', weight: 3, opacity: 0.85, dashArray: '6 4',\u001b[39m\n\u001b[31m    }));\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  _leafletLayer = L.layerGroup(layers).addTo(map);\u001b[39m\n\u001b[31m  if (linePts.length) {\u001b[39m\n\u001b[31m    try { map.fitBounds(L.latLngBounds(linePts), { padding: [30, 30], maxZoom: 13 }); }\u001b[39m\n\u001b[31m    catch (_) {}\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction scheduleMapRender() {\u001b[39m\n\u001b[31m  if (_mapRenderTimer) return;\u001b[39m\n\u001b[31m  _mapRenderTimer = setTimeout(() => { _mapRenderTimer = null; renderMap(); }, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleMap() {\u001b[39m\n\u001b[31m  const wrap = document.getElementById('map-wrap');\u001b[39m\n\u001b[31m  const tog  = document.getElementById('map-toggle');\u001b[39m\n\u001b[31m  const open = !wrap.classList.toggle('collapsed');\u001b[39m\n\u001b[31m  tog.textContent = open ? '▾' : '▴';\u001b[39m\n\u001b[31m  try { localStorage.setItem('tour-pg-map-open', open ? '1' : '0'); } catch (_) {}\u001b[39m\n\u001b[31m  if (open && _leafletMap) {\u001b[39m\n\u001b[31m    // After expansion the container size changed — Leaflet must recompute\u001b[39m\n\u001b[31m    setTimeout(() => { _leafletMap.invalidateSize(); renderMap(); }, 220);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction restoreMapPref() {\u001b[39m\n\u001b[31m  // Default collapsed — user must explicitly expand once for it to stick open.\u001b[39m\n\u001b[31m  let open = false;\u001b[39m\n\u001b[31m  try { open = localStorage.getItem('tour-pg-map-open') === '1'; } catch (_) {}\u001b[39m\n\u001b[31m  const wrap = document.getElementById('map-wrap');\u001b[39m\n\u001b[31m  const tog  = document.getElementById('map-toggle');\u001b[39m\n\u001b[31m  wrap.classList.toggle('collapsed', !open);\u001b[39m\n\u001b[31m  tog.textContent = open ? '▾' : '▴';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Origin edit / mid-tour replan ────────────────────\u001b[39m\n\u001b[31mfunction openOriginEdit() {\u001b[39m\n\u001b[31m  document.getElementById('origin-edit').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('inp-new-origin').value = state.origin.text;\u001b[39m\n\u001b[31m  document.getElementById('inp-new-origin').focus();\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction closeOriginEdit() {\u001b[39m\n\u001b[31m  document.getElementById('origin-edit').style.display = 'none';\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31masync function applyOriginAndReplan() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('inp-new-origin').value.trim();\u001b[39m\n\u001b[31m  if (!txt) return toast('Enter an origin', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: txt });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin = { text: txt, location: og.location, provider: og.provider };\u001b[39m\n\u001b[31m    closeOriginEdit();\u001b[39m\n\u001b[31m    await replanAll();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Origin failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function replanAll() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  if (!included.length) return toast('No stops included', 'err');\u001b[39m\n\u001b[31m  toast('Optimising…', 'ok');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const opt = await api('/optimize', {\u001b[39m\n\u001b[31m      origin:      locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:       included.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:   state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!opt.ok) throw new Error(opt.error || 'optimise failed');\u001b[39m\n\u001b[31m    const order = opt.waypoint_order || included.map((_, i) => i);\u001b[39m\n\u001b[31m    const reordered = order.map(i => included[i]);\u001b[39m\n\u001b[31m    // KEEP EXCLUDED STOPS IN THEIR EXISTING POSITIONS (per user feedback\u001b[39m\n\u001b[31m    // 2026-06-04). Excluded slots stay put; reordered-included stops fill\u001b[39m\n\u001b[31m    // the gaps. Previously appended at end (confusing) → then dropped\u001b[39m\n\u001b[31m    // (lossy) → final: stay where they are. User can re-include to put\u001b[39m\n\u001b[31m    // them back into the optimised route.\u001b[39m\n\u001b[31m    const newStops = [];\u001b[39m\n\u001b[31m    let r = 0;\u001b[39m\n\u001b[31m    for (const s of state.stops) {\u001b[39m\n\u001b[31m      if (s.included) newStops.push(reordered[r++]);\u001b[39m\n\u001b[31m      else            newStops.push(s);\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    state.stops = newStops;\u001b[39m\n\u001b[31m    state.proposedOrder = state.stops.map(s => s.id);\u001b[39m\n\u001b[31m    state.baseTotal     = { distance_m: opt.total_distance_m, duration_s: opt.total_duration_s };\u001b[39m\n\u001b[31m    state.currentTotal  = { ...state.baseTotal };\u001b[39m\n\u001b[31m    state.lastOptimisation = opt.optimisation;\u001b[39m\n\u001b[31m    state.needsReoptimize  = false;   // re-optim done — banner cleared\u001b[39m\n\u001b[31m    if (Array.isArray(opt.legs)) {\u001b[39m\n\u001b[31m      reordered.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = opt.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Re-optimised ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Replan failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Share / export ──────────────────────────────────\u001b[39m\n\u001b[31mfunction openShare() {\u001b[39m\n\u001b[31m  const km   = (state.currentTotal.distance_m / 1000).toFixed(1);\u001b[39m\n\u001b[31m  const min  = Math.round(state.currentTotal.duration_s / 60);\u001b[39m\n\u001b[31m  const hrs  = Math.floor(min / 60), mm = min % 60;\u001b[39m\n\u001b[31m  const timeStr = hrs > 0 ? `${hrs}h ${mm}min` : `${mm} min`;\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  let txt = `🚗 Tour plan (${new Date().toLocaleDateString('en-IN')})\\\\n`;\u001b[39m\n\u001b[31m  txt += `From: ${state.origin.text}\\\\n`;\u001b[39m\n\u001b[31m  txt += `${included.length} stops · ${km} km · ${timeStr}\\\\n`;\u001b[39m\n\u001b[31m  if (state.roundTrip) txt += `Round-trip\\\\n\\\\n`;\u001b[39m\n\u001b[31m  else                  txt += `One-way → ${state.destination || included[included.length-1]?.address || ''}\\\\n\\\\n`;\u001b[39m\n\u001b[31m  included.forEach((s, i) => {\u001b[39m\n\u001b[31m    const legKm  = (s.legDistance_m/1000).toFixed(1);\u001b[39m\n\u001b[31m    const legMin = Math.round(s.legDuration_s/60);\u001b[39m\n\u001b[31m    txt += `${i+1}. ${s.name}`;\u001b[39m\n\u001b[31m    if (s.city) txt += ` · ${s.city}`;\u001b[39m\n\u001b[31m    if (s.legDistance_m > 0) txt += `  (+${legKm}km · ${legMin}min)`;\u001b[39m\n\u001b[31m    if (s.phone) txt += `  ${s.phone}`;\u001b[39m\n\u001b[31m    txt += `\\\\n`;\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (state.roundTrip) txt += `↩ Return to ${state.origin.text}`;\u001b[39m\n\u001b[31m  document.getElementById('share-summary').textContent = txt;\u001b[39m\n\u001b[31m  document.getElementById('modal-share').classList.add('show');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction closeShare() {\u001b[39m\n\u001b[31m  document.getElementById('modal-share').classList.remove('show');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction shareWhatsApp() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('share-summary').textContent;\u001b[39m\n\u001b[31m  const url = `https://wa.me/?text=${encodeURIComponent(txt)}`;\u001b[39m\n\u001b[31m  window.open(url, '_blank');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Hands off the optimised tour to Google Maps Directions (free, zero infra).\u001b[39m\n\u001b[31m// Uses path-style URL `dir/A/B/C/...` because it accepts many more stops than\u001b[39m\n\u001b[31m// the `api=1` Directions URL (which Google caps at 9 waypoints). Each segment\u001b[39m\n\u001b[31m// is \\\"lat,lng\\\" for geocoded points (most reliable). If the user picked a\u001b[39m\n\u001b[31m// text destination on a one-way tour we URL-encode it as the last segment.\u001b[39m\n\u001b[31mfunction openInGoogleMaps() {\u001b[39m\n\u001b[31m  if (!state.origin?.location) { toast('Origin not yet geocoded', 'err'); return; }\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included && s.location);\u001b[39m\n\u001b[31m  if (included.length === 0)   { toast('No included stops to view', 'err'); return; }\u001b[39m\n\u001b[31m  const fmt = (loc) => `${loc.lat.toFixed(6)},${loc.lng.toFixed(6)}`;\u001b[39m\n\u001b[31m  const segs = [ fmt(state.origin.location) ];\u001b[39m\n\u001b[31m  included.forEach(s => segs.push(fmt(s.location)));\u001b[39m\n\u001b[31m  if (state.roundTrip) {\u001b[39m\n\u001b[31m    segs.push(fmt(state.origin.location));\u001b[39m\n\u001b[31m  } else if (state.destination) {\u001b[39m\n\u001b[31m    segs.push(encodeURIComponent(state.destination));\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // travelmode and dir_action=navigate auto-open in driving directions\u001b[39m\n\u001b[31m  const url = `https://www.google.com/maps/dir/${segs.join('/')}/?travelmode=driving`;\u001b[39m\n\u001b[31m  window.open(url, '_blank', 'noopener');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function copyText() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('share-summary').textContent;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await navigator.clipboard.writeText(txt);\u001b[39m\n\u001b[31m    toast('Copied ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Copy failed', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction downloadCsv() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  const rows = [['#','Name','City','Address','Phone','Lat','Lng','Leg km','Leg min','Provider']];\u001b[39m\n\u001b[31m  included.forEach((s, i) => {\u001b[39m\n\u001b[31m    rows.push([\u001b[39m\n\u001b[31m      i + 1,\u001b[39m\n\u001b[31m      s.name, s.city, s.address, s.phone,\u001b[39m\n\u001b[31m      s.location?.lat ?? '', s.location?.lng ?? '',\u001b[39m\n\u001b[31m      (s.legDistance_m/1000).toFixed(2),\u001b[39m\n\u001b[31m      Math.round(s.legDuration_s/60),\u001b[39m\n\u001b[31m      s.provider || '',\u001b[39m\n\u001b[31m    ]);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  const csv = rows.map(r => r.map(c => `\\\"${String(c).replace(/\\\"/g, '\\\"\\\"')}\\\"`).join(',')).join('\\\\n');\u001b[39m\n\u001b[31m  const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });\u001b[39m\n\u001b[31m  const url = URL.createObjectURL(blob);\u001b[39m\n\u001b[31m  const a = document.createElement('a');\u001b[39m\n\u001b[31m  a.href = url; a.download = `tour-${new Date().toISOString().slice(0,10)}.csv`;\u001b[39m\n\u001b[31m  document.body.appendChild(a); a.click(); a.remove();\u001b[39m\n\u001b[31m  URL.revokeObjectURL(url);\u001b[39m\n\u001b[31m  toast('CSV downloaded ✓', 'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction saveLocal() {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    localStorage.setItem('tour-pg-saved', JSON.stringify({\u001b[39m\n\u001b[31m      saved_at: new Date().toISOString(),\u001b[39m\n\u001b[31m      state,\u001b[39m\n\u001b[31m    }));\u001b[39m\n\u001b[31m    toast('Saved ✓', 'ok');\u001b[39m\n\u001b[31m    document.getElementById('btn-load').style.display = '';\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed (storage full?)', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction loadSaved() {\u001b[39m\n\u001b[31m  const raw = localStorage.getItem('tour-pg-saved');\u001b[39m\n\u001b[31m  if (!raw) return toast('No saved tour', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const { state: s } = JSON.parse(raw);\u001b[39m\n\u001b[31m    state = s;\u001b[39m\n\u001b[31m    showScreen('s-editor');\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Loaded ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Load failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Navigation ──────────────────────────────────────\u001b[39m\n\u001b[31mfunction confirmBack() {\u001b[39m\n\u001b[31m  if (!confirm('Start a new tour? Current state will be cleared (use Save first if you want to keep it).')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m  document.getElementById('parsed-preview').style.display = 'none';\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = true;\u001b[39m\n\u001b[31m  document.getElementById('excel-file').value = '';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showScreen(id) {\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById(id).classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Utils ───────────────────────────────────────────\u001b[39m\n\u001b[31mfunction escHtml(s) { if (s == null) return ''; return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\\\"/g,'&quot;'); }\u001b[39m\n\u001b[31mlet _toastTimer;\u001b[39m\n\u001b[31mfunction toast(msg, type='') {\u001b[39m\n\u001b[31m  const el = document.getElementById('toast');\u001b[39m\n\u001b[31m  el.textContent = msg; el.className = 'show ' + type;\u001b[39m\n\u001b[31m  clearTimeout(_toastTimer);\u001b[39m\n\u001b[31m  _toastTimer = setTimeout(() => el.className = '', 3200);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction registerSW() {\u001b[39m\n\u001b[31m  if (!('serviceWorker' in navigator)) return;\u001b[39m\n\u001b[31m  const sw = `\u001b[39m\n\u001b[31mconst C='${CACHE_VER}';\u001b[39m\n\u001b[31mself.addEventListener('install', e => self.skipWaiting());\u001b[39m\n\u001b[31mself.addEventListener('activate', e => {\u001b[39m\n\u001b[31m  e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== C).map(k => caches.delete(k)))));\u001b[39m\n\u001b[31m  self.clients.claim();\u001b[39m\n\u001b[31m});\u001b[39m\n\u001b[31mself.addEventListener('fetch', e => {\u001b[39m\n\u001b[31m  if (e.request.method !== 'GET') return;\u001b[39m\n\u001b[31m  const url = new URL(e.request.url);\u001b[39m\n\u001b[31m  if (url.pathname.startsWith('/tour-pg-proxy')) return;\u001b[39m\n\u001b[31m  if (url.pathname.startsWith('/tour-pg/')) {\u001b[39m\n\u001b[31m    e.respondWith(\u001b[39m\n\u001b[31m      caches.open(C).then(c =>\u001b[39m\n\u001b[31m        c.match(e.request).then(r => r || fetch(e.request).then(f => { c.put(e.request, f.clone()); return f; }))\u001b[39m\n\u001b[31m      )\u001b[39m\n\u001b[31m    );\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m});`;\u001b[39m\n\u001b[31m  const blob = new Blob([sw], { type: 'application/javascript' });\u001b[39m\n\u001b[31m  navigator.serviceWorker.register(URL.createObjectURL(blob), { scope: '/tour-pg/' }).catch(() => {});\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31minit();\u001b[39m\n\u001b[31m</script>·\u001b[39m\n\u001b[31m</body>\u001b[39m\n\u001b[31m</html>\u001b[39m\n\u001b[31m\"\u001b[39m",
                        "stack": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoContain\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // indexOf\u001b[22m\n\nExpected substring: \u001b[32m\"tour-pg-v10\"\u001b[39m\nReceived string:    \u001b[31m\"<!DOCTYPE html>\u001b[39m\n\u001b[31m<html lang=\\\"en\\\">\u001b[39m\n\u001b[31m<head>\u001b[39m\n\u001b[31m<meta charset=\\\"UTF-8\\\">\u001b[39m\n\u001b[31m<meta name=\\\"viewport\\\" content=\\\"width=device-width,initial-scale=1,viewport-fit=cover\\\">\u001b[39m\n\u001b[31m<title>Tour Planner Playground</title>\u001b[39m\n\u001b[31m<link rel=\\\"manifest\\\" href=\\\"manifest.json\\\">\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"/shared/safe-bottom.css\\\">\u001b[39m\n\u001b[31m<meta name=\\\"theme-color\\\" content=\\\"#0a0a0a\\\">\u001b[39m\n\u001b[31m<link rel=\\\"preconnect\\\" href=\\\"https://fonts.googleapis.com\\\">\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&display=swap\\\">\u001b[39m\n\u001b[31m<script src=\\\"https://cdn.sheetjs.com/xlsx-0.20.2/package/dist/xlsx.full.min.js\\\"></script>\u001b[39m\n\u001b[31m<script src=\\\"https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js\\\"></script>\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.css\\\" crossorigin=\\\"\\\">\u001b[39m\n\u001b[31m<script src=\\\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.js\\\" crossorigin=\\\"\\\" defer></script>\u001b[39m\n\u001b[31m<style>\u001b[39m\n\u001b[31m:root{\u001b[39m\n\u001b[31m  --bg:#0a0a0a;--card:#141414;--card2:#1a1a1a;--border:#262626;--text:#f5f5f5;--muted:#a3a3a3;\u001b[39m\n\u001b[31m  --accent:#0ea5e9;--accent2:#0284c7;--g:#22c55e;--r:#ef4444;--y:#eab308;--p:#a855f7;\u001b[39m\n\u001b[31m  --rad:12px;--rads:8px;--bnav-h:64px;\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31m*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}\u001b[39m\n\u001b[31mbody{background:var(--bg);color:var(--text);font-family:'DM Sans',sans-serif;min-height:100vh;overscroll-behavior-y:none}\u001b[39m\n\u001b[31mbutton{font-family:inherit;cursor:pointer;border:none;background:none;color:inherit}\u001b[39m\n\u001b[31ma{color:inherit;text-decoration:none}·\u001b[39m\n\u001b[31m.scr{display:none;flex-direction:column;min-height:100vh;padding-bottom:calc(var(--bnav-h) + env(safe-area-inset-bottom))}\u001b[39m\n\u001b[31m.scr.active{display:flex}·\u001b[39m\n\u001b[31m.hdr{background:var(--card);border-bottom:1px solid var(--border);padding:12px 14px;display:flex;align-items:center;gap:8px;position:sticky;top:0;z-index:10}\u001b[39m\n\u001b[31m.hdr h1{font-size:15px;font-weight:800;flex:1;min-width:0}\u001b[39m\n\u001b[31m.hdr-sub{font-size:11px;color:var(--muted)}\u001b[39m\n\u001b[31m.hdr-btn{background:rgba(255,255,255,.08);border:1px solid var(--border);font-size:12px;font-weight:700;padding:5px 10px;border-radius:14px;cursor:pointer;white-space:nowrap}\u001b[39m\n\u001b[31m.hdr-btn:active{background:rgba(255,255,255,.16)}\u001b[39m\n\u001b[31m.user-pill{background:rgba(255,255,255,.1);border:1px solid var(--border);color:var(--muted);font-size:10px;font-weight:600;padding:3px 8px;border-radius:11px}\u001b[39m\n\u001b[31m.user-pill b{color:var(--text)}·\u001b[39m\n\u001b[31m.body{flex:1;padding:14px;display:flex;flex-direction:column;gap:12px}·\u001b[39m\n\u001b[31m/* ── editor: header + controls + route preview stay put; only the stops list scrolls ── */\u001b[39m\n\u001b[31m#s-editor.scr{height:100vh;height:100dvh;min-height:0;padding-bottom:0;overflow:hidden}\u001b[39m\n\u001b[31m.editor-body{flex:1;min-height:0;display:flex;flex-direction:column;padding:14px 14px 0}\u001b[39m\n\u001b[31m.editor-fixed{display:flex;flex-direction:column;gap:12px;flex-shrink:0}\u001b[39m\n\u001b[31m.editor-scroll{flex:1;min-height:0;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:2px 0 calc(32px + env(safe-area-inset-bottom));margin-top:6px;scrollbar-gutter:stable}\u001b[39m\n\u001b[31m/* visible scrollbar so it's obvious the stops list is independently scrollable */\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar{width:8px}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-thumb{background:#3a3a3a;border-radius:4px}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-thumb:active{background:#525252}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-track{background:transparent}·\u001b[39m\n\u001b[31m/* ── column-mapping table (parsed preview): scroll horizontally if it doesn't fit ── */\u001b[39m\n\u001b[31m.map-table-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--border);border-radius:6px}\u001b[39m\n\u001b[31m.map-table{border-collapse:collapse;table-layout:fixed;width:100%;min-width:440px;font-size:11px}\u001b[39m\n\u001b[31m.map-table th{text-align:left;padding:6px 8px;color:var(--muted);font-weight:800;font-size:9.5px;text-transform:uppercase;letter-spacing:.3px;background:var(--card2);position:sticky;top:0}\u001b[39m\n\u001b[31m.map-table td{padding:6px 8px;vertical-align:middle}\u001b[39m\n\u001b[31m.map-table tr+tr td,.map-table tbody tr td{border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.map-table col.c-src{width:40%}.map-table col.c-field{width:28%}.map-table col.c-sample{width:32%}\u001b[39m\n\u001b[31m.map-table select{background:#0b1220;border:1px solid var(--border);color:var(--text);padding:6px 7px;border-radius:4px;font-size:11px;width:100%}\u001b[39m\n\u001b[31m.map-field{font-weight:800;color:var(--text);white-space:nowrap}\u001b[39m\n\u001b[31m.map-sample{color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}·\u001b[39m\n\u001b[31m/* ── upload ── */\u001b[39m\n\u001b[31m.dropzone{background:var(--card);border:2px dashed var(--border);border-radius:var(--rad);padding:28px 16px;text-align:center;cursor:pointer;transition:.15s}\u001b[39m\n\u001b[31m.dropzone:hover,.dropzone.drag{border-color:var(--accent);background:#0e1726}\u001b[39m\n\u001b[31m.dropzone .ico{font-size:42px;margin-bottom:8px}\u001b[39m\n\u001b[31m.dropzone .ttl{font-weight:800;font-size:15px;margin-bottom:4px}\u001b[39m\n\u001b[31m.dropzone .sub{font-size:11.5px;color:var(--muted);line-height:1.4}\u001b[39m\n\u001b[31m.dropzone input[type=file]{display:none}·\u001b[39m\n\u001b[31m.section-title{font-size:11px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.4px}·\u001b[39m\n\u001b[31m.form-card{background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:12px;display:flex;flex-direction:column;gap:10px}\u001b[39m\n\u001b[31m.field{display:flex;flex-direction:column;gap:3px}\u001b[39m\n\u001b[31m.field label{font-size:11px;font-weight:600;color:var(--muted)}\u001b[39m\n\u001b[31m.field input,.field select{background:#0b1220;border:1px solid var(--border);border-radius:6px;color:var(--text);padding:9px 11px;font-size:13.5px;font-family:inherit;outline:none}\u001b[39m\n\u001b[31m.field input:focus,.field select:focus{border-color:var(--accent)}\u001b[39m\n\u001b[31m.toggle{display:flex;align-items:center;gap:8px;font-size:13px}\u001b[39m\n\u001b[31m.toggle input{accent-color:var(--accent);width:18px;height:18px}·\u001b[39m\n\u001b[31m.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;border-radius:var(--rads);padding:12px 14px;font-size:14px;font-weight:800;cursor:pointer;width:100%;transition:.12s}\u001b[39m\n\u001b[31m.btn-p{background:var(--accent);color:#fff}\u001b[39m\n\u001b[31m.btn-p:active{background:var(--accent2)}\u001b[39m\n\u001b[31m.btn-p:disabled{opacity:.4;cursor:default}\u001b[39m\n\u001b[31m.btn-s{background:var(--card);color:var(--text);border:1px solid var(--border)}\u001b[39m\n\u001b[31m.btn-r{background:rgba(239,68,68,.15);color:#fca5a5;border:1px solid rgba(239,68,68,.3)}·\u001b[39m\n\u001b[31m/* ── progress ── */\u001b[39m\n\u001b[31m.progress{background:#0b1220;border-radius:8px;height:10px;overflow:hidden;border:1px solid var(--border)}\u001b[39m\n\u001b[31m.progress-bar{height:100%;background:linear-gradient(90deg,var(--accent),var(--g));transition:.2s}·\u001b[39m\n\u001b[31m/* ── tiles ── */\u001b[39m\n\u001b[31m.origin-pill{background:linear-gradient(135deg,#0c2e3a,#062a3a);border:1px solid var(--accent);border-radius:var(--rad);padding:11px 13px;display:flex;align-items:center;gap:10px}\u001b[39m\n\u001b[31m.origin-pill .ico{font-size:18px}\u001b[39m\n\u001b[31m.origin-pill .meta{flex:1;min-width:0}\u001b[39m\n\u001b[31m.origin-pill .lbl{font-size:10.5px;color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.4px}\u001b[39m\n\u001b[31m.origin-pill .val{font-size:13.5px;font-weight:700;margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.origin-pill button{background:rgba(255,255,255,.08);border-radius:14px;padding:5px 10px;font-size:11px;font-weight:700;color:var(--accent)}·\u001b[39m\n\u001b[31m.tiles{display:flex;flex-direction:column;gap:6px}\u001b[39m\n\u001b[31m.tile{background:var(--card);border:1px solid var(--border);border-radius:var(--rads);padding:9px 10px;display:flex;align-items:center;gap:10px;transition:.12s;touch-action:none}\u001b[39m\n\u001b[31m.tile.sortable-ghost{opacity:.35;background:var(--card2)}\u001b[39m\n\u001b[31m.tile.sortable-chosen{transform:scale(.98);box-shadow:0 4px 20px rgba(14,165,233,.25)}\u001b[39m\n\u001b[31m.tile.excluded{opacity:.4;background:#0c0c0c}\u001b[39m\n\u001b[31m.tile.excluded .name,.tile.excluded .city{text-decoration:line-through}\u001b[39m\n\u001b[31m.tile.err{border-color:var(--r);background:#1f0a0a}\u001b[39m\n\u001b[31m.tile .grip{font-size:18px;color:var(--muted);cursor:grab;padding:4px;line-height:1}\u001b[39m\n\u001b[31m.tile .grip:active{cursor:grabbing}\u001b[39m\n\u001b[31m.tile .ord{background:var(--accent);color:#fff;width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11.5px;font-weight:800;flex-shrink:0}\u001b[39m\n\u001b[31m.tile.excluded .ord{background:#444}\u001b[39m\n\u001b[31m.tile .info{flex:1;min-width:0}\u001b[39m\n\u001b[31m.tile .name{font-size:13.5px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .city{font-size:11.5px;color:#cbd5e1;font-weight:600;margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .addr{font-size:9.5px;color:var(--muted);margin-top:1px;line-height:1.3;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-style:italic;opacity:.75}\u001b[39m\n\u001b[31m.tile .leg{font-size:10.5px;color:var(--accent2);font-weight:600;margin-top:2px}\u001b[39m\n\u001b[31m.tile.excluded .leg{display:none}\u001b[39m\n\u001b[31m.tile .badge-provider{display:inline-block;font-size:8.5px;padding:1px 5px;border-radius:8px;background:rgba(255,255,255,.08);color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.3px;margin-left:4px;vertical-align:middle}\u001b[39m\n\u001b[31m.tile .incl{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:50%;border:2px solid var(--border);font-size:16px;font-weight:800}\u001b[39m\n\u001b[31m.tile .incl.on{background:var(--g);border-color:var(--g);color:#fff}\u001b[39m\n\u001b[31m.tile .incl.off{background:#0c0c0c;color:var(--r);border-color:var(--r)}·\u001b[39m\n\u001b[31m/* ── prominent top action bar (KM, Share, Re-optimise) ── */\u001b[39m\n\u001b[31m.top-actions{background:linear-gradient(135deg,#0c2e3a,#08111c);border:1px solid var(--accent);border-radius:var(--rad);padding:10px;display:grid;grid-template-columns:1fr auto auto;gap:8px;align-items:center;margin-bottom:6px}\u001b[39m\n\u001b[31m.top-totals{min-width:0}\u001b[39m\n\u001b[31m.top-totals-big{font-size:18px;font-weight:900;line-height:1.05;letter-spacing:.3px}\u001b[39m\n\u001b[31m.top-totals-delta{font-size:11px;color:var(--muted);margin-top:2px}\u001b[39m\n\u001b[31m.top-totals-delta.better{color:var(--g)}\u001b[39m\n\u001b[31m.top-totals-delta.worse{color:var(--y)}\u001b[39m\n\u001b[31m.top-actions button{padding:10px 12px;font-size:12px;font-weight:800;border-radius:8px;border:none;cursor:pointer;display:flex;align-items:center;gap:5px;white-space:nowrap}\u001b[39m\n\u001b[31m.top-share{background:#f59e0b;color:#000}\u001b[39m\n\u001b[31m.top-share:active{background:#d97706}\u001b[39m\n\u001b[31m.top-reopt{background:var(--accent);color:#fff}\u001b[39m\n\u001b[31m.top-reopt:active{background:var(--accent2)}\u001b[39m\n\u001b[31m/* ── Re-optimisation needed (after include/exclude toggle) ── */\u001b[39m\n\u001b[31m.reopt-needed{font-size:11px;font-weight:700;color:#fbbf24;background:rgba(234,179,8,.12);border-left:3px solid #fbbf24;padding:5px 8px;border-radius:4px;margin-top:5px;display:none;line-height:1.3}\u001b[39m\n\u001b[31m.reopt-needed.show{display:block}·\u001b[39m\n\u001b[31m/* ── inline route preview (Leaflet + OSM) ── */\u001b[39m\n\u001b[31m.map-wrap{margin:8px 0 0;background:var(--card);border:1px solid var(--border);border-radius:var(--rads);overflow:hidden}\u001b[39m\n\u001b[31m.map-hdr{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;font-size:12px;color:var(--muted);background:var(--card2);border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.map-hdr .ttl{display:flex;align-items:center;gap:6px;font-weight:600}\u001b[39m\n\u001b[31m.map-hdr .toggle{font-size:14px;color:var(--text);background:none;border:none;cursor:pointer;padding:0 4px}\u001b[39m\n\u001b[31m#tour-map{height:260px;width:100%;background:#1a1a1a;transition:height .2s ease}\u001b[39m\n\u001b[31m.map-wrap.collapsed #tour-map{height:0}\u001b[39m\n\u001b[31m.map-wrap.collapsed .map-hdr{border-bottom:none}\u001b[39m\n\u001b[31m.tour-pin{background:transparent;border:none}\u001b[39m\n\u001b[31m.tour-pin > div{background:#0ea5e9;color:#fff;border-radius:50%;width:26px;height:26px;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:12px;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,.45);font-family:'DM Sans',sans-serif}\u001b[39m\n\u001b[31m.tour-pin.origin > div{background:#22c55e;font-size:14px}\u001b[39m\n\u001b[31m.tour-pin.out > div{background:#6b7280;opacity:.55}\u001b[39m\n\u001b[31m.leaflet-tooltip{background:#141414;color:#f5f5f5;border:1px solid #262626;font-size:11px;padding:3px 6px;font-family:'DM Sans',sans-serif;box-shadow:0 1px 6px rgba(0,0,0,.4)}\u001b[39m\n\u001b[31m.leaflet-tooltip-top:before,.leaflet-tooltip-bottom:before,.leaflet-tooltip-left:before,.leaflet-tooltip-right:before{border-top-color:#262626;border-bottom-color:#262626;border-left-color:#262626;border-right-color:#262626}\u001b[39m\n\u001b[31m.leaflet-control-attribution{background:rgba(20,20,20,.85)!important;color:#a3a3a3!important;font-size:9px!important}\u001b[39m\n\u001b[31m.leaflet-control-attribution a{color:#0ea5e9!important}\u001b[39m\n\u001b[31m.top-reopt.pulse{animation:pulseReopt 1.4s ease-in-out infinite;box-shadow:0 0 0 0 rgba(14,165,233,.6)}\u001b[39m\n\u001b[31m@keyframes pulseReopt{0%{box-shadow:0 0 0 0 rgba(14,165,233,.7)}50%{box-shadow:0 0 0 8px rgba(14,165,233,0)}100%{box-shadow:0 0 0 0 rgba(14,165,233,0)}}\u001b[39m\n\u001b[31m/* Totals dim when stale to make it visually obvious */\u001b[39m\n\u001b[31m.top-totals-big.stale, .top-totals-delta.stale{opacity:.5;text-decoration:line-through;text-decoration-color:rgba(251,191,36,.6)}·\u001b[39m\n\u001b[31m/* recalc spinner that appears inline next to the top totals during recompute */\u001b[39m\n\u001b[31m.recalc{display:none;font-size:11px;color:var(--accent);margin-left:6px;vertical-align:middle}\u001b[39m\n\u001b[31m.recalc.show{display:inline-block;animation:spin 1s linear infinite}\u001b[39m\n\u001b[31m@keyframes spin{to{transform:rotate(360deg)}}·\u001b[39m\n\u001b[31m/* ── share modal ── */\u001b[39m\n\u001b[31m.modal{position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:200;display:none;align-items:flex-end;justify-content:center}\u001b[39m\n\u001b[31m.modal.show{display:flex}\u001b[39m\n\u001b[31m.modal-box{background:var(--card);border-radius:18px 18px 0 0;width:100%;max-height:85vh;overflow-y:auto;padding:16px 14px calc(16px + env(safe-area-inset-bottom));border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.modal-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\u001b[39m\n\u001b[31m.modal-ttl{font-size:15px;font-weight:800}\u001b[39m\n\u001b[31m.modal-close{font-size:24px;color:var(--muted);background:none;border:none;cursor:pointer}·\u001b[39m\n\u001b[31m/* ── toast ── */\u001b[39m\n\u001b[31m#toast{position:fixed;bottom:calc(var(--bnav-h)+16px);left:12px;right:12px;z-index:1000;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:11px 14px;font-size:13px;font-weight:600;text-align:center;transform:translateY(150px);opacity:0;transition:.25s;pointer-events:none}\u001b[39m\n\u001b[31m#toast.show{transform:translateY(0);opacity:1}\u001b[39m\n\u001b[31m#toast.ok{background:rgba(34,197,94,.18);border-color:var(--g);color:#86efac}\u001b[39m\n\u001b[31m#toast.err{background:rgba(239,68,68,.18);border-color:var(--r);color:#fca5a5}·\u001b[39m\n\u001b[31m/* ── helpers ── */\u001b[39m\n\u001b[31m.spinner{display:inline-block;width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;vertical-align:middle}·\u001b[39m\n\u001b[31m.empty{text-align:center;padding:30px 16px;color:var(--muted);font-size:12.5px}·\u001b[39m\n\u001b[31mtextarea{background:#0b1220;border:1px solid var(--border);color:var(--text);padding:10px;font-family:inherit;font-size:12px;border-radius:6px;resize:vertical;outline:none}·\u001b[39m\n\u001b[31m.kbd{background:rgba(255,255,255,.06);padding:1px 6px;border-radius:4px;font-family:monospace;font-size:11px;color:var(--muted)}\u001b[39m\n\u001b[31m</style>\u001b[39m\n\u001b[31m</head>\u001b[39m\n\u001b[31m<body>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: UPLOAD ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-upload\\\" class=\\\"scr active\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗺 Tour Playground</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Excel → optimised route → tweak</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"loadSaved()\\\" id=\\\"btn-load\\\" style=\\\"display:none\\\">↻ Load</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"location.href='/hub/'\\\">🏡 Hub</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <label class=\\\"dropzone\\\" id=\\\"dropzone\\\" for=\\\"excel-file\\\">\u001b[39m\n\u001b[31m      <input type=\\\"file\\\" id=\\\"excel-file\\\" accept=\\\".xlsx,.xls,.csv\\\">\u001b[39m\n\u001b[31m      <div class=\\\"ico\\\">📊</div>\u001b[39m\n\u001b[31m      <div class=\\\"ttl\\\">Drop Excel here, or tap to choose</div>\u001b[39m\n\u001b[31m      <div class=\\\"sub\\\">Required: <b>store name</b> + <b>address</b><br>Optional: <b>city</b>, <b>lat</b>, <b>lng</b>, <b>phone</b>, anything else.</div>\u001b[39m\n\u001b[31m    </label>·\u001b[39m\n\u001b[31m    <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\">Origin</div>\u001b[39m\n\u001b[31m      <div class=\\\"field\\\">\u001b[39m\n\u001b[31m        <label>Start point</label>\u001b[39m\n\u001b[31m        <input type=\\\"text\\\" id=\\\"inp-origin\\\" value=\\\"Mohali office Punjab\\\" placeholder=\\\"Address or place name\\\">\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div class=\\\"toggle\\\">\u001b[39m\n\u001b[31m        <input type=\\\"checkbox\\\" id=\\\"inp-roundtrip\\\" checked>\u001b[39m\n\u001b[31m        <label for=\\\"inp-roundtrip\\\" style=\\\"cursor:pointer;font-size:13px;font-weight:600\\\">Round-trip (return to start)</label>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"dest-wrap\\\" class=\\\"field\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m        <label>Final destination</label>\u001b[39m\n\u001b[31m        <input type=\\\"text\\\" id=\\\"inp-destination\\\" placeholder=\\\"Where to end if not returning to origin\\\">\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" id=\\\"btn-go\\\" onclick=\\\"parseAndOptimise()\\\" disabled>\u001b[39m\n\u001b[31m      📐 Parse + optimise route\u001b[39m\n\u001b[31m    </button>·\u001b[39m\n\u001b[31m    <div id=\\\"upload-progress\\\" style=\\\"display:none;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;font-size:12px\\\">\u001b[39m\n\u001b[31m        <span id=\\\"prog-label\\\">Geocoding…</span>\u001b[39m\n\u001b[31m        <span id=\\\"prog-count\\\" style=\\\"color:var(--muted)\\\">0 / 0</span>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div class=\\\"progress\\\"><div class=\\\"progress-bar\\\" id=\\\"prog-bar\\\" style=\\\"width:0%\\\"></div></div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div id=\\\"parsed-preview\\\" style=\\\"display:none;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:11px;font-size:12px\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\" style=\\\"margin-bottom:6px\\\">Parsed</div>\u001b[39m\n\u001b[31m      <div id=\\\"parsed-summary\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: EDITOR ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-editor\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"confirmBack()\\\">‹ New</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>Tour Editor</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"editor-sub\\\">— stops</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-2\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"openShare()\\\">📤</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"editor-body\\\">\u001b[39m\n\u001b[31m   <div class=\\\"editor-fixed\\\">·\u001b[39m\n\u001b[31m    <div class=\\\"origin-pill\\\" id=\\\"origin-pill\\\">\u001b[39m\n\u001b[31m      <span class=\\\"ico\\\">📍</span>\u001b[39m\n\u001b[31m      <div class=\\\"meta\\\">\u001b[39m\n\u001b[31m        <div class=\\\"lbl\\\">Start from</div>\u001b[39m\n\u001b[31m        <div class=\\\"val\\\" id=\\\"origin-val\\\">—</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button onclick=\\\"openOriginEdit()\\\">✏️ Change</button>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div id=\\\"origin-edit\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m        <div class=\\\"field\\\">\u001b[39m\n\u001b[31m          <label>New origin (for mid-tour replan)</label>\u001b[39m\n\u001b[31m          <input type=\\\"text\\\" id=\\\"inp-new-origin\\\" placeholder=\\\"Hotel B Mohali, or address…\\\">\u001b[39m\n\u001b[31m          <div style=\\\"font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4\\\">\u001b[39m\n\u001b[31m            Tip: toggle ✗ on completed stops, change origin to where you are now, hit Replan.\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div style=\\\"display:flex;gap:8px\\\">\u001b[39m\n\u001b[31m          <button class=\\\"btn btn-s\\\" onclick=\\\"closeOriginEdit()\\\" style=\\\"flex:1\\\">Cancel</button>\u001b[39m\n\u001b[31m          <button class=\\\"btn btn-p\\\" onclick=\\\"applyOriginAndReplan()\\\" style=\\\"flex:2\\\">🔄 Replan from here</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"top-actions\\\" id=\\\"top-actions\\\">\u001b[39m\n\u001b[31m      <div class=\\\"top-totals\\\">\u001b[39m\n\u001b[31m        <div class=\\\"top-totals-big\\\" id=\\\"top-totals-big\\\">— km · — min<span class=\\\"recalc\\\" id=\\\"recalc\\\">↻</span></div>\u001b[39m\n\u001b[31m        <div class=\\\"top-totals-delta\\\" id=\\\"top-totals-delta\\\">vs proposed: —</div>\u001b[39m\n\u001b[31m        <div class=\\\"reopt-needed\\\" id=\\\"reopt-needed\\\">⚠ Stops changed — tap 🔁 Re-optim to refresh the route plan</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"top-share\\\" onclick=\\\"openShare()\\\">📤 Share</button>\u001b[39m\n\u001b[31m      <button class=\\\"top-reopt\\\" onclick=\\\"replanAll()\\\">🔁 Re-optim</button>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"map-wrap\\\" id=\\\"map-wrap\\\">\u001b[39m\n\u001b[31m      <div class=\\\"map-hdr\\\">\u001b[39m\n\u001b[31m        <div class=\\\"ttl\\\">🗺️ Route preview <span style=\\\"opacity:.6;font-weight:400\\\">(straight-line)</span></div>\u001b[39m\n\u001b[31m        <button class=\\\"toggle\\\" id=\\\"map-toggle\\\" onclick=\\\"toggleMap()\\\" aria-label=\\\"Toggle map\\\">▾</button>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"tour-map\\\"></div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"section-title\\\" style=\\\"margin-top:2px\\\">Stops (drag ≡ to reorder · scroll ↕ to see all)</div>\u001b[39m\n\u001b[31m   </div><!-- /editor-fixed -->·\u001b[39m\n\u001b[31m   <div class=\\\"editor-scroll\\\" id=\\\"editor-scroll\\\">\u001b[39m\n\u001b[31m    <div id=\\\"tiles\\\" class=\\\"tiles\\\"></div>\u001b[39m\n\u001b[31m   </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── MODAL: SHARE ── -->\u001b[39m\n\u001b[31m<div class=\\\"modal\\\" id=\\\"modal-share\\\">\u001b[39m\n\u001b[31m  <div class=\\\"modal-box\\\">\u001b[39m\n\u001b[31m    <div class=\\\"modal-hdr\\\">\u001b[39m\n\u001b[31m      <div class=\\\"modal-ttl\\\">📤 Share / Export</div>\u001b[39m\n\u001b[31m      <button class=\\\"modal-close\\\" onclick=\\\"closeShare()\\\">×</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"share-summary\\\" style=\\\"background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:10px;font-size:12px;line-height:1.6;white-space:pre-wrap;font-family:monospace\\\"></div>\u001b[39m\n\u001b[31m    <div style=\\\"margin-top:12px;display:grid;gap:8px\\\">\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"openInGoogleMaps()\\\">🗺️ Open route in Google Maps</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"shareWhatsApp()\\\">💬 WhatsApp</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"copyText()\\\">📋 Copy text</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"downloadCsv()\\\">📥 Download CSV</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"saveLocal()\\\">💾 Save to this device</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<div id=\\\"toast\\\"></div>·\u001b[39m\n\u001b[31m<script>\u001b[39m\n\u001b[31m'use strict';·\u001b[39m\n\u001b[31mconst CACHE_VER = 'tour-pg-v11';\u001b[39m\n\u001b[31mconst PROXY     = '/tour-pg-proxy';·\u001b[39m\n\u001b[31m// ── State ────────────────────────────────────────────\u001b[39m\n\u001b[31mlet session   = null;\u001b[39m\n\u001b[31mlet myId      = '';\u001b[39m\n\u001b[31mlet myName    = '';\u001b[39m\n\u001b[31mlet state     = newState();\u001b[39m\n\u001b[31mlet _recalcTimer = null;\u001b[39m\n\u001b[31mlet _sortable    = null;·\u001b[39m\n\u001b[31mfunction newState() {\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    origin:     { text: 'Mohali office Punjab', location: null, provider: null },\u001b[39m\n\u001b[31m    destination:null,\u001b[39m\n\u001b[31m    roundTrip:  true,\u001b[39m\n\u001b[31m    stops:      [],          // { id, name, city, address, phone, location, included, legDistance_m, legDuration_s, provider, raw, geocodeErr }\u001b[39m\n\u001b[31m    proposedOrder: null,     // array of stop IDs in optimise-time order\u001b[39m\n\u001b[31m    baseTotal: { distance_m: 0, duration_s: 0 },\u001b[39m\n\u001b[31m    currentTotal: { distance_m: 0, duration_s: 0 },\u001b[39m\n\u001b[31m    lastOptimisation: null,  // 'tomtom-computeBestOrder' | 'matrix-nn-2opt'\u001b[39m\n\u001b[31m    needsReoptimize: false,  // true after include/exclude toggle → totals may be stale; prompt user to tap Re-optim\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction uid(prefix='st') { return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,6)}`; }·\u001b[39m\n\u001b[31m// ── Auth ─────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction init() {\u001b[39m\n\u001b[31m  registerSW();\u001b[39m\n\u001b[31m  const raw = localStorage.getItem('lm360-session');\u001b[39m\n\u001b[31m  // Bounce to Hub with ?next= so user lands back here after login (Hub honours\u001b[39m\n\u001b[31m  // same-origin relative paths). Pattern documented in\u001b[39m\n\u001b[31m  // feedback_hub_next_redirect.md memory file — all gated PWAs should follow.\u001b[39m\n\u001b[31m  const nextParam = '?next=' + encodeURIComponent(location.pathname + location.search);\u001b[39m\n\u001b[31m  if (!raw) { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  try { session = JSON.parse(raw); } catch { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  if (Date.now() - (session.loginAt ? new Date(session.loginAt).getTime() : 0) > 12*3600*1000) { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  myId   = session.empId || '';\u001b[39m\n\u001b[31m  myName = session.name  || myId;\u001b[39m\n\u001b[31m  const userText = `👤 <b>${escHtml(myName)}</b>`;\u001b[39m\n\u001b[31m  document.getElementById('user-pill').innerHTML   = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill-2').innerHTML = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill').style.display   = '';\u001b[39m\n\u001b[31m  document.getElementById('user-pill-2').style.display = '';·\u001b[39m\n\u001b[31m  // Load-saved chip if we have a saved tour\u001b[39m\n\u001b[31m  if (localStorage.getItem('tour-pg-saved')) document.getElementById('btn-load').style.display = '';·\u001b[39m\n\u001b[31m  // Wire UI\u001b[39m\n\u001b[31m  document.getElementById('inp-roundtrip').addEventListener('change', e => {\u001b[39m\n\u001b[31m    document.getElementById('dest-wrap').style.display = e.target.checked ? 'none' : '';\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('dragover', e => { e.preventDefault(); e.currentTarget.classList.add('drag'); });\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('dragleave', e => e.currentTarget.classList.remove('drag'));\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('drop', e => {\u001b[39m\n\u001b[31m    e.preventDefault();\u001b[39m\n\u001b[31m    e.currentTarget.classList.remove('drag');\u001b[39m\n\u001b[31m    const f = e.dataTransfer.files[0]; if (f) handleFile(f);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  document.getElementById('excel-file').addEventListener('change', e => {\u001b[39m\n\u001b[31m    const f = e.target.files[0]; if (f) handleFile(f);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction hubAuth() {\u001b[39m\n\u001b[31m  return 'Hub ' + btoa(JSON.stringify(session));\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function api(path, body) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, {\u001b[39m\n\u001b[31m    method: 'POST',\u001b[39m\n\u001b[31m    headers: { 'Authorization': hubAuth(), 'Content-Type': 'application/json' },\u001b[39m\n\u001b[31m    body: JSON.stringify(body),\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (r.status === 401) {\u001b[39m\n\u001b[31m    toast('Session expired — refreshing to re-login', 'err');\u001b[39m\n\u001b[31m    const nextParam = '?next=' + encodeURIComponent(location.pathname + location.search);\u001b[39m\n\u001b[31m    setTimeout(() => location.href = '/hub/' + nextParam, 1500);\u001b[39m\n\u001b[31m    throw new Error('unauthorized');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!r.ok) {\u001b[39m\n\u001b[31m    const txt = await r.text().catch(() => '');\u001b[39m\n\u001b[31m    throw new Error(`${path}: ${r.status} ${txt.slice(0, 100)}`);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return r.json();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Excel parsing + column detection ────────────────\u001b[39m\n\u001b[31mlet _parsedCols = null;   // exposed for the column-remap UI\u001b[39m\n\u001b[31mlet _parsedRows = null;\u001b[39m\n\u001b[31mfunction handleFile(file) {\u001b[39m\n\u001b[31m  const reader = new FileReader();\u001b[39m\n\u001b[31m  reader.onload = (e) => {\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      const wb = XLSX.read(e.target.result, { type: 'array' });\u001b[39m\n\u001b[31m      const ws = wb.Sheets[wb.SheetNames[0]];\u001b[39m\n\u001b[31m      const rows = XLSX.utils.sheet_to_json(ws, { defval: '' });\u001b[39m\n\u001b[31m      if (!rows.length) throw new Error('Empty sheet');\u001b[39m\n\u001b[31m      _parsedRows = rows;\u001b[39m\n\u001b[31m      _parsedCols = detectColumns(rows[0]);\u001b[39m\n\u001b[31m      if (!_parsedCols.name && !_parsedCols.address && !_parsedCols.city) throw new Error('Missing required columns (need at least one of: name, address, city)');\u001b[39m\n\u001b[31m      rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m      renderParsedPreview();\u001b[39m\n\u001b[31m      document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m      toast('Excel parsed ✓', 'ok');\u001b[39m\n\u001b[31m    } catch (err) {\u001b[39m\n\u001b[31m      toast('Excel error: ' + err.message, 'err');\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  reader.readAsArrayBuffer(file);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction rebuildStopsFromParsedRows() {\u001b[39m\n\u001b[31m  state.stops = _parsedRows.map((row, idx) => buildStop(row, _parsedCols, idx)).filter(s => s);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderParsedPreview() {\u001b[39m\n\u001b[31m  const skip = _parsedRows.length - state.stops.length;\u001b[39m\n\u001b[31m  const allCols = Object.keys(_parsedRows[0]);\u001b[39m\n\u001b[31m  // For each role, show:  <role>: <selected col name>  with a tiny dropdown to override\u001b[39m\n\u001b[31m  const ROLES = ['name','city','address','lat','lng','phone','notes'];\u001b[39m\n\u001b[31m  const sample = _parsedRows[0];\u001b[39m\n\u001b[31m  const previewVal = (key) => {\u001b[39m\n\u001b[31m    const v = sample[key]; if (v == null || v === '') return '';\u001b[39m\n\u001b[31m    return String(v).slice(0, 40);\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  // Column order (per user request): YOUR Excel column first, then the PWA field\u001b[39m\n\u001b[31m  // it maps to, then a sample value. Equal-ish widths via <colgroup>; the wrapper\u001b[39m\n\u001b[31m  // scrolls horizontally so long header names are never clipped on narrow phones.\u001b[39m\n\u001b[31m  let rows = '<div class=\\\"map-table-wrap\\\"><table class=\\\"map-table\\\">' +\u001b[39m\n\u001b[31m    '<colgroup><col class=\\\"c-src\\\"><col class=\\\"c-field\\\"><col class=\\\"c-sample\\\"></colgroup>' +\u001b[39m\n\u001b[31m    '<thead><tr><th>Your file column</th><th>→ PWA field</th><th>Sample value</th></tr></thead><tbody>';\u001b[39m\n\u001b[31m  for (const role of ROLES) {\u001b[39m\n\u001b[31m    const sel  = _parsedCols[role];\u001b[39m\n\u001b[31m    const need = role === 'name';\u001b[39m\n\u001b[31m    const opts = `<option value=\\\"\\\">— ignore —</option>` +\u001b[39m\n\u001b[31m      allCols.map(c => `<option value=\\\"${escHtml(c)}\\\" ${sel === c ? 'selected' : ''}>${escHtml(c)}</option>`).join('');\u001b[39m\n\u001b[31m    const previewText = sel ? escHtml(previewVal(sel)) : '';\u001b[39m\n\u001b[31m    const tip = !sel && need ? `<span style=\\\"color:var(--y);font-weight:700\\\">⚠ no match — uses city or \\\"Stop #N\\\"</span>` : '';\u001b[39m\n\u001b[31m    rows += `<tr>\u001b[39m\n\u001b[31m      <td><select onchange=\\\"remapColumn('${role}', this.value)\\\">${opts}</select></td>\u001b[39m\n\u001b[31m      <td class=\\\"map-field\\\">${role}${need?' <span style=\\\"color:var(--y)\\\">*</span>':''}</td>\u001b[39m\n\u001b[31m      <td class=\\\"map-sample\\\">${previewText}${tip}</td>\u001b[39m\n\u001b[31m    </tr>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  rows += '</tbody></table></div>';\u001b[39m\n\u001b[31m  document.getElementById('parsed-preview').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('parsed-summary').innerHTML =\u001b[39m\n\u001b[31m    `<div style=\\\"margin-bottom:6px\\\"><b>${state.stops.length}</b> stops detected` +\u001b[39m\n\u001b[31m    `${skip > 0 ? ` · ${skip} skipped (no name/city/address)` : ''}</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:10.5px;color:var(--muted);margin-bottom:6px\\\">If detection is wrong, pick the correct column from the dropdowns:</div>` +\u001b[39m\n\u001b[31m    rows;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction remapColumn(role, newCol) {\u001b[39m\n\u001b[31m  if (!_parsedCols) return;\u001b[39m\n\u001b[31m  _parsedCols[role] = newCol || null;\u001b[39m\n\u001b[31m  rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction normaliseColName(s) {\u001b[39m\n\u001b[31m  return String(s).toLowerCase().replace(/[^a-z]/g, '');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction detectColumns(sampleRow) {\u001b[39m\n\u001b[31m  const map = { name:null, city:null, address:null, lat:null, lng:null, phone:null, notes:null, others:[] };\u001b[39m\n\u001b[31m  const keys = Object.keys(sampleRow);\u001b[39m\n\u001b[31m  // Broadened aliases so most user-provided Excel files map sensibly without a UI:\u001b[39m\n\u001b[31m  //   name  ← store / shop / outlet / party / dealer / firm / customer / business /\u001b[39m\n\u001b[31m  //           company / vendor / agency (and \\\"<thing> name\\\")\u001b[39m\n\u001b[31m  //   addr  ← address / fulladdress / location / loc / addr\u001b[39m\n\u001b[31m  //   city  ← city / town / district / location (only if no address column took it)\u001b[39m\n\u001b[31m  for (const k of keys) {\u001b[39m\n\u001b[31m    const n = normaliseColName(k);\u001b[39m\n\u001b[31m    if (!map.name && /^(storename|name|store|client|outlet|party(name)?|dealer(name)?|firm(name)?|customer(name)?|shop(name)?|business(name)?|companyname|company|vendor(name)?|agency(name)?|merchant|chain|sitename|site)$/.test(n)) { map.name = k; continue; }\u001b[39m\n\u001b[31m    if (!map.address && /^(address|fulladdress|streetaddress|addr|location|loc|street)$/.test(n)) { map.address = k; continue; }\u001b[39m\n\u001b[31m    if (!map.city && /^(city|town|district|cityname|locality|area|region)$/.test(n)) { map.city = k; continue; }\u001b[39m\n\u001b[31m    if (!map.lat && /^(lat|latitude|y)$/.test(n)) { map.lat = k; continue; }\u001b[39m\n\u001b[31m    if (!map.lng && /^(lng|lon|long|longitude|x)$/.test(n)) { map.lng = k; continue; }\u001b[39m\n\u001b[31m    if (!map.phone && /^(phone(no)?|mobile(no)?|contact(no)?|tel(no)?|whatsapp|wa|cell)$/.test(n)) { map.phone = k; continue; }\u001b[39m\n\u001b[31m    if (!map.notes && /^(notes?|remarks?|comments?|description|desc|memo)$/.test(n)) { map.notes = k; continue; }\u001b[39m\n\u001b[31m    map.others.push(k);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return map;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction buildStop(row, cols, idx) {\u001b[39m\n\u001b[31m  const name    = cols.name    ? String(row[cols.name]   || '').trim() : '';\u001b[39m\n\u001b[31m  const address = cols.address ? String(row[cols.address]|| '').trim() : '';\u001b[39m\n\u001b[31m  const city    = cols.city    ? String(row[cols.city]   || '').trim() : '';\u001b[39m\n\u001b[31m  const phone   = cols.phone   ? String(row[cols.phone]  || '').trim() : '';\u001b[39m\n\u001b[31m  const notes   = cols.notes   ? String(row[cols.notes]  || '').trim() : '';\u001b[39m\n\u001b[31m  if (!name && !address && !city) return null;\u001b[39m\n\u001b[31m  // pre-resolved coords?\u001b[39m\n\u001b[31m  let location = null;\u001b[39m\n\u001b[31m  if (cols.lat && cols.lng) {\u001b[39m\n\u001b[31m    const lat = parseFloat(row[cols.lat]);\u001b[39m\n\u001b[31m    const lng = parseFloat(row[cols.lng]);\u001b[39m\n\u001b[31m    if (!isNaN(lat) && !isNaN(lng) && lat !== 0 && lng !== 0) location = { lat, lng };\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const fullAddr = [address, city].filter(Boolean).join(', ');\u001b[39m\n\u001b[31m  // Display name fallback hierarchy (NEVER truncated address — that confused users):\u001b[39m\n\u001b[31m  //   1. Explicit name column value\u001b[39m\n\u001b[31m  //   2. City + index (so excluded/duplicate cities stay distinguishable)\u001b[39m\n\u001b[31m  //   3. \\\"Stop #N\\\"\u001b[39m\n\u001b[31m  let displayName;\u001b[39m\n\u001b[31m  if (name) displayName = name;\u001b[39m\n\u001b[31m  else if (city) displayName = `${city} (stop ${idx + 1})`;\u001b[39m\n\u001b[31m  else displayName = `Stop ${idx + 1}`;\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    id:           uid('st'),\u001b[39m\n\u001b[31m    name:         displayName,\u001b[39m\n\u001b[31m    hasExplicitName: !!name,         // for UI to flag \\\"name auto-derived\\\"\u001b[39m\n\u001b[31m    city, phone, notes,\u001b[39m\n\u001b[31m    address:      fullAddr || name || '',\u001b[39m\n\u001b[31m    location,\u001b[39m\n\u001b[31m    provider:     location ? 'excel' : null,\u001b[39m\n\u001b[31m    included:     true,\u001b[39m\n\u001b[31m    legDistance_m: 0,\u001b[39m\n\u001b[31m    legDuration_s: 0,\u001b[39m\n\u001b[31m    geocodeErr:   null,\u001b[39m\n\u001b[31m    raw:          { ...row },\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Geocode + optimise ──────────────────────────────\u001b[39m\n\u001b[31masync function parseAndOptimise() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('Parse an Excel first', 'err');\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = true;\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = '';\u001b[39m\n\u001b[31m  state.origin.text  = document.getElementById('inp-origin').value.trim() || 'Mohali office Punjab';\u001b[39m\n\u001b[31m  state.roundTrip    = document.getElementById('inp-roundtrip').checked;\u001b[39m\n\u001b[31m  state.destination  = state.roundTrip ? null : (document.getElementById('inp-destination').value.trim() || null);\u001b[39m\n\u001b[31m  // Geocode origin first\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: state.origin.text });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin.location = og.location;\u001b[39m\n\u001b[31m    state.origin.provider = og.provider;\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    setProgress(0, 0, '');\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('Origin geocode failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Geocode each stop (skip if pre-resolved). Sequential to avoid rate limits.\u001b[39m\n\u001b[31m  const total = state.stops.length;\u001b[39m\n\u001b[31m  let done = 0;\u001b[39m\n\u001b[31m  for (const s of state.stops) {\u001b[39m\n\u001b[31m    if (s.location) { done++; setProgress(done, total, 'Pre-geocoded'); continue; }\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      const r = await api('/geocode', { address: s.address });\u001b[39m\n\u001b[31m      if (r.ok && r.location) {\u001b[39m\n\u001b[31m        s.location = r.location;\u001b[39m\n\u001b[31m        s.provider = r.provider;\u001b[39m\n\u001b[31m      } else {\u001b[39m\n\u001b[31m        s.geocodeErr = r.error || 'No match';\u001b[39m\n\u001b[31m      }\u001b[39m\n\u001b[31m    } catch (e) {\u001b[39m\n\u001b[31m      s.geocodeErr = e.message;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    done++;\u001b[39m\n\u001b[31m    setProgress(done, total, `Geocoding ${s.name.slice(0, 30)}…`);\u001b[39m\n\u001b[31m    // Tiny delay to be polite to the provider\u001b[39m\n\u001b[31m    await sleep(80);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Filter out failed-geocode stops\u001b[39m\n\u001b[31m  const good = state.stops.filter(s => s.location && !s.geocodeErr);\u001b[39m\n\u001b[31m  const bad  = state.stops.filter(s => !s.location || s.geocodeErr);\u001b[39m\n\u001b[31m  if (!good.length) {\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('No stops could be geocoded', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (bad.length) toast(`${bad.length} stop(s) failed geocode (excluded)`, 'err');\u001b[39m\n\u001b[31m  state.stops = good;·\u001b[39m\n\u001b[31m  // Optimise\u001b[39m\n\u001b[31m  setProgress(total, total, 'Optimising route…');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const opt = await api('/optimize', {\u001b[39m\n\u001b[31m      origin:    locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:     state.stops.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:  state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!opt.ok) throw new Error(opt.error || 'optimise failed');\u001b[39m\n\u001b[31m    // Apply order\u001b[39m\n\u001b[31m    const order = opt.waypoint_order || state.stops.map((_, i) => i);\u001b[39m\n\u001b[31m    state.stops = order.map(i => state.stops[i]);\u001b[39m\n\u001b[31m    state.proposedOrder = state.stops.map(s => s.id);\u001b[39m\n\u001b[31m    state.baseTotal     = { distance_m: opt.total_distance_m, duration_s: opt.total_duration_s };\u001b[39m\n\u001b[31m    state.currentTotal  = { ...state.baseTotal };\u001b[39m\n\u001b[31m    state.lastOptimisation = opt.optimisation;\u001b[39m\n\u001b[31m    state.needsReoptimize  = false;   // fresh optimise — flag cleared\u001b[39m\n\u001b[31m    // Map legs to stops (route returned len = stops + 1 (round-trip) or stops + 1 (one-way))\u001b[39m\n\u001b[31m    if (Array.isArray(opt.legs) && opt.legs.length >= state.stops.length) {\u001b[39m\n\u001b[31m      state.stops.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = opt.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('Optimise failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m  showScreen('s-editor');\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction setProgress(done, total, label) {\u001b[39m\n\u001b[31m  const pct = total ? Math.round(done/total*100) : 0;\u001b[39m\n\u001b[31m  document.getElementById('prog-bar').style.width   = pct + '%';\u001b[39m\n\u001b[31m  document.getElementById('prog-count').textContent = `${done} / ${total}`;\u001b[39m\n\u001b[31m  document.getElementById('prog-label').textContent = label;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction locStr(loc) { return loc ? `${loc.lat},${loc.lng}` : null; }\u001b[39m\n\u001b[31mconst sleep = ms => new Promise(r => setTimeout(r, ms));·\u001b[39m\n\u001b[31m// ── Editor screen ───────────────────────────────────\u001b[39m\n\u001b[31mfunction renderEditor() {\u001b[39m\n\u001b[31m  restoreMapPref();\u001b[39m\n\u001b[31m  document.getElementById('origin-val').textContent = state.origin.text;\u001b[39m\n\u001b[31m  document.getElementById('editor-sub').textContent =\u001b[39m\n\u001b[31m    `${state.stops.filter(s => s.included).length} of ${state.stops.length} included` +\u001b[39m\n\u001b[31m    (state.lastOptimisation ? ` · ${state.lastOptimisation}` : '');\u001b[39m\n\u001b[31m  const cont = document.getElementById('tiles');\u001b[39m\n\u001b[31m  cont.innerHTML = '';\u001b[39m\n\u001b[31m  state.stops.forEach((s, idx) => cont.appendChild(renderTile(s, idx)));\u001b[39m\n\u001b[31m  if (_sortable) _sortable.destroy();\u001b[39m\n\u001b[31m  _sortable = Sortable.create(cont, {\u001b[39m\n\u001b[31m    handle: '.grip',\u001b[39m\n\u001b[31m    animation: 150,\u001b[39m\n\u001b[31m    onEnd: (evt) => {\u001b[39m\n\u001b[31m      const moved = state.stops.splice(evt.oldIndex, 1)[0];\u001b[39m\n\u001b[31m      state.stops.splice(evt.newIndex, 0, moved);\u001b[39m\n\u001b[31m      renderEditor();\u001b[39m\n\u001b[31m      scheduleRecalc();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  updateTotalsBar();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderTile(s, idx) {\u001b[39m\n\u001b[31m  const el = document.createElement('div');\u001b[39m\n\u001b[31m  el.className = 'tile' + (s.included ? '' : ' excluded') + (s.geocodeErr ? ' err' : '');\u001b[39m\n\u001b[31m  el.dataset.id = s.id;\u001b[39m\n\u001b[31m  const providerBadge = s.provider ? `<span class=\\\"badge-provider\\\">${escHtml(s.provider)}</span>` : '';\u001b[39m\n\u001b[31m  const leg = s.legDistance_m > 0\u001b[39m\n\u001b[31m    ? `${(s.legDistance_m/1000).toFixed(1)} km · ${Math.round(s.legDuration_s/60)} min from prev`\u001b[39m\n\u001b[31m    : '';\u001b[39m\n\u001b[31m  // Per UX rule: Name + City are the primary display. Address is shown\u001b[39m\n\u001b[31m  // smaller below as supporting context. Falls back gracefully when city\u001b[39m\n\u001b[31m  // is missing.\u001b[39m\n\u001b[31m  const cityLine = s.city || '—';\u001b[39m\n\u001b[31m  const addrLine = s.address && s.address !== s.city ? s.address : '';\u001b[39m\n\u001b[31m  el.innerHTML = `\u001b[39m\n\u001b[31m    <span class=\\\"grip\\\">≡</span>\u001b[39m\n\u001b[31m    <span class=\\\"ord\\\">${idx + 1}</span>\u001b[39m\n\u001b[31m    <div class=\\\"info\\\">\u001b[39m\n\u001b[31m      <div class=\\\"name\\\">${escHtml(s.name)} ${providerBadge}</div>\u001b[39m\n\u001b[31m      <div class=\\\"city\\\">${escHtml(cityLine)}</div>\u001b[39m\n\u001b[31m      ${addrLine ? `<div class=\\\"addr\\\">${escHtml(addrLine)}</div>` : ''}\u001b[39m\n\u001b[31m      ${leg ? `<div class=\\\"leg\\\">${escHtml(leg)}</div>` : ''}\u001b[39m\n\u001b[31m      ${s.geocodeErr ? `<div class=\\\"leg\\\" style=\\\"color:var(--r)\\\">⚠ ${escHtml(s.geocodeErr)}</div>` : ''}\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"incl ${s.included ? 'on' : 'off'}\\\" onclick=\\\"toggleInclude('${s.id}')\\\" title=\\\"Include / exclude\\\">\u001b[39m\n\u001b[31m      ${s.included ? '✓' : '✗'}\u001b[39m\n\u001b[31m    </button>`;\u001b[39m\n\u001b[31m  return el;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleInclude(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id); if (!s) return;\u001b[39m\n\u001b[31m  s.included = !s.included;\u001b[39m\n\u001b[31m  // The SET of stops just changed — the existing route may no longer be optimal.\u001b[39m\n\u001b[31m  // Surface a banner + pulse the Re-optim button so the user knows to refresh.\u001b[39m\n\u001b[31m  // recalcRoute still fires (debounced) to update the cost of the CURRENT order,\u001b[39m\n\u001b[31m  // but that number is just informational until the user re-optimises.\u001b[39m\n\u001b[31m  state.needsReoptimize = true;\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  scheduleRecalc();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction scheduleRecalc() {\u001b[39m\n\u001b[31m  clearTimeout(_recalcTimer);\u001b[39m\n\u001b[31m  document.getElementById('recalc').classList.add('show');\u001b[39m\n\u001b[31m  _recalcTimer = setTimeout(recalcRoute, 400);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function recalcRoute() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  if (!included.length) {\u001b[39m\n\u001b[31m    state.currentTotal = { distance_m: 0, duration_s: 0 };\u001b[39m\n\u001b[31m    document.getElementById('recalc').classList.remove('show');\u001b[39m\n\u001b[31m    updateTotalsBar();\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/route', {\u001b[39m\n\u001b[31m      origin:      locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:       included.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:   state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!r.ok) throw new Error(r.error || 'route failed');\u001b[39m\n\u001b[31m    state.currentTotal = { distance_m: r.total_distance_m, duration_s: r.total_duration_s };\u001b[39m\n\u001b[31m    // Map legs back to tiles (legs index 0 = origin→stop0, n = lastStop→dest)\u001b[39m\n\u001b[31m    if (Array.isArray(r.legs)) {\u001b[39m\n\u001b[31m      included.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = r.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    // Clear leg info on excluded stops\u001b[39m\n\u001b[31m    state.stops.filter(s => !s.included).forEach(s => { s.legDistance_m = 0; s.legDuration_s = 0; });\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Recalc failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  } finally {\u001b[39m\n\u001b[31m    document.getElementById('recalc').classList.remove('show');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateTotalsBar() {\u001b[39m\n\u001b[31m  // Totals now live ONLY in the top-actions card (the sticky bottom bar was\u001b[39m\n\u001b[31m  // removed after user feedback that it duplicated the top info 2026-06-04).\u001b[39m\n\u001b[31m  // The big-text element holds the inline recalc spinner as a child <span>.\u001b[39m\n\u001b[31m  const km  = (state.currentTotal.distance_m / 1000).toFixed(1);\u001b[39m\n\u001b[31m  const min = Math.round(state.currentTotal.duration_s / 60);\u001b[39m\n\u001b[31m  const hrs = Math.floor(min / 60), mm = min % 60;\u001b[39m\n\u001b[31m  const timeStr = hrs > 0 ? `${hrs}h ${mm}min` : `${mm} min`;\u001b[39m\n\u001b[31m  const totalsText = `${km} km · ${timeStr}`;\u001b[39m\n\u001b[31m  const bigEl  = document.getElementById('top-totals-big');\u001b[39m\n\u001b[31m  const recalc = document.getElementById('recalc');\u001b[39m\n\u001b[31m  bigEl.textContent = totalsText;\u001b[39m\n\u001b[31m  if (recalc) bigEl.appendChild(document.createTextNode(' '));\u001b[39m\n\u001b[31m  if (recalc) bigEl.appendChild(recalc);\u001b[39m\n\u001b[31m  // Delta vs base\u001b[39m\n\u001b[31m  const baseKm   = state.baseTotal.distance_m / 1000;\u001b[39m\n\u001b[31m  const curKm    = state.currentTotal.distance_m / 1000;\u001b[39m\n\u001b[31m  const deltaKm  = curKm - baseKm;\u001b[39m\n\u001b[31m  const deltaMin = Math.round((state.currentTotal.duration_s - state.baseTotal.duration_s) / 60);\u001b[39m\n\u001b[31m  const dT = document.getElementById('top-totals-delta');\u001b[39m\n\u001b[31m  let deltaText, deltaClass;\u001b[39m\n\u001b[31m  if (Math.abs(deltaKm) < 0.05) {\u001b[39m\n\u001b[31m    deltaText  = 'matches proposed route';\u001b[39m\n\u001b[31m    deltaClass = '';\u001b[39m\n\u001b[31m  } else {\u001b[39m\n\u001b[31m    const sign = deltaKm > 0 ? '+' : '';\u001b[39m\n\u001b[31m    deltaText  = `vs proposed: ${sign}${deltaKm.toFixed(1)} km · ${sign}${deltaMin} min`;\u001b[39m\n\u001b[31m    deltaClass = deltaKm < 0 ? 'better' : 'worse';\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  dT.textContent = deltaText;\u001b[39m\n\u001b[31m  dT.className   = 'top-totals-delta ' + deltaClass;·\u001b[39m\n\u001b[31m  // ── Re-optim-needed banner + pulse + stale styling ──\u001b[39m\n\u001b[31m  // Surfaces a clear message whenever the user has added/removed stops since\u001b[39m\n\u001b[31m  // the last /optimize call. The current-order cost is still shown (so the\u001b[39m\n\u001b[31m  // user sees \\\"what this order costs\\\"), but it's struck-through and the\u001b[39m\n\u001b[31m  // Re-optim button pulses to draw attention.\u001b[39m\n\u001b[31m  const needsReopt = !!state.needsReoptimize;\u001b[39m\n\u001b[31m  document.getElementById('reopt-needed').classList.toggle('show', needsReopt);\u001b[39m\n\u001b[31m  bigEl.classList.toggle('stale', needsReopt);\u001b[39m\n\u001b[31m  dT.classList.toggle('stale', needsReopt);\u001b[39m\n\u001b[31m  document.querySelector('.top-reopt')?.classList.toggle('pulse', needsReopt);·\u001b[39m\n\u001b[31m  // Re-render inline map preview to reflect current order/include flags.\u001b[39m\n\u001b[31m  // Debounced via setTimeout 0 so multiple updates in the same tick coalesce.\u001b[39m\n\u001b[31m  scheduleMapRender();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Inline route preview (Leaflet + free OSM tiles) ──────────────\u001b[39m\n\u001b[31m// Renders origin + stops + straight-line polyline inside the editor screen.\u001b[39m\n\u001b[31m// \\\"Straight-line\\\" because the proxy only returns leg distances/durations,\u001b[39m\n\u001b[31m// not the per-leg point arrays from TomTom — this preview is enough for\u001b[39m\n\u001b[31m// orientation; \\\"Open in Google Maps\\\" gives the user the real road geometry.\u001b[39m\n\u001b[31mlet _leafletMap   = null;\u001b[39m\n\u001b[31mlet _leafletLayer = null;\u001b[39m\n\u001b[31mlet _mapRenderTimer = null;·\u001b[39m\n\u001b[31mfunction ensureMap() {\u001b[39m\n\u001b[31m  if (_leafletMap) return _leafletMap;\u001b[39m\n\u001b[31m  if (typeof L === 'undefined') return null;   // CDN not ready yet\u001b[39m\n\u001b[31m  const el = document.getElementById('tour-map');\u001b[39m\n\u001b[31m  if (!el || !el.offsetParent) return null;    // container not visible yet\u001b[39m\n\u001b[31m  _leafletMap = L.map(el, { attributionControl: false, zoomControl: true, preferCanvas: true });\u001b[39m\n\u001b[31m  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18 })\u001b[39m\n\u001b[31m    .addTo(_leafletMap);\u001b[39m\n\u001b[31m  L.control.attribution({ position: 'bottomright', prefix: false })\u001b[39m\n\u001b[31m    .addAttribution('© OpenStreetMap contributors')\u001b[39m\n\u001b[31m    .addTo(_leafletMap);\u001b[39m\n\u001b[31m  return _leafletMap;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderMap() {\u001b[39m\n\u001b[31m  const map = ensureMap();\u001b[39m\n\u001b[31m  if (!map) return;\u001b[39m\n\u001b[31m  if (_leafletLayer) { map.removeLayer(_leafletLayer); _leafletLayer = null; }\u001b[39m\n\u001b[31m  const layers = [];\u001b[39m\n\u001b[31m  const linePts = [];\u001b[39m\n\u001b[31m  // Origin marker (always first in line)\u001b[39m\n\u001b[31m  if (state.origin?.location) {\u001b[39m\n\u001b[31m    const ll = [state.origin.location.lat, state.origin.location.lng];\u001b[39m\n\u001b[31m    layers.push(L.marker(ll, { icon: L.divIcon({\u001b[39m\n\u001b[31m      className: 'tour-pin origin', html: '<div>🏁</div>',\u001b[39m\n\u001b[31m      iconSize: [28, 28], iconAnchor: [14, 14],\u001b[39m\n\u001b[31m    })}).bindTooltip('Start: ' + (state.origin.text || 'origin'), { direction: 'top' }));\u001b[39m\n\u001b[31m    linePts.push(ll);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Stops in current visit order\u001b[39m\n\u001b[31m  let visitNum = 0;\u001b[39m\n\u001b[31m  state.stops.forEach(s => {\u001b[39m\n\u001b[31m    if (!s.location) return;\u001b[39m\n\u001b[31m    const ll = [s.location.lat, s.location.lng];\u001b[39m\n\u001b[31m    const included = s.included;\u001b[39m\n\u001b[31m    if (included) visitNum++;\u001b[39m\n\u001b[31m    layers.push(L.marker(ll, { icon: L.divIcon({\u001b[39m\n\u001b[31m      className: 'tour-pin' + (included ? '' : ' out'),\u001b[39m\n\u001b[31m      html: `<div>${included ? visitNum : '·'}</div>`,\u001b[39m\n\u001b[31m      iconSize: [26, 26], iconAnchor: [13, 13],\u001b[39m\n\u001b[31m    })}).bindTooltip(\u001b[39m\n\u001b[31m      (included ? `${visitNum}. ` : '✗ ') + s.name + (s.city ? ` · ${s.city}` : ''),\u001b[39m\n\u001b[31m      { direction: 'top' }\u001b[39m\n\u001b[31m    ));\u001b[39m\n\u001b[31m    if (included) linePts.push(ll);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  // Close the loop on round-trip\u001b[39m\n\u001b[31m  if (state.roundTrip && state.origin?.location) {\u001b[39m\n\u001b[31m    linePts.push([state.origin.location.lat, state.origin.location.lng]);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (linePts.length >= 2) {\u001b[39m\n\u001b[31m    layers.push(L.polyline(linePts, {\u001b[39m\n\u001b[31m      color: '#0ea5e9', weight: 3, opacity: 0.85, dashArray: '6 4',\u001b[39m\n\u001b[31m    }));\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  _leafletLayer = L.layerGroup(layers).addTo(map);\u001b[39m\n\u001b[31m  if (linePts.length) {\u001b[39m\n\u001b[31m    try { map.fitBounds(L.latLngBounds(linePts), { padding: [30, 30], maxZoom: 13 }); }\u001b[39m\n\u001b[31m    catch (_) {}\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction scheduleMapRender() {\u001b[39m\n\u001b[31m  if (_mapRenderTimer) return;\u001b[39m\n\u001b[31m  _mapRenderTimer = setTimeout(() => { _mapRenderTimer = null; renderMap(); }, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleMap() {\u001b[39m\n\u001b[31m  const wrap = document.getElementById('map-wrap');\u001b[39m\n\u001b[31m  const tog  = document.getElementById('map-toggle');\u001b[39m\n\u001b[31m  const open = !wrap.classList.toggle('collapsed');\u001b[39m\n\u001b[31m  tog.textContent = open ? '▾' : '▴';\u001b[39m\n\u001b[31m  try { localStorage.setItem('tour-pg-map-open', open ? '1' : '0'); } catch (_) {}\u001b[39m\n\u001b[31m  if (open && _leafletMap) {\u001b[39m\n\u001b[31m    // After expansion the container size changed — Leaflet must recompute\u001b[39m\n\u001b[31m    setTimeout(() => { _leafletMap.invalidateSize(); renderMap(); }, 220);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction restoreMapPref() {\u001b[39m\n\u001b[31m  // Default collapsed — user must explicitly expand once for it to stick open.\u001b[39m\n\u001b[31m  let open = false;\u001b[39m\n\u001b[31m  try { open = localStorage.getItem('tour-pg-map-open') === '1'; } catch (_) {}\u001b[39m\n\u001b[31m  const wrap = document.getElementById('map-wrap');\u001b[39m\n\u001b[31m  const tog  = document.getElementById('map-toggle');\u001b[39m\n\u001b[31m  wrap.classList.toggle('collapsed', !open);\u001b[39m\n\u001b[31m  tog.textContent = open ? '▾' : '▴';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Origin edit / mid-tour replan ────────────────────\u001b[39m\n\u001b[31mfunction openOriginEdit() {\u001b[39m\n\u001b[31m  document.getElementById('origin-edit').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('inp-new-origin').value = state.origin.text;\u001b[39m\n\u001b[31m  document.getElementById('inp-new-origin').focus();\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction closeOriginEdit() {\u001b[39m\n\u001b[31m  document.getElementById('origin-edit').style.display = 'none';\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31masync function applyOriginAndReplan() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('inp-new-origin').value.trim();\u001b[39m\n\u001b[31m  if (!txt) return toast('Enter an origin', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: txt });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin = { text: txt, location: og.location, provider: og.provider };\u001b[39m\n\u001b[31m    closeOriginEdit();\u001b[39m\n\u001b[31m    await replanAll();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Origin failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function replanAll() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  if (!included.length) return toast('No stops included', 'err');\u001b[39m\n\u001b[31m  toast('Optimising…', 'ok');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const opt = await api('/optimize', {\u001b[39m\n\u001b[31m      origin:      locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:       included.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:   state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!opt.ok) throw new Error(opt.error || 'optimise failed');\u001b[39m\n\u001b[31m    const order = opt.waypoint_order || included.map((_, i) => i);\u001b[39m\n\u001b[31m    const reordered = order.map(i => included[i]);\u001b[39m\n\u001b[31m    // KEEP EXCLUDED STOPS IN THEIR EXISTING POSITIONS (per user feedback\u001b[39m\n\u001b[31m    // 2026-06-04). Excluded slots stay put; reordered-included stops fill\u001b[39m\n\u001b[31m    // the gaps. Previously appended at end (confusing) → then dropped\u001b[39m\n\u001b[31m    // (lossy) → final: stay where they are. User can re-include to put\u001b[39m\n\u001b[31m    // them back into the optimised route.\u001b[39m\n\u001b[31m    const newStops = [];\u001b[39m\n\u001b[31m    let r = 0;\u001b[39m\n\u001b[31m    for (const s of state.stops) {\u001b[39m\n\u001b[31m      if (s.included) newStops.push(reordered[r++]);\u001b[39m\n\u001b[31m      else            newStops.push(s);\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    state.stops = newStops;\u001b[39m\n\u001b[31m    state.proposedOrder = state.stops.map(s => s.id);\u001b[39m\n\u001b[31m    state.baseTotal     = { distance_m: opt.total_distance_m, duration_s: opt.total_duration_s };\u001b[39m\n\u001b[31m    state.currentTotal  = { ...state.baseTotal };\u001b[39m\n\u001b[31m    state.lastOptimisation = opt.optimisation;\u001b[39m\n\u001b[31m    state.needsReoptimize  = false;   // re-optim done — banner cleared\u001b[39m\n\u001b[31m    if (Array.isArray(opt.legs)) {\u001b[39m\n\u001b[31m      reordered.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = opt.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Re-optimised ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Replan failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Share / export ──────────────────────────────────\u001b[39m\n\u001b[31mfunction openShare() {\u001b[39m\n\u001b[31m  const km   = (state.currentTotal.distance_m / 1000).toFixed(1);\u001b[39m\n\u001b[31m  const min  = Math.round(state.currentTotal.duration_s / 60);\u001b[39m\n\u001b[31m  const hrs  = Math.floor(min / 60), mm = min % 60;\u001b[39m\n\u001b[31m  const timeStr = hrs > 0 ? `${hrs}h ${mm}min` : `${mm} min`;\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  let txt = `🚗 Tour plan (${new Date().toLocaleDateString('en-IN')})\\\\n`;\u001b[39m\n\u001b[31m  txt += `From: ${state.origin.text}\\\\n`;\u001b[39m\n\u001b[31m  txt += `${included.length} stops · ${km} km · ${timeStr}\\\\n`;\u001b[39m\n\u001b[31m  if (state.roundTrip) txt += `Round-trip\\\\n\\\\n`;\u001b[39m\n\u001b[31m  else                  txt += `One-way → ${state.destination || included[included.length-1]?.address || ''}\\\\n\\\\n`;\u001b[39m\n\u001b[31m  included.forEach((s, i) => {\u001b[39m\n\u001b[31m    const legKm  = (s.legDistance_m/1000).toFixed(1);\u001b[39m\n\u001b[31m    const legMin = Math.round(s.legDuration_s/60);\u001b[39m\n\u001b[31m    txt += `${i+1}. ${s.name}`;\u001b[39m\n\u001b[31m    if (s.city) txt += ` · ${s.city}`;\u001b[39m\n\u001b[31m    if (s.legDistance_m > 0) txt += `  (+${legKm}km · ${legMin}min)`;\u001b[39m\n\u001b[31m    if (s.phone) txt += `  ${s.phone}`;\u001b[39m\n\u001b[31m    txt += `\\\\n`;\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (state.roundTrip) txt += `↩ Return to ${state.origin.text}`;\u001b[39m\n\u001b[31m  document.getElementById('share-summary').textContent = txt;\u001b[39m\n\u001b[31m  document.getElementById('modal-share').classList.add('show');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction closeShare() {\u001b[39m\n\u001b[31m  document.getElementById('modal-share').classList.remove('show');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction shareWhatsApp() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('share-summary').textContent;\u001b[39m\n\u001b[31m  const url = `https://wa.me/?text=${encodeURIComponent(txt)}`;\u001b[39m\n\u001b[31m  window.open(url, '_blank');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Hands off the optimised tour to Google Maps Directions (free, zero infra).\u001b[39m\n\u001b[31m// Uses path-style URL `dir/A/B/C/...` because it accepts many more stops than\u001b[39m\n\u001b[31m// the `api=1` Directions URL (which Google caps at 9 waypoints). Each segment\u001b[39m\n\u001b[31m// is \\\"lat,lng\\\" for geocoded points (most reliable). If the user picked a\u001b[39m\n\u001b[31m// text destination on a one-way tour we URL-encode it as the last segment.\u001b[39m\n\u001b[31mfunction openInGoogleMaps() {\u001b[39m\n\u001b[31m  if (!state.origin?.location) { toast('Origin not yet geocoded', 'err'); return; }\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included && s.location);\u001b[39m\n\u001b[31m  if (included.length === 0)   { toast('No included stops to view', 'err'); return; }\u001b[39m\n\u001b[31m  const fmt = (loc) => `${loc.lat.toFixed(6)},${loc.lng.toFixed(6)}`;\u001b[39m\n\u001b[31m  const segs = [ fmt(state.origin.location) ];\u001b[39m\n\u001b[31m  included.forEach(s => segs.push(fmt(s.location)));\u001b[39m\n\u001b[31m  if (state.roundTrip) {\u001b[39m\n\u001b[31m    segs.push(fmt(state.origin.location));\u001b[39m\n\u001b[31m  } else if (state.destination) {\u001b[39m\n\u001b[31m    segs.push(encodeURIComponent(state.destination));\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // travelmode and dir_action=navigate auto-open in driving directions\u001b[39m\n\u001b[31m  const url = `https://www.google.com/maps/dir/${segs.join('/')}/?travelmode=driving`;\u001b[39m\n\u001b[31m  window.open(url, '_blank', 'noopener');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function copyText() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('share-summary').textContent;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await navigator.clipboard.writeText(txt);\u001b[39m\n\u001b[31m    toast('Copied ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Copy failed', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction downloadCsv() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  const rows = [['#','Name','City','Address','Phone','Lat','Lng','Leg km','Leg min','Provider']];\u001b[39m\n\u001b[31m  included.forEach((s, i) => {\u001b[39m\n\u001b[31m    rows.push([\u001b[39m\n\u001b[31m      i + 1,\u001b[39m\n\u001b[31m      s.name, s.city, s.address, s.phone,\u001b[39m\n\u001b[31m      s.location?.lat ?? '', s.location?.lng ?? '',\u001b[39m\n\u001b[31m      (s.legDistance_m/1000).toFixed(2),\u001b[39m\n\u001b[31m      Math.round(s.legDuration_s/60),\u001b[39m\n\u001b[31m      s.provider || '',\u001b[39m\n\u001b[31m    ]);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  const csv = rows.map(r => r.map(c => `\\\"${String(c).replace(/\\\"/g, '\\\"\\\"')}\\\"`).join(',')).join('\\\\n');\u001b[39m\n\u001b[31m  const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });\u001b[39m\n\u001b[31m  const url = URL.createObjectURL(blob);\u001b[39m\n\u001b[31m  const a = document.createElement('a');\u001b[39m\n\u001b[31m  a.href = url; a.download = `tour-${new Date().toISOString().slice(0,10)}.csv`;\u001b[39m\n\u001b[31m  document.body.appendChild(a); a.click(); a.remove();\u001b[39m\n\u001b[31m  URL.revokeObjectURL(url);\u001b[39m\n\u001b[31m  toast('CSV downloaded ✓', 'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction saveLocal() {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    localStorage.setItem('tour-pg-saved', JSON.stringify({\u001b[39m\n\u001b[31m      saved_at: new Date().toISOString(),\u001b[39m\n\u001b[31m      state,\u001b[39m\n\u001b[31m    }));\u001b[39m\n\u001b[31m    toast('Saved ✓', 'ok');\u001b[39m\n\u001b[31m    document.getElementById('btn-load').style.display = '';\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed (storage full?)', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction loadSaved() {\u001b[39m\n\u001b[31m  const raw = localStorage.getItem('tour-pg-saved');\u001b[39m\n\u001b[31m  if (!raw) return toast('No saved tour', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const { state: s } = JSON.parse(raw);\u001b[39m\n\u001b[31m    state = s;\u001b[39m\n\u001b[31m    showScreen('s-editor');\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Loaded ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Load failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Navigation ──────────────────────────────────────\u001b[39m\n\u001b[31mfunction confirmBack() {\u001b[39m\n\u001b[31m  if (!confirm('Start a new tour? Current state will be cleared (use Save first if you want to keep it).')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m  document.getElementById('parsed-preview').style.display = 'none';\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = true;\u001b[39m\n\u001b[31m  document.getElementById('excel-file').value = '';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showScreen(id) {\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById(id).classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Utils ───────────────────────────────────────────\u001b[39m\n\u001b[31mfunction escHtml(s) { if (s == null) return ''; return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\\\"/g,'&quot;'); }\u001b[39m\n\u001b[31mlet _toastTimer;\u001b[39m\n\u001b[31mfunction toast(msg, type='') {\u001b[39m\n\u001b[31m  const el = document.getElementById('toast');\u001b[39m\n\u001b[31m  el.textContent = msg; el.className = 'show ' + type;\u001b[39m\n\u001b[31m  clearTimeout(_toastTimer);\u001b[39m\n\u001b[31m  _toastTimer = setTimeout(() => el.className = '', 3200);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction registerSW() {\u001b[39m\n\u001b[31m  if (!('serviceWorker' in navigator)) return;\u001b[39m\n\u001b[31m  const sw = `\u001b[39m\n\u001b[31mconst C='${CACHE_VER}';\u001b[39m\n\u001b[31mself.addEventListener('install', e => self.skipWaiting());\u001b[39m\n\u001b[31mself.addEventListener('activate', e => {\u001b[39m\n\u001b[31m  e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== C).map(k => caches.delete(k)))));\u001b[39m\n\u001b[31m  self.clients.claim();\u001b[39m\n\u001b[31m});\u001b[39m\n\u001b[31mself.addEventListener('fetch', e => {\u001b[39m\n\u001b[31m  if (e.request.method !== 'GET') return;\u001b[39m\n\u001b[31m  const url = new URL(e.request.url);\u001b[39m\n\u001b[31m  if (url.pathname.startsWith('/tour-pg-proxy')) return;\u001b[39m\n\u001b[31m  if (url.pathname.startsWith('/tour-pg/')) {\u001b[39m\n\u001b[31m    e.respondWith(\u001b[39m\n\u001b[31m      caches.open(C).then(c =>\u001b[39m\n\u001b[31m        c.match(e.request).then(r => r || fetch(e.request).then(f => { c.put(e.request, f.clone()); return f; }))\u001b[39m\n\u001b[31m      )\u001b[39m\n\u001b[31m    );\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m});`;\u001b[39m\n\u001b[31m  const blob = new Blob([sw], { type: 'application/javascript' });\u001b[39m\n\u001b[31m  navigator.serviceWorker.register(URL.createObjectURL(blob), { scope: '/tour-pg/' }).catch(() => {});\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31minit();\u001b[39m\n\u001b[31m</script>·\u001b[39m\n\u001b[31m</body>\u001b[39m\n\u001b[31m</html>\u001b[39m\n\u001b[31m\"\u001b[39m\n    at /var/www/360lm/tests/tour_pg.spec.js:54:18",
                        "location": {
                          "file": "/var/www/360lm/tests/tour_pg.spec.js",
                          "column": 18,
                          "line": 54
                        },
                        "snippet": "  52 |     const ctx = await request.newContext();\n  53 |     const html = await (await ctx.get(BASE_URL)).text();\n> 54 |     expect(html).toContain('tour-pg-v10');\n     |                  ^\n  55 |     expect(html).toContain('Tour Playground');\n  56 |     await ctx.dispose();\n  57 |   });"
                      },
                      "errors": [
                        {
                          "location": {
                            "file": "/var/www/360lm/tests/tour_pg.spec.js",
                            "column": 18,
                            "line": 54
                          },
                          "message": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoContain\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // indexOf\u001b[22m\n\nExpected substring: \u001b[32m\"tour-pg-v10\"\u001b[39m\nReceived string:    \u001b[31m\"<!DOCTYPE html>\u001b[39m\n\u001b[31m<html lang=\\\"en\\\">\u001b[39m\n\u001b[31m<head>\u001b[39m\n\u001b[31m<meta charset=\\\"UTF-8\\\">\u001b[39m\n\u001b[31m<meta name=\\\"viewport\\\" content=\\\"width=device-width,initial-scale=1,viewport-fit=cover\\\">\u001b[39m\n\u001b[31m<title>Tour Planner Playground</title>\u001b[39m\n\u001b[31m<link rel=\\\"manifest\\\" href=\\\"manifest.json\\\">\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"/shared/safe-bottom.css\\\">\u001b[39m\n\u001b[31m<meta name=\\\"theme-color\\\" content=\\\"#0a0a0a\\\">\u001b[39m\n\u001b[31m<link rel=\\\"preconnect\\\" href=\\\"https://fonts.googleapis.com\\\">\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&display=swap\\\">\u001b[39m\n\u001b[31m<script src=\\\"https://cdn.sheetjs.com/xlsx-0.20.2/package/dist/xlsx.full.min.js\\\"></script>\u001b[39m\n\u001b[31m<script src=\\\"https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js\\\"></script>\u001b[39m\n\u001b[31m<link rel=\\\"stylesheet\\\" href=\\\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.css\\\" crossorigin=\\\"\\\">\u001b[39m\n\u001b[31m<script src=\\\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.js\\\" crossorigin=\\\"\\\" defer></script>\u001b[39m\n\u001b[31m<style>\u001b[39m\n\u001b[31m:root{\u001b[39m\n\u001b[31m  --bg:#0a0a0a;--card:#141414;--card2:#1a1a1a;--border:#262626;--text:#f5f5f5;--muted:#a3a3a3;\u001b[39m\n\u001b[31m  --accent:#0ea5e9;--accent2:#0284c7;--g:#22c55e;--r:#ef4444;--y:#eab308;--p:#a855f7;\u001b[39m\n\u001b[31m  --rad:12px;--rads:8px;--bnav-h:64px;\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31m*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}\u001b[39m\n\u001b[31mbody{background:var(--bg);color:var(--text);font-family:'DM Sans',sans-serif;min-height:100vh;overscroll-behavior-y:none}\u001b[39m\n\u001b[31mbutton{font-family:inherit;cursor:pointer;border:none;background:none;color:inherit}\u001b[39m\n\u001b[31ma{color:inherit;text-decoration:none}·\u001b[39m\n\u001b[31m.scr{display:none;flex-direction:column;min-height:100vh;padding-bottom:calc(var(--bnav-h) + env(safe-area-inset-bottom))}\u001b[39m\n\u001b[31m.scr.active{display:flex}·\u001b[39m\n\u001b[31m.hdr{background:var(--card);border-bottom:1px solid var(--border);padding:12px 14px;display:flex;align-items:center;gap:8px;position:sticky;top:0;z-index:10}\u001b[39m\n\u001b[31m.hdr h1{font-size:15px;font-weight:800;flex:1;min-width:0}\u001b[39m\n\u001b[31m.hdr-sub{font-size:11px;color:var(--muted)}\u001b[39m\n\u001b[31m.hdr-btn{background:rgba(255,255,255,.08);border:1px solid var(--border);font-size:12px;font-weight:700;padding:5px 10px;border-radius:14px;cursor:pointer;white-space:nowrap}\u001b[39m\n\u001b[31m.hdr-btn:active{background:rgba(255,255,255,.16)}\u001b[39m\n\u001b[31m.user-pill{background:rgba(255,255,255,.1);border:1px solid var(--border);color:var(--muted);font-size:10px;font-weight:600;padding:3px 8px;border-radius:11px}\u001b[39m\n\u001b[31m.user-pill b{color:var(--text)}·\u001b[39m\n\u001b[31m.body{flex:1;padding:14px;display:flex;flex-direction:column;gap:12px}·\u001b[39m\n\u001b[31m/* ── editor: header + controls + route preview stay put; only the stops list scrolls ── */\u001b[39m\n\u001b[31m#s-editor.scr{height:100vh;height:100dvh;min-height:0;padding-bottom:0;overflow:hidden}\u001b[39m\n\u001b[31m.editor-body{flex:1;min-height:0;display:flex;flex-direction:column;padding:14px 14px 0}\u001b[39m\n\u001b[31m.editor-fixed{display:flex;flex-direction:column;gap:12px;flex-shrink:0}\u001b[39m\n\u001b[31m.editor-scroll{flex:1;min-height:0;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:2px 0 calc(32px + env(safe-area-inset-bottom));margin-top:6px;scrollbar-gutter:stable}\u001b[39m\n\u001b[31m/* visible scrollbar so it's obvious the stops list is independently scrollable */\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar{width:8px}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-thumb{background:#3a3a3a;border-radius:4px}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-thumb:active{background:#525252}\u001b[39m\n\u001b[31m.editor-scroll::-webkit-scrollbar-track{background:transparent}·\u001b[39m\n\u001b[31m/* ── column-mapping table (parsed preview): scroll horizontally if it doesn't fit ── */\u001b[39m\n\u001b[31m.map-table-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--border);border-radius:6px}\u001b[39m\n\u001b[31m.map-table{border-collapse:collapse;table-layout:fixed;width:100%;min-width:440px;font-size:11px}\u001b[39m\n\u001b[31m.map-table th{text-align:left;padding:6px 8px;color:var(--muted);font-weight:800;font-size:9.5px;text-transform:uppercase;letter-spacing:.3px;background:var(--card2);position:sticky;top:0}\u001b[39m\n\u001b[31m.map-table td{padding:6px 8px;vertical-align:middle}\u001b[39m\n\u001b[31m.map-table tr+tr td,.map-table tbody tr td{border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.map-table col.c-src{width:40%}.map-table col.c-field{width:28%}.map-table col.c-sample{width:32%}\u001b[39m\n\u001b[31m.map-table select{background:#0b1220;border:1px solid var(--border);color:var(--text);padding:6px 7px;border-radius:4px;font-size:11px;width:100%}\u001b[39m\n\u001b[31m.map-field{font-weight:800;color:var(--text);white-space:nowrap}\u001b[39m\n\u001b[31m.map-sample{color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}·\u001b[39m\n\u001b[31m/* ── upload ── */\u001b[39m\n\u001b[31m.dropzone{background:var(--card);border:2px dashed var(--border);border-radius:var(--rad);padding:28px 16px;text-align:center;cursor:pointer;transition:.15s}\u001b[39m\n\u001b[31m.dropzone:hover,.dropzone.drag{border-color:var(--accent);background:#0e1726}\u001b[39m\n\u001b[31m.dropzone .ico{font-size:42px;margin-bottom:8px}\u001b[39m\n\u001b[31m.dropzone .ttl{font-weight:800;font-size:15px;margin-bottom:4px}\u001b[39m\n\u001b[31m.dropzone .sub{font-size:11.5px;color:var(--muted);line-height:1.4}\u001b[39m\n\u001b[31m.dropzone input[type=file]{display:none}·\u001b[39m\n\u001b[31m.section-title{font-size:11px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.4px}·\u001b[39m\n\u001b[31m.form-card{background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:12px;display:flex;flex-direction:column;gap:10px}\u001b[39m\n\u001b[31m.field{display:flex;flex-direction:column;gap:3px}\u001b[39m\n\u001b[31m.field label{font-size:11px;font-weight:600;color:var(--muted)}\u001b[39m\n\u001b[31m.field input,.field select{background:#0b1220;border:1px solid var(--border);border-radius:6px;color:var(--text);padding:9px 11px;font-size:13.5px;font-family:inherit;outline:none}\u001b[39m\n\u001b[31m.field input:focus,.field select:focus{border-color:var(--accent)}\u001b[39m\n\u001b[31m.toggle{display:flex;align-items:center;gap:8px;font-size:13px}\u001b[39m\n\u001b[31m.toggle input{accent-color:var(--accent);width:18px;height:18px}·\u001b[39m\n\u001b[31m.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;border-radius:var(--rads);padding:12px 14px;font-size:14px;font-weight:800;cursor:pointer;width:100%;transition:.12s}\u001b[39m\n\u001b[31m.btn-p{background:var(--accent);color:#fff}\u001b[39m\n\u001b[31m.btn-p:active{background:var(--accent2)}\u001b[39m\n\u001b[31m.btn-p:disabled{opacity:.4;cursor:default}\u001b[39m\n\u001b[31m.btn-s{background:var(--card);color:var(--text);border:1px solid var(--border)}\u001b[39m\n\u001b[31m.btn-r{background:rgba(239,68,68,.15);color:#fca5a5;border:1px solid rgba(239,68,68,.3)}·\u001b[39m\n\u001b[31m/* ── progress ── */\u001b[39m\n\u001b[31m.progress{background:#0b1220;border-radius:8px;height:10px;overflow:hidden;border:1px solid var(--border)}\u001b[39m\n\u001b[31m.progress-bar{height:100%;background:linear-gradient(90deg,var(--accent),var(--g));transition:.2s}·\u001b[39m\n\u001b[31m/* ── tiles ── */\u001b[39m\n\u001b[31m.origin-pill{background:linear-gradient(135deg,#0c2e3a,#062a3a);border:1px solid var(--accent);border-radius:var(--rad);padding:11px 13px;display:flex;align-items:center;gap:10px}\u001b[39m\n\u001b[31m.origin-pill .ico{font-size:18px}\u001b[39m\n\u001b[31m.origin-pill .meta{flex:1;min-width:0}\u001b[39m\n\u001b[31m.origin-pill .lbl{font-size:10.5px;color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.4px}\u001b[39m\n\u001b[31m.origin-pill .val{font-size:13.5px;font-weight:700;margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.origin-pill button{background:rgba(255,255,255,.08);border-radius:14px;padding:5px 10px;font-size:11px;font-weight:700;color:var(--accent)}·\u001b[39m\n\u001b[31m.tiles{display:flex;flex-direction:column;gap:6px}\u001b[39m\n\u001b[31m.tile{background:var(--card);border:1px solid var(--border);border-radius:var(--rads);padding:9px 10px;display:flex;align-items:center;gap:10px;transition:.12s;touch-action:none}\u001b[39m\n\u001b[31m.tile.sortable-ghost{opacity:.35;background:var(--card2)}\u001b[39m\n\u001b[31m.tile.sortable-chosen{transform:scale(.98);box-shadow:0 4px 20px rgba(14,165,233,.25)}\u001b[39m\n\u001b[31m.tile.excluded{opacity:.4;background:#0c0c0c}\u001b[39m\n\u001b[31m.tile.excluded .name,.tile.excluded .city{text-decoration:line-through}\u001b[39m\n\u001b[31m.tile.err{border-color:var(--r);background:#1f0a0a}\u001b[39m\n\u001b[31m.tile .grip{font-size:18px;color:var(--muted);cursor:grab;padding:4px;line-height:1}\u001b[39m\n\u001b[31m.tile .grip:active{cursor:grabbing}\u001b[39m\n\u001b[31m.tile .ord{background:var(--accent);color:#fff;width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11.5px;font-weight:800;flex-shrink:0}\u001b[39m\n\u001b[31m.tile.excluded .ord{background:#444}\u001b[39m\n\u001b[31m.tile .info{flex:1;min-width:0}\u001b[39m\n\u001b[31m.tile .name{font-size:13.5px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .city{font-size:11.5px;color:#cbd5e1;font-weight:600;margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .addr{font-size:9.5px;color:var(--muted);margin-top:1px;line-height:1.3;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-style:italic;opacity:.75}\u001b[39m\n\u001b[31m.tile .leg{font-size:10.5px;color:var(--accent2);font-weight:600;margin-top:2px}\u001b[39m\n\u001b[31m.tile.excluded .leg{display:none}\u001b[39m\n\u001b[31m.tile .badge-provider{display:inline-block;font-size:8.5px;padding:1px 5px;border-radius:8px;background:rgba(255,255,255,.08);color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.3px;margin-left:4px;vertical-align:middle}\u001b[39m\n\u001b[31m.tile .incl{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:50%;border:2px solid var(--border);font-size:16px;font-weight:800}\u001b[39m\n\u001b[31m.tile .incl.on{background:var(--g);border-color:var(--g);color:#fff}\u001b[39m\n\u001b[31m.tile .incl.off{background:#0c0c0c;color:var(--r);border-color:var(--r)}·\u001b[39m\n\u001b[31m/* ── prominent top action bar (KM, Share, Re-optimise) ── */\u001b[39m\n\u001b[31m.top-actions{background:linear-gradient(135deg,#0c2e3a,#08111c);border:1px solid var(--accent);border-radius:var(--rad);padding:10px;display:grid;grid-template-columns:1fr auto auto;gap:8px;align-items:center;margin-bottom:6px}\u001b[39m\n\u001b[31m.top-totals{min-width:0}\u001b[39m\n\u001b[31m.top-totals-big{font-size:18px;font-weight:900;line-height:1.05;letter-spacing:.3px}\u001b[39m\n\u001b[31m.top-totals-delta{font-size:11px;color:var(--muted);margin-top:2px}\u001b[39m\n\u001b[31m.top-totals-delta.better{color:var(--g)}\u001b[39m\n\u001b[31m.top-totals-delta.worse{color:var(--y)}\u001b[39m\n\u001b[31m.top-actions button{padding:10px 12px;font-size:12px;font-weight:800;border-radius:8px;border:none;cursor:pointer;display:flex;align-items:center;gap:5px;white-space:nowrap}\u001b[39m\n\u001b[31m.top-share{background:#f59e0b;color:#000}\u001b[39m\n\u001b[31m.top-share:active{background:#d97706}\u001b[39m\n\u001b[31m.top-reopt{background:var(--accent);color:#fff}\u001b[39m\n\u001b[31m.top-reopt:active{background:var(--accent2)}\u001b[39m\n\u001b[31m/* ── Re-optimisation needed (after include/exclude toggle) ── */\u001b[39m\n\u001b[31m.reopt-needed{font-size:11px;font-weight:700;color:#fbbf24;background:rgba(234,179,8,.12);border-left:3px solid #fbbf24;padding:5px 8px;border-radius:4px;margin-top:5px;display:none;line-height:1.3}\u001b[39m\n\u001b[31m.reopt-needed.show{display:block}·\u001b[39m\n\u001b[31m/* ── inline route preview (Leaflet + OSM) ── */\u001b[39m\n\u001b[31m.map-wrap{margin:8px 0 0;background:var(--card);border:1px solid var(--border);border-radius:var(--rads);overflow:hidden}\u001b[39m\n\u001b[31m.map-hdr{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;font-size:12px;color:var(--muted);background:var(--card2);border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.map-hdr .ttl{display:flex;align-items:center;gap:6px;font-weight:600}\u001b[39m\n\u001b[31m.map-hdr .toggle{font-size:14px;color:var(--text);background:none;border:none;cursor:pointer;padding:0 4px}\u001b[39m\n\u001b[31m#tour-map{height:260px;width:100%;background:#1a1a1a;transition:height .2s ease}\u001b[39m\n\u001b[31m.map-wrap.collapsed #tour-map{height:0}\u001b[39m\n\u001b[31m.map-wrap.collapsed .map-hdr{border-bottom:none}\u001b[39m\n\u001b[31m.tour-pin{background:transparent;border:none}\u001b[39m\n\u001b[31m.tour-pin > div{background:#0ea5e9;color:#fff;border-radius:50%;width:26px;height:26px;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:12px;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,.45);font-family:'DM Sans',sans-serif}\u001b[39m\n\u001b[31m.tour-pin.origin > div{background:#22c55e;font-size:14px}\u001b[39m\n\u001b[31m.tour-pin.out > div{background:#6b7280;opacity:.55}\u001b[39m\n\u001b[31m.leaflet-tooltip{background:#141414;color:#f5f5f5;border:1px solid #262626;font-size:11px;padding:3px 6px;font-family:'DM Sans',sans-serif;box-shadow:0 1px 6px rgba(0,0,0,.4)}\u001b[39m\n\u001b[31m.leaflet-tooltip-top:before,.leaflet-tooltip-bottom:before,.leaflet-tooltip-left:before,.leaflet-tooltip-right:before{border-top-color:#262626;border-bottom-color:#262626;border-left-color:#262626;border-right-color:#262626}\u001b[39m\n\u001b[31m.leaflet-control-attribution{background:rgba(20,20,20,.85)!important;color:#a3a3a3!important;font-size:9px!important}\u001b[39m\n\u001b[31m.leaflet-control-attribution a{color:#0ea5e9!important}\u001b[39m\n\u001b[31m.top-reopt.pulse{animation:pulseReopt 1.4s ease-in-out infinite;box-shadow:0 0 0 0 rgba(14,165,233,.6)}\u001b[39m\n\u001b[31m@keyframes pulseReopt{0%{box-shadow:0 0 0 0 rgba(14,165,233,.7)}50%{box-shadow:0 0 0 8px rgba(14,165,233,0)}100%{box-shadow:0 0 0 0 rgba(14,165,233,0)}}\u001b[39m\n\u001b[31m/* Totals dim when stale to make it visually obvious */\u001b[39m\n\u001b[31m.top-totals-big.stale, .top-totals-delta.stale{opacity:.5;text-decoration:line-through;text-decoration-color:rgba(251,191,36,.6)}·\u001b[39m\n\u001b[31m/* recalc spinner that appears inline next to the top totals during recompute */\u001b[39m\n\u001b[31m.recalc{display:none;font-size:11px;color:var(--accent);margin-left:6px;vertical-align:middle}\u001b[39m\n\u001b[31m.recalc.show{display:inline-block;animation:spin 1s linear infinite}\u001b[39m\n\u001b[31m@keyframes spin{to{transform:rotate(360deg)}}·\u001b[39m\n\u001b[31m/* ── share modal ── */\u001b[39m\n\u001b[31m.modal{position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:200;display:none;align-items:flex-end;justify-content:center}\u001b[39m\n\u001b[31m.modal.show{display:flex}\u001b[39m\n\u001b[31m.modal-box{background:var(--card);border-radius:18px 18px 0 0;width:100%;max-height:85vh;overflow-y:auto;padding:16px 14px calc(16px + env(safe-area-inset-bottom));border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.modal-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\u001b[39m\n\u001b[31m.modal-ttl{font-size:15px;font-weight:800}\u001b[39m\n\u001b[31m.modal-close{font-size:24px;color:var(--muted);background:none;border:none;cursor:pointer}·\u001b[39m\n\u001b[31m/* ── toast ── */\u001b[39m\n\u001b[31m#toast{position:fixed;bottom:calc(var(--bnav-h)+16px);left:12px;right:12px;z-index:1000;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:11px 14px;font-size:13px;font-weight:600;text-align:center;transform:translateY(150px);opacity:0;transition:.25s;pointer-events:none}\u001b[39m\n\u001b[31m#toast.show{transform:translateY(0);opacity:1}\u001b[39m\n\u001b[31m#toast.ok{background:rgba(34,197,94,.18);border-color:var(--g);color:#86efac}\u001b[39m\n\u001b[31m#toast.err{background:rgba(239,68,68,.18);border-color:var(--r);color:#fca5a5}·\u001b[39m\n\u001b[31m/* ── helpers ── */\u001b[39m\n\u001b[31m.spinner{display:inline-block;width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;vertical-align:middle}·\u001b[39m\n\u001b[31m.empty{text-align:center;padding:30px 16px;color:var(--muted);font-size:12.5px}·\u001b[39m\n\u001b[31mtextarea{background:#0b1220;border:1px solid var(--border);color:var(--text);padding:10px;font-family:inherit;font-size:12px;border-radius:6px;resize:vertical;outline:none}·\u001b[39m\n\u001b[31m.kbd{background:rgba(255,255,255,.06);padding:1px 6px;border-radius:4px;font-family:monospace;font-size:11px;color:var(--muted)}\u001b[39m\n\u001b[31m</style>\u001b[39m\n\u001b[31m</head>\u001b[39m\n\u001b[31m<body>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: UPLOAD ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-upload\\\" class=\\\"scr active\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗺 Tour Playground</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Excel → optimised route → tweak</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"loadSaved()\\\" id=\\\"btn-load\\\" style=\\\"display:none\\\">↻ Load</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"location.href='/hub/'\\\">🏡 Hub</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <label class=\\\"dropzone\\\" id=\\\"dropzone\\\" for=\\\"excel-file\\\">\u001b[39m\n\u001b[31m      <input type=\\\"file\\\" id=\\\"excel-file\\\" accept=\\\".xlsx,.xls,.csv\\\">\u001b[39m\n\u001b[31m      <div class=\\\"ico\\\">📊</div>\u001b[39m\n\u001b[31m      <div class=\\\"ttl\\\">Drop Excel here, or tap to choose</div>\u001b[39m\n\u001b[31m      <div class=\\\"sub\\\">Required: <b>store name</b> + <b>address</b><br>Optional: <b>city</b>, <b>lat</b>, <b>lng</b>, <b>phone</b>, anything else.</div>\u001b[39m\n\u001b[31m    </label>·\u001b[39m\n\u001b[31m    <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\">Origin</div>\u001b[39m\n\u001b[31m      <div class=\\\"field\\\">\u001b[39m\n\u001b[31m        <label>Start point</label>\u001b[39m\n\u001b[31m        <input type=\\\"text\\\" id=\\\"inp-origin\\\" value=\\\"Mohali office Punjab\\\" placeholder=\\\"Address or place name\\\">\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div class=\\\"toggle\\\">\u001b[39m\n\u001b[31m        <input type=\\\"checkbox\\\" id=\\\"inp-roundtrip\\\" checked>\u001b[39m\n\u001b[31m        <label for=\\\"inp-roundtrip\\\" style=\\\"cursor:pointer;font-size:13px;font-weight:600\\\">Round-trip (return to start)</label>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"dest-wrap\\\" class=\\\"field\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m        <label>Final destination</label>\u001b[39m\n\u001b[31m        <input type=\\\"text\\\" id=\\\"inp-destination\\\" placeholder=\\\"Where to end if not returning to origin\\\">\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" id=\\\"btn-go\\\" onclick=\\\"parseAndOptimise()\\\" disabled>\u001b[39m\n\u001b[31m      📐 Parse + optimise route\u001b[39m\n\u001b[31m    </button>·\u001b[39m\n\u001b[31m    <div id=\\\"upload-progress\\\" style=\\\"display:none;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;font-size:12px\\\">\u001b[39m\n\u001b[31m        <span id=\\\"prog-label\\\">Geocoding…</span>\u001b[39m\n\u001b[31m        <span id=\\\"prog-count\\\" style=\\\"color:var(--muted)\\\">0 / 0</span>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div class=\\\"progress\\\"><div class=\\\"progress-bar\\\" id=\\\"prog-bar\\\" style=\\\"width:0%\\\"></div></div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div id=\\\"parsed-preview\\\" style=\\\"display:none;background:var(--card);border:1px solid var(--border);border-radius:var(--rad);padding:11px;font-size:12px\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\" style=\\\"margin-bottom:6px\\\">Parsed</div>\u001b[39m\n\u001b[31m      <div id=\\\"parsed-summary\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: EDITOR ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-editor\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"confirmBack()\\\">‹ New</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>Tour Editor</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"editor-sub\\\">— stops</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-2\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"openShare()\\\">📤</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"editor-body\\\">\u001b[39m\n\u001b[31m   <div class=\\\"editor-fixed\\\">·\u001b[39m\n\u001b[31m    <div class=\\\"origin-pill\\\" id=\\\"origin-pill\\\">\u001b[39m\n\u001b[31m      <span class=\\\"ico\\\">📍</span>\u001b[39m\n\u001b[31m      <div class=\\\"meta\\\">\u001b[39m\n\u001b[31m        <div class=\\\"lbl\\\">Start from</div>\u001b[39m\n\u001b[31m        <div class=\\\"val\\\" id=\\\"origin-val\\\">—</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button onclick=\\\"openOriginEdit()\\\">✏️ Change</button>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div id=\\\"origin-edit\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m        <div class=\\\"field\\\">\u001b[39m\n\u001b[31m          <label>New origin (for mid-tour replan)</label>\u001b[39m\n\u001b[31m          <input type=\\\"text\\\" id=\\\"inp-new-origin\\\" placeholder=\\\"Hotel B Mohali, or address…\\\">\u001b[39m\n\u001b[31m          <div style=\\\"font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4\\\">\u001b[39m\n\u001b[31m            Tip: toggle ✗ on completed stops, change origin to where you are now, hit Replan.\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div style=\\\"display:flex;gap:8px\\\">\u001b[39m\n\u001b[31m          <button class=\\\"btn btn-s\\\" onclick=\\\"closeOriginEdit()\\\" style=\\\"flex:1\\\">Cancel</button>\u001b[39m\n\u001b[31m          <button class=\\\"btn btn-p\\\" onclick=\\\"applyOriginAndReplan()\\\" style=\\\"flex:2\\\">🔄 Replan from here</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"top-actions\\\" id=\\\"top-actions\\\">\u001b[39m\n\u001b[31m      <div class=\\\"top-totals\\\">\u001b[39m\n\u001b[31m        <div class=\\\"top-totals-big\\\" id=\\\"top-totals-big\\\">— km · — min<span class=\\\"recalc\\\" id=\\\"recalc\\\">↻</span></div>\u001b[39m\n\u001b[31m        <div class=\\\"top-totals-delta\\\" id=\\\"top-totals-delta\\\">vs proposed: —</div>\u001b[39m\n\u001b[31m        <div class=\\\"reopt-needed\\\" id=\\\"reopt-needed\\\">⚠ Stops changed — tap 🔁 Re-optim to refresh the route plan</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"top-share\\\" onclick=\\\"openShare()\\\">📤 Share</button>\u001b[39m\n\u001b[31m      <button class=\\\"top-reopt\\\" onclick=\\\"replanAll()\\\">🔁 Re-optim</button>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"map-wrap\\\" id=\\\"map-wrap\\\">\u001b[39m\n\u001b[31m      <div class=\\\"map-hdr\\\">\u001b[39m\n\u001b[31m        <div class=\\\"ttl\\\">🗺️ Route preview <span style=\\\"opacity:.6;font-weight:400\\\">(straight-line)</span></div>\u001b[39m\n\u001b[31m        <button class=\\\"toggle\\\" id=\\\"map-toggle\\\" onclick=\\\"toggleMap()\\\" aria-label=\\\"Toggle map\\\">▾</button>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"tour-map\\\"></div>\u001b[39m\n\u001b[31m    </div>·\u001b[39m\n\u001b[31m    <div class=\\\"section-title\\\" style=\\\"margin-top:2px\\\">Stops (drag ≡ to reorder · scroll ↕ to see all)</div>\u001b[39m\n\u001b[31m   </div><!-- /editor-fixed -->·\u001b[39m\n\u001b[31m   <div class=\\\"editor-scroll\\\" id=\\\"editor-scroll\\\">\u001b[39m\n\u001b[31m    <div id=\\\"tiles\\\" class=\\\"tiles\\\"></div>\u001b[39m\n\u001b[31m   </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── MODAL: SHARE ── -->\u001b[39m\n\u001b[31m<div class=\\\"modal\\\" id=\\\"modal-share\\\">\u001b[39m\n\u001b[31m  <div class=\\\"modal-box\\\">\u001b[39m\n\u001b[31m    <div class=\\\"modal-hdr\\\">\u001b[39m\n\u001b[31m      <div class=\\\"modal-ttl\\\">📤 Share / Export</div>\u001b[39m\n\u001b[31m      <button class=\\\"modal-close\\\" onclick=\\\"closeShare()\\\">×</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"share-summary\\\" style=\\\"background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:10px;font-size:12px;line-height:1.6;white-space:pre-wrap;font-family:monospace\\\"></div>\u001b[39m\n\u001b[31m    <div style=\\\"margin-top:12px;display:grid;gap:8px\\\">\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"openInGoogleMaps()\\\">🗺️ Open route in Google Maps</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"shareWhatsApp()\\\">💬 WhatsApp</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"copyText()\\\">📋 Copy text</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"downloadCsv()\\\">📥 Download CSV</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"saveLocal()\\\">💾 Save to this device</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<div id=\\\"toast\\\"></div>·\u001b[39m\n\u001b[31m<script>\u001b[39m\n\u001b[31m'use strict';·\u001b[39m\n\u001b[31mconst CACHE_VER = 'tour-pg-v11';\u001b[39m\n\u001b[31mconst PROXY     = '/tour-pg-proxy';·\u001b[39m\n\u001b[31m// ── State ────────────────────────────────────────────\u001b[39m\n\u001b[31mlet session   = null;\u001b[39m\n\u001b[31mlet myId      = '';\u001b[39m\n\u001b[31mlet myName    = '';\u001b[39m\n\u001b[31mlet state     = newState();\u001b[39m\n\u001b[31mlet _recalcTimer = null;\u001b[39m\n\u001b[31mlet _sortable    = null;·\u001b[39m\n\u001b[31mfunction newState() {\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    origin:     { text: 'Mohali office Punjab', location: null, provider: null },\u001b[39m\n\u001b[31m    destination:null,\u001b[39m\n\u001b[31m    roundTrip:  true,\u001b[39m\n\u001b[31m    stops:      [],          // { id, name, city, address, phone, location, included, legDistance_m, legDuration_s, provider, raw, geocodeErr }\u001b[39m\n\u001b[31m    proposedOrder: null,     // array of stop IDs in optimise-time order\u001b[39m\n\u001b[31m    baseTotal: { distance_m: 0, duration_s: 0 },\u001b[39m\n\u001b[31m    currentTotal: { distance_m: 0, duration_s: 0 },\u001b[39m\n\u001b[31m    lastOptimisation: null,  // 'tomtom-computeBestOrder' | 'matrix-nn-2opt'\u001b[39m\n\u001b[31m    needsReoptimize: false,  // true after include/exclude toggle → totals may be stale; prompt user to tap Re-optim\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction uid(prefix='st') { return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,6)}`; }·\u001b[39m\n\u001b[31m// ── Auth ─────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction init() {\u001b[39m\n\u001b[31m  registerSW();\u001b[39m\n\u001b[31m  const raw = localStorage.getItem('lm360-session');\u001b[39m\n\u001b[31m  // Bounce to Hub with ?next= so user lands back here after login (Hub honours\u001b[39m\n\u001b[31m  // same-origin relative paths). Pattern documented in\u001b[39m\n\u001b[31m  // feedback_hub_next_redirect.md memory file — all gated PWAs should follow.\u001b[39m\n\u001b[31m  const nextParam = '?next=' + encodeURIComponent(location.pathname + location.search);\u001b[39m\n\u001b[31m  if (!raw) { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  try { session = JSON.parse(raw); } catch { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  if (Date.now() - (session.loginAt ? new Date(session.loginAt).getTime() : 0) > 12*3600*1000) { location.href = '/hub/' + nextParam; return; }\u001b[39m\n\u001b[31m  myId   = session.empId || '';\u001b[39m\n\u001b[31m  myName = session.name  || myId;\u001b[39m\n\u001b[31m  const userText = `👤 <b>${escHtml(myName)}</b>`;\u001b[39m\n\u001b[31m  document.getElementById('user-pill').innerHTML   = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill-2').innerHTML = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill').style.display   = '';\u001b[39m\n\u001b[31m  document.getElementById('user-pill-2').style.display = '';·\u001b[39m\n\u001b[31m  // Load-saved chip if we have a saved tour\u001b[39m\n\u001b[31m  if (localStorage.getItem('tour-pg-saved')) document.getElementById('btn-load').style.display = '';·\u001b[39m\n\u001b[31m  // Wire UI\u001b[39m\n\u001b[31m  document.getElementById('inp-roundtrip').addEventListener('change', e => {\u001b[39m\n\u001b[31m    document.getElementById('dest-wrap').style.display = e.target.checked ? 'none' : '';\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('dragover', e => { e.preventDefault(); e.currentTarget.classList.add('drag'); });\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('dragleave', e => e.currentTarget.classList.remove('drag'));\u001b[39m\n\u001b[31m  document.getElementById('dropzone').addEventListener('drop', e => {\u001b[39m\n\u001b[31m    e.preventDefault();\u001b[39m\n\u001b[31m    e.currentTarget.classList.remove('drag');\u001b[39m\n\u001b[31m    const f = e.dataTransfer.files[0]; if (f) handleFile(f);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  document.getElementById('excel-file').addEventListener('change', e => {\u001b[39m\n\u001b[31m    const f = e.target.files[0]; if (f) handleFile(f);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction hubAuth() {\u001b[39m\n\u001b[31m  return 'Hub ' + btoa(JSON.stringify(session));\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function api(path, body) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, {\u001b[39m\n\u001b[31m    method: 'POST',\u001b[39m\n\u001b[31m    headers: { 'Authorization': hubAuth(), 'Content-Type': 'application/json' },\u001b[39m\n\u001b[31m    body: JSON.stringify(body),\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (r.status === 401) {\u001b[39m\n\u001b[31m    toast('Session expired — refreshing to re-login', 'err');\u001b[39m\n\u001b[31m    const nextParam = '?next=' + encodeURIComponent(location.pathname + location.search);\u001b[39m\n\u001b[31m    setTimeout(() => location.href = '/hub/' + nextParam, 1500);\u001b[39m\n\u001b[31m    throw new Error('unauthorized');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!r.ok) {\u001b[39m\n\u001b[31m    const txt = await r.text().catch(() => '');\u001b[39m\n\u001b[31m    throw new Error(`${path}: ${r.status} ${txt.slice(0, 100)}`);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return r.json();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Excel parsing + column detection ────────────────\u001b[39m\n\u001b[31mlet _parsedCols = null;   // exposed for the column-remap UI\u001b[39m\n\u001b[31mlet _parsedRows = null;\u001b[39m\n\u001b[31mfunction handleFile(file) {\u001b[39m\n\u001b[31m  const reader = new FileReader();\u001b[39m\n\u001b[31m  reader.onload = (e) => {\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      const wb = XLSX.read(e.target.result, { type: 'array' });\u001b[39m\n\u001b[31m      const ws = wb.Sheets[wb.SheetNames[0]];\u001b[39m\n\u001b[31m      const rows = XLSX.utils.sheet_to_json(ws, { defval: '' });\u001b[39m\n\u001b[31m      if (!rows.length) throw new Error('Empty sheet');\u001b[39m\n\u001b[31m      _parsedRows = rows;\u001b[39m\n\u001b[31m      _parsedCols = detectColumns(rows[0]);\u001b[39m\n\u001b[31m      if (!_parsedCols.name && !_parsedCols.address && !_parsedCols.city) throw new Error('Missing required columns (need at least one of: name, address, city)');\u001b[39m\n\u001b[31m      rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m      renderParsedPreview();\u001b[39m\n\u001b[31m      document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m      toast('Excel parsed ✓', 'ok');\u001b[39m\n\u001b[31m    } catch (err) {\u001b[39m\n\u001b[31m      toast('Excel error: ' + err.message, 'err');\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  reader.readAsArrayBuffer(file);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction rebuildStopsFromParsedRows() {\u001b[39m\n\u001b[31m  state.stops = _parsedRows.map((row, idx) => buildStop(row, _parsedCols, idx)).filter(s => s);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderParsedPreview() {\u001b[39m\n\u001b[31m  const skip = _parsedRows.length - state.stops.length;\u001b[39m\n\u001b[31m  const allCols = Object.keys(_parsedRows[0]);\u001b[39m\n\u001b[31m  // For each role, show:  <role>: <selected col name>  with a tiny dropdown to override\u001b[39m\n\u001b[31m  const ROLES = ['name','city','address','lat','lng','phone','notes'];\u001b[39m\n\u001b[31m  const sample = _parsedRows[0];\u001b[39m\n\u001b[31m  const previewVal = (key) => {\u001b[39m\n\u001b[31m    const v = sample[key]; if (v == null || v === '') return '';\u001b[39m\n\u001b[31m    return String(v).slice(0, 40);\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  // Column order (per user request): YOUR Excel column first, then the PWA field\u001b[39m\n\u001b[31m  // it maps to, then a sample value. Equal-ish widths via <colgroup>; the wrapper\u001b[39m\n\u001b[31m  // scrolls horizontally so long header names are never clipped on narrow phones.\u001b[39m\n\u001b[31m  let rows = '<div class=\\\"map-table-wrap\\\"><table class=\\\"map-table\\\">' +\u001b[39m\n\u001b[31m    '<colgroup><col class=\\\"c-src\\\"><col class=\\\"c-field\\\"><col class=\\\"c-sample\\\"></colgroup>' +\u001b[39m\n\u001b[31m    '<thead><tr><th>Your file column</th><th>→ PWA field</th><th>Sample value</th></tr></thead><tbody>';\u001b[39m\n\u001b[31m  for (const role of ROLES) {\u001b[39m\n\u001b[31m    const sel  = _parsedCols[role];\u001b[39m\n\u001b[31m    const need = role === 'name';\u001b[39m\n\u001b[31m    const opts = `<option value=\\\"\\\">— ignore —</option>` +\u001b[39m\n\u001b[31m      allCols.map(c => `<option value=\\\"${escHtml(c)}\\\" ${sel === c ? 'selected' : ''}>${escHtml(c)}</option>`).join('');\u001b[39m\n\u001b[31m    const previewText = sel ? escHtml(previewVal(sel)) : '';\u001b[39m\n\u001b[31m    const tip = !sel && need ? `<span style=\\\"color:var(--y);font-weight:700\\\">⚠ no match — uses city or \\\"Stop #N\\\"</span>` : '';\u001b[39m\n\u001b[31m    rows += `<tr>\u001b[39m\n\u001b[31m      <td><select onchange=\\\"remapColumn('${role}', this.value)\\\">${opts}</select></td>\u001b[39m\n\u001b[31m      <td class=\\\"map-field\\\">${role}${need?' <span style=\\\"color:var(--y)\\\">*</span>':''}</td>\u001b[39m\n\u001b[31m      <td class=\\\"map-sample\\\">${previewText}${tip}</td>\u001b[39m\n\u001b[31m    </tr>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  rows += '</tbody></table></div>';\u001b[39m\n\u001b[31m  document.getElementById('parsed-preview').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('parsed-summary').innerHTML =\u001b[39m\n\u001b[31m    `<div style=\\\"margin-bottom:6px\\\"><b>${state.stops.length}</b> stops detected` +\u001b[39m\n\u001b[31m    `${skip > 0 ? ` · ${skip} skipped (no name/city/address)` : ''}</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:10.5px;color:var(--muted);margin-bottom:6px\\\">If detection is wrong, pick the correct column from the dropdowns:</div>` +\u001b[39m\n\u001b[31m    rows;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction remapColumn(role, newCol) {\u001b[39m\n\u001b[31m  if (!_parsedCols) return;\u001b[39m\n\u001b[31m  _parsedCols[role] = newCol || null;\u001b[39m\n\u001b[31m  rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction normaliseColName(s) {\u001b[39m\n\u001b[31m  return String(s).toLowerCase().replace(/[^a-z]/g, '');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction detectColumns(sampleRow) {\u001b[39m\n\u001b[31m  const map = { name:null, city:null, address:null, lat:null, lng:null, phone:null, notes:null, others:[] };\u001b[39m\n\u001b[31m  const keys = Object.keys(sampleRow);\u001b[39m\n\u001b[31m  // Broadened aliases so most user-provided Excel files map sensibly without a UI:\u001b[39m\n\u001b[31m  //   name  ← store / shop / outlet / party / dealer / firm / customer / business /\u001b[39m\n\u001b[31m  //           company / vendor / agency (and \\\"<thing> name\\\")\u001b[39m\n\u001b[31m  //   addr  ← address / fulladdress / location / loc / addr\u001b[39m\n\u001b[31m  //   city  ← city / town / district / location (only if no address column took it)\u001b[39m\n\u001b[31m  for (const k of keys) {\u001b[39m\n\u001b[31m    const n = normaliseColName(k);\u001b[39m\n\u001b[31m    if (!map.name && /^(storename|name|store|client|outlet|party(name)?|dealer(name)?|firm(name)?|customer(name)?|shop(name)?|business(name)?|companyname|company|vendor(name)?|agency(name)?|merchant|chain|sitename|site)$/.test(n)) { map.name = k; continue; }\u001b[39m\n\u001b[31m    if (!map.address && /^(address|fulladdress|streetaddress|addr|location|loc|street)$/.test(n)) { map.address = k; continue; }\u001b[39m\n\u001b[31m    if (!map.city && /^(city|town|district|cityname|locality|area|region)$/.test(n)) { map.city = k; continue; }\u001b[39m\n\u001b[31m    if (!map.lat && /^(lat|latitude|y)$/.test(n)) { map.lat = k; continue; }\u001b[39m\n\u001b[31m    if (!map.lng && /^(lng|lon|long|longitude|x)$/.test(n)) { map.lng = k; continue; }\u001b[39m\n\u001b[31m    if (!map.phone && /^(phone(no)?|mobile(no)?|contact(no)?|tel(no)?|whatsapp|wa|cell)$/.test(n)) { map.phone = k; continue; }\u001b[39m\n\u001b[31m    if (!map.notes && /^(notes?|remarks?|comments?|description|desc|memo)$/.test(n)) { map.notes = k; continue; }\u001b[39m\n\u001b[31m    map.others.push(k);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return map;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction buildStop(row, cols, idx) {\u001b[39m\n\u001b[31m  const name    = cols.name    ? String(row[cols.name]   || '').trim() : '';\u001b[39m\n\u001b[31m  const address = cols.address ? String(row[cols.address]|| '').trim() : '';\u001b[39m\n\u001b[31m  const city    = cols.city    ? String(row[cols.city]   || '').trim() : '';\u001b[39m\n\u001b[31m  const phone   = cols.phone   ? String(row[cols.phone]  || '').trim() : '';\u001b[39m\n\u001b[31m  const notes   = cols.notes   ? String(row[cols.notes]  || '').trim() : '';\u001b[39m\n\u001b[31m  if (!name && !address && !city) return null;\u001b[39m\n\u001b[31m  // pre-resolved coords?\u001b[39m\n\u001b[31m  let location = null;\u001b[39m\n\u001b[31m  if (cols.lat && cols.lng) {\u001b[39m\n\u001b[31m    const lat = parseFloat(row[cols.lat]);\u001b[39m\n\u001b[31m    const lng = parseFloat(row[cols.lng]);\u001b[39m\n\u001b[31m    if (!isNaN(lat) && !isNaN(lng) && lat !== 0 && lng !== 0) location = { lat, lng };\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const fullAddr = [address, city].filter(Boolean).join(', ');\u001b[39m\n\u001b[31m  // Display name fallback hierarchy (NEVER truncated address — that confused users):\u001b[39m\n\u001b[31m  //   1. Explicit name column value\u001b[39m\n\u001b[31m  //   2. City + index (so excluded/duplicate cities stay distinguishable)\u001b[39m\n\u001b[31m  //   3. \\\"Stop #N\\\"\u001b[39m\n\u001b[31m  let displayName;\u001b[39m\n\u001b[31m  if (name) displayName = name;\u001b[39m\n\u001b[31m  else if (city) displayName = `${city} (stop ${idx + 1})`;\u001b[39m\n\u001b[31m  else displayName = `Stop ${idx + 1}`;\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    id:           uid('st'),\u001b[39m\n\u001b[31m    name:         displayName,\u001b[39m\n\u001b[31m    hasExplicitName: !!name,         // for UI to flag \\\"name auto-derived\\\"\u001b[39m\n\u001b[31m    city, phone, notes,\u001b[39m\n\u001b[31m    address:      fullAddr || name || '',\u001b[39m\n\u001b[31m    location,\u001b[39m\n\u001b[31m    provider:     location ? 'excel' : null,\u001b[39m\n\u001b[31m    included:     true,\u001b[39m\n\u001b[31m    legDistance_m: 0,\u001b[39m\n\u001b[31m    legDuration_s: 0,\u001b[39m\n\u001b[31m    geocodeErr:   null,\u001b[39m\n\u001b[31m    raw:          { ...row },\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Geocode + optimise ──────────────────────────────\u001b[39m\n\u001b[31masync function parseAndOptimise() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('Parse an Excel first', 'err');\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = true;\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = '';\u001b[39m\n\u001b[31m  state.origin.text  = document.getElementById('inp-origin').value.trim() || 'Mohali office Punjab';\u001b[39m\n\u001b[31m  state.roundTrip    = document.getElementById('inp-roundtrip').checked;\u001b[39m\n\u001b[31m  state.destination  = state.roundTrip ? null : (document.getElementById('inp-destination').value.trim() || null);\u001b[39m\n\u001b[31m  // Geocode origin first\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: state.origin.text });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin.location = og.location;\u001b[39m\n\u001b[31m    state.origin.provider = og.provider;\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    setProgress(0, 0, '');\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('Origin geocode failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Geocode each stop (skip if pre-resolved). Sequential to avoid rate limits.\u001b[39m\n\u001b[31m  const total = state.stops.length;\u001b[39m\n\u001b[31m  let done = 0;\u001b[39m\n\u001b[31m  for (const s of state.stops) {\u001b[39m\n\u001b[31m    if (s.location) { done++; setProgress(done, total, 'Pre-geocoded'); continue; }\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      const r = await api('/geocode', { address: s.address });\u001b[39m\n\u001b[31m      if (r.ok && r.location) {\u001b[39m\n\u001b[31m        s.location = r.location;\u001b[39m\n\u001b[31m        s.provider = r.provider;\u001b[39m\n\u001b[31m      } else {\u001b[39m\n\u001b[31m        s.geocodeErr = r.error || 'No match';\u001b[39m\n\u001b[31m      }\u001b[39m\n\u001b[31m    } catch (e) {\u001b[39m\n\u001b[31m      s.geocodeErr = e.message;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    done++;\u001b[39m\n\u001b[31m    setProgress(done, total, `Geocoding ${s.name.slice(0, 30)}…`);\u001b[39m\n\u001b[31m    // Tiny delay to be polite to the provider\u001b[39m\n\u001b[31m    await sleep(80);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Filter out failed-geocode stops\u001b[39m\n\u001b[31m  const good = state.stops.filter(s => s.location && !s.geocodeErr);\u001b[39m\n\u001b[31m  const bad  = state.stops.filter(s => !s.location || s.geocodeErr);\u001b[39m\n\u001b[31m  if (!good.length) {\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('No stops could be geocoded', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (bad.length) toast(`${bad.length} stop(s) failed geocode (excluded)`, 'err');\u001b[39m\n\u001b[31m  state.stops = good;·\u001b[39m\n\u001b[31m  // Optimise\u001b[39m\n\u001b[31m  setProgress(total, total, 'Optimising route…');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const opt = await api('/optimize', {\u001b[39m\n\u001b[31m      origin:    locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:     state.stops.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:  state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!opt.ok) throw new Error(opt.error || 'optimise failed');\u001b[39m\n\u001b[31m    // Apply order\u001b[39m\n\u001b[31m    const order = opt.waypoint_order || state.stops.map((_, i) => i);\u001b[39m\n\u001b[31m    state.stops = order.map(i => state.stops[i]);\u001b[39m\n\u001b[31m    state.proposedOrder = state.stops.map(s => s.id);\u001b[39m\n\u001b[31m    state.baseTotal     = { distance_m: opt.total_distance_m, duration_s: opt.total_duration_s };\u001b[39m\n\u001b[31m    state.currentTotal  = { ...state.baseTotal };\u001b[39m\n\u001b[31m    state.lastOptimisation = opt.optimisation;\u001b[39m\n\u001b[31m    state.needsReoptimize  = false;   // fresh optimise — flag cleared\u001b[39m\n\u001b[31m    // Map legs to stops (route returned len = stops + 1 (round-trip) or stops + 1 (one-way))\u001b[39m\n\u001b[31m    if (Array.isArray(opt.legs) && opt.legs.length >= state.stops.length) {\u001b[39m\n\u001b[31m      state.stops.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = opt.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m    document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m    return toast('Optimise failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m  showScreen('s-editor');\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction setProgress(done, total, label) {\u001b[39m\n\u001b[31m  const pct = total ? Math.round(done/total*100) : 0;\u001b[39m\n\u001b[31m  document.getElementById('prog-bar').style.width   = pct + '%';\u001b[39m\n\u001b[31m  document.getElementById('prog-count').textContent = `${done} / ${total}`;\u001b[39m\n\u001b[31m  document.getElementById('prog-label').textContent = label;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction locStr(loc) { return loc ? `${loc.lat},${loc.lng}` : null; }\u001b[39m\n\u001b[31mconst sleep = ms => new Promise(r => setTimeout(r, ms));·\u001b[39m\n\u001b[31m// ── Editor screen ───────────────────────────────────\u001b[39m\n\u001b[31mfunction renderEditor() {\u001b[39m\n\u001b[31m  restoreMapPref();\u001b[39m\n\u001b[31m  document.getElementById('origin-val').textContent = state.origin.text;\u001b[39m\n\u001b[31m  document.getElementById('editor-sub').textContent =\u001b[39m\n\u001b[31m    `${state.stops.filter(s => s.included).length} of ${state.stops.length} included` +\u001b[39m\n\u001b[31m    (state.lastOptimisation ? ` · ${state.lastOptimisation}` : '');\u001b[39m\n\u001b[31m  const cont = document.getElementById('tiles');\u001b[39m\n\u001b[31m  cont.innerHTML = '';\u001b[39m\n\u001b[31m  state.stops.forEach((s, idx) => cont.appendChild(renderTile(s, idx)));\u001b[39m\n\u001b[31m  if (_sortable) _sortable.destroy();\u001b[39m\n\u001b[31m  _sortable = Sortable.create(cont, {\u001b[39m\n\u001b[31m    handle: '.grip',\u001b[39m\n\u001b[31m    animation: 150,\u001b[39m\n\u001b[31m    onEnd: (evt) => {\u001b[39m\n\u001b[31m      const moved = state.stops.splice(evt.oldIndex, 1)[0];\u001b[39m\n\u001b[31m      state.stops.splice(evt.newIndex, 0, moved);\u001b[39m\n\u001b[31m      renderEditor();\u001b[39m\n\u001b[31m      scheduleRecalc();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  updateTotalsBar();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderTile(s, idx) {\u001b[39m\n\u001b[31m  const el = document.createElement('div');\u001b[39m\n\u001b[31m  el.className = 'tile' + (s.included ? '' : ' excluded') + (s.geocodeErr ? ' err' : '');\u001b[39m\n\u001b[31m  el.dataset.id = s.id;\u001b[39m\n\u001b[31m  const providerBadge = s.provider ? `<span class=\\\"badge-provider\\\">${escHtml(s.provider)}</span>` : '';\u001b[39m\n\u001b[31m  const leg = s.legDistance_m > 0\u001b[39m\n\u001b[31m    ? `${(s.legDistance_m/1000).toFixed(1)} km · ${Math.round(s.legDuration_s/60)} min from prev`\u001b[39m\n\u001b[31m    : '';\u001b[39m\n\u001b[31m  // Per UX rule: Name + City are the primary display. Address is shown\u001b[39m\n\u001b[31m  // smaller below as supporting context. Falls back gracefully when city\u001b[39m\n\u001b[31m  // is missing.\u001b[39m\n\u001b[31m  const cityLine = s.city || '—';\u001b[39m\n\u001b[31m  const addrLine = s.address && s.address !== s.city ? s.address : '';\u001b[39m\n\u001b[31m  el.innerHTML = `\u001b[39m\n\u001b[31m    <span class=\\\"grip\\\">≡</span>\u001b[39m\n\u001b[31m    <span class=\\\"ord\\\">${idx + 1}</span>\u001b[39m\n\u001b[31m    <div class=\\\"info\\\">\u001b[39m\n\u001b[31m      <div class=\\\"name\\\">${escHtml(s.name)} ${providerBadge}</div>\u001b[39m\n\u001b[31m      <div class=\\\"city\\\">${escHtml(cityLine)}</div>\u001b[39m\n\u001b[31m      ${addrLine ? `<div class=\\\"addr\\\">${escHtml(addrLine)}</div>` : ''}\u001b[39m\n\u001b[31m      ${leg ? `<div class=\\\"leg\\\">${escHtml(leg)}</div>` : ''}\u001b[39m\n\u001b[31m      ${s.geocodeErr ? `<div class=\\\"leg\\\" style=\\\"color:var(--r)\\\">⚠ ${escHtml(s.geocodeErr)}</div>` : ''}\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"incl ${s.included ? 'on' : 'off'}\\\" onclick=\\\"toggleInclude('${s.id}')\\\" title=\\\"Include / exclude\\\">\u001b[39m\n\u001b[31m      ${s.included ? '✓' : '✗'}\u001b[39m\n\u001b[31m    </button>`;\u001b[39m\n\u001b[31m  return el;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleInclude(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id); if (!s) return;\u001b[39m\n\u001b[31m  s.included = !s.included;\u001b[39m\n\u001b[31m  // The SET of stops just changed — the existing route may no longer be optimal.\u001b[39m\n\u001b[31m  // Surface a banner + pulse the Re-optim button so the user knows to refresh.\u001b[39m\n\u001b[31m  // recalcRoute still fires (debounced) to update the cost of the CURRENT order,\u001b[39m\n\u001b[31m  // but that number is just informational until the user re-optimises.\u001b[39m\n\u001b[31m  state.needsReoptimize = true;\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  scheduleRecalc();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction scheduleRecalc() {\u001b[39m\n\u001b[31m  clearTimeout(_recalcTimer);\u001b[39m\n\u001b[31m  document.getElementById('recalc').classList.add('show');\u001b[39m\n\u001b[31m  _recalcTimer = setTimeout(recalcRoute, 400);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function recalcRoute() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  if (!included.length) {\u001b[39m\n\u001b[31m    state.currentTotal = { distance_m: 0, duration_s: 0 };\u001b[39m\n\u001b[31m    document.getElementById('recalc').classList.remove('show');\u001b[39m\n\u001b[31m    updateTotalsBar();\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/route', {\u001b[39m\n\u001b[31m      origin:      locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:       included.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:   state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!r.ok) throw new Error(r.error || 'route failed');\u001b[39m\n\u001b[31m    state.currentTotal = { distance_m: r.total_distance_m, duration_s: r.total_duration_s };\u001b[39m\n\u001b[31m    // Map legs back to tiles (legs index 0 = origin→stop0, n = lastStop→dest)\u001b[39m\n\u001b[31m    if (Array.isArray(r.legs)) {\u001b[39m\n\u001b[31m      included.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = r.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    // Clear leg info on excluded stops\u001b[39m\n\u001b[31m    state.stops.filter(s => !s.included).forEach(s => { s.legDistance_m = 0; s.legDuration_s = 0; });\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Recalc failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  } finally {\u001b[39m\n\u001b[31m    document.getElementById('recalc').classList.remove('show');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateTotalsBar() {\u001b[39m\n\u001b[31m  // Totals now live ONLY in the top-actions card (the sticky bottom bar was\u001b[39m\n\u001b[31m  // removed after user feedback that it duplicated the top info 2026-06-04).\u001b[39m\n\u001b[31m  // The big-text element holds the inline recalc spinner as a child <span>.\u001b[39m\n\u001b[31m  const km  = (state.currentTotal.distance_m / 1000).toFixed(1);\u001b[39m\n\u001b[31m  const min = Math.round(state.currentTotal.duration_s / 60);\u001b[39m\n\u001b[31m  const hrs = Math.floor(min / 60), mm = min % 60;\u001b[39m\n\u001b[31m  const timeStr = hrs > 0 ? `${hrs}h ${mm}min` : `${mm} min`;\u001b[39m\n\u001b[31m  const totalsText = `${km} km · ${timeStr}`;\u001b[39m\n\u001b[31m  const bigEl  = document.getElementById('top-totals-big');\u001b[39m\n\u001b[31m  const recalc = document.getElementById('recalc');\u001b[39m\n\u001b[31m  bigEl.textContent = totalsText;\u001b[39m\n\u001b[31m  if (recalc) bigEl.appendChild(document.createTextNode(' '));\u001b[39m\n\u001b[31m  if (recalc) bigEl.appendChild(recalc);\u001b[39m\n\u001b[31m  // Delta vs base\u001b[39m\n\u001b[31m  const baseKm   = state.baseTotal.distance_m / 1000;\u001b[39m\n\u001b[31m  const curKm    = state.currentTotal.distance_m / 1000;\u001b[39m\n\u001b[31m  const deltaKm  = curKm - baseKm;\u001b[39m\n\u001b[31m  const deltaMin = Math.round((state.currentTotal.duration_s - state.baseTotal.duration_s) / 60);\u001b[39m\n\u001b[31m  const dT = document.getElementById('top-totals-delta');\u001b[39m\n\u001b[31m  let deltaText, deltaClass;\u001b[39m\n\u001b[31m  if (Math.abs(deltaKm) < 0.05) {\u001b[39m\n\u001b[31m    deltaText  = 'matches proposed route';\u001b[39m\n\u001b[31m    deltaClass = '';\u001b[39m\n\u001b[31m  } else {\u001b[39m\n\u001b[31m    const sign = deltaKm > 0 ? '+' : '';\u001b[39m\n\u001b[31m    deltaText  = `vs proposed: ${sign}${deltaKm.toFixed(1)} km · ${sign}${deltaMin} min`;\u001b[39m\n\u001b[31m    deltaClass = deltaKm < 0 ? 'better' : 'worse';\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  dT.textContent = deltaText;\u001b[39m\n\u001b[31m  dT.className   = 'top-totals-delta ' + deltaClass;·\u001b[39m\n\u001b[31m  // ── Re-optim-needed banner + pulse + stale styling ──\u001b[39m\n\u001b[31m  // Surfaces a clear message whenever the user has added/removed stops since\u001b[39m\n\u001b[31m  // the last /optimize call. The current-order cost is still shown (so the\u001b[39m\n\u001b[31m  // user sees \\\"what this order costs\\\"), but it's struck-through and the\u001b[39m\n\u001b[31m  // Re-optim button pulses to draw attention.\u001b[39m\n\u001b[31m  const needsReopt = !!state.needsReoptimize;\u001b[39m\n\u001b[31m  document.getElementById('reopt-needed').classList.toggle('show', needsReopt);\u001b[39m\n\u001b[31m  bigEl.classList.toggle('stale', needsReopt);\u001b[39m\n\u001b[31m  dT.classList.toggle('stale', needsReopt);\u001b[39m\n\u001b[31m  document.querySelector('.top-reopt')?.classList.toggle('pulse', needsReopt);·\u001b[39m\n\u001b[31m  // Re-render inline map preview to reflect current order/include flags.\u001b[39m\n\u001b[31m  // Debounced via setTimeout 0 so multiple updates in the same tick coalesce.\u001b[39m\n\u001b[31m  scheduleMapRender();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Inline route preview (Leaflet + free OSM tiles) ──────────────\u001b[39m\n\u001b[31m// Renders origin + stops + straight-line polyline inside the editor screen.\u001b[39m\n\u001b[31m// \\\"Straight-line\\\" because the proxy only returns leg distances/durations,\u001b[39m\n\u001b[31m// not the per-leg point arrays from TomTom — this preview is enough for\u001b[39m\n\u001b[31m// orientation; \\\"Open in Google Maps\\\" gives the user the real road geometry.\u001b[39m\n\u001b[31mlet _leafletMap   = null;\u001b[39m\n\u001b[31mlet _leafletLayer = null;\u001b[39m\n\u001b[31mlet _mapRenderTimer = null;·\u001b[39m\n\u001b[31mfunction ensureMap() {\u001b[39m\n\u001b[31m  if (_leafletMap) return _leafletMap;\u001b[39m\n\u001b[31m  if (typeof L === 'undefined') return null;   // CDN not ready yet\u001b[39m\n\u001b[31m  const el = document.getElementById('tour-map');\u001b[39m\n\u001b[31m  if (!el || !el.offsetParent) return null;    // container not visible yet\u001b[39m\n\u001b[31m  _leafletMap = L.map(el, { attributionControl: false, zoomControl: true, preferCanvas: true });\u001b[39m\n\u001b[31m  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18 })\u001b[39m\n\u001b[31m    .addTo(_leafletMap);\u001b[39m\n\u001b[31m  L.control.attribution({ position: 'bottomright', prefix: false })\u001b[39m\n\u001b[31m    .addAttribution('© OpenStreetMap contributors')\u001b[39m\n\u001b[31m    .addTo(_leafletMap);\u001b[39m\n\u001b[31m  return _leafletMap;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderMap() {\u001b[39m\n\u001b[31m  const map = ensureMap();\u001b[39m\n\u001b[31m  if (!map) return;\u001b[39m\n\u001b[31m  if (_leafletLayer) { map.removeLayer(_leafletLayer); _leafletLayer = null; }\u001b[39m\n\u001b[31m  const layers = [];\u001b[39m\n\u001b[31m  const linePts = [];\u001b[39m\n\u001b[31m  // Origin marker (always first in line)\u001b[39m\n\u001b[31m  if (state.origin?.location) {\u001b[39m\n\u001b[31m    const ll = [state.origin.location.lat, state.origin.location.lng];\u001b[39m\n\u001b[31m    layers.push(L.marker(ll, { icon: L.divIcon({\u001b[39m\n\u001b[31m      className: 'tour-pin origin', html: '<div>🏁</div>',\u001b[39m\n\u001b[31m      iconSize: [28, 28], iconAnchor: [14, 14],\u001b[39m\n\u001b[31m    })}).bindTooltip('Start: ' + (state.origin.text || 'origin'), { direction: 'top' }));\u001b[39m\n\u001b[31m    linePts.push(ll);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // Stops in current visit order\u001b[39m\n\u001b[31m  let visitNum = 0;\u001b[39m\n\u001b[31m  state.stops.forEach(s => {\u001b[39m\n\u001b[31m    if (!s.location) return;\u001b[39m\n\u001b[31m    const ll = [s.location.lat, s.location.lng];\u001b[39m\n\u001b[31m    const included = s.included;\u001b[39m\n\u001b[31m    if (included) visitNum++;\u001b[39m\n\u001b[31m    layers.push(L.marker(ll, { icon: L.divIcon({\u001b[39m\n\u001b[31m      className: 'tour-pin' + (included ? '' : ' out'),\u001b[39m\n\u001b[31m      html: `<div>${included ? visitNum : '·'}</div>`,\u001b[39m\n\u001b[31m      iconSize: [26, 26], iconAnchor: [13, 13],\u001b[39m\n\u001b[31m    })}).bindTooltip(\u001b[39m\n\u001b[31m      (included ? `${visitNum}. ` : '✗ ') + s.name + (s.city ? ` · ${s.city}` : ''),\u001b[39m\n\u001b[31m      { direction: 'top' }\u001b[39m\n\u001b[31m    ));\u001b[39m\n\u001b[31m    if (included) linePts.push(ll);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  // Close the loop on round-trip\u001b[39m\n\u001b[31m  if (state.roundTrip && state.origin?.location) {\u001b[39m\n\u001b[31m    linePts.push([state.origin.location.lat, state.origin.location.lng]);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (linePts.length >= 2) {\u001b[39m\n\u001b[31m    layers.push(L.polyline(linePts, {\u001b[39m\n\u001b[31m      color: '#0ea5e9', weight: 3, opacity: 0.85, dashArray: '6 4',\u001b[39m\n\u001b[31m    }));\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  _leafletLayer = L.layerGroup(layers).addTo(map);\u001b[39m\n\u001b[31m  if (linePts.length) {\u001b[39m\n\u001b[31m    try { map.fitBounds(L.latLngBounds(linePts), { padding: [30, 30], maxZoom: 13 }); }\u001b[39m\n\u001b[31m    catch (_) {}\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction scheduleMapRender() {\u001b[39m\n\u001b[31m  if (_mapRenderTimer) return;\u001b[39m\n\u001b[31m  _mapRenderTimer = setTimeout(() => { _mapRenderTimer = null; renderMap(); }, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleMap() {\u001b[39m\n\u001b[31m  const wrap = document.getElementById('map-wrap');\u001b[39m\n\u001b[31m  const tog  = document.getElementById('map-toggle');\u001b[39m\n\u001b[31m  const open = !wrap.classList.toggle('collapsed');\u001b[39m\n\u001b[31m  tog.textContent = open ? '▾' : '▴';\u001b[39m\n\u001b[31m  try { localStorage.setItem('tour-pg-map-open', open ? '1' : '0'); } catch (_) {}\u001b[39m\n\u001b[31m  if (open && _leafletMap) {\u001b[39m\n\u001b[31m    // After expansion the container size changed — Leaflet must recompute\u001b[39m\n\u001b[31m    setTimeout(() => { _leafletMap.invalidateSize(); renderMap(); }, 220);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction restoreMapPref() {\u001b[39m\n\u001b[31m  // Default collapsed — user must explicitly expand once for it to stick open.\u001b[39m\n\u001b[31m  let open = false;\u001b[39m\n\u001b[31m  try { open = localStorage.getItem('tour-pg-map-open') === '1'; } catch (_) {}\u001b[39m\n\u001b[31m  const wrap = document.getElementById('map-wrap');\u001b[39m\n\u001b[31m  const tog  = document.getElementById('map-toggle');\u001b[39m\n\u001b[31m  wrap.classList.toggle('collapsed', !open);\u001b[39m\n\u001b[31m  tog.textContent = open ? '▾' : '▴';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Origin edit / mid-tour replan ────────────────────\u001b[39m\n\u001b[31mfunction openOriginEdit() {\u001b[39m\n\u001b[31m  document.getElementById('origin-edit').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('inp-new-origin').value = state.origin.text;\u001b[39m\n\u001b[31m  document.getElementById('inp-new-origin').focus();\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction closeOriginEdit() {\u001b[39m\n\u001b[31m  document.getElementById('origin-edit').style.display = 'none';\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31masync function applyOriginAndReplan() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('inp-new-origin').value.trim();\u001b[39m\n\u001b[31m  if (!txt) return toast('Enter an origin', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: txt });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin = { text: txt, location: og.location, provider: og.provider };\u001b[39m\n\u001b[31m    closeOriginEdit();\u001b[39m\n\u001b[31m    await replanAll();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Origin failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function replanAll() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  if (!included.length) return toast('No stops included', 'err');\u001b[39m\n\u001b[31m  toast('Optimising…', 'ok');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const opt = await api('/optimize', {\u001b[39m\n\u001b[31m      origin:      locStr(state.origin.location),\u001b[39m\n\u001b[31m      stops:       included.map(s => ({ location: s.location })),\u001b[39m\n\u001b[31m      destination: state.destination,\u001b[39m\n\u001b[31m      roundTrip:   state.roundTrip,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (!opt.ok) throw new Error(opt.error || 'optimise failed');\u001b[39m\n\u001b[31m    const order = opt.waypoint_order || included.map((_, i) => i);\u001b[39m\n\u001b[31m    const reordered = order.map(i => included[i]);\u001b[39m\n\u001b[31m    // KEEP EXCLUDED STOPS IN THEIR EXISTING POSITIONS (per user feedback\u001b[39m\n\u001b[31m    // 2026-06-04). Excluded slots stay put; reordered-included stops fill\u001b[39m\n\u001b[31m    // the gaps. Previously appended at end (confusing) → then dropped\u001b[39m\n\u001b[31m    // (lossy) → final: stay where they are. User can re-include to put\u001b[39m\n\u001b[31m    // them back into the optimised route.\u001b[39m\n\u001b[31m    const newStops = [];\u001b[39m\n\u001b[31m    let r = 0;\u001b[39m\n\u001b[31m    for (const s of state.stops) {\u001b[39m\n\u001b[31m      if (s.included) newStops.push(reordered[r++]);\u001b[39m\n\u001b[31m      else            newStops.push(s);\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    state.stops = newStops;\u001b[39m\n\u001b[31m    state.proposedOrder = state.stops.map(s => s.id);\u001b[39m\n\u001b[31m    state.baseTotal     = { distance_m: opt.total_distance_m, duration_s: opt.total_duration_s };\u001b[39m\n\u001b[31m    state.currentTotal  = { ...state.baseTotal };\u001b[39m\n\u001b[31m    state.lastOptimisation = opt.optimisation;\u001b[39m\n\u001b[31m    state.needsReoptimize  = false;   // re-optim done — banner cleared\u001b[39m\n\u001b[31m    if (Array.isArray(opt.legs)) {\u001b[39m\n\u001b[31m      reordered.forEach((s, i) => {\u001b[39m\n\u001b[31m        const leg = opt.legs[i];\u001b[39m\n\u001b[31m        if (leg) {\u001b[39m\n\u001b[31m          s.legDistance_m = leg.distance_m;\u001b[39m\n\u001b[31m          s.legDuration_s = leg.duration_s;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Re-optimised ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Replan failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Share / export ──────────────────────────────────\u001b[39m\n\u001b[31mfunction openShare() {\u001b[39m\n\u001b[31m  const km   = (state.currentTotal.distance_m / 1000).toFixed(1);\u001b[39m\n\u001b[31m  const min  = Math.round(state.currentTotal.duration_s / 60);\u001b[39m\n\u001b[31m  const hrs  = Math.floor(min / 60), mm = min % 60;\u001b[39m\n\u001b[31m  const timeStr = hrs > 0 ? `${hrs}h ${mm}min` : `${mm} min`;\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  let txt = `🚗 Tour plan (${new Date().toLocaleDateString('en-IN')})\\\\n`;\u001b[39m\n\u001b[31m  txt += `From: ${state.origin.text}\\\\n`;\u001b[39m\n\u001b[31m  txt += `${included.length} stops · ${km} km · ${timeStr}\\\\n`;\u001b[39m\n\u001b[31m  if (state.roundTrip) txt += `Round-trip\\\\n\\\\n`;\u001b[39m\n\u001b[31m  else                  txt += `One-way → ${state.destination || included[included.length-1]?.address || ''}\\\\n\\\\n`;\u001b[39m\n\u001b[31m  included.forEach((s, i) => {\u001b[39m\n\u001b[31m    const legKm  = (s.legDistance_m/1000).toFixed(1);\u001b[39m\n\u001b[31m    const legMin = Math.round(s.legDuration_s/60);\u001b[39m\n\u001b[31m    txt += `${i+1}. ${s.name}`;\u001b[39m\n\u001b[31m    if (s.city) txt += ` · ${s.city}`;\u001b[39m\n\u001b[31m    if (s.legDistance_m > 0) txt += `  (+${legKm}km · ${legMin}min)`;\u001b[39m\n\u001b[31m    if (s.phone) txt += `  ${s.phone}`;\u001b[39m\n\u001b[31m    txt += `\\\\n`;\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (state.roundTrip) txt += `↩ Return to ${state.origin.text}`;\u001b[39m\n\u001b[31m  document.getElementById('share-summary').textContent = txt;\u001b[39m\n\u001b[31m  document.getElementById('modal-share').classList.add('show');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction closeShare() {\u001b[39m\n\u001b[31m  document.getElementById('modal-share').classList.remove('show');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction shareWhatsApp() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('share-summary').textContent;\u001b[39m\n\u001b[31m  const url = `https://wa.me/?text=${encodeURIComponent(txt)}`;\u001b[39m\n\u001b[31m  window.open(url, '_blank');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Hands off the optimised tour to Google Maps Directions (free, zero infra).\u001b[39m\n\u001b[31m// Uses path-style URL `dir/A/B/C/...` because it accepts many more stops than\u001b[39m\n\u001b[31m// the `api=1` Directions URL (which Google caps at 9 waypoints). Each segment\u001b[39m\n\u001b[31m// is \\\"lat,lng\\\" for geocoded points (most reliable). If the user picked a\u001b[39m\n\u001b[31m// text destination on a one-way tour we URL-encode it as the last segment.\u001b[39m\n\u001b[31mfunction openInGoogleMaps() {\u001b[39m\n\u001b[31m  if (!state.origin?.location) { toast('Origin not yet geocoded', 'err'); return; }\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included && s.location);\u001b[39m\n\u001b[31m  if (included.length === 0)   { toast('No included stops to view', 'err'); return; }\u001b[39m\n\u001b[31m  const fmt = (loc) => `${loc.lat.toFixed(6)},${loc.lng.toFixed(6)}`;\u001b[39m\n\u001b[31m  const segs = [ fmt(state.origin.location) ];\u001b[39m\n\u001b[31m  included.forEach(s => segs.push(fmt(s.location)));\u001b[39m\n\u001b[31m  if (state.roundTrip) {\u001b[39m\n\u001b[31m    segs.push(fmt(state.origin.location));\u001b[39m\n\u001b[31m  } else if (state.destination) {\u001b[39m\n\u001b[31m    segs.push(encodeURIComponent(state.destination));\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // travelmode and dir_action=navigate auto-open in driving directions\u001b[39m\n\u001b[31m  const url = `https://www.google.com/maps/dir/${segs.join('/')}/?travelmode=driving`;\u001b[39m\n\u001b[31m  window.open(url, '_blank', 'noopener');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function copyText() {\u001b[39m\n\u001b[31m  const txt = document.getElementById('share-summary').textContent;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await navigator.clipboard.writeText(txt);\u001b[39m\n\u001b[31m    toast('Copied ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Copy failed', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction downloadCsv() {\u001b[39m\n\u001b[31m  const included = state.stops.filter(s => s.included);\u001b[39m\n\u001b[31m  const rows = [['#','Name','City','Address','Phone','Lat','Lng','Leg km','Leg min','Provider']];\u001b[39m\n\u001b[31m  included.forEach((s, i) => {\u001b[39m\n\u001b[31m    rows.push([\u001b[39m\n\u001b[31m      i + 1,\u001b[39m\n\u001b[31m      s.name, s.city, s.address, s.phone,\u001b[39m\n\u001b[31m      s.location?.lat ?? '', s.location?.lng ?? '',\u001b[39m\n\u001b[31m      (s.legDistance_m/1000).toFixed(2),\u001b[39m\n\u001b[31m      Math.round(s.legDuration_s/60),\u001b[39m\n\u001b[31m      s.provider || '',\u001b[39m\n\u001b[31m    ]);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  const csv = rows.map(r => r.map(c => `\\\"${String(c).replace(/\\\"/g, '\\\"\\\"')}\\\"`).join(',')).join('\\\\n');\u001b[39m\n\u001b[31m  const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });\u001b[39m\n\u001b[31m  const url = URL.createObjectURL(blob);\u001b[39m\n\u001b[31m  const a = document.createElement('a');\u001b[39m\n\u001b[31m  a.href = url; a.download = `tour-${new Date().toISOString().slice(0,10)}.csv`;\u001b[39m\n\u001b[31m  document.body.appendChild(a); a.click(); a.remove();\u001b[39m\n\u001b[31m  URL.revokeObjectURL(url);\u001b[39m\n\u001b[31m  toast('CSV downloaded ✓', 'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction saveLocal() {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    localStorage.setItem('tour-pg-saved', JSON.stringify({\u001b[39m\n\u001b[31m      saved_at: new Date().toISOString(),\u001b[39m\n\u001b[31m      state,\u001b[39m\n\u001b[31m    }));\u001b[39m\n\u001b[31m    toast('Saved ✓', 'ok');\u001b[39m\n\u001b[31m    document.getElementById('btn-load').style.display = '';\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed (storage full?)', 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction loadSaved() {\u001b[39m\n\u001b[31m  const raw = localStorage.getItem('tour-pg-saved');\u001b[39m\n\u001b[31m  if (!raw) return toast('No saved tour', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const { state: s } = JSON.parse(raw);\u001b[39m\n\u001b[31m    state = s;\u001b[39m\n\u001b[31m    showScreen('s-editor');\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Loaded ✓', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Load failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Navigation ──────────────────────────────────────\u001b[39m\n\u001b[31mfunction confirmBack() {\u001b[39m\n\u001b[31m  if (!confirm('Start a new tour? Current state will be cleared (use Save first if you want to keep it).')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m  document.getElementById('parsed-preview').style.display = 'none';\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = true;\u001b[39m\n\u001b[31m  document.getElementById('excel-file').value = '';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showScreen(id) {\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById(id).classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Utils ───────────────────────────────────────────\u001b[39m\n\u001b[31mfunction escHtml(s) { if (s == null) return ''; return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\\\"/g,'&quot;'); }\u001b[39m\n\u001b[31mlet _toastTimer;\u001b[39m\n\u001b[31mfunction toast(msg, type='') {\u001b[39m\n\u001b[31m  const el = document.getElementById('toast');\u001b[39m\n\u001b[31m  el.textContent = msg; el.className = 'show ' + type;\u001b[39m\n\u001b[31m  clearTimeout(_toastTimer);\u001b[39m\n\u001b[31m  _toastTimer = setTimeout(() => el.className = '', 3200);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction registerSW() {\u001b[39m\n\u001b[31m  if (!('serviceWorker' in navigator)) return;\u001b[39m\n\u001b[31m  const sw = `\u001b[39m\n\u001b[31mconst C='${CACHE_VER}';\u001b[39m\n\u001b[31mself.addEventListener('install', e => self.skipWaiting());\u001b[39m\n\u001b[31mself.addEventListener('activate', e => {\u001b[39m\n\u001b[31m  e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== C).map(k => caches.delete(k)))));\u001b[39m\n\u001b[31m  self.clients.claim();\u001b[39m\n\u001b[31m});\u001b[39m\n\u001b[31mself.addEventListener('fetch', e => {\u001b[39m\n\u001b[31m  if (e.request.method !== 'GET') return;\u001b[39m\n\u001b[31m  const url = new URL(e.request.url);\u001b[39m\n\u001b[31m  if (url.pathname.startsWith('/tour-pg-proxy')) return;\u001b[39m\n\u001b[31m  if (url.pathname.startsWith('/tour-pg/')) {\u001b[39m\n\u001b[31m    e.respondWith(\u001b[39m\n\u001b[31m      caches.open(C).then(c =>\u001b[39m\n\u001b[31m        c.match(e.request).then(r => r || fetch(e.request).then(f => { c.put(e.request, f.clone()); return f; }))\u001b[39m\n\u001b[31m      )\u001b[39m\n\u001b[31m    );\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m});`;\u001b[39m\n\u001b[31m  const blob = new Blob([sw], { type: 'application/javascript' });\u001b[39m\n\u001b[31m  navigator.serviceWorker.register(URL.createObjectURL(blob), { scope: '/tour-pg/' }).catch(() => {});\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31minit();\u001b[39m\n\u001b[31m</script>·\u001b[39m\n\u001b[31m</body>\u001b[39m\n\u001b[31m</html>\u001b[39m\n\u001b[31m\"\u001b[39m\n\n  52 |     const ctx = await request.newContext();\n  53 |     const html = await (await ctx.get(BASE_URL)).text();\n> 54 |     expect(html).toContain('tour-pg-v10');\n     |                  ^\n  55 |     expect(html).toContain('Tour Playground');\n  56 |     await ctx.dispose();\n  57 |   });\n    at /var/www/360lm/tests/tour_pg.spec.js:54:18"
                        }
                      ],
                      "stdout": [],
                      "stderr": [],
                      "retry": 1,
                      "startTime": "2026-06-05T09:15:31.488Z",
                      "annotations": [],
                      "attachments": [
                        {
                          "name": "error-context",
                          "contentType": "text/markdown",
                          "path": "/var/www/360lm/test-results/tour_pg-Tour-Playground-—--22e4b-ries-tour-pg-v10-SW-version-desktop-chrome-retry1/error-context.md"
                        }
                      ],
                      "errorLocation": {
                        "file": "/var/www/360lm/tests/tour_pg.spec.js",
                        "column": 18,
                        "line": 54
                      }
                    }
                  ],
                  "status": "unexpected"
                }
              ],
              "id": "c6527e00e898397fb4a5-63379f62b19961f707a2",
              "file": "tour_pg.spec.js",
              "line": 51,
              "column": 3
            },
            {
              "title": "proxy /health returns ok",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "desktop-chrome",
                  "projectName": "desktop-chrome",
                  "results": [
                    {
                      "workerIndex": 5,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 100,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:32.764Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-c27a1f81f0f1bef71bf0",
              "file": "tour_pg.spec.js",
              "line": 59,
              "column": 3
            }
          ]
        },
        {
          "title": "Tour Playground — HTML structure",
          "file": "tour_pg.spec.js",
          "line": 72,
          "column": 6,
          "specs": [
            {
              "title": "two main screens declared",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "android-chrome",
                  "projectName": "android-chrome",
                  "results": [
                    {
                      "workerIndex": 2,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 7,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:11.901Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-695c509119fd5d12f8b3",
              "file": "tour_pg.spec.js",
              "line": 80,
              "column": 3
            },
            {
              "title": "upload form widgets present",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "android-chrome",
                  "projectName": "android-chrome",
                  "results": [
                    {
                      "workerIndex": 2,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 19,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:12.012Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-0a8482cda7d5afe32715",
              "file": "tour_pg.spec.js",
              "line": 85,
              "column": 3
            },
            {
              "title": "editor screen widgets present",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "android-chrome",
                  "projectName": "android-chrome",
                  "results": [
                    {
                      "workerIndex": 2,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 34,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:12.066Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-4aeb9f684d0e10040cef",
              "file": "tour_pg.spec.js",
              "line": 93,
              "column": 3
            },
            {
              "title": "share modal present with 5 actions",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "android-chrome",
                  "projectName": "android-chrome",
                  "results": [
                    {
                      "workerIndex": 2,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 7,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:12.161Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-e3ac227106e400cac68e",
              "file": "tour_pg.spec.js",
              "line": 109,
              "column": 3
            },
            {
              "title": "mid-tour replan widget present",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "android-chrome",
                  "projectName": "android-chrome",
                  "results": [
                    {
                      "workerIndex": 2,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 19,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:12.189Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-8391bbee5cdba382f02e",
              "file": "tour_pg.spec.js",
              "line": 118,
              "column": 3
            },
            {
              "title": "SortableJS + SheetJS CDN loaded",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "android-chrome",
                  "projectName": "android-chrome",
                  "results": [
                    {
                      "workerIndex": 2,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 4,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:12.217Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-d3bd809f42cfadf22097",
              "file": "tour_pg.spec.js",
              "line": 124,
              "column": 3
            },
            {
              "title": "Leaflet CDN + inline map widget present",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "android-chrome",
                  "projectName": "android-chrome",
                  "results": [
                    {
                      "workerIndex": 2,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 18,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:12.233Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-6a6396b44064bf473745",
              "file": "tour_pg.spec.js",
              "line": 129,
              "column": 3
            },
            {
              "title": "two main screens declared",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "desktop-chrome",
                  "projectName": "desktop-chrome",
                  "results": [
                    {
                      "workerIndex": 5,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 14,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:32.943Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-3efaae47ee3ef653f64c",
              "file": "tour_pg.spec.js",
              "line": 80,
              "column": 3
            },
            {
              "title": "upload form widgets present",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "desktop-chrome",
                  "projectName": "desktop-chrome",
                  "results": [
                    {
                      "workerIndex": 5,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 12,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:33.105Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-5c26810bed9cc28fce33",
              "file": "tour_pg.spec.js",
              "line": 85,
              "column": 3
            },
            {
              "title": "editor screen widgets present",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "desktop-chrome",
                  "projectName": "desktop-chrome",
                  "results": [
                    {
                      "workerIndex": 5,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 16,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:33.173Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-a56bef580f604eff9386",
              "file": "tour_pg.spec.js",
              "line": 93,
              "column": 3
            },
            {
              "title": "share modal present with 5 actions",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "desktop-chrome",
                  "projectName": "desktop-chrome",
                  "results": [
                    {
                      "workerIndex": 5,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 5,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:33.216Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-b3d5bcbc5dc2526d303a",
              "file": "tour_pg.spec.js",
              "line": 109,
              "column": 3
            },
            {
              "title": "mid-tour replan widget present",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "desktop-chrome",
                  "projectName": "desktop-chrome",
                  "results": [
                    {
                      "workerIndex": 5,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 9,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:33.246Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-6dbbc2f246d66bdb483d",
              "file": "tour_pg.spec.js",
              "line": 118,
              "column": 3
            },
            {
              "title": "SortableJS + SheetJS CDN loaded",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "desktop-chrome",
                  "projectName": "desktop-chrome",
                  "results": [
                    {
                      "workerIndex": 5,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 2,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:33.269Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-1e95e7080f03ce35d91c",
              "file": "tour_pg.spec.js",
              "line": 124,
              "column": 3
            },
            {
              "title": "Leaflet CDN + inline map widget present",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "desktop-chrome",
                  "projectName": "desktop-chrome",
                  "results": [
                    {
                      "workerIndex": 5,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 6,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:33.283Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-ac9849b17b955308785e",
              "file": "tour_pg.spec.js",
              "line": 129,
              "column": 3
            }
          ]
        },
        {
          "title": "Tour Playground — Auth",
          "file": "tour_pg.spec.js",
          "line": 143,
          "column": 6,
          "specs": [
            {
              "title": "no session → redirects to /hub/",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "android-chrome",
                  "projectName": "android-chrome",
                  "results": [
                    {
                      "workerIndex": 2,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 851,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:12.274Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-83dd50fbaa77ebc47a5d",
              "file": "tour_pg.spec.js",
              "line": 144,
              "column": 3
            },
            {
              "title": "valid session → upload screen visible",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "android-chrome",
                  "projectName": "android-chrome",
                  "results": [
                    {
                      "workerIndex": 2,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 1115,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:13.372Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-ac58e3c45c229c64a5c8",
              "file": "tour_pg.spec.js",
              "line": 153,
              "column": 3
            },
            {
              "title": "user pill shows logged-in name",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "android-chrome",
                  "projectName": "android-chrome",
                  "results": [
                    {
                      "workerIndex": 2,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 1094,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:14.517Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-b105e116251a5a974331",
              "file": "tour_pg.spec.js",
              "line": 162,
              "column": 3
            },
            {
              "title": "no session → redirects to /hub/",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "desktop-chrome",
                  "projectName": "desktop-chrome",
                  "results": [
                    {
                      "workerIndex": 5,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 718,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:33.302Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-8a7b32ab13ba7e650d6d",
              "file": "tour_pg.spec.js",
              "line": 144,
              "column": 3
            },
            {
              "title": "valid session → upload screen visible",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "desktop-chrome",
                  "projectName": "desktop-chrome",
                  "results": [
                    {
                      "workerIndex": 5,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 1072,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:34.230Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-c08ca36371ec8fd17b5d",
              "file": "tour_pg.spec.js",
              "line": 153,
              "column": 3
            },
            {
              "title": "user pill shows logged-in name",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "desktop-chrome",
                  "projectName": "desktop-chrome",
                  "results": [
                    {
                      "workerIndex": 5,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 957,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:35.342Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-debfcd19a58dd43fb67b",
              "file": "tour_pg.spec.js",
              "line": 162,
              "column": 3
            }
          ]
        },
        {
          "title": "Tour Playground — Proxy end-to-end",
          "file": "tour_pg.spec.js",
          "line": 173,
          "column": 6,
          "specs": [
            {
              "title": "full optimise + drag-reorder + exclude flow",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "android-chrome",
                  "projectName": "android-chrome",
                  "results": [
                    {
                      "workerIndex": 2,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 2542,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:15.635Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-d9eb7610af698ae1097f",
              "file": "tour_pg.spec.js",
              "line": 174,
              "column": 3
            },
            {
              "title": "matrix + 2-opt path for 14 stops",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "android-chrome",
                  "projectName": "android-chrome",
                  "results": [
                    {
                      "workerIndex": 2,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 755,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:18.253Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-ae600ef94ad0ea6686b6",
              "file": "tour_pg.spec.js",
              "line": 221,
              "column": 3
            },
            {
              "title": "full optimise + drag-reorder + exclude flow",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "desktop-chrome",
                  "projectName": "desktop-chrome",
                  "results": [
                    {
                      "workerIndex": 5,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 2458,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:36.331Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-803599e44c1e6f67a1c1",
              "file": "tour_pg.spec.js",
              "line": 174,
              "column": 3
            },
            {
              "title": "matrix + 2-opt path for 14 stops",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "desktop-chrome",
                  "projectName": "desktop-chrome",
                  "results": [
                    {
                      "workerIndex": 5,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 737,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:38.807Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-68347481273a6865f1ad",
              "file": "tour_pg.spec.js",
              "line": 221,
              "column": 3
            }
          ]
        },
        {
          "title": "Tour Playground — Browser JS state",
          "file": "tour_pg.spec.js",
          "line": 250,
          "column": 6,
          "specs": [
            {
              "title": "clicking Go without Excel shows error toast",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "android-chrome",
                  "projectName": "android-chrome",
                  "results": [
                    {
                      "workerIndex": 2,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 1179,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:19.031Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-1ad6d74924fbe20b2a74",
              "file": "tour_pg.spec.js",
              "line": 251,
              "column": 3
            },
            {
              "title": "round-trip toggle hides/shows destination input",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "android-chrome",
                  "projectName": "android-chrome",
                  "results": [
                    {
                      "workerIndex": 2,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 2106,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:20.260Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-d37ddfb5b63aa657d1cf",
              "file": "tour_pg.spec.js",
              "line": 259,
              "column": 3
            },
            {
              "title": "full state pipeline via page.evaluate (mimics Excel upload)",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "android-chrome",
                  "projectName": "android-chrome",
                  "results": [
                    {
                      "workerIndex": 2,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 2563,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:22.419Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-76a529476034b7654e66",
              "file": "tour_pg.spec.js",
              "line": 272,
              "column": 3
            },
            {
              "title": "toggle include then verify state.stops still has the item",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "android-chrome",
                  "projectName": "android-chrome",
                  "results": [
                    {
                      "workerIndex": 2,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 1561,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:25.022Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-fb5425d4cf28fdd89fa0",
              "file": "tour_pg.spec.js",
              "line": 308,
              "column": 3
            },
            {
              "title": "clicking Go without Excel shows error toast",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "desktop-chrome",
                  "projectName": "desktop-chrome",
                  "results": [
                    {
                      "workerIndex": 5,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 890,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:39.565Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-b55f22712ed011390d18",
              "file": "tour_pg.spec.js",
              "line": 251,
              "column": 3
            },
            {
              "title": "round-trip toggle hides/shows destination input",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "desktop-chrome",
                  "projectName": "desktop-chrome",
                  "results": [
                    {
                      "workerIndex": 5,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 1305,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:40.477Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-2542672225a60b02d394",
              "file": "tour_pg.spec.js",
              "line": 259,
              "column": 3
            },
            {
              "title": "full state pipeline via page.evaluate (mimics Excel upload)",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "desktop-chrome",
                  "projectName": "desktop-chrome",
                  "results": [
                    {
                      "workerIndex": 5,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 2347,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:41.801Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-ee875d59fbce16fa3a8f",
              "file": "tour_pg.spec.js",
              "line": 272,
              "column": 3
            },
            {
              "title": "toggle include then verify state.stops still has the item",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "desktop-chrome",
                  "projectName": "desktop-chrome",
                  "results": [
                    {
                      "workerIndex": 5,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 1481,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:44.165Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-ee0ea69eaa8d3fc3f0d7",
              "file": "tour_pg.spec.js",
              "line": 308,
              "column": 3
            }
          ]
        },
        {
          "title": "Tour Playground — Hub",
          "file": "tour_pg.spec.js",
          "line": 340,
          "column": 6,
          "specs": [
            {
              "title": "PWA is registered in hub.pwa_registry with grants",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "android-chrome",
                  "projectName": "android-chrome",
                  "results": [
                    {
                      "workerIndex": 2,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 32,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:26.616Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-dbd8d78ec13d6ad2657d",
              "file": "tour_pg.spec.js",
              "line": 341,
              "column": 3
            },
            {
              "title": "PWA is registered in hub.pwa_registry with grants",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "desktop-chrome",
                  "projectName": "desktop-chrome",
                  "results": [
                    {
                      "workerIndex": 5,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 60,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-05T09:15:45.662Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-8827f07514089a554a45",
              "file": "tour_pg.spec.js",
              "line": 341,
              "column": 3
            }
          ]
        }
      ]
    }
  ],
  "errors": [],
  "stats": {
    "startTime": "2026-06-05T09:14:55.961Z",
    "duration": 49882.530999999995,
    "expected": 42,
    "skipped": 0,
    "unexpected": 2,
    "flaky": 0
  }
}
